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 Workers | Node.js | |
|---|---|---|
sandboxRunner | sandbox() from @emdash-cms/cloudflare | "@emdash-cms/sandbox-workerd/sandbox" |
| Requirements | Workers Paid plan, a worker_loaders binding, PluginBridge exported from the Worker entry point | The workerd package |
| Database access | The DB D1 binding, independent of the configured adapter | The configured database |
| Enforced limits | CPU time, subrequests, wall time | Wall time |
Cloudflare Workers
Section titled “Cloudflare Workers”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.
-
Enable the Worker Loader binding in
wrangler.jsonc. The runner reads it under the nameLOADERand 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_ENVduring the Astro build. The Cloudflare Vite plugin andsandbox()then read the same environment. Bindings are not inherited, so addLOADERto each named environment that runs sandboxed plugins. -
Export
PluginBridgefrom the Worker entry point, and pointmainat that file.PluginBridgeis 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",} -
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(),});
Node.js
Section titled “Node.js”-
Install the runner together with
workerd, which is a peer dependency:Terminal window npm install @emdash-cms/sandbox-workerd workerdThe
workerdpackage 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. -
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.
How the workerd process runs
Section titled “How the workerd process runs”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.
Resource limits
Section titled “Resource limits”Each runner applies the same set of limits per plugin invocation. The limits are fixed; the emdash() integration has no option for them.
| Limit | Value | Cloudflare Workers | Node.js |
|---|---|---|---|
| CPU time | 50 ms | Enforced by the Worker Loader; the plugin throws when it hits the limit | Not enforced |
| Subrequests | 10 | Enforced by the Worker Loader; the plugin throws when it hits the limit | Not enforced |
| Memory | 128 MB | Not enforced per plugin; the platform’s isolate memory ceiling applies | Not enforced |
| Wall time | 30 s | Enforced by the runner | Enforced 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.
When the runner is unavailable
Section titled “When the runner is unavailable”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.
Running sandboxed plugins in-process
Section titled “Running sandboxed plugins in-process”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:
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.
Troubleshooting
Section titled “Troubleshooting”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:
./node_modules/.bin/workerd --versionOn 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.