diff --git a/.changeset/create-emdash-free-tier-dynamic-plugins.md b/.changeset/create-emdash-free-tier-dynamic-plugins.md
new file mode 100644
index 0000000000..edd50ef1ac
--- /dev/null
+++ b/.changeset/create-emdash-free-tier-dynamic-plugins.md
@@ -0,0 +1,5 @@
+---
+"create-emdash": patch
+---
+
+Scaffolds Cloudflare projects that deploy on the Workers free tier out of the box. The Worker Loader binding — needed only for dynamic plugins, which require the Workers paid plan — now ships commented out. Opt in by answering "yes" to the new dynamic-plugins prompt, or pass `--dynamic-plugins` (use `--no-dynamic-plugins` to keep it off non-interactively).
diff --git a/.changeset/gate-dynamic-plugins-sandbox.md b/.changeset/gate-dynamic-plugins-sandbox.md
new file mode 100644
index 0000000000..5e6ec2e77d
--- /dev/null
+++ b/.changeset/gate-dynamic-plugins-sandbox.md
@@ -0,0 +1,5 @@
+---
+"emdash": patch
+---
+
+Gates the admin marketplace and registry screens behind sandbox availability. On a deployment with no sandbox runner — for example a Cloudflare free-tier site without the Worker Loader binding — the browse and install views are replaced with a prompt explaining that dynamic plugins need Worker Loader (a Workers paid-plan feature) and how to enable it, instead of letting an install fail with a 503. The manifest now reports a `sandboxAvailable` flag.
diff --git a/docs/src/content/docs/plugins/installing.mdx b/docs/src/content/docs/plugins/installing.mdx
index 975fb0c261..be96912f89 100644
--- a/docs/src/content/docs/plugins/installing.mdx
+++ b/docs/src/content/docs/plugins/installing.mdx
@@ -19,9 +19,14 @@ The admin dashboard includes a marketplace browser where you can search, install
To install marketplace plugins, your site needs:
-1. **Sandbox runner configured** — Marketplace plugins run in an isolated runtime, which requires the sandbox runner. The following configuration enables it:
+1. **Sandbox runner configured** — Marketplace plugins run in an isolated runtime. Configure the runner for your deployment platform:
+
+ **Cloudflare Workers**
+
+ Import `sandbox` from the Cloudflare adapter and pass it to EmDash:
```typescript title="astro.config.mjs"
+ import { sandbox } from "@emdash-cms/cloudflare";
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
@@ -29,16 +34,28 @@ To install marketplace plugins, your site needs:
integrations: [
emdash({
marketplace: "https://marketplace.emdashcms.com",
- sandboxRunner: "@emdash-cms/sandbox-cloudflare",
+ sandboxRunner: sandbox(),
}),
],
});
```
- On **Cloudflare Workers**, sandboxing uses the Dynamic Worker Loader API (no additional setup needed). On **Node.js**, install the workerd sandbox runner:
+ The Cloudflare runner uses Worker Loader, which requires the Workers paid plan. Add its binding to Wrangler and redeploy:
+
+ ```jsonc title="wrangler.jsonc"
+ {
+ "worker_loaders": [{ "binding": "LOADER" }]
+ }
+ ```
+
+ Without the binding, the runner reports as unavailable and the admin cannot install dynamic plugins.
+
+ **Node.js**
+
+ Install the workerd sandbox runner and its workerd peer dependency:
```bash
- npm install @emdash-cms/sandbox-workerd
+ npm install @emdash-cms/sandbox-workerd workerd
```
Then pass the runner explicitly:
diff --git a/e2e/fixture-cloudflare/noop-sandbox.mjs b/e2e/fixture-cloudflare/noop-sandbox.mjs
index d3257ab4c4..80b2cb86bf 100644
--- a/e2e/fixture-cloudflare/noop-sandbox.mjs
+++ b/e2e/fixture-cloudflare/noop-sandbox.mjs
@@ -1,10 +1,18 @@
/**
* Noop sandbox runner for e2e tests.
*
- * The marketplace admin pages only need `marketplace: true` in the manifest
- * to render browse/detail UI. The sandbox runner is only used at install time.
- * This stub satisfies the config validation without importing cloudflare:workers.
+ * The marketplace admin pages need an available sandbox in the manifest to
+ * render browse/detail UI. The sandbox runner is only used at install time.
+ * This stub satisfies the availability gate without executing plugin code.
*/
-import { createNoopSandboxRunner } from "emdash";
+import { NoopSandboxRunner } from "emdash";
-export { createNoopSandboxRunner as createSandboxRunner };
+class MarketplaceTestSandboxRunner extends NoopSandboxRunner {
+ isAvailable() {
+ return true;
+ }
+}
+
+export function createSandboxRunner() {
+ return new MarketplaceTestSandboxRunner();
+}
diff --git a/e2e/fixture/noop-sandbox.mjs b/e2e/fixture/noop-sandbox.mjs
index d3257ab4c4..80b2cb86bf 100644
--- a/e2e/fixture/noop-sandbox.mjs
+++ b/e2e/fixture/noop-sandbox.mjs
@@ -1,10 +1,18 @@
/**
* Noop sandbox runner for e2e tests.
*
- * The marketplace admin pages only need `marketplace: true` in the manifest
- * to render browse/detail UI. The sandbox runner is only used at install time.
- * This stub satisfies the config validation without importing cloudflare:workers.
+ * The marketplace admin pages need an available sandbox in the manifest to
+ * render browse/detail UI. The sandbox runner is only used at install time.
+ * This stub satisfies the availability gate without executing plugin code.
*/
-import { createNoopSandboxRunner } from "emdash";
+import { NoopSandboxRunner } from "emdash";
-export { createNoopSandboxRunner as createSandboxRunner };
+class MarketplaceTestSandboxRunner extends NoopSandboxRunner {
+ isAvailable() {
+ return true;
+ }
+}
+
+export function createSandboxRunner() {
+ return new MarketplaceTestSandboxRunner();
+}
diff --git a/packages/admin/src/components/DynamicPluginsUnavailable.tsx b/packages/admin/src/components/DynamicPluginsUnavailable.tsx
new file mode 100644
index 0000000000..87a614f291
--- /dev/null
+++ b/packages/admin/src/components/DynamicPluginsUnavailable.tsx
@@ -0,0 +1,50 @@
+/**
+ * Dynamic Plugins Unavailable
+ *
+ * Shown in place of the marketplace / registry browse UI when the deployment
+ * has no available sandbox runner (`manifest.sandboxAvailable === false`).
+ * Rather than let the user browse and hit an error at install time, direct
+ * them to the platform-specific setup instructions.
+ */
+
+import { LinkButton } from "@cloudflare/kumo";
+import { Trans, useLingui } from "@lingui/react/macro";
+import { ArrowSquareOut, ShieldWarning } from "@phosphor-icons/react";
+
+/** Docs page covering sandbox runner setup (Cloudflare Worker Loader + Node workerd). */
+const INSTALL_DOCS_URL = "https://docs.emdashcms.com/plugins/installing/";
+
+export function DynamicPluginsUnavailable() {
+ const { t } = useLingui();
+
+ return (
+
+
+
+
+
+
+
+ Dynamic plugins aren't available on this deployment
+
+
+
+
+ Installing plugins at runtime requires an available sandbox runner. Configure one for
+ your deployment platform and redeploy to enable dynamic plugins.
+
+
+
+
}
+ className="mt-4"
+ >
+ {t`Learn how to enable dynamic plugins`}
+
+
+
+ );
+}
diff --git a/packages/admin/src/lib/api/client.ts b/packages/admin/src/lib/api/client.ts
index 9b0ff18ef2..e40437df01 100644
--- a/packages/admin/src/lib/api/client.ts
+++ b/packages/admin/src/lib/api/client.ts
@@ -204,6 +204,16 @@ export interface AdminManifest {
minimumReleaseAgeExclude?: string[];
};
};
+ /**
+ * Whether dynamic plugins can actually run on this deployment. Dynamic
+ * plugins (marketplace + registry installs) run in a sandbox — on Cloudflare
+ * that is Worker Loader, a Workers paid-plan feature. `false` when the
+ * runner is missing (e.g. no `worker_loaders` binding on a free-tier
+ * deploy). When `marketplace`/`registry` is configured but this is `false`,
+ * the browse/install UI is replaced with a prompt explaining how to enable
+ * dynamic plugins rather than failing at install time with a 503.
+ */
+ sandboxAvailable?: boolean;
/**
* Admin branding overrides for white-labeling.
* Set via the `admin` config in `astro.config.mjs`.
diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx
index 18e7a82aba..baad72db39 100644
--- a/packages/admin/src/router.tsx
+++ b/packages/admin/src/router.tsx
@@ -34,6 +34,7 @@ import { ContentTypeEditor } from "./components/ContentTypeEditor";
import { ContentTypeList } from "./components/ContentTypeList";
import { Dashboard } from "./components/Dashboard";
import { DeviceAuthorizePage } from "./components/DeviceAuthorizePage";
+import { DynamicPluginsUnavailable } from "./components/DynamicPluginsUnavailable";
import { InviteAcceptPage } from "./components/InviteAcceptPage";
import { LoginPage } from "./components/LoginPage";
import { MarketplaceBrowse } from "./components/MarketplaceBrowse";
@@ -1643,6 +1644,14 @@ function MarketplaceBrowsePage() {
return new Set(plugins.map((p) => p.id));
}, [plugins]);
+ // Dynamic plugins run sandboxed — on Cloudflare via Worker Loader (a paid
+ // feature). When the runner isn't available, browsing would only lead to a
+ // 503 at install time, so show the how-to-enable prompt instead. Wait for
+ // the manifest before deciding so we don't flash the prompt on load.
+ if (manifest && manifest.sandboxAvailable === false) {
+ return ;
+ }
+
// When `experimental.registry` is configured, the registry browse
// replaces the centralized marketplace browse on this route. Existing
// sidebar / deep links stay valid; users see the registry without any
@@ -1694,6 +1703,13 @@ function MarketplaceDetailPage() {
return new Set(plugins.map((p) => p.id));
}, [plugins]);
+ // Same gate as the browse route: no sandbox runner means install can't
+ // succeed, so surface the how-to-enable prompt rather than a detail page
+ // whose only action would 503.
+ if (manifest && manifest.sandboxAvailable === false) {
+ return ;
+ }
+
// Discriminate by param shape, not by the manifest flag. A registry
// pluginId is always `${handle}/${slug}` and contains exactly one `/`;
// a marketplace pluginId is a single segment with no `/`. This keeps
diff --git a/packages/admin/tests/components/DynamicPluginsUnavailable.test.tsx b/packages/admin/tests/components/DynamicPluginsUnavailable.test.tsx
new file mode 100644
index 0000000000..e0fd3b5b9a
--- /dev/null
+++ b/packages/admin/tests/components/DynamicPluginsUnavailable.test.tsx
@@ -0,0 +1,37 @@
+import { describe, it, expect } from "vitest";
+
+import { render } from "../utils/render.tsx";
+
+const { DynamicPluginsUnavailable } =
+ await import("../../src/components/DynamicPluginsUnavailable");
+
+describe("DynamicPluginsUnavailable", () => {
+ it("explains that dynamic plugins aren't available", async () => {
+ const screen = await render();
+ await expect
+ .element(screen.getByText("Dynamic plugins aren't available on this deployment"))
+ .toBeInTheDocument();
+ });
+
+ it("gives platform-neutral sandbox guidance", async () => {
+ const screen = await render();
+ await expect
+ .element(
+ screen.getByText(
+ "Installing plugins at runtime requires an available sandbox runner. Configure one for your deployment platform and redeploy to enable dynamic plugins.",
+ ),
+ )
+ .toBeInTheDocument();
+ await expect
+ .element(screen.getByText('"worker_loaders": [{ "binding": "LOADER" }]'), { timeout: 100 })
+ .not.toBeInTheDocument();
+ });
+
+ it("links to the install docs", async () => {
+ const screen = await render();
+ const link = screen.getByRole("link", { name: /enable dynamic plugins/i });
+ await expect
+ .element(link)
+ .toHaveAttribute("href", "https://docs.emdashcms.com/plugins/installing/");
+ });
+});
diff --git a/packages/admin/tests/router.test.tsx b/packages/admin/tests/router.test.tsx
index bb20659a63..77f5206d4f 100644
--- a/packages/admin/tests/router.test.tsx
+++ b/packages/admin/tests/router.test.tsx
@@ -42,6 +42,19 @@ vi.mock("../src/components/AdminCommandPalette", () => ({
AdminCommandPalette: () => null,
}));
+// Stub the marketplace browse component: this file exercises the *route gate*
+// (does MarketplaceBrowsePage route to browse vs the unavailable prompt), not
+// MarketplaceBrowse's own rendering, which has dedicated coverage in
+// tests/components/MarketplaceBrowse.test.tsx. Other exports (e.g. AuditBadge)
+// are preserved so unrelated importers keep working.
+vi.mock("../src/components/MarketplaceBrowse", async () => {
+ const actual = await vi.importActual("../src/components/MarketplaceBrowse");
+ return {
+ ...actual,
+ MarketplaceBrowse: () => ,
+ };
+});
+
vi.mock("../src/components/ContentEditor", () => ({
ContentEditor: ({
item,
@@ -923,3 +936,62 @@ describe("ContentEditPage – autosave cache patching", () => {
}
});
});
+
+// ---------------------------------------------------------------------------
+// Tests: marketplace route gated on dynamic-plugins sandbox availability
+// ---------------------------------------------------------------------------
+//
+// A Cloudflare free-tier site can have `marketplace` configured but no Worker
+// Loader binding, so `sandboxAvailable` is false. Browsing would only lead to a
+// 503 at install, so the route shows the how-to-enable prompt instead.
+
+describe("marketplace route – dynamic-plugins sandbox gate", () => {
+ let mockFetch: ReturnType;
+
+ afterEach(() => {
+ mockFetch.restore();
+ });
+
+ function setup(sandboxAvailable: boolean) {
+ mockFetch = createMockFetch();
+ const manifest: AdminManifest = {
+ ...MANIFEST,
+ i18n: undefined,
+ marketplace: "https://marketplace.example.com",
+ sandboxAvailable,
+ };
+ mockFetch
+ .on("GET", "/_emdash/api/manifest", { data: manifest })
+ .on("GET", "/_emdash/api/auth/me", { data: { id: "user_01", role: 60 } })
+ .on("GET", "/_emdash/api/admin/plugins", { data: [] })
+ // Prefix match also covers the ?q=... search variant.
+ .on("GET", "/_emdash/api/admin/plugins/marketplace", {
+ data: { items: [], nextCursor: undefined },
+ });
+ }
+
+ it("shows the enable-dynamic-plugins prompt when the sandbox is unavailable", async () => {
+ setup(false);
+ const { router, TestApp } = buildRouter();
+ await router.navigate({ to: "/plugins/marketplace" });
+ const screen = await render();
+
+ await expect
+ .element(screen.getByText("Dynamic plugins aren't available on this deployment"))
+ .toBeInTheDocument();
+ });
+
+ it("shows the marketplace browse UI when the sandbox is available", async () => {
+ setup(true);
+ const { router, TestApp } = buildRouter();
+ await router.navigate({ to: "/plugins/marketplace" });
+ const screen = await render();
+
+ // The gate lets the request through to the browse component (stubbed).
+ await expect.element(screen.getByTestId("marketplace-browse")).toBeInTheDocument();
+ // ...and the unavailable prompt must NOT be shown.
+ await expect
+ .element(screen.getByText("Dynamic plugins aren't available on this deployment"))
+ .not.toBeInTheDocument();
+ });
+});
diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts
index 3ed85d1e3c..c6faa1766b 100644
--- a/packages/core/src/astro/types.ts
+++ b/packages/core/src/astro/types.ts
@@ -189,6 +189,22 @@ export interface EmDashManifest {
minimumReleaseAgeExclude?: string[];
};
};
+ /**
+ * Whether dynamic plugins can actually run on this deployment.
+ *
+ * Dynamic plugins (marketplace + registry installs) execute in a sandbox
+ * runner. On Cloudflare that is Worker Loader, a Workers paid-plan feature;
+ * on Node it is the workerd sidecar. `true` when the configured runner
+ * reports available, or when the in-process `sandbox: false` bypass is
+ * active. `false` when the runner is missing (e.g. the `worker_loaders`
+ * binding is absent on a free-tier Cloudflare deploy).
+ *
+ * The admin UI gates its marketplace/registry browse + install surfaces on
+ * this: when `marketplace`/`registry` is configured but this is `false`, it
+ * shows a prompt explaining how to enable dynamic plugins instead of the
+ * browse UI (which would otherwise fail at install time with a 503).
+ */
+ sandboxAvailable?: boolean;
/**
* Admin branding overrides for white-labeling.
* Set via the `admin` config in `astro.config.mjs`.
diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts
index c50f363211..6bb251dd6e 100644
--- a/packages/core/src/emdash-runtime.ts
+++ b/packages/core/src/emdash-runtime.ts
@@ -502,6 +502,30 @@ const marketplaceManifestCache = new Map<
const sandboxedRouteMetaCache = new Map>();
let sandboxRunner: SandboxRunner | null = null;
+const SANDBOX_RUNNER_AVAILABILITY_KEY = Symbol.for("emdash:sandbox-runner-availability");
+
+function getSandboxRunnerAvailabilityCache(): WeakMap {
+ // eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis symbol slot, written only below
+ let cache = globalSymbolStore[SANDBOX_RUNNER_AVAILABILITY_KEY] as
+ | WeakMap
+ | undefined;
+ if (!cache) {
+ cache = new WeakMap();
+ globalSymbolStore[SANDBOX_RUNNER_AVAILABILITY_KEY] = cache;
+ }
+ return cache;
+}
+
+function isSandboxRunnerAvailable(): boolean {
+ if (!sandboxRunner) return false;
+ const cache = getSandboxRunnerAvailabilityCache();
+ const cached = cache.get(sandboxRunner);
+ if (cached !== undefined) return cached;
+ const available = sandboxRunner.isAvailable();
+ cache.set(sandboxRunner, available);
+ return available;
+}
+
/**
* EmDashRuntime - singleton per worker
*/
@@ -811,7 +835,7 @@ export class EmDashRuntime {
*/
private async syncSandboxedSourcePlugins(source: "marketplace" | "registry"): Promise {
if (!this.storage) return;
- if (!sandboxRunner || !sandboxRunner.isAvailable()) return;
+ if (!sandboxRunner || !isSandboxRunnerAvailable()) return;
const keySet = source === "marketplace" ? marketplacePluginKeys : registryPluginKeys;
@@ -1946,7 +1970,7 @@ export class EmDashRuntime {
// Check if the runner is actually available (has required bindings).
// Warn regardless of whether there are plugins to load, so operators
// see the issue even if no marketplace plugins are installed yet.
- if (!sandboxRunner.isAvailable()) {
+ if (!isSandboxRunnerAvailable()) {
console.warn(
"EmDash: Plugin sandbox is configured but not available on this platform. " +
"Sandboxed plugins will not be loaded. " +
@@ -2052,7 +2076,7 @@ export class EmDashRuntime {
// BEFORE pipeline creation by EmDashRuntime.create(). Skip here.
if (deps.sandboxBypassed) return;
- if (!sandboxRunner || !sandboxRunner.isAvailable()) {
+ if (!sandboxRunner || !isSandboxRunnerAvailable()) {
return;
}
@@ -2539,6 +2563,15 @@ export class EmDashRuntime {
i18n,
marketplace: !!this.config.marketplace,
registry,
+ // Whether dynamic plugins can actually run here. The sandbox runner
+ // is instantiated eagerly during create() (loadSandboxedPlugins), so
+ // this reflects real binding availability, not a lazy-init miss. The
+ // bypass term keeps `sandbox: false` dev mode reporting available,
+ // since it loads dynamic plugins in-process instead. Availability is
+ // memoized (isSandboxRunnerAvailable) so this manifest hot path never
+ // re-runs the runner probe, which is a blocking subprocess spawn on
+ // Node's workerd runner.
+ sandboxAvailable: this.isSandboxBypassed() || isSandboxRunnerAvailable(),
};
}
diff --git a/packages/core/tests/integration/runtime/manifest-sandbox-availability.test.ts b/packages/core/tests/integration/runtime/manifest-sandbox-availability.test.ts
new file mode 100644
index 0000000000..4ac79d091d
--- /dev/null
+++ b/packages/core/tests/integration/runtime/manifest-sandbox-availability.test.ts
@@ -0,0 +1,77 @@
+/**
+ * Manifest sandbox-availability probing.
+ *
+ * Regression: `getManifest()` reports `sandboxAvailable` by asking the sandbox
+ * runner whether it's available. `SandboxRunner.isAvailable()` is cheap on
+ * Cloudflare but on Node's workerd runner it spawns `workerd --version`
+ * synchronously. The manifest is built per admin request, so probing on every
+ * call would stall the Node event loop on a hot path. Availability is
+ * process-stable, so the runtime must probe at most once and memoize.
+ *
+ * This test drives the real cold-boot path with a fake runner whose
+ * `isAvailable()` is spied, then asserts repeated `getManifest()` calls do not
+ * re-probe.
+ */
+
+import { randomUUID } from "node:crypto";
+
+import Database from "better-sqlite3";
+import { SqliteDialect } from "kysely";
+import { describe, expect, it, vi } from "vitest";
+
+import { EmDashRuntime } from "../../../src/emdash-runtime.js";
+import type { RuntimeDependencies } from "../../../src/emdash-runtime.js";
+import type { SandboxRunner } from "../../../src/plugins/sandbox/types.js";
+
+function createDeps(
+ createSandboxRunner: RuntimeDependencies["createSandboxRunner"],
+): RuntimeDependencies {
+ return {
+ config: {
+ database: {
+ // Unique entrypoint so the module-level dbCache never serves a
+ // stale instance across tests.
+ entrypoint: `test-manifest-sandbox-${randomUUID()}`,
+ config: {},
+ type: "sqlite",
+ },
+ },
+ plugins: [],
+ createDialect: () => new SqliteDialect({ database: new Database(":memory:") }),
+ createStorage: null,
+ sandboxEnabled: true,
+ sandboxedPluginEntries: [],
+ createSandboxRunner,
+ };
+}
+
+describe("EmDashRuntime.getManifest — sandbox availability", () => {
+ it("reuses the startup probe across repeated manifest builds", async () => {
+ const isAvailable = vi.fn(() => true);
+ const fakeRunner: SandboxRunner = {
+ isAvailable,
+ isHealthy: () => true,
+ load: () => Promise.reject(new Error("not used in this test")),
+ setEmailSend: () => {},
+ terminateAll: () => Promise.resolve(),
+ };
+
+ const runtime = await EmDashRuntime.create(createDeps(() => fakeRunner));
+ try {
+ const probesAfterCreate = isAvailable.mock.calls.length;
+ expect(probesAfterCreate).toBe(1);
+
+ const m1 = await runtime.getManifest();
+ const m2 = await runtime.getManifest();
+ const m3 = await runtime.getManifest();
+
+ expect(m1.sandboxAvailable).toBe(true);
+ expect(m2.sandboxAvailable).toBe(true);
+ expect(m3.sandboxAvailable).toBe(true);
+
+ expect(isAvailable).toHaveBeenCalledTimes(probesAfterCreate);
+ } finally {
+ await runtime.stopCron();
+ }
+ });
+});
diff --git a/packages/core/tests/unit/runtime/manifest-build.test.ts b/packages/core/tests/unit/runtime/manifest-build.test.ts
index ee9e379005..3f4953027c 100644
--- a/packages/core/tests/unit/runtime/manifest-build.test.ts
+++ b/packages/core/tests/unit/runtime/manifest-build.test.ts
@@ -24,7 +24,10 @@ import { createHookPipeline } from "../../../src/plugins/hooks.js";
import { SchemaRegistry } from "../../../src/schema/registry.js";
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
-function buildRuntime(db: Kysely): EmDashRuntime {
+function buildRuntime(
+ db: Kysely,
+ overrides?: { sandboxEnabled?: boolean; sandboxBypassed?: boolean },
+): EmDashRuntime {
const config: EmDashConfig = {};
const pipelineFactoryOptions = { db } as const;
const hooks = createHookPipeline([], pipelineFactoryOptions);
@@ -37,7 +40,8 @@ function buildRuntime(db: Kysely): EmDashRuntime {
throw new Error("createDialect not used in this test");
}) as any,
createStorage: null,
- sandboxEnabled: false,
+ sandboxEnabled: overrides?.sandboxEnabled ?? false,
+ sandboxBypassed: overrides?.sandboxBypassed,
sandboxedPluginEntries: [],
createSandboxRunner: null,
};
@@ -158,4 +162,23 @@ describe("EmDashRuntime.getManifest()", () => {
expect(manifest.collections[`coll_${i}`]?.fields.title?.kind).toBe("string");
}
});
+
+ // `sandboxAvailable` gates the admin's dynamic-plugins UI. The runner-
+ // present-and-available branch is a passthrough to SandboxRunner.isAvailable()
+ // (tested in the cloudflare/workerd packages); here we pin the two states
+ // reachable without instantiating a runner.
+ it("reports sandboxAvailable=false when no sandbox runner is configured", async () => {
+ // The free-tier Cloudflare case: no worker_loaders binding → no runner.
+ const runtime = buildRuntime(db);
+ const manifest = await runtime.getManifest();
+ expect(manifest.sandboxAvailable).toBe(false);
+ });
+
+ it("reports sandboxAvailable=true in sandbox bypass mode (sandbox: false)", async () => {
+ // Bypass loads dynamic plugins in-process, so the feature works even
+ // though there is no isolate runner — the admin must not gate it off.
+ const runtime = buildRuntime(db, { sandboxEnabled: true, sandboxBypassed: true });
+ const manifest = await runtime.getManifest();
+ expect(manifest.sandboxAvailable).toBe(true);
+ });
});
diff --git a/packages/create-emdash/src/flags.ts b/packages/create-emdash/src/flags.ts
index 38924df3f0..1a3ecb278a 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;
+ /**
+ * `--dynamic-plugins` / `--no-dynamic-plugins`. Undefined means "ask"
+ * (Cloudflare only). Enables the Worker Loader binding for dynamic plugins,
+ * which requires the Cloudflare Workers paid plan.
+ */
+ dynamicPlugins?: 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" },
+ "dynamic-plugins": { type: "boolean" },
+ "no-dynamic-plugins": { type: "boolean" },
yes: { type: "boolean", short: "y" },
force: { type: "boolean" },
help: { type: "boolean", short: "h" },
@@ -222,6 +230,15 @@ export function parseFlags(argv: string[]): ParsedFlags {
if (values.install === true) flags.install = true;
if (values["no-install"] === true) flags.install = false;
+ // Dynamic plugins: --dynamic-plugins / --no-dynamic-plugins. Same shape as
+ // the install toggle. Only meaningful for Cloudflare templates (Node has no
+ // Worker Loader); index.ts ignores it on the Node platform.
+ if (values["dynamic-plugins"] === true && values["no-dynamic-plugins"] === true) {
+ throw new FlagError(`--dynamic-plugins and --no-dynamic-plugins cannot both be set.`);
+ }
+ if (values["dynamic-plugins"] === true) flags.dynamicPlugins = true;
+ if (values["no-dynamic-plugins"] === true) flags.dynamicPlugins = false;
+
return flags;
}
@@ -260,6 +277,10 @@ Options:
--package-manager Alias of --pm
--install Install dependencies after scaffolding
--no-install Skip dependency install
+ --dynamic-plugins Enable the Worker Loader capability (Cloudflare only;
+ required by dynamic plugins; needs the Workers paid plan)
+ --no-dynamic-plugins Leave the Worker Loader capability off (free-tier safe;
+ 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 5a3fabdade..2beda6d791 100644
--- a/packages/create-emdash/src/index.ts
+++ b/packages/create-emdash/src/index.ts
@@ -31,7 +31,12 @@ import {
validateProjectName,
wantsHelp,
} from "./flags.js";
-import { isDirNonEmpty, sanitizePackageName, writeEncryptionKey } from "./utils.js";
+import {
+ isDirNonEmpty,
+ sanitizePackageName,
+ setWorkerLoader,
+ writeEncryptionKey,
+} from "./utils.js";
const GITHUB_REPO = "emdash-cms/templates";
@@ -300,6 +305,28 @@ async function resolveShouldInstall(flags: ParsedFlags): Promise {
return shouldInstall;
}
+/**
+ * Resolve whether to enable dynamic plugins (the Cloudflare Worker Loader
+ * binding). Cloudflare-only — Node templates have no Worker Loader — and off by
+ * default, since Worker Loader needs the Workers paid plan and a stray binding
+ * blocks free-tier deploys.
+ */
+async function resolveDynamicPlugins(flags: ParsedFlags, platform: Platform): Promise {
+ if (platform !== "cloudflare") return false;
+ if (flags.dynamicPlugins !== undefined) return flags.dynamicPlugins;
+ if (flags.yes) return false;
+ const enable = await p.confirm({
+ message:
+ "Enable the Cloudflare Worker Loader capability? (required by dynamic plugins; needs the Workers paid plan)",
+ initialValue: false,
+ });
+ if (p.isCancel(enable)) {
+ p.cancel("Operation cancelled.");
+ process.exit(0);
+ }
+ return enable;
+}
+
async function main() {
// Short-circuit --help before strict parsing so a user typing
// `npm create emdash@latest --help --template nope` gets the help they
@@ -336,6 +363,7 @@ async function main() {
const platform = await resolvePlatform(flags);
const templateKey = await resolveTemplate(flags, platform);
const templateConfig = getTemplateConfig(platform, templateKey);
+ const enableDynamicPlugins = await resolveDynamicPlugins(flags, platform);
const pm = await resolvePackageManager(flags);
const shouldInstall = await resolveShouldInstall(flags);
@@ -386,6 +414,13 @@ async function main() {
const keyResult = writeEncryptionKey(projectDir, secretsFile);
ensureGitignored(projectDir, secretsFile);
+ // Toggle the Worker Loader binding (dynamic plugins) in wrangler.jsonc.
+ // Templates ship with it commented out; enabling uncomments it. No-op
+ // on Node templates (no wrangler.jsonc). Also normalises the legacy
+ // multi-line block so opting out always yields a free-tier-safe config,
+ // even when a not-yet-resynced template still ships the old form.
+ const loaderResult = setWorkerLoader(projectDir, enableDynamicPlugins);
+
s.stop("Project created!");
// Wrangler loads either `.dev.vars` or `.env`, but never both: when a
@@ -409,6 +444,20 @@ async function main() {
p.log.info(`Wrote ${pc.cyan("EMDASH_ENCRYPTION_KEY")} to ${pc.cyan(secretsFile)}.`);
}
+ // Only surface the dynamic-plugins state for Cloudflare projects, where
+ // the toggle is meaningful (loaderResult is "absent" on Node).
+ if (loaderResult === "enabled") {
+ p.log.info(
+ `Enabled the Cloudflare Worker Loader capability (${pc.cyan("worker_loaders")} in ${pc.cyan("wrangler.jsonc")}). ` +
+ `This is required by dynamic plugins and needs the Workers paid plan to deploy.`,
+ );
+ } else if (loaderResult === "disabled") {
+ p.log.info(
+ `The Cloudflare Worker Loader capability is off. Uncomment ${pc.cyan("worker_loaders")} in ${pc.cyan("wrangler.jsonc")} ` +
+ `to enable it later for dynamic plugins (needs the Workers paid plan).`,
+ );
+ }
+
if (shouldInstall) {
s.start(`Installing dependencies with ${pc.cyan(pm)}...`);
try {
diff --git a/packages/create-emdash/src/utils.ts b/packages/create-emdash/src/utils.ts
index dfc7bf0bf0..e9c4f45a8a 100644
--- a/packages/create-emdash/src/utils.ts
+++ b/packages/create-emdash/src/utils.ts
@@ -86,3 +86,77 @@ export function isDirNonEmpty(dir: string): boolean {
export function parseTargetArg(argv: string[]): string | undefined {
return argv.slice(2).find((a) => !a.startsWith("-"));
}
+
+/**
+ * Canonical single-line form of the Cloudflare Worker Loader binding.
+ *
+ * Worker Loader ("dynamic workers") is a Workers *paid-plan* feature used only
+ * for dynamic plugins (marketplace + sandboxed plugins). Templates ship with
+ * this commented out so free-tier deploys succeed; the scaffolder uncomments it
+ * when the user opts in.
+ */
+const WORKER_LOADER_LINE = `"worker_loaders": [{ "binding": "LOADER" }],`;
+const WORKER_LOADER_COMMENT =
+ "Dynamic plugins need the Cloudflare Workers paid plan (Worker Loader).";
+
+/** The worker_loaders key line, whether commented or not. */
+const WORKER_LOADER_DECL = /^(\s*)(?:\/\/\s*)?"worker_loaders"\s*:/;
+/** A preceding comment line that belongs to the worker_loaders block. */
+const WORKER_LOADER_OWN_COMMENT = /^\s*\/\/.*worker loader/i;
+/** Split on either Unix or Windows line endings. */
+const NEWLINE_SPLIT = /\r?\n/;
+
+/**
+ * Enable or disable the Worker Loader binding in a project's `wrangler.jsonc`.
+ *
+ * Normalises whatever form is present — the legacy multi-line block that older
+ * published templates carry, or the canonical single-line form — into the
+ * requested state. Disabling comments the binding out (free-tier safe);
+ * enabling leaves an active single-line declaration.
+ *
+ * Idempotent, and a no-op returning `"absent"` when there is no
+ * `wrangler.jsonc` (Node templates) or no `worker_loaders` declaration.
+ */
+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 declIdx = lines.findIndex((line) => WORKER_LOADER_DECL.test(line));
+ if (declIdx === -1) return "absent";
+
+ // Extend past a legacy multi-line block: the array value spans until a line
+ // whose trimmed content starts with the closing "]". A single-line form has
+ // the "]" on the declaration line itself.
+ let endIdx = declIdx;
+ if (!lines[declIdx].includes("]")) {
+ while (endIdx < lines.length - 1 && !lines[endIdx].trim().startsWith("]")) {
+ endIdx++;
+ }
+ }
+
+ // Absorb a single immediately-preceding comment line that belongs to the
+ // block (legacy "// Worker Loader for plugin sandboxing" or our own).
+ let startIdx = declIdx;
+ if (declIdx > 0 && WORKER_LOADER_OWN_COMMENT.test(lines[declIdx - 1])) {
+ startIdx = declIdx - 1;
+ }
+
+ const indent = WORKER_LOADER_DECL.exec(lines[declIdx])?.[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(startIdx, endIdx - startIdx + 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..7bc01506db 100644
--- a/packages/create-emdash/tests/flags.test.ts
+++ b/packages/create-emdash/tests/flags.test.ts
@@ -218,6 +218,24 @@ describe("parseFlags — install toggle", () => {
});
});
+describe("parseFlags — dynamic-plugins toggle", () => {
+ it("--dynamic-plugins sets dynamicPlugins: true", () => {
+ expect(parseFlags(argv("--dynamic-plugins")).dynamicPlugins).toBe(true);
+ });
+
+ it("--no-dynamic-plugins sets dynamicPlugins: false", () => {
+ expect(parseFlags(argv("--no-dynamic-plugins")).dynamicPlugins).toBe(false);
+ });
+
+ it("dynamicPlugins is undefined when neither flag is passed", () => {
+ expect(parseFlags(argv()).dynamicPlugins).toBeUndefined();
+ });
+
+ it("errors when both --dynamic-plugins and --no-dynamic-plugins are passed", () => {
+ expect(() => parseFlags(argv("--dynamic-plugins", "--no-dynamic-plugins"))).toThrow(FlagError);
+ });
+});
+
describe("parseFlags — --yes / -y", () => {
it("--yes sets yes: true", () => {
expect(parseFlags(argv("--yes")).yes).toBe(true);
@@ -343,6 +361,12 @@ describe("parseFlags — full one-shot install line", () => {
});
describe("HELP_TEXT", () => {
+ it("describes --dynamic-plugins as enabling the Worker Loader capability", () => {
+ expect(HELP_TEXT).toContain("Enable the Worker Loader capability");
+ expect(HELP_TEXT).toContain("required by dynamic plugins");
+ expect(HELP_TEXT).not.toContain("Enable dynamic plugins");
+ });
+
it("documents every supported flag", () => {
// Cheap lint to keep HELP_TEXT in sync with parseFlags. If you
// add a new flag and don't document it, this fails.
diff --git a/packages/create-emdash/tests/utils.test.ts b/packages/create-emdash/tests/utils.test.ts
index 5b430ebea9..f129eee896 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,150 @@ describe("writeEncryptionKey", () => {
expect(content.endsWith("\n")).toBe(true);
});
});
+
+// ---------------------------------------------------------------------------
+// setWorkerLoader — toggling the Cloudflare Worker Loader binding
+// ---------------------------------------------------------------------------
+//
+// Worker Loader ("dynamic workers") is a Workers *paid-plan* feature used only
+// for dynamic plugins. Templates ship with it commented out so free-tier
+// deploys work; the scaffolder uncomments it when the user opts in.
+//
+// The toggle must also normalise the *legacy* multi-line block that older
+// published templates still carry: during the release window a new
+// create-emdash can download an old template, and disabling must comment the
+// binding out or the free-tier deploy the user asked for would still fail.
+describe("setWorkerLoader", () => {
+ let tempDir: string;
+ const fileName = "wrangler.jsonc";
+
+ // The legacy shape older published templates carry (uncommented, active).
+ const LEGACY = `{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "my-emdash-site",
+ "main": "./src/worker.ts",
+ "d1_databases": [
+ {
+ "binding": "DB",
+ "database_name": "my-emdash-site",
+ },
+ ],
+ "r2_buckets": [
+ {
+ "binding": "MEDIA",
+ "bucket_name": "my-emdash-media",
+ },
+ ],
+ // Worker Loader for plugin sandboxing
+ "worker_loaders": [
+ {
+ "binding": "LOADER",
+ },
+ ],
+ "triggers": {
+ "crons": ["* * * * *"],
+ },
+}
+`;
+
+ // The canonical commented shape new templates ship.
+ const COMMENTED = `{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "my-emdash-site",
+ "r2_buckets": [
+ {
+ "binding": "MEDIA",
+ "bucket_name": "my-emdash-media",
+ },
+ ],
+ // Dynamic plugins need the Cloudflare Workers paid plan (Worker Loader). Uncomment to enable:
+ // "worker_loaders": [{ "binding": "LOADER" }],
+ "triggers": {
+ "crons": ["* * * * *"],
+ },
+}
+`;
+
+ /** Matches an *active* (uncommented) worker_loaders declaration. */
+ const ACTIVE = /^\s*"worker_loaders"\s*:/m;
+ /** Matches a *commented-out* worker_loaders declaration. */
+ const INACTIVE = /^\s*\/\/\s*"worker_loaders"\s*:/m;
+
+ 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("returns 'absent' and does nothing when wrangler.jsonc is missing (Node template)", () => {
+ expect(setWorkerLoader(tempDir, true)).toBe("absent");
+ expect(setWorkerLoader(tempDir, false)).toBe("absent");
+ });
+
+ it("returns 'absent' when the file has no worker_loaders declaration", () => {
+ write(`{\n\t"name": "x",\n\t"triggers": { "crons": ["* * * * *"] },\n}\n`);
+ expect(setWorkerLoader(tempDir, false)).toBe("absent");
+ });
+
+ it("disables the legacy multi-line block (rollout window: old template, opt out)", () => {
+ write(LEGACY);
+ expect(setWorkerLoader(tempDir, false)).toBe("disabled");
+ const out = read();
+ expect(out).not.toMatch(ACTIVE);
+ expect(out).toMatch(INACTIVE);
+ // Unrelated config preserved.
+ expect(out).toContain(`"binding": "MEDIA"`);
+ expect(out).toContain(`"crons": ["* * * * *"]`);
+ });
+
+ it("enables the legacy multi-line block (opt in on an old template)", () => {
+ write(LEGACY);
+ expect(setWorkerLoader(tempDir, true)).toBe("enabled");
+ const out = read();
+ expect(out).toMatch(ACTIVE);
+ expect(out).not.toMatch(INACTIVE);
+ });
+
+ it("enables the canonical commented form", () => {
+ write(COMMENTED);
+ expect(setWorkerLoader(tempDir, true)).toBe("enabled");
+ const out = read();
+ expect(out).toMatch(ACTIVE);
+ expect(out).not.toMatch(INACTIVE);
+ });
+
+ it("disabling an already-commented form is idempotent (stays commented)", () => {
+ write(COMMENTED);
+ expect(setWorkerLoader(tempDir, false)).toBe("disabled");
+ const out = read();
+ expect(out).not.toMatch(ACTIVE);
+ expect(out).toMatch(INACTIVE);
+ });
+
+ it("round-trips: enable then disable returns to a commented binding", () => {
+ write(COMMENTED);
+ setWorkerLoader(tempDir, true);
+ expect(read()).toMatch(ACTIVE);
+ setWorkerLoader(tempDir, false);
+ const out = read();
+ expect(out).not.toMatch(ACTIVE);
+ expect(out).toMatch(INACTIVE);
+ // No duplicate declarations left behind.
+ expect(out.match(/worker_loaders/g)?.length).toBe(1);
+ });
+
+ it("preserves the trailing newline", () => {
+ write(COMMENTED);
+ setWorkerLoader(tempDir, true);
+ expect(read().endsWith("\n")).toBe(true);
+ });
+});
diff --git a/templates/blog-cloudflare/wrangler.jsonc b/templates/blog-cloudflare/wrangler.jsonc
index f6baeeca71..18c6591259 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",
- },
- ],
+ // Dynamic plugins need the Cloudflare Workers paid plan (Worker Loader). Uncomment to enable:
+ // "worker_loaders": [{ "binding": "LOADER" }],
// Drives scheduled publishing, plugin cron, and maintenance (see src/worker.ts)
"triggers": {
"crons": ["* * * * *"],
diff --git a/templates/marketing-cloudflare/wrangler.jsonc b/templates/marketing-cloudflare/wrangler.jsonc
index c020df0791..3d150308f4 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",
- },
- ],
+ // Dynamic plugins need the Cloudflare Workers paid plan (Worker Loader). Uncomment to enable:
+ // "worker_loaders": [{ "binding": "LOADER" }],
// Drives scheduled publishing, plugin cron, and maintenance (see src/worker.ts)
"triggers": {
"crons": ["* * * * *"],
diff --git a/templates/portfolio-cloudflare/wrangler.jsonc b/templates/portfolio-cloudflare/wrangler.jsonc
index 62ee430e60..aa172c78ad 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",
- },
- ],
+ // Dynamic plugins need the Cloudflare Workers paid plan (Worker Loader). Uncomment to enable:
+ // "worker_loaders": [{ "binding": "LOADER" }],
// Drives scheduled publishing, plugin cron, and maintenance (see src/worker.ts)
"triggers": {
"crons": ["* * * * *"],
diff --git a/templates/starter-cloudflare/wrangler.jsonc b/templates/starter-cloudflare/wrangler.jsonc
index f6baeeca71..18c6591259 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",
- },
- ],
+ // Dynamic plugins need the Cloudflare Workers paid plan (Worker Loader). Uncomment to enable:
+ // "worker_loaders": [{ "binding": "LOADER" }],
// Drives scheduled publishing, plugin cron, and maintenance (see src/worker.ts)
"triggers": {
"crons": ["* * * * *"],