MishkaCmsCore.Runtime.Compilers.JavaScriptCompiler (Mishka CMS Core v0.0.1)

Copy Markdown View Source

Production-ready JavaScript compiler for runtime JS generation.

This module provides comprehensive JavaScript compilation functionality for MishkaCMS, combining application JS files, vendor dependencies, and database-stored JavaScript hooks into optimized, site-specific bundles.

Features

  • Runtime JS Hook Compilation: Dynamically compiles JavaScript hooks from database
  • Site-specific Bundling: Creates optimized JS bundles per site configuration
  • Phoenix Colocated Integration: Supports Phoenix colocated JavaScript hooks
  • ESBuild Integration: Uses ESBuild for fast, modern JavaScript bundling
  • Asset Compression: Automatic Brotli and Gzip compression for optimal delivery
  • Asset State Management: Caches compiled JS in memory for fast serving
  • Automatic Cleanup: Manages temporary files and build artifacts
  • Error Handling: Comprehensive error reporting and recovery

JavaScript Architecture

The compiler creates a hierarchical JavaScript structure:

Entry File (app.js + runtime hooks)
├── Base Application JavaScript (app.js)
├── Phoenix Colocated Hooks (phoenix-colocated/mishka_cms.js)
└── Site-specific Runtime Components
    ├── Global JavaScript Hooks (database)
    └── Site-specific JavaScript Hooks (database)

Compilation Process

  1. Prepare Environment: Creates temporary directories and asset structure
  2. Generate Runtime Hooks: Fetches JS hooks from database and creates component files
  3. Ensure Colocated Hooks: Sets up Phoenix colocated JavaScript integration
  4. Create Entry File: Combines app.js with runtime components
  5. Execute ESBuild: Bundles and minifies JavaScript using ESBuild
  6. Compress Assets: Creates Brotli and Gzip versions for optimal delivery
  7. Store Results: Caches compiled JS in AssetsState for fast serving
  8. Cleanup: Removes temporary files and build artifacts

Usage

# Compile JavaScript for a specific site
{:ok, js} = JavaScriptCompiler.compile("my_site")

# Compile and store in cache
{:ok, js} = JavaScriptCompiler.compile_and_store("my_site")

# Retrieve cached JavaScript
js = JavaScriptCompiler.get_compiled_js("my_site")

# Check if JavaScript exists
exists = JavaScriptCompiler.js_exists?("my_site")

# Refresh JavaScript (recompile and update cache)
{:ok, js} = JavaScriptCompiler.refresh_js("my_site")

ESBuild Configuration

The compiler uses ESBuild with the following configuration:

  • Target: ES2022 for modern browser support
  • Format: IIFE (Immediately Invoked Function Expression) for browser compatibility
  • Platform: Browser-specific optimizations
  • Minification: Enabled for production builds
  • External Resources: Excludes fonts and images from bundling
  • Alias Support: Uses @ alias for cleaner imports

Error Handling

Compilation failures are handled gracefully with:

  • A hook that does not build is left out and the rest of the bundle is built without it; the reason is read with left_out/1 and sent as runtime:compile:js_hook_skipped
  • Detailed error messages including ESBuild output
  • Automatic cleanup of temporary files
  • Logging of compilation status and errors
  • Fallback behavior for missing JavaScript

Lazy hooks

A hook marked lazy is not built into the bundle. It is built on its own, in the same compile, as an ES module whose name is the hash of its content, and stored beside the bundle under lazy_type/1. The bundle registers a stub under the hook's browser name instead; the first element that mounts it imports the module once, and from then on the hook's own mounted, beforeUpdate, updated, destroyed, disconnected and reconnected run on that element. A page that never mounts the hook never downloads it. Each module passes through runtime:compile:lazy_js, with %{site: site_name, hook: hook_name} as its meta, before it is hashed — as the bundle passes through runtime:compile:js.

The module is read beside the bundle that asked for it — /assets/js/<site>/<hash>.js, with the bundle's query string — so the bundle has to be loaded from its URL, not inlined. It is served by MishkaCmsCoreWeb.AssetsController to whoever may read the bundle, with the same headers, and replaced with it: the modules a bundle names are the ones stored under its site. A same-origin script, it needs nothing from the Content-Security-Policy that 'self' does not already give.

A lazy hook's default export is a hook object, or a hook class whose state is set in mounted: its constructor does not run.

Integration with Phoenix

