Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .changeset/quiet-sandboxes-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@emdash-cms/cloudflare": patch
"create-emdash": patch
"emdash": patch
---

New Cloudflare projects leave the paid-plan Worker Loader binding disabled so they can deploy on the Workers free plan. Enable sandboxed plugins in the scaffold prompt or with `--sandboxed-plugins`.

The Cloudflare `sandbox()` helper now selects the runner from the `LOADER` binding in `wrangler.jsonc`, including the named environment selected with `CLOUDFLARE_ENV`. Without it, config-based sandboxed plugins do not load and marketplace or registry installs return `SANDBOX_NOT_AVAILABLE`, while browsing remains available.
36 changes: 21 additions & 15 deletions docs/src/content/docs/deployment/plugin-sandbox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Aside, Steps } from "@astrojs/starlight/components";

Sandboxed plugins need a platform runner in addition to their plugin declaration. Marketplace and registry installs always use that runner, as do plugins listed under `sandboxed: []`. Native plugins under `plugins: []` run in the EmDash server process and do not gain sandbox isolation.

The runner depends on the deployment platform. On Cloudflare Workers, each plugin runs as a [Dynamic Worker](https://developers.cloudflare.com/dynamic-workers/) created through the Worker Loader binding. On Node.js, the server starts [`workerd`](https://github.com/cloudflare/workerd), the open-source Workers runtime, as a child process and runs each plugin as a service inside it. The `sandboxRunner` option of `emdash()` selects the runner. Without it, the plugins under `sandboxed: []` are never loaded, and a configured `marketplace` fails the build with "Marketplace requires `sandboxRunner` to be configured".
The runner depends on the deployment platform. On Cloudflare Workers, each plugin runs as a [Dynamic Worker](https://developers.cloudflare.com/dynamic-workers/) created through the Worker Loader binding. On Node.js, the server starts [`workerd`](https://github.com/cloudflare/workerd), the open-source Workers runtime, as a child process and runs each plugin as a service inside it. The `sandboxRunner` option of `emdash()` selects the runner. Without it, plugins under `sandboxed: []` are not loaded. Marketplace and registry browsing remains available, but installing or updating a sandboxed plugin fails with `SANDBOX_NOT_AVAILABLE`.

The following table summarizes what each runner needs and enforces.

Expand All @@ -20,11 +20,11 @@ The following table summarizes what each runner needs and enforces.

## Cloudflare Workers

Dynamic Workers are available on the [Workers Paid plan](https://developers.cloudflare.com/dynamic-workers/pricing/). The runner needs the binding and the entry point export below; the `*-cloudflare` templates ship both.
Dynamic Workers are available on the [Workers Paid plan](https://developers.cloudflare.com/dynamic-workers/pricing/). The `*-cloudflare` templates include the entry point export below but leave the binding commented out, so new projects deploy on the Workers free plan unless you enable sandboxed plugins during scaffolding.

<Steps>

1. Add the Worker Loader binding to `wrangler.jsonc`. The runner reads it under the name `LOADER`:
1. Enable the Worker Loader binding in `wrangler.jsonc`. The runner reads it under the name `LOADER` and selects the Cloudflare sandbox only when this binding is present:

```jsonc title="wrangler.jsonc"
{
Expand All @@ -36,6 +36,8 @@ Dynamic Workers are available on the [Workers Paid plan](https://developers.clou
}
```

If the Wrangler config uses named environments, set `CLOUDFLARE_ENV` during the Astro build. The Cloudflare Vite plugin and `sandbox()` then read the same environment. Bindings are not inherited, so add `LOADER` to each named environment that runs sandboxed plugins.
Comment thread
ascorbic marked this conversation as resolved.

2. Export `PluginBridge` from the Worker entry point, and point `main` at that file. `PluginBridge` is the entrypoint through which sandboxed plugins reach content, media, storage, and email; the runner looks it up on the exports of the entry module:

```ts title="src/worker.ts"
Expand Down Expand Up @@ -96,16 +98,10 @@ Dynamic Workers are available on the [Workers Paid plan](https://developers.clou
});
```

3. For development, install `miniflare` as a dev dependency:

```bash
npm install -D miniflare
```

When `NODE_ENV` is `development`, which `astro dev` sets, and `miniflare` is installed, the runner hands the plugins to Miniflare, which manages its own `workerd` process; the crash policy below does not apply. `astro preview` sets `NODE_ENV` to `production` and `node ./dist/server/entry.mjs` leaves it unset; both use `workerd`.

</Steps>

The runner declares Miniflare as an optional dependency. Package managers install it by default. When `NODE_ENV` is `development`, which `astro dev` sets, the runner hands the plugins to Miniflare, which manages its own `workerd` process; the crash policy below does not apply. If optional dependencies were omitted, the runner uses `workerd` instead. `astro preview` sets `NODE_ENV` to `production` and `node ./dist/server/entry.mjs` leaves it unset; both use `workerd`.

### How the `workerd` process runs

EmDash starts `workerd` while it initializes on the first request to the site, once the sandboxed plugins are loaded, and waits up to 10 seconds for the plugin services to answer. Installing or updating a plugin from the admin restarts it. Everything `workerd` writes to stdout or stderr appears in the server's output with the prefix `[emdash:workerd]`.
Expand All @@ -131,7 +127,13 @@ When a hook or route exceeds the wall-time limit, the invocation fails with `Plu

## When the runner is unavailable

A configured runner can still be unavailable: on Cloudflare Workers when the `worker_loaders` binding or the `PluginBridge` export is missing, on Node.js when `workerd` is not installed or its binary does not run. EmDash then logs a warning when the runtime starts, with the cause the runner reports after the colon. The following warning is logged on Cloudflare Workers when the binding is missing:
On Cloudflare Workers, `sandbox()` checks `wrangler.jsonc` at build time. Without a `worker_loaders` binding named `LOADER`, it leaves the runner unset and logs the following warning:

```plain
[emdash] Sandboxed plugins are disabled because wrangler.jsonc has no LOADER Worker Loader binding. Worker Loader requires a Workers paid plan.
```

A selected runner can still be unavailable at runtime: on Cloudflare Workers when the deployed `LOADER` binding or `PluginBridge` export is missing, and on Node.js when `workerd` is not installed or its binary does not run. EmDash then logs a warning with the cause the runner reports after the colon. The following warning is logged on Cloudflare Workers when the binding is missing:

```plain
EmDash: Plugin sandbox is configured but not available on this platform: the worker has no worker_loaders binding named LOADER. Sandboxed plugins will not be loaded.
Expand Down Expand Up @@ -160,17 +162,21 @@ On Cloudflare Workers, the runtime refuses to start with `sandbox: false is not

Each entry is headed by the message as the server logs it, or by the error code the admin returns.

### "[emdash] Sandboxed plugins are disabled because wrangler.jsonc has no LOADER Worker Loader binding"

The Cloudflare adapter did not select a sandbox runner because the build-time Wrangler config has no `worker_loaders` binding named `LOADER`. This is the expected configuration on the Workers free plan. On a Workers paid plan, enable the binding in `wrangler.jsonc` and rebuild the site.

### "Plugin sandbox is configured but not available on this platform"

The text after the colon names the cause. On Cloudflare Workers, `the worker has no worker_loaders binding named LOADER` means `wrangler.jsonc` needs a `worker_loaders` binding named `LOADER`, and `the worker entrypoint does not export PluginBridge` means the file `main` points to must export `PluginBridge`. Deploying the binding needs the Workers Paid plan.

On Node.js, `workerd is missing or its binary does not run on this platform` means the runner could not run `workerd`. The following command runs the binary the runner uses:
On Node.js, `workerd is missing or its binary does not run on this platform` means the runner could not run `workerd`. Run the installed binary directly so the check cannot download a missing package:

```bash
npx workerd --version
./node_modules/.bin/workerd --version
```

If the command fails, `workerd` is missing from `node_modules` or the installed binary does not run on this platform. Reinstall on the target platform with optional dependencies enabled.
On Windows, run `node_modules\\.bin\\workerd.cmd --version`. If the command fails, `workerd` is missing from `node_modules` or the installed binary does not run on this platform. Reinstall on the target platform with optional dependencies enabled.

### "workerd failed to start within 10 seconds"

Expand Down
4 changes: 3 additions & 1 deletion docs/src/content/docs/plugins/installing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ export default defineConfig({
});
```

The Cloudflare runner uses Worker Loader to create a separate Worker for each plugin. It requires the Workers Paid plan and a `worker_loaders` binding named `LOADER`. Sandboxed plugins reach content, media, storage, network, and email APIs through `PluginBridge`, so the site's Worker entry point must export that class. The `*-cloudflare` templates include the binding and export. Follow the [Cloudflare sandbox setup](/deployment/plugin-sandbox/#cloudflare-workers) when adding the runner to an existing site.
The Cloudflare runner uses Worker Loader to create a separate Worker for each plugin. It requires the Workers Paid plan and a `worker_loaders` binding named `LOADER`. Sandboxed plugins reach content, media, storage, network, and email APIs through `PluginBridge`, so the site's Worker entry point must export that class.

The `*-cloudflare` templates export `PluginBridge` but leave the Worker Loader binding commented out, so new projects can deploy on the Workers free plan. Enable sandboxed plugins during scaffolding or follow the [Cloudflare sandbox setup](/deployment/plugin-sandbox/#cloudflare-workers) to add the binding later. The `sandbox()` helper reads the effective Wrangler config at build time. Without `LOADER`, registry browsing remains available, but config-managed sandboxed plugins do not load and install or update requests return `SANDBOX_NOT_AVAILABLE`.

On Node.js, install the runner and its `workerd` peer dependency:

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -622,7 +622,7 @@ In every mode, the toolbar can be dismissed in the browser via its × button (pe

#### `experimental.registry`

**Optional.** Use the experimental [plugin registry](/plugins/registry/) as the admin dashboard's source for browsing and installing plugins instead of the marketplace. Registry plugins run sandboxed, so this option requires a [`sandboxRunner`](/deployment/plugin-sandbox/).
**Optional.** Use the experimental [plugin registry](/plugins/registry/) as the admin dashboard's source for browsing and installing plugins instead of the marketplace. Browsing works without a [`sandboxRunner`](/deployment/plugin-sandbox/), but installing or updating a registry plugin requires an available runner because registry plugins run sandboxed.

Pass the registry service URL as a string, or use an object when the site needs moderation sources or a release-age policy. The following example uses the object form:

Expand Down
2 changes: 1 addition & 1 deletion e2e/tests/content-actions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ test.describe("Schedule content", () => {
const lineCount = (target: Element) => {
const range = document.createRange();
range.selectNodeContents(target);
return range.getClientRects().length;
return new Set(Array.from(range.getClientRects(), (rect) => Math.round(rect.top))).size;
};
return {
height: element.getBoundingClientRect().height,
Expand Down
6 changes: 4 additions & 2 deletions packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@
"astro": ">=6.0.0-beta.0",
"kysely": ">=0.28.17",
"pg": ">=8.16.3",
"react": "^18.0.0 || ^19.0.0"
"react": "^18.0.0 || ^19.0.0",
"wrangler": ">=4.99.0"
},
"peerDependenciesMeta": {
"pg": {
Expand Down Expand Up @@ -154,7 +155,8 @@
"react": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
"vitest": "catalog:",
"wrangler": "catalog:"
},
"repository": {
"type": "git",
Expand Down
23 changes: 20 additions & 3 deletions packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import type {
ObjectCacheDescriptor,
StorageDescriptor,
} from "emdash";
import { unstable_readConfig } from "wrangler";

import type { DurableObjectsConfig } from "./db/do-sql-types.js";
import type { PreviewDOConfig } from "./db/do-types.js";
Expand Down Expand Up @@ -529,15 +530,31 @@ export function access(config: AccessConfig): AuthDescriptor {
/**
* Cloudflare Worker Loader sandbox adapter
*
* Returns the module path for the Cloudflare sandbox runner.
* Use this in the `sandboxRunner` config option.
* Returns the module path for the Cloudflare sandbox runner when the project's
* Wrangler config includes the `LOADER` Worker Loader binding. Without that
* paid-plan binding, sandboxed plugins are disabled at build time.
*
* @example
* ```ts
* sandboxRunner: sandbox()
* ```
*/
export function sandbox(): string {
export function sandbox(): string | undefined {
const environment = process.env.CLOUDFLARE_ENV;
const config = unstable_readConfig(environment ? { env: environment } : {}, {
hideWarnings: true,
});
const hasWorkerLoader = config.worker_loaders?.some(
(loader: { binding?: string }) => loader.binding === "LOADER",
);

if (!hasWorkerLoader) {
console.warn(
"[emdash] Sandboxed plugins are disabled because wrangler.jsonc has no LOADER Worker Loader binding. Worker Loader requires a Workers paid plan.",
);
return undefined;
}

return "@emdash-cms/cloudflare/sandbox";
}

Expand Down
57 changes: 57 additions & 0 deletions packages/cloudflare/tests/sandbox-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const { readConfig } = vi.hoisted(() => ({
readConfig: vi.fn(),
}));

vi.mock("wrangler", () => ({
unstable_readConfig: readConfig,
}));

import { sandbox } from "../src/index.js";

describe("sandbox", () => {
beforeEach(() => {
readConfig.mockReset();
});

afterEach(() => {
vi.unstubAllEnvs();
});

it("returns the Cloudflare sandbox runner when the LOADER binding is configured", () => {
readConfig.mockReturnValue({ worker_loaders: [{ binding: "LOADER" }] });

expect(sandbox()).toBe("@emdash-cms/cloudflare/sandbox");
expect(readConfig).toHaveBeenCalledWith({}, { hideWarnings: true });
});

it("disables sandboxed plugins when Worker Loader is not configured", () => {
readConfig.mockReturnValue({ worker_loaders: undefined });
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});

expect(sandbox()).toBeUndefined();
expect(warn).toHaveBeenCalledWith(
"[emdash] Sandboxed plugins are disabled because wrangler.jsonc has no LOADER Worker Loader binding. Worker Loader requires a Workers paid plan.",
);

warn.mockRestore();
});

it("requires the binding name used by the runtime", () => {
readConfig.mockReturnValue({ worker_loaders: [{ binding: "OTHER_LOADER" }] });
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});

expect(sandbox()).toBeUndefined();

warn.mockRestore();
});

it("reads the named environment selected by the Cloudflare Vite plugin", () => {
vi.stubEnv("CLOUDFLARE_ENV", "production");
readConfig.mockReturnValue({ worker_loaders: [{ binding: "LOADER" }] });

expect(sandbox()).toBe("@emdash-cms/cloudflare/sandbox");
expect(readConfig).toHaveBeenCalledWith({ env: "production" }, { hideWarnings: true });
});
});
6 changes: 0 additions & 6 deletions packages/core/src/astro/integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,12 +346,6 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
}
throw e;
}
if (!resolvedConfig.sandboxRunner) {
throw new Error(
"Marketplace requires `sandboxRunner` to be configured. " +
"Marketplace plugins run in sandboxed V8 isolates.",
);
}
}

// Validate siteUrl if provided in astro.config.mjs.
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/astro/integration/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,8 @@ export interface EmDashConfig {
* and uninstall plugins from a remote marketplace.
*
* Must be an HTTPS URL in production, or localhost/127.0.0.1 in dev.
* Requires `sandboxRunner` to be configured (marketplace plugins run sandboxed).
* Installing or updating plugins requires an available `sandboxRunner`.
* Browsing remains available when no runner is configured.
*
* When `registry` is also configured, the registry replaces the marketplace
* for the admin UI's browse and install flows. Existing marketplace-installed
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";

import emdash from "../../../../src/astro/integration/index.js";

describe("marketplace without a sandbox runner", () => {
it("allows the marketplace to remain available when sandboxed plugins are disabled", () => {
expect(() =>
emdash({
marketplace: "https://marketplace.emdashcms.com",
sandboxRunner: undefined,
}),
).not.toThrow();
});
});
17 changes: 17 additions & 0 deletions packages/create-emdash/src/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ export interface ParsedFlags {
packageManager?: PackageManager;
/** `--install` / `--no-install`. Undefined means "ask". */
install?: boolean;
/**
* `--sandboxed-plugins` / `--no-sandboxed-plugins`. Undefined means
* "ask" on Cloudflare. Sandboxed plugins require Worker Loader, which is
* available on Workers paid plans.
*/
sandboxedPlugins?: boolean;
/** `--yes` — auto-accept remaining defaults and skip overwrite prompts. */
yes: boolean;
/**
Expand Down Expand Up @@ -100,6 +106,8 @@ export function parseFlags(argv: string[]): ParsedFlags {
"package-manager": { type: "string" },
install: { type: "boolean" },
"no-install": { type: "boolean" },
"sandboxed-plugins": { type: "boolean" },
"no-sandboxed-plugins": { type: "boolean" },
yes: { type: "boolean", short: "y" },
force: { type: "boolean" },
help: { type: "boolean", short: "h" },
Expand Down Expand Up @@ -222,6 +230,12 @@ export function parseFlags(argv: string[]): ParsedFlags {
if (values.install === true) flags.install = true;
if (values["no-install"] === true) flags.install = false;

if (values["sandboxed-plugins"] === true && values["no-sandboxed-plugins"] === true) {
throw new FlagError(`--sandboxed-plugins and --no-sandboxed-plugins cannot both be set.`);
}
if (values["sandboxed-plugins"] === true) flags.sandboxedPlugins = true;
if (values["no-sandboxed-plugins"] === true) flags.sandboxedPlugins = false;

return flags;
}

Expand Down Expand Up @@ -260,6 +274,9 @@ Options:
--package-manager <key> Alias of --pm
--install Install dependencies after scaffolding
--no-install Skip dependency install
--sandboxed-plugins Enable sandboxed plugins on Cloudflare (requires
Worker Loader, available on Workers paid plans)
--no-sandboxed-plugins Leave sandboxed plugins disabled (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)
Expand Down
Loading
Loading