diff --git a/.changeset/quiet-sandboxes-listen.md b/.changeset/quiet-sandboxes-listen.md
new file mode 100644
index 0000000000..38d22b8fd9
--- /dev/null
+++ b/.changeset/quiet-sandboxes-listen.md
@@ -0,0 +1,9 @@
+---
+"@emdash-cms/cloudflare": patch
+"create-emdash": patch
+"emdash": patch
+---
+
+New Cloudflare projects leave the paid-plan Worker Loader binding disabled so they can deploy on the Workers free plan. Enable sandboxed plugins in the scaffold prompt or with `--sandboxed-plugins`.
+
+The Cloudflare `sandbox()` helper now selects the runner from the `LOADER` binding in `wrangler.jsonc`, including the named environment selected with `CLOUDFLARE_ENV`. Without it, config-based sandboxed plugins do not load and marketplace or registry installs return `SANDBOX_NOT_AVAILABLE`, while browsing remains available.
diff --git a/docs/src/content/docs/deployment/plugin-sandbox.mdx b/docs/src/content/docs/deployment/plugin-sandbox.mdx
index ec54be8b07..1c27933eeb 100644
--- a/docs/src/content/docs/deployment/plugin-sandbox.mdx
+++ b/docs/src/content/docs/deployment/plugin-sandbox.mdx
@@ -7,7 +7,7 @@ import { Aside, Steps } from "@astrojs/starlight/components";
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](https://developers.cloudflare.com/dynamic-workers/) created through the Worker Loader binding. On Node.js, the server starts [`workerd`](https://github.com/cloudflare/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, the plugins under `sandboxed: []` are never loaded, and a configured `marketplace` fails the build with "Marketplace requires `sandboxRunner` to be configured".
+The runner depends on the deployment platform. On Cloudflare Workers, each plugin runs as a [Dynamic Worker](https://developers.cloudflare.com/dynamic-workers/) created through the Worker Loader binding. On Node.js, the server starts [`workerd`](https://github.com/cloudflare/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.
@@ -20,11 +20,11 @@ The following table summarizes what each runner needs and enforces.
## Cloudflare Workers
-Dynamic Workers are available on the [Workers Paid plan](https://developers.cloudflare.com/dynamic-workers/pricing/). The runner needs the binding and the entry point export below; the `*-cloudflare` templates ship both.
+Dynamic Workers are available on the [Workers Paid plan](https://developers.cloudflare.com/dynamic-workers/pricing/). 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. Add the Worker Loader binding to `wrangler.jsonc`. The runner reads it under the name `LOADER`:
+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:
```jsonc title="wrangler.jsonc"
{
@@ -36,6 +36,8 @@ Dynamic Workers are available on the [Workers Paid plan](https://developers.clou
}
```
+ 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:
```ts title="src/worker.ts"
@@ -96,16 +98,10 @@ Dynamic Workers are available on the [Workers Paid plan](https://developers.clou
});
```
-3. For development, install `miniflare` as a dev dependency:
-
- ```bash
- npm install -D miniflare
- ```
-
- When `NODE_ENV` is `development`, which `astro dev` sets, and `miniflare` is installed, the runner hands the plugins to Miniflare, which manages its own `workerd` process; the crash policy below does not apply. `astro preview` sets `NODE_ENV` to `production` and `node ./dist/server/entry.mjs` leaves it unset; both use `workerd`.
-
+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
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]`.
@@ -131,7 +127,13 @@ When a hook or route exceeds the wall-time limit, the invocation fails with `Plu
## When the runner is unavailable
-A configured runner can still be unavailable: on Cloudflare Workers when the `worker_loaders` binding or the `PluginBridge` export is missing, on Node.js when `workerd` is not installed or its binary does not run. EmDash then logs a warning when the runtime starts, with the cause the runner reports after the colon. The following warning is logged on Cloudflare Workers when the binding is missing:
+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:
+
+```plain
+[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:
```plain
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.
@@ -160,17 +162,21 @@ On Cloudflare Workers, the runtime refuses to start with `sandbox: false is not
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"
+
+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"
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`. The following command runs the binary the runner uses:
+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:
```bash
-npx workerd --version
+./node_modules/.bin/workerd --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.
+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"
diff --git a/docs/src/content/docs/plugins/installing.mdx b/docs/src/content/docs/plugins/installing.mdx
index e4e6eb463f..8fbbaeccbb 100644
--- a/docs/src/content/docs/plugins/installing.mdx
+++ b/docs/src/content/docs/plugins/installing.mdx
@@ -34,7 +34,9 @@ export default defineConfig({
});
```
-The Cloudflare runner uses Worker Loader to create a separate Worker for each plugin. It requires the Workers Paid plan and a `worker_loaders` binding named `LOADER`. Sandboxed plugins reach content, media, storage, network, and email APIs through `PluginBridge`, so the site's Worker entry point must export that class. The `*-cloudflare` templates include the binding and export. Follow the [Cloudflare sandbox setup](/deployment/plugin-sandbox/#cloudflare-workers) when adding the runner to an existing site.
+The Cloudflare runner uses Worker Loader to create a separate Worker for each plugin. It requires the Workers Paid plan and a `worker_loaders` binding named `LOADER`. Sandboxed plugins reach content, media, storage, network, and email APIs through `PluginBridge`, so the site's Worker entry point must export that class.
+
+The `*-cloudflare` templates export `PluginBridge` but leave the Worker Loader binding commented out, so new projects can deploy on the Workers free plan. Enable sandboxed plugins during scaffolding or follow the [Cloudflare sandbox setup](/deployment/plugin-sandbox/#cloudflare-workers) to add the binding later. The `sandbox()` helper reads the effective Wrangler config at build time. Without `LOADER`, registry browsing remains available, but config-managed sandboxed plugins do not load and install or update requests return `SANDBOX_NOT_AVAILABLE`.
On Node.js, install the runner and its `workerd` peer dependency:
diff --git a/docs/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx
index fa515951af..1c7d047ae8 100644
--- a/docs/src/content/docs/reference/configuration.mdx
+++ b/docs/src/content/docs/reference/configuration.mdx
@@ -622,7 +622,7 @@ In every mode, the toolbar can be dismissed in the browser via its × button (pe
#### `experimental.registry`
-**Optional.** Use the experimental [plugin registry](/plugins/registry/) as the admin dashboard's source for browsing and installing plugins instead of the marketplace. Registry plugins run sandboxed, so this option requires a [`sandboxRunner`](/deployment/plugin-sandbox/).
+**Optional.** Use the experimental [plugin registry](/plugins/registry/) as the admin dashboard's source for browsing and installing plugins instead of the marketplace. Browsing works without a [`sandboxRunner`](/deployment/plugin-sandbox/), but installing or updating a registry plugin requires an available runner because registry plugins run sandboxed.
Pass the registry service URL as a string, or use an object when the site needs moderation sources or a release-age policy. The following example uses the object form:
diff --git a/e2e/tests/content-actions.spec.ts b/e2e/tests/content-actions.spec.ts
index b3316b1517..fb73cf9232 100644
--- a/e2e/tests/content-actions.spec.ts
+++ b/e2e/tests/content-actions.spec.ts
@@ -338,7 +338,7 @@ test.describe("Schedule content", () => {
const lineCount = (target: Element) => {
const range = document.createRange();
range.selectNodeContents(target);
- return range.getClientRects().length;
+ return new Set(Array.from(range.getClientRects(), (rect) => Math.round(rect.top))).size;
};
return {
height: element.getBoundingClientRect().height,
diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json
index a2833b0267..498694a86d 100644
--- a/packages/cloudflare/package.json
+++ b/packages/cloudflare/package.json
@@ -126,7 +126,8 @@
"astro": ">=6.0.0-beta.0",
"kysely": ">=0.28.17",
"pg": ">=8.16.3",
- "react": "^18.0.0 || ^19.0.0"
+ "react": "^18.0.0 || ^19.0.0",
+ "wrangler": ">=4.99.0"
},
"peerDependenciesMeta": {
"pg": {
@@ -154,7 +155,8 @@
"react": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
- "vitest": "catalog:"
+ "vitest": "catalog:",
+ "wrangler": "catalog:"
},
"repository": {
"type": "git",
diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts
index 8ab6e8b01e..151955a8db 100644
--- a/packages/cloudflare/src/index.ts
+++ b/packages/cloudflare/src/index.ts
@@ -39,6 +39,7 @@ import type {
ObjectCacheDescriptor,
StorageDescriptor,
} from "emdash";
+import { unstable_readConfig } from "wrangler";
import type { DurableObjectsConfig } from "./db/do-sql-types.js";
import type { PreviewDOConfig } from "./db/do-types.js";
@@ -529,15 +530,31 @@ export function access(config: AccessConfig): AuthDescriptor {
/**
* Cloudflare Worker Loader sandbox adapter
*
- * Returns the module path for the Cloudflare sandbox runner.
- * Use this in the `sandboxRunner` config option.
+ * Returns the module path for the Cloudflare sandbox runner when the project's
+ * Wrangler config includes the `LOADER` Worker Loader binding. Without that
+ * paid-plan binding, sandboxed plugins are disabled at build time.
*
* @example
* ```ts
* sandboxRunner: sandbox()
* ```
*/
-export function sandbox(): string {
+export function sandbox(): string | undefined {
+ const environment = process.env.CLOUDFLARE_ENV;
+ const config = unstable_readConfig(environment ? { env: environment } : {}, {
+ hideWarnings: true,
+ });
+ const hasWorkerLoader = config.worker_loaders?.some(
+ (loader: { binding?: string }) => loader.binding === "LOADER",
+ );
+
+ if (!hasWorkerLoader) {
+ console.warn(
+ "[emdash] Sandboxed plugins are disabled because wrangler.jsonc has no LOADER Worker Loader binding. Worker Loader requires a Workers paid plan.",
+ );
+ return undefined;
+ }
+
return "@emdash-cms/cloudflare/sandbox";
}
diff --git a/packages/cloudflare/tests/sandbox-config.test.ts b/packages/cloudflare/tests/sandbox-config.test.ts
new file mode 100644
index 0000000000..180540335a
--- /dev/null
+++ b/packages/cloudflare/tests/sandbox-config.test.ts
@@ -0,0 +1,57 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const { readConfig } = vi.hoisted(() => ({
+ readConfig: vi.fn(),
+}));
+
+vi.mock("wrangler", () => ({
+ unstable_readConfig: readConfig,
+}));
+
+import { sandbox } from "../src/index.js";
+
+describe("sandbox", () => {
+ beforeEach(() => {
+ readConfig.mockReset();
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("returns the Cloudflare sandbox runner when the LOADER binding is configured", () => {
+ readConfig.mockReturnValue({ worker_loaders: [{ binding: "LOADER" }] });
+
+ expect(sandbox()).toBe("@emdash-cms/cloudflare/sandbox");
+ expect(readConfig).toHaveBeenCalledWith({}, { hideWarnings: true });
+ });
+
+ it("disables sandboxed plugins when Worker Loader is not configured", () => {
+ readConfig.mockReturnValue({ worker_loaders: undefined });
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+ expect(sandbox()).toBeUndefined();
+ expect(warn).toHaveBeenCalledWith(
+ "[emdash] Sandboxed plugins are disabled because wrangler.jsonc has no LOADER Worker Loader binding. Worker Loader requires a Workers paid plan.",
+ );
+
+ warn.mockRestore();
+ });
+
+ it("requires the binding name used by the runtime", () => {
+ readConfig.mockReturnValue({ worker_loaders: [{ binding: "OTHER_LOADER" }] });
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+ expect(sandbox()).toBeUndefined();
+
+ warn.mockRestore();
+ });
+
+ it("reads the named environment selected by the Cloudflare Vite plugin", () => {
+ vi.stubEnv("CLOUDFLARE_ENV", "production");
+ readConfig.mockReturnValue({ worker_loaders: [{ binding: "LOADER" }] });
+
+ expect(sandbox()).toBe("@emdash-cms/cloudflare/sandbox");
+ expect(readConfig).toHaveBeenCalledWith({ env: "production" }, { hideWarnings: true });
+ });
+});
diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts
index 6ab8564883..721a514a63 100644
--- a/packages/core/src/astro/integration/index.ts
+++ b/packages/core/src/astro/integration/index.ts
@@ -346,12 +346,6 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
}
throw e;
}
- if (!resolvedConfig.sandboxRunner) {
- throw new Error(
- "Marketplace requires `sandboxRunner` to be configured. " +
- "Marketplace plugins run in sandboxed V8 isolates.",
- );
- }
}
// Validate siteUrl if provided in astro.config.mjs.
diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts
index da546bef6c..566ac7e5e4 100644
--- a/packages/core/src/astro/integration/runtime.ts
+++ b/packages/core/src/astro/integration/runtime.ts
@@ -362,7 +362,8 @@ export interface EmDashConfig {
* and uninstall plugins from a remote marketplace.
*
* Must be an HTTPS URL in production, or localhost/127.0.0.1 in dev.
- * Requires `sandboxRunner` to be configured (marketplace plugins run sandboxed).
+ * Installing or updating plugins requires an available `sandboxRunner`.
+ * Browsing remains available when no runner is configured.
*
* When `registry` is also configured, the registry replaces the marketplace
* for the admin UI's browse and install flows. Existing marketplace-installed
diff --git a/packages/core/tests/unit/astro/integration/marketplace-sandbox.test.ts b/packages/core/tests/unit/astro/integration/marketplace-sandbox.test.ts
new file mode 100644
index 0000000000..bd317f6fd7
--- /dev/null
+++ b/packages/core/tests/unit/astro/integration/marketplace-sandbox.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vitest";
+
+import emdash from "../../../../src/astro/integration/index.js";
+
+describe("marketplace without a sandbox runner", () => {
+ it("allows the marketplace to remain available when sandboxed plugins are disabled", () => {
+ expect(() =>
+ emdash({
+ marketplace: "https://marketplace.emdashcms.com",
+ sandboxRunner: undefined,
+ }),
+ ).not.toThrow();
+ });
+});
diff --git a/packages/create-emdash/src/flags.ts b/packages/create-emdash/src/flags.ts
index 38924df3f0..616901835e 100644
--- a/packages/create-emdash/src/flags.ts
+++ b/packages/create-emdash/src/flags.ts
@@ -18,6 +18,12 @@ export interface ParsedFlags {
packageManager?: PackageManager;
/** `--install` / `--no-install`. Undefined means "ask". */
install?: boolean;
+ /**
+ * `--sandboxed-plugins` / `--no-sandboxed-plugins`. Undefined means
+ * "ask" on Cloudflare. Sandboxed plugins require Worker Loader, which is
+ * available on Workers paid plans.
+ */
+ sandboxedPlugins?: boolean;
/** `--yes` — auto-accept remaining defaults and skip overwrite prompts. */
yes: boolean;
/**
@@ -100,6 +106,8 @@ export function parseFlags(argv: string[]): ParsedFlags {
"package-manager": { type: "string" },
install: { type: "boolean" },
"no-install": { type: "boolean" },
+ "sandboxed-plugins": { type: "boolean" },
+ "no-sandboxed-plugins": { type: "boolean" },
yes: { type: "boolean", short: "y" },
force: { type: "boolean" },
help: { type: "boolean", short: "h" },
@@ -222,6 +230,12 @@ export function parseFlags(argv: string[]): ParsedFlags {
if (values.install === true) flags.install = true;
if (values["no-install"] === true) flags.install = false;
+ if (values["sandboxed-plugins"] === true && values["no-sandboxed-plugins"] === true) {
+ throw new FlagError(`--sandboxed-plugins and --no-sandboxed-plugins cannot both be set.`);
+ }
+ if (values["sandboxed-plugins"] === true) flags.sandboxedPlugins = true;
+ if (values["no-sandboxed-plugins"] === true) flags.sandboxedPlugins = false;
+
return flags;
}
@@ -260,6 +274,9 @@ Options:
--package-manager Alias of --pm
--install Install dependencies after scaffolding
--no-install Skip dependency install
+ --sandboxed-plugins Enable sandboxed plugins on Cloudflare (requires
+ Worker Loader, available on Workers paid plans)
+ --no-sandboxed-plugins Leave sandboxed plugins disabled (default)
-y, --yes Accept defaults; skip confirmation prompts
--force Allow overwriting a non-empty target dir
(required with --yes when the target is non-empty)
diff --git a/packages/create-emdash/src/index.ts b/packages/create-emdash/src/index.ts
index 2931acd39c..69e9075079 100644
--- a/packages/create-emdash/src/index.ts
+++ b/packages/create-emdash/src/index.ts
@@ -27,7 +27,13 @@ import {
validateProjectName,
wantsHelp,
} from "./flags.js";
-import { isDirNonEmpty, runCommand, sanitizePackageName, writeEncryptionKey } from "./utils.js";
+import {
+ isDirNonEmpty,
+ runCommand,
+ sanitizePackageName,
+ setWorkerLoader,
+ writeEncryptionKey,
+} from "./utils.js";
const GITHUB_REPO = "emdash-cms/templates";
@@ -296,6 +302,26 @@ async function resolveShouldInstall(flags: ParsedFlags): Promise {
return shouldInstall;
}
+/**
+ * Resolve the Cloudflare-only sandboxed-plugins capability. It defaults off
+ * because Worker Loader is only available on Workers paid plans.
+ */
+async function resolveSandboxedPlugins(flags: ParsedFlags, platform: Platform): Promise {
+ if (platform !== "cloudflare") return false;
+ if (flags.sandboxedPlugins !== undefined) return flags.sandboxedPlugins;
+ if (flags.yes) return false;
+
+ const enabled = await p.confirm({
+ message: "Enable sandboxed plugins? (Requires Worker Loader, available on Workers paid plans)",
+ initialValue: false,
+ });
+ if (p.isCancel(enabled)) {
+ p.cancel("Operation cancelled.");
+ process.exit(0);
+ }
+ return enabled;
+}
+
async function main() {
// Short-circuit --help before strict parsing so a user typing
// `npm create emdash@latest --help --template nope` gets the help they
@@ -334,6 +360,7 @@ async function main() {
const templateConfig = getTemplateConfig(platform, templateKey);
const pm = await resolvePackageManager(flags);
const shouldInstall = await resolveShouldInstall(flags);
+ const enableSandboxedPlugins = await resolveSandboxedPlugins(flags, platform);
const installCmd = `${pm} install`;
const runCmd = (script: string) => (pm === "npm" ? `npm run ${script}` : `${pm} ${script}`);
@@ -381,6 +408,7 @@ async function main() {
const secretsFile = ".env";
const keyResult = writeEncryptionKey(projectDir, secretsFile);
ensureGitignored(projectDir, secretsFile);
+ const loaderResult = setWorkerLoader(projectDir, enableSandboxedPlugins);
s.stop("Project created!");
@@ -405,6 +433,16 @@ async function main() {
p.log.info(`Wrote ${pc.cyan("EMDASH_ENCRYPTION_KEY")} to ${pc.cyan(secretsFile)}.`);
}
+ if (loaderResult === "enabled") {
+ p.log.info(
+ `Enabled sandboxed plugins (${pc.cyan("worker_loaders")} in ${pc.cyan("wrangler.jsonc")}; requires a Workers paid plan).`,
+ );
+ } else if (loaderResult === "disabled") {
+ p.log.info(
+ `Sandboxed plugins are disabled. Uncomment ${pc.cyan("worker_loaders")} in ${pc.cyan("wrangler.jsonc")} to enable them later on a Workers paid plan.`,
+ );
+ }
+
if (shouldInstall) {
p.log.info(`Installing dependencies with ${pc.cyan(pm)}...`);
try {
diff --git a/packages/create-emdash/src/utils.ts b/packages/create-emdash/src/utils.ts
index 5d0480c292..5ab991f0fe 100644
--- a/packages/create-emdash/src/utils.ts
+++ b/packages/create-emdash/src/utils.ts
@@ -106,3 +106,55 @@ export function isDirNonEmpty(dir: string): boolean {
export function parseTargetArg(argv: string[]): string | undefined {
return argv.slice(2).find((a) => !a.startsWith("-"));
}
+
+const WORKER_LOADER_LINE = `"worker_loaders": [{ "binding": "LOADER" }],`;
+const WORKER_LOADER_COMMENT =
+ "Sandboxed plugins require Worker Loader, available on Workers paid plans.";
+const WORKER_LOADER_DECL = /^(\s*)(?:\/\/\s*)?"worker_loaders"\s*:/;
+const WORKER_LOADER_OWN_COMMENT = /^\s*\/\/.*worker loader/i;
+const NEWLINE_SPLIT = /\r?\n/;
+
+/**
+ * Enable or disable Worker Loader in a project's `wrangler.jsonc`.
+ *
+ * Older templates used a multi-line active block; current templates use a
+ * canonical commented line so they deploy on the Workers free plan. Normalize
+ * both shapes to keep scaffolding idempotent across the template rollout.
+ */
+export function setWorkerLoader(
+ projectDir: string,
+ enabled: boolean,
+): "enabled" | "disabled" | "absent" {
+ const target = resolve(projectDir, "wrangler.jsonc");
+ if (!existsSync(target)) return "absent";
+
+ const original = readFileSync(target, "utf-8");
+ const newline = original.includes("\r\n") ? "\r\n" : "\n";
+ const lines = original.split(NEWLINE_SPLIT);
+ const declarationIndex = lines.findIndex((line) => WORKER_LOADER_DECL.test(line));
+ if (declarationIndex === -1) return "absent";
+
+ let endIndex = declarationIndex;
+ if (!lines[declarationIndex].includes("]")) {
+ while (endIndex < lines.length - 1 && !lines[endIndex].trim().startsWith("]")) {
+ endIndex++;
+ }
+ }
+
+ let startIndex = declarationIndex;
+ if (declarationIndex > 0 && WORKER_LOADER_OWN_COMMENT.test(lines[declarationIndex - 1])) {
+ startIndex = declarationIndex - 1;
+ }
+
+ const indent = WORKER_LOADER_DECL.exec(lines[declarationIndex])?.[1] ?? "\t";
+ const replacement = enabled
+ ? [`${indent}// ${WORKER_LOADER_COMMENT}`, `${indent}${WORKER_LOADER_LINE}`]
+ : [
+ `${indent}// ${WORKER_LOADER_COMMENT} Uncomment to enable:`,
+ `${indent}// ${WORKER_LOADER_LINE}`,
+ ];
+
+ lines.splice(startIndex, endIndex - startIndex + 1, ...replacement);
+ writeFileSync(target, lines.join(newline));
+ return enabled ? "enabled" : "disabled";
+}
diff --git a/packages/create-emdash/tests/flags.test.ts b/packages/create-emdash/tests/flags.test.ts
index 383a889962..fd4ac2039a 100644
--- a/packages/create-emdash/tests/flags.test.ts
+++ b/packages/create-emdash/tests/flags.test.ts
@@ -218,6 +218,26 @@ describe("parseFlags — install toggle", () => {
});
});
+describe("parseFlags — sandboxed-plugins toggle", () => {
+ it("--sandboxed-plugins enables sandboxed plugins", () => {
+ expect(parseFlags(argv("--sandboxed-plugins")).sandboxedPlugins).toBe(true);
+ });
+
+ it("--no-sandboxed-plugins disables sandboxed plugins", () => {
+ expect(parseFlags(argv("--no-sandboxed-plugins")).sandboxedPlugins).toBe(false);
+ });
+
+ it("leaves sandboxedPlugins undefined when neither flag is passed", () => {
+ expect(parseFlags(argv()).sandboxedPlugins).toBeUndefined();
+ });
+
+ it("rejects conflicting sandboxed-plugin flags", () => {
+ expect(() => parseFlags(argv("--sandboxed-plugins", "--no-sandboxed-plugins"))).toThrow(
+ FlagError,
+ );
+ });
+});
+
describe("parseFlags — --yes / -y", () => {
it("--yes sets yes: true", () => {
expect(parseFlags(argv("--yes")).yes).toBe(true);
@@ -353,6 +373,8 @@ describe("HELP_TEXT", () => {
"--package-manager",
"--install",
"--no-install",
+ "--sandboxed-plugins",
+ "--no-sandboxed-plugins",
"--yes",
"--force",
"--help",
diff --git a/packages/create-emdash/tests/utils.test.ts b/packages/create-emdash/tests/utils.test.ts
index 5b430ebea9..60d8f934a4 100644
--- a/packages/create-emdash/tests/utils.test.ts
+++ b/packages/create-emdash/tests/utils.test.ts
@@ -10,6 +10,7 @@ import {
isDirNonEmpty,
parseTargetArg,
sanitizePackageName,
+ setWorkerLoader,
writeEncryptionKey,
} from "../src/utils.js";
@@ -303,3 +304,72 @@ describe("writeEncryptionKey", () => {
expect(content.endsWith("\n")).toBe(true);
});
});
+
+describe("setWorkerLoader", () => {
+ let tempDir: string;
+ const fileName = "wrangler.jsonc";
+ const legacy = `{
+ // Worker Loader for plugin sandboxing
+ "worker_loaders": [
+ {
+ "binding": "LOADER",
+ },
+ ],
+ "triggers": { "crons": ["* * * * *"] },
+}
+`;
+ const commented = `{
+ // Sandboxed plugins require Worker Loader, available on Workers paid plans. Uncomment to enable:
+ // "worker_loaders": [{ "binding": "LOADER" }],
+ "triggers": { "crons": ["* * * * *"] },
+}
+`;
+
+ beforeEach(() => {
+ tempDir = mkdtempSync(join(tmpdir(), "create-emdash-loader-"));
+ });
+
+ afterEach(() => {
+ rmSync(tempDir, { recursive: true, force: true });
+ });
+
+ function write(content: string): void {
+ writeFileSync(join(tempDir, fileName), content);
+ }
+
+ function read(): string {
+ return readFileSync(join(tempDir, fileName), "utf-8");
+ }
+
+ it("does nothing when wrangler.jsonc is absent", () => {
+ expect(setWorkerLoader(tempDir, true)).toBe("absent");
+ expect(setWorkerLoader(tempDir, false)).toBe("absent");
+ });
+
+ it("disables the legacy multi-line binding", () => {
+ write(legacy);
+
+ expect(setWorkerLoader(tempDir, false)).toBe("disabled");
+ expect(read()).not.toMatch(/^\s*"worker_loaders"\s*:/m);
+ expect(read()).toMatch(/^\s*\/\/\s*"worker_loaders"\s*:/m);
+ expect(read()).toContain(`"crons": ["* * * * *"]`);
+ });
+
+ it("enables the canonical commented binding", () => {
+ write(commented);
+
+ expect(setWorkerLoader(tempDir, true)).toBe("enabled");
+ expect(read()).toMatch(/^\s*"worker_loaders"\s*:/m);
+ expect(read()).not.toMatch(/^\s*\/\/\s*"worker_loaders"\s*:/m);
+ });
+
+ it("round-trips without duplicating the declaration", () => {
+ write(commented);
+
+ setWorkerLoader(tempDir, true);
+ setWorkerLoader(tempDir, false);
+
+ expect(read().match(/worker_loaders/g)).toHaveLength(1);
+ expect(read().endsWith("\n")).toBe(true);
+ });
+});
diff --git a/packages/workerd/src/sandbox/runner.ts b/packages/workerd/src/sandbox/runner.ts
index bdf0244781..f72c75827f 100644
--- a/packages/workerd/src/sandbox/runner.ts
+++ b/packages/workerd/src/sandbox/runner.ts
@@ -1087,15 +1087,16 @@ class WorkerdSandboxedPlugin implements SandboxedPluginInstance {
* Factory function for creating the workerd sandbox runner.
*
* Selects MiniflareDevRunner only when explicitly in development mode
- * (NODE_ENV === "development"). Any other value — including unset (which
- * is the default for `node server.js` and `astro preview` on self-hosted
- * deployments) — uses the production WorkerdSandboxRunner.
+ * (NODE_ENV === "development"). Any other value — including unset for
+ * `node server.js` and `"production"` from `astro preview` — uses the
+ * production WorkerdSandboxRunner.
*
* The dev runner skips production hardening (wall-time wrapper, child
* process supervision, crash/restart with backoff), so falling back to
* it silently in production would be a security regression.
*
- * Operators who want the dev runner explicitly should set NODE_ENV=development.
+ * Operators who want the dev runner explicitly should set EMDASH_SANDBOX_DEV=1
+ * or NODE_ENV=development.
*/
export const createSandboxRunner: SandboxRunnerFactory = (options) => {
const isDev = process.env.EMDASH_SANDBOX_DEV === "1" || process.env.NODE_ENV === "development";
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e7c536a86f..0047eb3a50 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1952,6 +1952,9 @@ importers:
vitest:
specifier: 'catalog:'
version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))
+ wrangler:
+ specifier: 'catalog:'
+ version: 4.124.0(@cloudflare/workers-types@5.20260906.1)
packages/contentful-to-portable-text:
dependencies:
diff --git a/templates/blog-cloudflare/wrangler.jsonc b/templates/blog-cloudflare/wrangler.jsonc
index cd8fd49e24..8590566f01 100644
--- a/templates/blog-cloudflare/wrangler.jsonc
+++ b/templates/blog-cloudflare/wrangler.jsonc
@@ -16,12 +16,8 @@
"bucket_name": "my-emdash-media",
},
],
- // Worker Loader for plugin sandboxing
- "worker_loaders": [
- {
- "binding": "LOADER",
- },
- ],
+ // Sandboxed plugins require Worker Loader, available on Workers paid plans. Uncomment to enable:
+ // "worker_loaders": [{ "binding": "LOADER" }],
// General maintenance cron trigger
"triggers": {
"crons": ["* * * * *"],
diff --git a/templates/marketing-cloudflare/wrangler.jsonc b/templates/marketing-cloudflare/wrangler.jsonc
index fcc837d63f..e6397e8a28 100644
--- a/templates/marketing-cloudflare/wrangler.jsonc
+++ b/templates/marketing-cloudflare/wrangler.jsonc
@@ -16,12 +16,8 @@
"bucket_name": "my-marketing-media",
},
],
- // Worker Loader for plugin sandboxing
- "worker_loaders": [
- {
- "binding": "LOADER",
- },
- ],
+ // Sandboxed plugins require Worker Loader, available on Workers paid plans. Uncomment to enable:
+ // "worker_loaders": [{ "binding": "LOADER" }],
// General maintenance cron trigger
"triggers": {
"crons": ["* * * * *"],
diff --git a/templates/portfolio-cloudflare/wrangler.jsonc b/templates/portfolio-cloudflare/wrangler.jsonc
index ebc2105082..377d4a85b0 100644
--- a/templates/portfolio-cloudflare/wrangler.jsonc
+++ b/templates/portfolio-cloudflare/wrangler.jsonc
@@ -16,12 +16,8 @@
"bucket_name": "my-portfolio-media",
},
],
- // Worker Loader for plugin sandboxing
- "worker_loaders": [
- {
- "binding": "LOADER",
- },
- ],
+ // Sandboxed plugins require Worker Loader, available on Workers paid plans. Uncomment to enable:
+ // "worker_loaders": [{ "binding": "LOADER" }],
// General maintenance cron trigger
"triggers": {
"crons": ["* * * * *"],
diff --git a/templates/starter-cloudflare/wrangler.jsonc b/templates/starter-cloudflare/wrangler.jsonc
index cd8fd49e24..8590566f01 100644
--- a/templates/starter-cloudflare/wrangler.jsonc
+++ b/templates/starter-cloudflare/wrangler.jsonc
@@ -16,12 +16,8 @@
"bucket_name": "my-emdash-media",
},
],
- // Worker Loader for plugin sandboxing
- "worker_loaders": [
- {
- "binding": "LOADER",
- },
- ],
+ // Sandboxed plugins require Worker Loader, available on Workers paid plans. Uncomment to enable:
+ // "worker_loaders": [{ "binding": "LOADER" }],
// General maintenance cron trigger
"triggers": {
"crons": ["* * * * *"],