# In your layout template
<script>
  <%= raw(MishkaCmsCore.Runtime.Compilers.JavaScriptCompiler.get_compiled_js(@site_name)) %>
</script>

# Or serve as static asset with compression
defmodule MyAppWeb.AssetController do
  def javascript(conn, %{"site" => site_name}) do
    js = JavaScriptCompiler.get_compiled_js(site_name)

    conn
    |> put_resp_content_type("application/javascript")
    |> put_resp_header("cache-control", "public, max-age=31536000")
    |> send_resp(200, js)
  end
end

Summary

Functions

The name a runtime hook is registered under in the browser.

Compiles JavaScript from app.js, vendor files, and hooks for a specific site.

Compiles and stores the JS in AssetsState for a specific site.

Compiles + stores the shared base JS (app.js + global hooks only) under __shared_base__. Built once and reused by every global-only site.

Deletes compiled JS for a site.

Drops the cached shared base JS (all formats), and the lazy hooks' modules it loads.

Ensures the configured ESBuild is installed and runnable, caching the answer in :persistent_term so the check costs one --version run per node rather than one per compile.

Retrieves compiled JS from AssetsState for a specific site.

Cached shared base JS, compiling + caching it on first use.

Checks if compiled JS exists for a site.

The AssetsState type a lazy hook's module is stored under, beside its site's bundle, from the hash its file is named by: lazy_js:<hash>, with brotli_lazy_js:<hash> and gzip_lazy_js:<hash> for its compressed copies.

The bundles the JS hook hook_id was left out of when they were last built, each with the reason, sorted by bundle: [{"localhost", ~s(Could not resolve "echarts" (globalChart.js:28))}].

Refreshes JS for a site (recompiles and updates).

Functions

browser_name(hook, site_name)

@spec browser_name(map(), String.t() | nil) :: String.t()

The name a runtime hook is registered under in the browser.

Public because the builder's hook picker has to write exactly this string into phx-hook, and a second copy of the rule would drift from the one that does the registering.

compile(site_name, opts \\ [])

Compiles JavaScript from app.js, vendor files, and hooks for a specific site.

This is the main compilation function that orchestrates the entire JavaScript bundling process for a specific site. It handles temporary file management, runtime hook generation, and ESBuild execution.

Parameters

  • site_name - The name/identifier of the site to compile JavaScript for

Returns

  • {:ok, %{js: compiled_js, lazy: modules}} - On successful compilation: the bundle, and the module of each lazy hook it loads on first use, as %{hook: name, hash: hash, js: module}
  • {:error, reason} - On failure, returns an error with descriptive message

Compilation Steps

  1. Creates temporary directories for build process
  2. Generates runtime hooks from database-stored JavaScript
  3. Ensures Phoenix colocated hooks are available
  4. Executes ESBuild bundling with optimizations
  5. Cleans up temporary files

Error Handling

Failures at any step will result in automatic cleanup of temporary files and return a descriptive error message. Common failure points:

  • Missing app.js file
  • ESBuild compilation errors
  • Database connection issues when fetching hooks
  • File system permission errors

Example

# Compile JavaScript for main site
case JavaScriptCompiler.compile("main") do
  {:ok, %{js: js}} ->
    IO.puts("Compiled JS size: #{byte_size(js)} bytes")
  {:error, reason} ->
    IO.puts("Compilation failed: #{reason}")
end

compile_and_store(site_name)

Compiles and stores the JS in AssetsState for a specific site.

This function combines compilation with caching, storing the compiled JavaScript along with compressed versions (Brotli and Gzip) in the AssetsState for fast serving.

Parameters

  • site_name - The name/identifier of the site to compile JavaScript for

Returns

  • {:ok, compiled_js} - On success, returns the compiled JavaScript string
  • {:error, reason} - On failure, returns compilation error

Storage Details

Creates three entries in AssetsState:

  • "js" - Original compiled JavaScript
  • "brotli_js" - Brotli-compressed version (best compression)
  • "gzip_js" - Gzip-compressed version (wide compatibility)

Each entry includes:

  • MD5 hash for cache validation
  • Timestamp for cache invalidation
  • Compressed content for optimal delivery

Example

# Compile and cache JavaScript
case JavaScriptCompiler.compile_and_store("main") do
  {:ok, js} ->
    Logger.info("JavaScript compiled and cached: #{byte_size(js)} bytes")
  {:error, reason} ->
    Logger.error("Failed to compile: #{reason}")
end

