From ee39810fac520bad361d7a4bd02e7dc43282e70f Mon Sep 17 00:00:00 2001 From: Matt TK Taylor Date: Thu, 6 Aug 2026 18:32:50 +0100 Subject: [PATCH 1/6] feat(create-emdash): make dynamic plugins opt-in so free-tier Cloudflare deploys work Worker Loader (dynamic workers) is the only paid-plan binding the Cloudflare templates shipped, and it's used solely for dynamic plugins. The templates now ship it commented out, and create-emdash uncomments it only when the user opts in -- a Cloudflare-only prompt (default no), or --dynamic-plugins. The toggle normalises the legacy multi-line block too, so opting out always yields a free-tier-safe config even against a not-yet-resynced template. --- ...create-emdash-free-tier-dynamic-plugins.md | 5 + packages/create-emdash/src/flags.ts | 20 +++ packages/create-emdash/src/index.ts | 51 +++++- packages/create-emdash/src/utils.ts | 74 +++++++++ packages/create-emdash/tests/flags.test.ts | 18 +++ packages/create-emdash/tests/utils.test.ts | 148 ++++++++++++++++++ templates/blog-cloudflare/wrangler.jsonc | 8 +- templates/marketing-cloudflare/wrangler.jsonc | 8 +- templates/portfolio-cloudflare/wrangler.jsonc | 8 +- templates/starter-cloudflare/wrangler.jsonc | 8 +- 10 files changed, 323 insertions(+), 25 deletions(-) create mode 100644 .changeset/create-emdash-free-tier-dynamic-plugins.md 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/packages/create-emdash/src/flags.ts b/packages/create-emdash/src/flags.ts index 38924df3f0..1cf5075371 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,9 @@ Options: --package-manager Alias of --pm --install Install dependencies after scaffolding --no-install Skip dependency install + --dynamic-plugins Enable dynamic plugins (Cloudflare only; adds the + Worker Loader binding — needs the Workers paid plan) + --no-dynamic-plugins Leave dynamic plugins 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..72e921f528 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 dynamic plugins? (marketplace + sandboxed plugins; needs the Cloudflare 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 dynamic plugins (${pc.cyan("worker_loaders")} in ${pc.cyan("wrangler.jsonc")}). ` + + `This needs the Cloudflare Workers paid plan to deploy.`, + ); + } else if (loaderResult === "disabled") { + p.log.info( + `Dynamic plugins are off. Uncomment ${pc.cyan("worker_loaders")} in ${pc.cyan("wrangler.jsonc")} ` + + `to enable them later (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..97033b4918 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); 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": ["* * * * *"], From 4ed304df5f5e01dd41ccdd322f58494606e3bfd8 Mon Sep 17 00:00:00 2001 From: Matt TK Taylor Date: Thu, 6 Aug 2026 18:33:51 +0100 Subject: [PATCH 2/6] feat(admin): gate the dynamic-plugins UI behind sandbox availability getManifest() now reports a memoized sandboxAvailable flag. When marketplace or registry is configured but no sandbox runner is available -- e.g. a free-tier Cloudflare site with no Worker Loader binding -- the admin shows a prompt explaining how to enable dynamic plugins instead of a browse UI that would only 503 at install time. The flag is memoized so the per-request manifest never re-runs the runner probe (a blocking subprocess spawn on Node's workerd runner). --- .changeset/gate-dynamic-plugins-sandbox.md | 5 ++ .../components/DynamicPluginsUnavailable.tsx | 63 +++++++++++++++ packages/admin/src/lib/api/client.ts | 10 +++ packages/admin/src/router.tsx | 16 ++++ .../DynamicPluginsUnavailable.test.tsx | 30 +++++++ packages/admin/tests/router.test.tsx | 72 +++++++++++++++++ packages/core/src/astro/types.ts | 16 ++++ packages/core/src/emdash-runtime.ts | 28 +++++++ .../manifest-sandbox-availability.test.ts | 78 +++++++++++++++++++ .../tests/unit/runtime/manifest-build.test.ts | 27 ++++++- 10 files changed, 343 insertions(+), 2 deletions(-) create mode 100644 .changeset/gate-dynamic-plugins-sandbox.md create mode 100644 packages/admin/src/components/DynamicPluginsUnavailable.tsx create mode 100644 packages/admin/tests/components/DynamicPluginsUnavailable.test.tsx create mode 100644 packages/core/tests/integration/runtime/manifest-sandbox-availability.test.ts 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/packages/admin/src/components/DynamicPluginsUnavailable.tsx b/packages/admin/src/components/DynamicPluginsUnavailable.tsx new file mode 100644 index 0000000000..6ed7999b31 --- /dev/null +++ b/packages/admin/src/components/DynamicPluginsUnavailable.tsx @@ -0,0 +1,63 @@ +/** + * Dynamic Plugins Unavailable + * + * Shown in place of the marketplace / registry browse UI when the deployment + * has no sandbox runner (`manifest.sandboxAvailable === false`). Dynamic + * plugins run sandboxed — on Cloudflare that is Worker Loader, a Workers + * paid-plan feature — so a free-tier site with `worker_loaders` absent can't + * install them. Rather than let the user browse and hit a 503 at install time, + * we explain what's needed and how to enable it. + */ + +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 runs them in a sandbox. On Cloudflare that uses Worker + Loader, which needs the Workers paid plan. Add the binding below to your{" "} + + wrangler.jsonc + {" "} + and redeploy to enable it. + +

