Skip to content

feat: gate dynamic plugins behind Worker Loader so free-tier Cloudflare deploys work - #2351

Open
MattieTK wants to merge 7 commits into
mainfrom
feat/gate-dynamic-plugins-worker-loader
Open

feat: gate dynamic plugins behind Worker Loader so free-tier Cloudflare deploys work#2351
MattieTK wants to merge 7 commits into
mainfrom
feat/gate-dynamic-plugins-worker-loader

Conversation

@MattieTK

@MattieTK MattieTK commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Cloudflare Worker Loader ("dynamic workers") is the only paid-plan binding the *-cloudflare templates shipped, and it's used solely for the dynamic-plugins feature (marketplace + registry installs, which run sandboxed). On the Workers free tier that binding blocks wrangler deploy, so a scaffolded project couldn't ship without hand-editing wrangler.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 a SANDBOX_NOT_AVAILABLE 503.

This makes dynamic plugins an explicit opt-in and gates the UI on real availability:

  • Templates ship worker_loaders commented out — a scaffolded (or directly cloned) Cloudflare project now deploys on the free tier by default.
  • create-emdash adds a Cloudflare-only prompt (default: no) and --dynamic-plugins / --no-dynamic-plugins flags. 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.
  • emdash reports a new sandboxAvailable flag 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).
  • Admin shows a "dynamic plugins aren't available" prompt — with the wrangler.jsonc snippet and a docs link — in place of the marketplace/registry browse + detail views when sandboxAvailable is 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 report sandboxAvailable: true (no change). On the default blog-cloudflare scaffold with dynamic plugins off, the bundled sandboxed webhook 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

Note: this originates from the maintainer as a fix for the free-tier deploy blocker, so there is no separate Discussion — happy to open one if preferred. It can also be read as a bug fix (undeployable default) plus the opt-in mechanism.

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes — verified clean on the packages this PR touches (create-emdash, emdash). See note under test output about pre-existing admin errors.
  • pnpm lint passes (baseline was clean; changed files lint clean with --type-aware --deny-warnings)
  • pnpm test passes — targeted tests for the change (see below)
  • pnpm format has been run (oxfmt)
  • I have added/updated tests for my changes
  • User-visible strings in the admin UI are wrapped for translation (Lingui). No messages.po changes are included.
  • I have added a changeset — one for create-emdash, one for emdash
  • New features link to an approved Discussion — see note above

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.8

Screenshots / test output

Targeted tests (all pass):

  • create-emdash: 115 passed — flag parsing + setWorkerLoader toggle (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 — the DynamicPluginsUnavailable prompt and a router gate test (prompt when sandboxAvailable is false; browse when true).

Note on pnpm typecheck: locally the @emdash-cms/admin package 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/react install (a local, out-of-sync pnpm-lock.yaml), not present in files this PR touches. create-emdash and emdash typecheck 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.

…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).
Copilot AI lite review requested due to automatic review settings August 6, 2026 17:35
@changeset-bot

changeset-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 83a3a3f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
create-emdash Patch
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/plugin-mcp-smoke Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

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

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-cache 83a3a3f Aug 07 2026, 02:34 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-playground 83a3a3f Aug 07 2026, 02:34 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-do 83a3a3f Aug 07 2026, 02:34 PM

@github-actions github-actions Bot added the review/needs-review No maintainer or bot review yet label Aug 6, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +457 to +462

function isSandboxRunnerAvailable(): boolean {
if (!sandboxRunner) return false;
sandboxRunnerAvailable ??= sandboxRunner.isAvailable();
return sandboxRunnerAvailable;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

Suggested change
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 />;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

Suggested change
}
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
// 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 />;
}

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Scope check

This 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.

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond query-count changed PR diff modifies query-count snapshot files and removed review/needs-review No maintainer or bot review yet labels Aug 6, 2026
@pkg-pr-new

pkg-pr-new Bot commented Aug 6, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@2351

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@2351

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@2351

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@2351

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@2351

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@2351

emdash

npm i https://pkg.pr.new/emdash@2351

create-emdash

npm i https://pkg.pr.new/create-emdash@2351

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@2351

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@2351

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@2351

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@2351

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@2351

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@2351

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@2351

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@2351

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@2351

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@2351

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@2351

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@2351

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@2351

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@2351

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@2351

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@2351

commit: 83a3a3f

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot removed the query-count changed PR diff modifies query-count snapshot files label Aug 7, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 7, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
docs 3e4e7bf Aug 07 2026, 02:24 PM

@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Aug 7, 2026
@khoinguyenpham04 khoinguyenpham04 added the bot:review Trigger an emdashbot code review on this PR label Aug 7, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Aug 7, 2026
@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Aug 7, 2026

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. the simples way is to just make create-emdash remove the sandboxRunner as well as the binding. This means the sandbox is disabled and will just work.
  2. 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_readConfig is probably fine, though it won't work with the new config when we add support. Is there a new API to read it?) and return undefined when there's no worker_loaders binding, 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.

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/approved Approved; no new commits since labels Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants