Skip to content

Configure the plugin sandbox

Sandboxed plugins need a platform runner in addition to their plugin declaration. Marketplace and registry installs always use that runner, as do plugins listed under sandboxed: []. Native plugins under plugins: [] run in the EmDash server process and do not gain sandbox isolation.

The runner depends on the deployment platform. On Cloudflare Workers, each plugin runs as a Dynamic Worker created through the Worker Loader binding. On Node.js, the server starts workerd, the open-source Workers runtime, as a child process and runs each plugin as a service inside it. The sandboxRunner option of emdash() selects the runner. Without it, plugins under sandboxed: [] are not loaded. Marketplace and registry browsing remains available, but installing or updating a sandboxed plugin fails with SANDBOX_NOT_AVAILABLE.

The following table summarizes what each runner needs and enforces.

Cloudflare WorkersNode.js
sandboxRunnersandbox() from @emdash-cms/cloudflare"@emdash-cms/sandbox-workerd/sandbox"
RequirementsWorkers Paid plan, a worker_loaders binding, PluginBridge exported from the Worker entry pointThe workerd package
Database accessThe DB D1 binding, independent of the configured adapterThe configured database
Enforced limitsCPU time, subrequests, wall timeWall time

Dynamic Workers are available on the Workers Paid plan. The *-cloudflare templates include the entry point export below but leave the binding commented out, so new projects deploy on the Workers free plan unless you enable sandboxed plugins during scaffolding.

  1. Enable the Worker Loader binding in wrangler.jsonc. The runner reads it under the name LOADER and selects the Cloudflare sandbox only when this binding is present:

    wrangler.jsonc
    {
    "worker_loaders": [
    {
    "binding": "LOADER",
    },
    ],
    }

    If the Wrangler config uses named environments, set CLOUDFLARE_ENV during the Astro build. The Cloudflare Vite plugin and sandbox() then read the same environment. Bindings are not inherited, so add LOADER to each named environment that runs sandboxed plugins.

  2. Export PluginBridge from the Worker entry point, and point main at that file. PluginBridge is the entrypoint through which sandboxed plugins reach content, media, storage, and email; the runner looks it up on the exports of the entry module:

    src/worker.ts
    import handler, { createScheduledHandler, PluginBridge } from "@emdash-cms/cloudflare/worker";
    export { PluginBridge };
    export default {
    ...handler,
    scheduled: createScheduledHandler(),
    } satisfies ExportedHandler;
    wrangler.jsonc
    {
    "main": "./src/worker.ts",
    }
  3. Select the runner in the emdash() integration:

    astro.config.mjs
    import { d1, r2, sandbox } from "@emdash-cms/cloudflare";
    emdash({
    database: d1({ binding: "DB" }),
    storage: r2({ binding: "MEDIA" }),
    sandboxRunner: sandbox(),
    });
  1. Install the runner together with workerd, which is a peer dependency:

    Terminal window
    npm install @emdash-cms/sandbox-workerd workerd

    The workerd package installs the binary for the current platform (Linux, macOS, and Windows on x64; Linux and macOS on arm64) through an optional dependency. Install with optional dependencies enabled, on the platform the server runs on. In a multi-stage Docker build, run the install in a stage with the same platform as the runtime stage.

  2. Select the runner in the emdash() integration:

    astro.config.mjs
    import { sqlite } from "emdash/db";
    emdash({
    database: sqlite({ url: "file:./data/emdash.db" }),
    sandboxRunner: "@emdash-cms/sandbox-workerd/sandbox",
    });

The runner declares Miniflare as an optional dependency. Package managers install it by default. When NODE_ENV is development, which astro dev sets, the runner hands the plugins to Miniflare, which manages its own workerd process; the crash policy below does not apply. If optional dependencies were omitted, the runner uses workerd instead. astro preview sets NODE_ENV to production and node ./dist/server/entry.mjs leaves it unset; both use workerd.

EmDash starts workerd while it initializes on the first request to the site, once the sandboxed plugins are loaded, and waits up to 10 seconds for the plugin services to answer. Installing or updating a plugin from the admin restarts it. Everything workerd writes to stdout or stderr appears in the server’s output with the prefix [emdash:workerd].

Plugin services listen on 127.0.0.1, and the channel back to the server is a Unix domain socket (a 127.0.0.1 TCP port on Windows). No inbound port needs to be opened.

The child process receives only PATH, HOME, TMPDIR, TMP, TEMP, LANG, and LC_ALL from the server’s environment, so secrets in the server’s environment stay out of the sandbox. To pass more variables, set EMDASH_WORKERD_PASSTHROUGH_ENV to a comma-separated list of variable names.

If workerd exits unexpectedly, the runner logs [emdash:workerd] workerd exited with <reason> and restarts it on the next invocation, with a delay that starts at 1 second and doubles up to 30 seconds. When workerd crashes more than five times within 60 seconds, the runner stops restarting it and logs [emdash:workerd] workerd crashed 5 times in 60 seconds, giving up. From then on, every sandboxed plugin hook and route fails with Plugin sandbox unavailable for <plugin>: workerd crashed 5 times in 60 seconds and the runner stopped retrying; restart the server. Restarting the server starts workerd again, as does installing or updating a plugin from the admin. A SIGTERM to the server terminates workerd with it.

Each runner applies the same set of limits per plugin invocation. The limits are fixed; the emdash() integration has no option for them.

LimitValueCloudflare WorkersNode.js
CPU time50 msEnforced by the Worker Loader; the plugin throws when it hits the limitNot enforced
Subrequests10Enforced by the Worker Loader; the plugin throws when it hits the limitNot enforced
Memory128 MBNot enforced per plugin; the platform’s isolate memory ceiling appliesNot enforced
Wall time30 sEnforced by the runnerEnforced by the runner

When a hook or route exceeds the wall-time limit, the invocation fails with Plugin <id> exceeded wall-time limit of 30000ms during hook:<name> (or route:<name>). For a hook, EmDash logs the failure with the prefix EmDash: Sandboxed plugin <id> and continues the request without that plugin’s result. A plugin route that exceeds the limit fails for its caller.

On Cloudflare Workers, sandbox() checks wrangler.jsonc at build time. Without a worker_loaders binding named LOADER, it leaves the runner unset and logs the following warning:

[emdash] Sandboxed plugins are disabled because wrangler.jsonc has no LOADER Worker Loader binding. Worker Loader requires a Workers paid plan.

A selected runner can still be unavailable at runtime: on Cloudflare Workers when the deployed LOADER binding or PluginBridge export is missing, and on Node.js when workerd is not installed or its binary does not run. EmDash then logs a warning with the cause the runner reports after the colon. The following warning is logged on Cloudflare Workers when the binding is missing:

EmDash: Plugin sandbox is configured but not available on this platform: the worker has no worker_loaders binding named LOADER. Sandboxed plugins will not be loaded.

Plugins under sandboxed: [] are not loaded, installed marketplace and registry plugins do not run, and a new install from the admin fails with the error code SANDBOX_NOT_AVAILABLE. The rest of the site is unaffected.

Set sandbox: false in emdash() to run the plugins under sandboxed: [] and installed marketplace plugins in the server process, without isolation or limits. It is a debugging option that tells a bug in a plugin from a bug in the sandbox. The following configuration turns the sandbox off on a Node.js site:

astro.config.mjs
emdash({
sandboxRunner: "@emdash-cms/sandbox-workerd/sandbox",
sandbox: false,
});

On Cloudflare Workers, the runtime refuses to start with sandbox: false is not supported in Cloudflare Workers.

Each entry is headed by the message as the server logs it, or by the error code the admin returns.

”[emdash] Sandboxed plugins are disabled because wrangler.jsonc has no LOADER Worker Loader binding”

Section titled “”[emdash] Sandboxed plugins are disabled because wrangler.jsonc has no LOADER Worker Loader binding””

The Cloudflare adapter did not select a sandbox runner because the build-time Wrangler config has no worker_loaders binding named LOADER. This is the expected configuration on the Workers free plan. On a Workers paid plan, enable the binding in wrangler.jsonc and rebuild the site.

”Plugin sandbox is configured but not available on this platform”

Section titled “”Plugin sandbox is configured but not available on this platform””

The text after the colon names the cause. On Cloudflare Workers, the worker has no worker_loaders binding named LOADER means wrangler.jsonc needs a worker_loaders binding named LOADER, and the worker entrypoint does not export PluginBridge means the file main points to must export PluginBridge. Deploying the binding needs the Workers Paid plan.

On Node.js, workerd is missing or its binary does not run on this platform means the runner could not run workerd. Run the installed binary directly so the check cannot download a missing package:

Terminal window
./node_modules/.bin/workerd --version

On Windows, run node_modules\\.bin\\workerd.cmd --version. If the command fails, workerd is missing from node_modules or the installed binary does not run on this platform. Reinstall on the target platform with optional dependencies enabled.

”workerd failed to start within 10 seconds”

Section titled “”workerd failed to start within 10 seconds””

The child process started, but its plugin services did not answer within 10 seconds. The lines prefixed [emdash:workerd] before this message carry the output of workerd itself, including configuration and startup errors. The runner retries on the next invocation.

”workerd crashed 5 times in 60 seconds, giving up”

Section titled “”workerd crashed 5 times in 60 seconds, giving up””

The runner has stopped restarting workerd. The [emdash:workerd] workerd exited with <reason> lines before this message name the exit code or signal of each crash. Fix the cause, then restart the server.

SANDBOX_NOT_AVAILABLE when installing a plugin

Section titled “SANDBOX_NOT_AVAILABLE when installing a plugin”

The admin’s install request was refused because the runner is missing or unavailable. When a runner is configured, the error message ends with the same cause as the startup warning above. Configure the runner for the platform, or fix that cause, and redeploy.