Node Plugins

Node plugins run in a subprocess with full access to the Node.js runtime, npm packages, and native addons. Use them when your plugin needs capabilities beyond pure computation – filesystem access, network requests, or npm dependencies.

// plugins/css-minifier.js
export const runtime = "node";

import postcss from 'postcss';
import cssnano from 'cssnano';

export default function(alloy) {
  alloy.hook("onAssetProcess", {}, async (file) => {
    if (file.path.endsWith('.css')) {
      const result = await postcss([cssnano]).process(file.content, {
        from: file.path
      });
      return { ...file, content: result.css };
    }
    return file;
  });
}

Marking a Plugin as Node

Any .js file in plugins/ runs on embedded QuickJS by default. To use the Node subprocess, export runtime: "node":

export const runtime = "node";

Without this marker, your plugin runs sandboxed on QuickJS with no system access.

Prerequisites

Node plugins require:

  • Node.js installed and available in PATH
  • ESM project: "type": "module" in your package.json
  • Dependencies installed: run npm install in your project root

Alloy does not ship Node.js, manage package.json, or run npm install. If Node plugins exist but node is not found, the build fails:

[alloy] ERROR Node.js not found in PATH.
        Node plugins require Node.js to be installed.
        Build aborted.

IPC Protocol

Node plugins communicate with Alloy via length-prefixed JSON-RPC over stdin/stdout (LSP-style framing).

Content-Length: 82\r\n
\r\n
{"id": 1, "type": "hook", "name": "onContentTransformed", "payload": [...]}

You never interact with this protocol directly — the alloy API object handles serialization.

stdout isolation

Stdout is reserved for the plugin protocol. The bridge script intercepts JS-level writes before any plugin code loads:

  • console.log, console.warn, console.info, console.debug → stderr
  • process.stdout.write → stderr

Plugin output and library logging appear in the terminal alongside Alloy’s own output, not in a log file. Plugins cannot corrupt the protocol by logging.

Known limitation: A child process spawned with stdio: 'inherit' writes to the real stdout file descriptor, bypassing the JS-level patch. This can corrupt the protocol. Spawn children with explicit stdio instead:

import { spawn } from 'child_process';

// Correct — child output goes to stderr, not stdout
const child = spawn('cmd', args, {
  stdio: ['ignore', 'pipe', 'pipe']
});
child.stdout.pipe(process.stderr);

Troubleshooting

If you see this error:

plugin bridge protocol error: expected Content-Length header, got "..." —
a plugin or one of its dependencies wrote non-protocol output to stdout

A plugin or one of its dependencies is writing to stdout at the file descriptor level, bypassing the bridge’s process.stdout.write patch. Common causes:

  • A child process spawned with stdio: 'inherit'
  • A native addon writing directly to fd 1
  • require('fs').writeSync(1, ...) or similar fd-level writes

Fix by redirecting the child’s stdio as shown above.

Registering Filters

// plugins/smart-quotes.js
export const runtime = "node";

import smartypants from 'smartypants';

export default function(alloy) {
  alloy.filter("smartQuotes", (text) => {
    return smartypants(text, 1);
  });
}
Liquid Go templates
{{ page.content | smartQuotes }}
{{ smartQuotes .page.content }}

Filter arguments are passed as additional parameters:

alloy.filter("imageUrl", (path, width, format) => {
  return `https://cdn.example.com/${path}?w=${width}&fmt=${format}`;
});
Liquid Go templates
{{ "hero.jpg" | imageUrl: 800, "webp" }}
{{ imageUrl "hero.jpg" 800 "webp" }}

Registering Shortcodes

// plugins/code-playground.js
export const runtime = "node";

import { highlight } from 'some-highlighter';

export default function(alloy) {
  // Inline shortcode
  alloy.shortcode("highlight", (args) => {
    const [code, lang] = args;
    return highlight(code, { language: lang });
  });

  // Block shortcode (receives inner content)
  alloy.shortcode("playground", (args, content) => {
    const lang = args[0] || "javascript";
    return `<div class="playground">
      <div class="code">${highlight(content, { language: lang })}</div>
      <iframe srcdoc="${content}"></iframe>
    </div>`;
  });
}

Registering Hooks

Node plugins can register any lifecycle hook:

// plugins/lit-ssr.js
export const runtime = "node";

import { render } from '@lit-labs/ssr';
import { html } from 'lit';