+ +
+					{`"worker_loaders": [{ "binding": "LOADER" }]`}
+				
+ + } + 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 bfe5228ee4..9aff0dd388 100644 --- a/packages/admin/src/lib/api/client.ts +++ b/packages/admin/src/lib/api/client.ts @@ -176,6 +176,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 57ad9cf423..a5577d305e 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"; @@ -1424,6 +1425,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 @@ -1475,6 +1484,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..a7a12f4a0f --- /dev/null +++ b/packages/admin/tests/components/DynamicPluginsUnavailable.test.tsx @@ -0,0 +1,30 @@ +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("shows the worker_loaders binding to add", async () => { + const screen = await render(); + await expect + .element(screen.getByText('"worker_loaders": [{ "binding": "LOADER" }]')) + .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 f24486a2e0..977975626f 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, @@ -416,3 +429,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 35a4fea7ba..7c62a34221 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -187,6 +187,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 1bf91dd137..018ca3f6ff 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -442,6 +442,25 @@ const marketplaceManifestCache = new Map< const sandboxedRouteMetaCache = new Map>(); let sandboxRunner: SandboxRunner | null = null; +/** + * Memoized sandbox-runner availability. + * + * `SandboxRunner.isAvailable()` is cheap on Cloudflare (a binding property + * read) but expensive on Node: the workerd runner spawns `workerd --version` + * synchronously (`execFileSync`, 5s timeout). 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 (binary / binding presence doesn't + * change), so we probe once and cache. Left `undefined` until the runner is + * wired (during create()), so an early call can't cache a false negative. + */ +let sandboxRunnerAvailable: boolean | undefined; + +function isSandboxRunnerAvailable(): boolean { + if (!sandboxRunner) return false; + sandboxRunnerAvailable ??= sandboxRunner.isAvailable(); + return sandboxRunnerAvailable; +} + /** * EmDashRuntime - singleton per worker */ @@ -2368,6 +2387,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..953cffdfa0 --- /dev/null +++ b/packages/core/tests/integration/runtime/manifest-sandbox-availability.test.ts @@ -0,0 +1,78 @@ +/** + * 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("probes the runner at most once 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; + + 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); + + // Three fresh manifest builds must add at most one probe (the first, + // then memoized). Before the fix this grew by three. + expect(isAvailable.mock.calls.length - probesAfterCreate).toBeLessThanOrEqual(1); + } 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); + }); }); From 247043d7bc7e22247d315d7e2e8dcf4880f8bfba Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Fri, 7 Aug 2026 15:05:52 +0100 Subject: [PATCH 3/6] test(e2e): restore marketplace fixture availability --- e2e/fixture-cloudflare/noop-sandbox.mjs | 18 +++++++++++++----- e2e/fixture/noop-sandbox.mjs | 18 +++++++++++++----- 2 files changed, 26 insertions(+), 10 deletions(-) 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(); +} From ebdfba22fc06165f71fdb310982d039fe94b80f6 Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Fri, 7 Aug 2026 15:12:58 +0100 Subject: [PATCH 4/6] fix(create-emdash): clarify worker loader capability --- packages/create-emdash/src/flags.ts | 7 ++++--- packages/create-emdash/src/index.ts | 10 +++++----- packages/create-emdash/tests/flags.test.ts | 6 ++++++ 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/create-emdash/src/flags.ts b/packages/create-emdash/src/flags.ts index 1cf5075371..1a3ecb278a 100644 --- a/packages/create-emdash/src/flags.ts +++ b/packages/create-emdash/src/flags.ts @@ -277,9 +277,10 @@ Options: --package-manager Alias of --pm --install Install dependencies after scaffolding --no-install Skip dependency install - --dynamic-plugins Enable dynamic plugins (Cloudflare only; adds the - Worker Loader binding — needs the Workers paid plan) - --no-dynamic-plugins Leave dynamic plugins off (free-tier safe; default) + --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 72e921f528..2beda6d791 100644 --- a/packages/create-emdash/src/index.ts +++ b/packages/create-emdash/src/index.ts @@ -317,7 +317,7 @@ async function resolveDynamicPlugins(flags: ParsedFlags, platform: Platform): Pr if (flags.yes) return false; const enable = await p.confirm({ message: - "Enable dynamic plugins? (marketplace + sandboxed plugins; needs the Cloudflare Workers paid plan)", + "Enable the Cloudflare Worker Loader capability? (required by dynamic plugins; needs the Workers paid plan)", initialValue: false, }); if (p.isCancel(enable)) { @@ -448,13 +448,13 @@ async function main() { // the toggle is meaningful (loaderResult is "absent" on Node). if (loaderResult === "enabled") { p.log.info( - `Enabled dynamic plugins (${pc.cyan("worker_loaders")} in ${pc.cyan("wrangler.jsonc")}). ` + - `This needs the Cloudflare Workers paid plan to deploy.`, + `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( - `Dynamic plugins are off. Uncomment ${pc.cyan("worker_loaders")} in ${pc.cyan("wrangler.jsonc")} ` + - `to enable them later (needs the Workers paid plan).`, + `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).`, ); } diff --git a/packages/create-emdash/tests/flags.test.ts b/packages/create-emdash/tests/flags.test.ts index 97033b4918..7bc01506db 100644 --- a/packages/create-emdash/tests/flags.test.ts +++ b/packages/create-emdash/tests/flags.test.ts @@ -361,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. From 3e4e7bfbe93d262703980373d7aeab8b0ef09128 Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Fri, 7 Aug 2026 15:20:58 +0100 Subject: [PATCH 5/6] fix(admin): clarify dynamic plugin setup guidance --- docs/src/content/docs/plugins/installing.mdx | 25 ++++++++++++++++--- .../components/DynamicPluginsUnavailable.tsx | 23 ++++------------- .../DynamicPluginsUnavailable.test.tsx | 11 ++++++-- 3 files changed, 35 insertions(+), 24 deletions(-) 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/packages/admin/src/components/DynamicPluginsUnavailable.tsx b/packages/admin/src/components/DynamicPluginsUnavailable.tsx index 6ed7999b31..87a614f291 100644 --- a/packages/admin/src/components/DynamicPluginsUnavailable.tsx +++ b/packages/admin/src/components/DynamicPluginsUnavailable.tsx @@ -2,11 +2,9 @@ * Dynamic Plugins Unavailable * * Shown in place of the marketplace / registry browse UI when the deployment - * has no sandbox runner (`manifest.sandboxAvailable === false`). Dynamic - * plugins run sandboxed — on Cloudflare that is Worker Loader, a Workers - * paid-plan feature — so a free-tier site with `worker_loaders` absent can't - * install them. Rather than let the user browse and hit a 503 at install time, - * we explain what's needed and how to enable it. + * 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"; @@ -32,22 +30,11 @@ export function DynamicPluginsUnavailable() {