compile_and_store_shared_base()

Compiles + stores the shared base JS (app.js + global hooks only) under __shared_base__. Built once and reused by every global-only site.

delete_compiled_js(site_name)

Deletes compiled JS for a site.

Removes all cached JavaScript assets for the specified site from AssetsState. This includes the original JS and all compressed versions.

Parameters

  • site_name - The site identifier to delete JavaScript for

Returns

  • :ok on successful deletion
  • Error tuple if deletion fails

Usage

# Clean up old JavaScript
JavaScriptCompiler.delete_compiled_js("old_site")

# Force recompilation by deleting and recompiling
JavaScriptCompiler.delete_compiled_js("main")
JavaScriptCompiler.compile_and_store("main")

delete_shared_base_js()

Drops the cached shared base JS (all formats), and the lazy hooks' modules it loads.

ensure_installed()

Ensures the configured ESBuild is installed and runnable, caching the answer in :persistent_term so the check costs one --version run per node rather than one per compile.

It asks the binary, not the path. Esbuild.bin_path/0 BUILDS a string and returns it whether or not a file sits at the end of it, so testing it told us only that esbuild has a name — a missing binary then reached System.cmd and came back as ErlangError :enoent, an error naming neither esbuild nor a path, underneath a log line saying the binary had been found.

The version is checked too, and not as a formality: bin_path/0 puts no version in the filename, and Esbuild.configured_version/0 falls back to the library's own @latest_version when this project's config is not loaded. So anything that installs esbuild from outside this config leaves a differently-versioned binary at exactly the path we look at, and it stays there. That is how 0.25.0 came to sit where 0.25.4 was pinned.

Raises when esbuild is still unusable after installing, rather than caching a verification that is not true. MishkaCmsCore.Runtime.Handlers.BootHandler expects that and boots without it.

get_compiled_js(site_name)

Retrieves compiled JS from AssetsState for a specific site.

Fast retrieval of cached JavaScript without recompilation. Returns a fallback comment if JavaScript is not found for the specified site.

Parameters

  • site_name - The site identifier to retrieve JavaScript for

Returns

  • Compiled JavaScript string if found in cache
  • Fallback comment string if not found: "// JS not found for site: {site_name}"

Usage

js = JavaScriptCompiler.get_compiled_js("main")

# In Phoenix template
<script><%= raw(js) %></script>

get_or_compile_shared_base()

Cached shared base JS, compiling + caching it on first use.

js_exists?(site_name)

Checks if compiled JS exists for a site.

Quick boolean check to determine if JavaScript has been compiled and cached for the specified site.

Parameters

  • site_name - The site identifier to check

Returns

  • true if compiled JavaScript exists in cache
  • false if no JavaScript found for the site

Usage

if JavaScriptCompiler.js_exists?("main") do
  js = JavaScriptCompiler.get_compiled_js("main")
else
  JavaScriptCompiler.compile_and_store("main")
end

lazy_type(hash)

@spec lazy_type(String.t()) :: String.t()

The AssetsState type a lazy hook's module is stored under, beside its site's bundle, from the hash its file is named by: lazy_js:<hash>, with brotli_lazy_js:<hash> and gzip_lazy_js:<hash> for its compressed copies.

left_out(hook_id)

@spec left_out(String.t()) :: [{String.t(), String.t()}]

The bundles the JS hook hook_id was left out of when they were last built, each with the reason, sorted by bundle: [{"localhost", ~s(Could not resolve "echarts" (globalChart.js:28))}].

A bundle is a site's name, or "__shared_base__" for the global hooks every site without hooks of its own shares. A global hook is built into every site's bundle, so it can be left out of several.

refresh_js(site_name)

Refreshes JS for a site (recompiles and updates).

Convenience function that recompiles JavaScript for a site and updates the cache. This is useful when JavaScript hooks or configurations change.

Parameters

  • site_name - The site identifier to refresh JavaScript for

Returns

  • {:ok, compiled_js} - On successful recompilation
  • {:error, reason} - On compilation failure

Usage

# Refresh after updating JavaScript hooks
case JavaScriptCompiler.refresh_js("main") do
  {:ok, js} ->
    Logger.info("JavaScript refreshed successfully")
  {:error, reason} ->
    Logger.error("Failed to refresh: #{reason}")
end

When to Use

  • After updating JavaScript hooks in database
  • When site configuration changes
  • During development when app.js changes
  • For periodic cache invalidation