feat: gate dynamic plugins behind Worker Loader so free-tier Cloudflare deploys work - #2351
feat: gate dynamic plugins behind Worker Loader so free-tier Cloudflare deploys work#2351MattieTK wants to merge 7 commits into
Conversation
…are 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.
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 detectedLatest commit: 83a3a3f The changes in this PR will be included in the next version bump. This PR includes changesets to release 17 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | 83a3a3f | Aug 07 2026, 02:34 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | 83a3a3f | Aug 07 2026, 02:34 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-do | 83a3a3f | Aug 07 2026, 02:34 PM |
There was a problem hiding this comment.
Approach judgment: This is the right change for the stated problem. Gating dynamic plugins behind real sandbox availability keeps the default Cloudflare scaffold deployable on the Workers free tier, gives users a clear opt-in path, and surfaces missing setup in the admin UI instead of a late 503. The maintainer-requested origin makes the lack of a separate Discussion reasonable. The design is additive and backwards-compatible: new scaffolds default to worker_loaders commented out; existing sites with the binding keep reporting sandboxAvailable: true and see no behavior change.
What I checked: full diff, the changed runtime/admin/create-emdash files, route structure around marketplace/registry, the manifest endpoint route, SandboxRunner usage, the existing globalThis singleton patterns in emdash-runtime.ts, and the changeset config (which groups emdash, @emdash-cms/admin, and create-emdash in one fixed release, so the two changesets cover the admin changes as well).
Headline conclusion: Implementation is solid and well-tested. I found one AGENTS.md convention issue with a new module-scope singleton cache and one UX suggestion to avoid rendering the marketplace UI while the manifest is still loading. Both are fixable in small follow-up edits; neither blocks understanding the PR.
|
|
||
| function isSandboxRunnerAvailable(): boolean { | ||
| if (!sandboxRunner) return false; | ||
| sandboxRunnerAvailable ??= sandboxRunner.isAvailable(); | ||
| return sandboxRunnerAvailable; | ||
| } |
There was a problem hiding this comment.
[needs fixing] Introduces a new module-scope singleton cache (let sandboxRunnerAvailable) exactly where AGENTS.md forbids one: "Module-scope singletons must live on globalThis. Vite duplicates modules across SSR chunks; a plain let cache = null becomes two variables." Deduplicating the isAvailable() probe is a good idea, but a module-scope boolean means the cache can be duplicated/inconsistent if the runtime module is bundled into multiple SSR chunks, and it leaks across test runtimes that reuse the same sandboxRunner instance.
Cache the result on the runner object instead, so the lifetime of the memoized value matches the lifetime of the runner:
| function isSandboxRunnerAvailable(): boolean { | |
| if (!sandboxRunner) return false; | |
| sandboxRunnerAvailable ??= sandboxRunner.isAvailable(); | |
| return sandboxRunnerAvailable; | |
| } | |
| const SANDBOX_AVAILABLE = Symbol.for("emdash.sandbox-available"); | |
| function isSandboxRunnerAvailable(): boolean { | |
| if (!sandboxRunner) return false; | |
| const cached = (sandboxRunner as SandboxRunner & Record<symbol, boolean | undefined>)[SANDBOX_AVAILABLE]; | |
| if (cached !== undefined) return cached; | |
| const available = sandboxRunner.isAvailable(); | |
| (sandboxRunner as SandboxRunner & Record<symbol, boolean | undefined>)[SANDBOX_AVAILABLE] = available; | |
| return available; | |
| } |
| // the manifest before deciding so we don't flash the prompt on load. | ||
| if (manifest && manifest.sandboxAvailable === false) { | ||
| return <DynamicPluginsUnavailable />; | ||
| } |
There was a problem hiding this comment.
[suggestion] This gate only fires once manifest is truthy. While the manifest query is pending, the route falls through to <MarketplaceBrowse>, which starts fetching marketplace data. On a free-tier site (sandboxAvailable: false) that data fetch is wasted and the user may see a brief flash of the marketplace before the prompt replaces it.
Return a loader while the manifest is pending so the gate actually controls what first appears:
| } | |
| const { data: manifest, isPending } = useQuery({ | |
| queryKey: ["manifest"], | |
| queryFn: fetchManifest, | |
| }); | |
| if (isPending) { | |
| return <Loader />; | |
| } | |
| // 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. | |
| if (manifest && manifest.sandboxAvailable === false) { | |
| return <DynamicPluginsUnavailable />; | |
| } |
|
|
||
| // 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. |
There was a problem hiding this comment.
[suggestion] Same as the browse-route gate: the detail route renders <MarketplacePluginDetail> while the manifest is pending, so a free-tier user may briefly see the detail page (and trigger the detail fetch) before the unavailable prompt appears. Wait for the manifest to resolve before deciding which view to show.
| // whose only action would 503. | |
| const { data: manifest, isPending } = useQuery({ | |
| queryKey: ["manifest"], | |
| queryFn: fetchManifest, | |
| }); | |
| if (isPending) { | |
| return <Loader />; | |
| } | |
| // 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 <DynamicPluginsUnavailable />; | |
| } |
Scope checkThis PR changes 693 lines across 20 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
docs | 3e4e7bf | Aug 07 2026, 02:24 PM |
There was a problem hiding this comment.
This is the right change for the stated free-tier deploy blocker: gating dynamic plugins behind real sandbox availability keeps the default Cloudflare scaffold deployable, gives users a clear opt-in path, and surfaces missing setup in the admin UI instead of a late 503. The design is additive and backwards-compatible throughout.
In this re-review I checked the full diff, the changed runtime/admin/create-emdash files, the manifest endpoint, SandboxRunner usage, the existing globalThis singleton patterns, and the changeset config.
Headline conclusion: the implementation is solid and well-tested. The blocking-style concern from the previous review — the new module-scope availability cache not living on globalThis — has been fixed: getSandboxRunnerAvailabilityCache() now stores the WeakMap behind Symbol.for("emdash:sandbox-runner-availability") on globalThis, matching the DB_HOLDER_KEY / SEED_HOLDER_KEY patterns already in emdash-runtime.ts.
The previous UX suggestion about avoiding the marketplace UI while the manifest is still loading has been addressed with an explicit design choice: the routes wait for the manifest before deciding, and deliberately fall through to the browse/detail UI during load so the common paid-tier path doesn't flash the unavailable prompt. I'm not raising it again as a finding because that's a reasonable judgment call.
I found no new logic bugs, regressions, security issues, or AGENTS.md convention violations. Test coverage is good across create-emdash, emdash runtime, and admin routes. Changeset grouping covers @emdash-cms/admin via the fixed release. LGTM.
ascorbic
left a comment
There was a problem hiding this comment.
Thanks for this. It makes sense to fix this. However I think this isn't the right approah. It treats "enable Worker Loader" and "enable the sandbox" as separate things: the scaffolder toggles the binding while astro.config.mjs keeps sandboxRunner: sandbox() and the bundled sandboxed plugin. This then requires a lot of extra code in the manifest to work around this broken state.
I don't think these should be separate things. The cleanest way is to just disable the sandbox runner entirely if the worker loader binding is missing. The sandbox will never work on Cloudflare without the binding, so there's no need to support that state.
There are two ways to fix this:
- the simples way is to just make
create-emdashremove the sandboxRunner as well as the binding. This means the sandbox is disabled and will just work. - cleaner, but more complex: make the binding the single source of truth, and derive everything from it inside the Cloudflare package.
sandbox()already runs at build time in the project root. Have it read the wrangler config (unstable_readConfigis probably fine, though it won't work with the new config when we add support. Is there a new API to read it?) and returnundefinedwhen there's noworker_loadersbinding, logging one build-time line saying sandboxed plugins are disabled and why.
Naming, while you're in there: these are sandboxed plugins, matching the rest of the codebase, not "dynamic plugins" - the flags, component, copy, and changesets should follow. And the scaffolder prompt should ask about enabling sandboxed plugins (the feature), with Worker Loader and the paid-plan requirement as the explanation, not headline a Cloudflare binding in the first-run UX.
What does this PR do?
Cloudflare Worker Loader ("dynamic workers") is the only paid-plan binding the
*-cloudflaretemplates shipped, and it's used solely for the dynamic-plugins feature (marketplace + registry installs, which run sandboxed). On the Workers free tier that binding blockswrangler deploy, so a scaffolded project couldn't ship without hand-editingwrangler.jsonc. And once deployed, the admin showed the marketplace/registry UI regardless of whether Worker Loader was actually present, so a free-tier install only failed at the end with aSANDBOX_NOT_AVAILABLE503.This makes dynamic plugins an explicit opt-in and gates the UI on real availability:
worker_loaderscommented out — a scaffolded (or directly cloned) Cloudflare project now deploys on the free tier by default.create-emdashadds a Cloudflare-only prompt (default: no) and--dynamic-plugins/--no-dynamic-pluginsflags. Opting in uncomments the binding. The toggle also normalises the legacy multi-line block, so opting out is always free-tier-safe — even against a published template that hasn't been re-synced yet.emdashreports a newsandboxAvailableflag in the admin manifest. It's memoized, so the per-request manifest never re-runs the runner's availability probe (a blocking subprocess spawn on Node's workerd runner).wrangler.jsoncsnippet and a docs link — in place of the marketplace/registry browse + detail views whensandboxAvailableis false. The nav item stays visible so the feature is discoverable; the theme marketplace is not gated.Backwards-compatible throughout: template edits affect new scaffolds only; existing deployed sites keep their own
wrangler.jsonc; paid-tier sites reportsandboxAvailable: true(no change). On the defaultblog-cloudflarescaffold with dynamic plugins off, the bundledsandboxedwebhook plugin logs a boot warning and doesn't load (graceful — it does not crash) until the user opts in.No linked issue. Opening at maintainer request.
Type of change
Checklist
pnpm typecheckpasses — verified clean on the packages this PR touches (create-emdash,emdash). See note under test output about pre-existing admin errors.pnpm lintpasses (baseline was clean; changed files lint clean with--type-aware --deny-warnings)pnpm testpasses — targeted tests for the change (see below)pnpm formathas been run (oxfmt)messages.pochanges are included.create-emdash, one foremdashAI-generated code disclosure
Screenshots / test output
Targeted tests (all pass):
create-emdash: 115 passed — flag parsing +setWorkerLoadertoggle (legacy multi-line and canonical forms, both directions, idempotent, no-op on Node).emdash: manifest-build (5), manifest-route (3), and a new integration test asserting the sandbox availability probe is memoized (3 manifest builds → ≤1 probe; fails without the fix at "expected 3 to be less than or equal to 1").@emdash-cms/admin: 10 passed — theDynamicPluginsUnavailableprompt and a router gate test (prompt whensandboxAvailableis false; browse when true).Note on
pnpm typecheck: locally the@emdash-cms/adminpackage reports 39 type errors, but they are pre-existing and unrelated to this PR — the identical count appears with this branch's changes stashed, and they are the signature of a duplicate@types/reactinstall (a local, out-of-syncpnpm-lock.yaml), not present in files this PR touches.create-emdashandemdashtypecheck clean.Try this PR
Open a fresh playground →
A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.
Tracks
feat/gate-dynamic-plugins-worker-loader. Updated automatically when the playground redeploys.