- Installing plugins at runtime runs them in a sandbox. On Cloudflare that uses Worker - Loader, which needs the Workers paid plan. Add the binding below to your{" "} - - wrangler.jsonc - {" "} - and redeploy to enable it. + Installing plugins at runtime requires an available sandbox runner. Configure one for + your deployment platform and redeploy to enable dynamic plugins.

-
-					{`"worker_loaders": [{ "binding": "LOADER" }]`}
-				
- { .toBeInTheDocument(); }); - it("shows the worker_loaders binding to add", async () => { + it("gives platform-neutral sandbox guidance", async () => { const screen = await render(); await expect - .element(screen.getByText('"worker_loaders": [{ "binding": "LOADER" }]')) + .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 () => { From 83a3a3fa2a1c62bc46b291a71398afc3b34243d5 Mon Sep 17 00:00:00 2001 From: Noah Pham Date: Fri, 7 Aug 2026 15:30:18 +0100 Subject: [PATCH 6/6] fix(core): reuse sandbox availability probe --- packages/core/src/emdash-runtime.ts | 39 +++++++++++-------- .../manifest-sandbox-availability.test.ts | 7 ++-- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index d050565fd7..6bb251dd6e 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -502,23 +502,28 @@ const marketplaceManifestCache = new Map< const sandboxedRouteMetaCache = new Map>(); let sandboxRunner: SandboxRunner | null = null; -/** - * Memoized sandbox-runner availability. - * - * `SandboxRunner.isAvailable()` is cheap on Cloudflare (a binding property - * read) but expensive on Node: the workerd runner spawns `workerd --version` - * synchronously (`execFileSync`, 5s timeout). 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 (binary / binding presence doesn't - * change), so we probe once and cache. Left `undefined` until the runner is - * wired (during create()), so an early call can't cache a false negative. - */ -let sandboxRunnerAvailable: boolean | undefined; +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; - sandboxRunnerAvailable ??= sandboxRunner.isAvailable(); - return sandboxRunnerAvailable; + const cache = getSandboxRunnerAvailabilityCache(); + const cached = cache.get(sandboxRunner); + if (cached !== undefined) return cached; + const available = sandboxRunner.isAvailable(); + cache.set(sandboxRunner, available); + return available; } /** @@ -830,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; @@ -1965,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. " + @@ -2071,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; } diff --git a/packages/core/tests/integration/runtime/manifest-sandbox-availability.test.ts b/packages/core/tests/integration/runtime/manifest-sandbox-availability.test.ts index 953cffdfa0..4ac79d091d 100644 --- a/packages/core/tests/integration/runtime/manifest-sandbox-availability.test.ts +++ b/packages/core/tests/integration/runtime/manifest-sandbox-availability.test.ts @@ -46,7 +46,7 @@ function createDeps( } describe("EmDashRuntime.getManifest — sandbox availability", () => { - it("probes the runner at most once across repeated manifest builds", async () => { + it("reuses the startup probe across repeated manifest builds", async () => { const isAvailable = vi.fn(() => true); const fakeRunner: SandboxRunner = { isAvailable, @@ -59,6 +59,7 @@ describe("EmDashRuntime.getManifest — sandbox availability", () => { 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(); @@ -68,9 +69,7 @@ describe("EmDashRuntime.getManifest — sandbox availability", () => { expect(m2.sandboxAvailable).toBe(true); expect(m3.sandboxAvailable).toBe(true); - // Three fresh manifest builds must add at most one probe (the first, - // then memoized). Before the fix this grew by three. - expect(isAvailable.mock.calls.length - probesAfterCreate).toBeLessThanOrEqual(1); + expect(isAvailable).toHaveBeenCalledTimes(probesAfterCreate); } finally { await runtime.stopCron(); }