Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/create-emdash-free-tier-dynamic-plugins.md
Original file line number Diff line number Diff line change
@@ -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).
5 changes: 5 additions & 0 deletions .changeset/gate-dynamic-plugins-sandbox.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 21 additions & 4 deletions docs/src/content/docs/plugins/installing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,26 +19,43 @@ 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";

export default defineConfig({
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:
Expand Down
18 changes: 13 additions & 5 deletions e2e/fixture-cloudflare/noop-sandbox.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
18 changes: 13 additions & 5 deletions e2e/fixture/noop-sandbox.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
50 changes: 50 additions & 0 deletions packages/admin/src/components/DynamicPluginsUnavailable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Dynamic Plugins Unavailable
*
* Shown in place of the marketplace / registry browse UI when the deployment
* has no available sandbox runner (`manifest.sandboxAvailable === false`).
* Rather than let the user browse and hit an error at install time, direct
* them to the platform-specific setup instructions.
*/

import { LinkButton } from "@cloudflare/kumo";
import { Trans, useLingui } from "@lingui/react/macro";
import { ArrowSquareOut, ShieldWarning } from "@phosphor-icons/react";

/** Docs page covering sandbox runner setup (Cloudflare Worker Loader + Node workerd). */
const INSTALL_DOCS_URL = "https://docs.emdashcms.com/plugins/installing/";

export function DynamicPluginsUnavailable() {
const { t } = useLingui();

return (
<div className="mx-auto max-w-2xl">
<div className="flex flex-col items-center rounded-lg border bg-kumo-base p-8 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-kumo-warning/10 text-kumo-warning">
<ShieldWarning className="h-6 w-6" aria-hidden="true" />
</div>

<h2 className="mt-4 text-lg font-medium">
<Trans>Dynamic plugins aren't available on this deployment</Trans>
</h2>

<p className="mt-2 text-sm text-kumo-subtle">
<Trans>
Installing plugins at runtime requires an available sandbox runner. Configure one for
your deployment platform and redeploy to enable dynamic plugins.
</Trans>
</p>

<LinkButton
href={INSTALL_DOCS_URL}
external
variant="outline"
icon={<ArrowSquareOut />}
className="mt-4"
>
{t`Learn how to enable dynamic plugins`}
</LinkButton>
</div>
</div>
);
}
10 changes: 10 additions & 0 deletions packages/admin/src/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,16 @@ export interface AdminManifest {
minimumReleaseAgeExclude?: string[];
};
};
/**
* Whether dynamic plugins can actually run on this deployment. Dynamic
* plugins (marketplace + registry installs) run in a sandbox — on Cloudflare
* that is Worker Loader, a Workers paid-plan feature. `false` when the
* runner is missing (e.g. no `worker_loaders` binding on a free-tier
* deploy). When `marketplace`/`registry` is configured but this is `false`,
* the browse/install UI is replaced with a prompt explaining how to enable
* dynamic plugins rather than failing at install time with a 503.
*/
sandboxAvailable?: boolean;
/**
* Admin branding overrides for white-labeling.
* Set via the `admin` config in `astro.config.mjs`.
Expand Down
16 changes: 16 additions & 0 deletions packages/admin/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1643,6 +1644,14 @@ function MarketplaceBrowsePage() {
return new Set(plugins.map((p) => p.id));
}, [plugins]);

// Dynamic plugins run sandboxed — on Cloudflare via Worker Loader (a paid
// feature). When the runner isn't available, browsing would only lead to a
// 503 at install time, so show the how-to-enable prompt instead. Wait for
// the manifest before deciding so we don't flash the prompt on load.
if (manifest && manifest.sandboxAvailable === false) {
return <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 />;
}


// 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
Expand Down Expand Up @@ -1694,6 +1703,13 @@ function MarketplaceDetailPage() {
return new Set(plugins.map((p) => p.id));
}, [plugins]);

// Same gate as the browse route: no sandbox runner means install can't
// succeed, so surface the how-to-enable prompt rather than a detail page
// whose only action would 503.

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

if (manifest && manifest.sandboxAvailable === false) {
return <DynamicPluginsUnavailable />;
}

// 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
Expand Down
37 changes: 37 additions & 0 deletions packages/admin/tests/components/DynamicPluginsUnavailable.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";

import { render } from "../utils/render.tsx";

const { DynamicPluginsUnavailable } =
await import("../../src/components/DynamicPluginsUnavailable");

describe("DynamicPluginsUnavailable", () => {
it("explains that dynamic plugins aren't available", async () => {
const screen = await render(<DynamicPluginsUnavailable />);
await expect
.element(screen.getByText("Dynamic plugins aren't available on this deployment"))
.toBeInTheDocument();
});

it("gives platform-neutral sandbox guidance", async () => {
const screen = await render(<DynamicPluginsUnavailable />);
await expect
.element(
screen.getByText(
"Installing plugins at runtime requires an available sandbox runner. Configure one for your deployment platform and redeploy to enable dynamic plugins.",
),
)
.toBeInTheDocument();
await expect
.element(screen.getByText('"worker_loaders": [{ "binding": "LOADER" }]'), { timeout: 100 })
.not.toBeInTheDocument();
});

it("links to the install docs", async () => {
const screen = await render(<DynamicPluginsUnavailable />);
const link = screen.getByRole("link", { name: /enable dynamic plugins/i });
await expect
.element(link)
.toHaveAttribute("href", "https://docs.emdashcms.com/plugins/installing/");
});
});
72 changes: 72 additions & 0 deletions packages/admin/tests/router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => <div data-testid="marketplace-browse" />,
};
});

vi.mock("../src/components/ContentEditor", () => ({
ContentEditor: ({
item,
Expand Down Expand Up @@ -923,3 +936,62 @@ describe("ContentEditPage – autosave cache patching", () => {
}
});
});

// ---------------------------------------------------------------------------
// Tests: marketplace route gated on dynamic-plugins sandbox availability
// ---------------------------------------------------------------------------
//
// A Cloudflare free-tier site can have `marketplace` configured but no Worker
// Loader binding, so `sandboxAvailable` is false. Browsing would only lead to a
// 503 at install, so the route shows the how-to-enable prompt instead.

describe("marketplace route – dynamic-plugins sandbox gate", () => {
let mockFetch: ReturnType<typeof createMockFetch>;

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(<TestApp />);

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(<TestApp />);

// 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();
});
});
16 changes: 16 additions & 0 deletions packages/core/src/astro/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,22 @@ export interface EmDashManifest {
minimumReleaseAgeExclude?: string[];
};
};
/**
* Whether dynamic plugins can actually run on this deployment.
*
* Dynamic plugins (marketplace + registry installs) execute in a sandbox
* runner. On Cloudflare that is Worker Loader, a Workers paid-plan feature;
* on Node it is the workerd sidecar. `true` when the configured runner
* reports available, or when the in-process `sandbox: false` bypass is
* active. `false` when the runner is missing (e.g. the `worker_loaders`
* binding is absent on a free-tier Cloudflare deploy).
*
* The admin UI gates its marketplace/registry browse + install surfaces on
* this: when `marketplace`/`registry` is configured but this is `false`, it
* shows a prompt explaining how to enable dynamic plugins instead of the
* browse UI (which would otherwise fail at install time with a 503).
*/
sandboxAvailable?: boolean;
/**
* Admin branding overrides for white-labeling.
* Set via the `admin` config in `astro.config.mjs`.
Expand Down
Loading
Loading