export default function(alloy) {
  alloy.hook("onPageRendered", { priority: 90 }, async (page) => {
    // SSR Lit components in the final HTML
    const result = render(html`${page.html}`);
    page.html = await collectResult(result);
    return page;
  });
}

Hook Priority

Control execution order with the priority option:

// Runs first (priority 10)
alloy.hook("onPageRendered", { priority: 10 }, earlyTransform);

// Runs at default position (priority 50)
alloy.hook("onPageRendered", {}, defaultTransform);

// Runs last (priority 100)
alloy.hook("onPageRendered", { priority: 100 }, finalTransform);

Hook Scoping

Declare what data your hook needs to minimize serialization overhead:

alloy.hook("onContentLoaded", {
  data: ["navigation"],           // only these site.data keys
  pages: "/blog/**",              // only blog pages
  pageFields: ["frontMatter", "url"]  // only these fields per page
}, (pages) => {
  // Process only what you need
  return pages;
});

See Hook Scoping for the full scoping API.

Data Source Plugins

The built-in rest and graphql source types handle simple, unauthenticated, single-request fetches. For anything beyond that — authentication, pagination, retries, multi-endpoint aggregation, database access — use type: "plugin". The plugin owns the entire data acquisition lifecycle and returns the final dataset. Alloy caches the result and injects it into the data cascade. For a comparison table, see Built-in types vs plugin sources.

Register a source handler:

// plugins/cms-posts.js
export const runtime = "node";

export default function(alloy) {
  alloy.source("cms-posts", async () => {
    const API_URL = process.env.CMS_API_URL;
    const TOKEN = process.env.CMS_TOKEN;

    let allPosts = [];
    let page = 1;
    let hasMore = true;

    while (hasMore) {
      const response = await fetch(`${API_URL}/posts?page=${page}`, {
        headers: { Authorization: `Bearer ${TOKEN}` }
      });
      const json = await response.json();
      allPosts = allPosts.concat(json.data);
      hasMore = json.meta.nextPage !== null;
      page++;
    }

    return allPosts;
  });
}

Configure the source in alloy.config.yaml:

sources:
  blog:
    type: "plugin"
    plugin: "cms-posts"
    cache: 3600
    as: "blog"

The fetched data is available as site.data.blog in templates and can drive virtual page generation via pagination.

Worker Pool

For per-page hooks (onPageRendered, onFormatRendered, onContentTransformed), Alloy distributes pages across multiple Node subprocess workers to parallelize the work:

# alloy.config.yaml
plugins:
  workers: auto    # default -- auto-scale based on CPU count
  # workers: 4    # explicit override

Auto-scaling uses min(CPU_count / 2, 8) with a floor of 2. Each worker loads the same plugins via ESM import() so Node’s module cache prevents side-effect collisions.

Only Tier 3 (Node subprocess) plugins use the worker pool – Tier 2 plugins run in-process.

Restarting on File Changes

Return { restart: true } from an onFileChanged hook to kill and respawn all Node bridge workers before the rebuild. Alloy re-imports every plugin file, which clears Node’s ESM module cache so import() loads the changed code.

SSR plugins that import component definitions at startup need this. Without it, the workers keep serving stale modules.

// plugins/element-watcher.js
export const runtime = "node";

export default function(alloy) {
  alloy.hook("onFileChanged", {}, (events) => {
    const changed = events
      .filter(ev => ev.Path.startsWith("elements/") && ev.Path.endsWith(".js"))
      .map(ev => ev.Path);
    if (changed.length > 0) {
      return { invalidateByDependency: changed, restart: true };
    }
  });
}

restart only affects Node (Tier 3) plugins. QuickJS and WASM plugins run in-process with no subprocess state.

See onFileChanged for the full return value API.

Module Resolution

The Alloy bridge script is written to .alloy/bridge.js in your project root. This ensures ESM import() resolves packages from your project’s node_modules/. Both import and dynamic import() work:

export const runtime = "node";

import postcss from 'postcss';              // static import
const cssnano = await import('cssnano');    // dynamic import

export default function(alloy) {
  // Both packages are available
}

Plugin Timeout

Each plugin call respects the configured timeout (default 5 seconds):

plugins:
  timeout: 5000    # milliseconds

A timed-out call produces a warning and continues with unmodified data. Plugin process crashes return an error.

Security

Node plugins run with the same permissions as the user. They have full access to:

  • Filesystem (fs, path)
  • Network (fetch, http, net)
  • Environment variables (process.env)
  • Child processes (child_process)

Only install plugins you have reviewed or that come from trusted sources.