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
32 changes: 16 additions & 16 deletions apps/host-cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ Executor as a single Cloudflare Worker. The fourth app on the shared
`ExecutorApp.make` facade (alongside cloud, self-host, and local) — same code
paths, different injected providers:

| Seam | Cloudflare provider |
| ------------ | ---------------------------------------------------------------- |
| **identity** | Cloudflare Access JWT (`Cf-Access-Jwt-Assertion`) — no app login |
| **db** | D1 (SQLite) via the shared FumaDB assembly |
| **engine** | QuickJS-WASM, in-Worker (no extra binding) |
| **mcp** | Access-JWT auth + the shared in-process session store |
| **account** | `/account/me` from the Access principal (members/keys → Access) |
| **web** | the shared multiplayer SPA (Workers Static Assets) |
| Seam | Cloudflare provider |
| ------------ | ----------------------------------------------------------------- |
| **identity** | Cloudflare Access JWT (`Cf-Access-Jwt-Assertion`) — no app login |
| **db** | D1 (SQLite) via the shared FumaDB assembly |
| **engine** | dynamic-worker (Worker Loaders via `LOADER`), same as cloud |
| **mcp** | Access-JWT auth + the `McpSessionDO` Durable Object session store |
| **account** | `/account/me` from the Access principal (members/keys → Access) |
| **web** | the shared multiplayer SPA (Workers Static Assets) |

Single-tenant: every Access-verified principal belongs to the one configured
org. Members and credentials are managed in Cloudflare Access, not in-app.
Expand Down Expand Up @@ -79,11 +79,11 @@ environment (it disables the Access gate).

## Notes

- The QuickJS engine WASM is vendored into `src/quickjs-engine.wasm` (Workers
forbid runtime WASM compilation; it must be statically imported). Refresh it
after bumping the engine with `bun run vendor-wasm`.
- MCP sessions live in-process (one isolate owns a session). The cross-isolate
upgrade is a Durable Object behind the same `McpSessionStore` seam.
- When Cloudflare's dynamic Worker Loader leaves closed beta, the QuickJS code
substrate swaps for the dynamic-worker executor behind the `engine` seam — a
one-Layer change.
- The `engine` seam runs the dynamic-worker executor (the same substrate cloud
uses): each code execution loads a fresh workerd isolate through the `LOADER`
Worker Loader binding (`wrangler.jsonc` → `worker_loaders`). This gives
structured-clone fidelity at the tool boundary (`Blob`/`File`/`Uint8Array`,
`Date`/`Map`/`Set`) plus `nodejs_compat`/`fetch`. Worker Loaders is open beta
(since 2026-03-24) and requires a **paid** Workers plan.
- MCP sessions are stored in the `McpSessionDO` Durable Object (the DO id is the
session id), so a session survives across the Worker's stateless isolates.
7 changes: 2 additions & 5 deletions apps/host-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
"typecheck": "tsgo --noEmit",
"cf-typegen": "wrangler types",
"deploy:setup": "bash scripts/deploy.sh",
"vendor-wasm": "bun run scripts/vendor-quickjs-wasm.ts",
"test": "vitest run",
"test:watch": "vitest",
"routes:gen": "bun scripts/gen-routes.ts"
Expand All @@ -30,20 +29,18 @@
"@executor-js/plugin-microsoft": "workspace:*",
"@executor-js/plugin-openapi": "workspace:*",
"@executor-js/react": "workspace:*",
"@executor-js/runtime-quickjs": "workspace:*",
"@executor-js/runtime-dynamic-worker": "workspace:*",
"@executor-js/sdk": "workspace:*",
"@jitl/quickjs-wasmfile-release-sync": "catalog:",
"@modelcontextprotocol/sdk": "^1.29.0",
"@tanstack/react-router": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"jose": "^5.9.6",
"quickjs-emscripten-core": "0.31.0",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250410.0",
"@cloudflare/workers-types": "^4.20250620.0",
"@effect/vitest": "catalog:",
"@executor-js/vite-plugin": "workspace:*",
"@tailwindcss/vite": "catalog:",
Expand Down
18 changes: 0 additions & 18 deletions apps/host-cloudflare/scripts/vendor-quickjs-wasm.ts

This file was deleted.

20 changes: 8 additions & 12 deletions apps/host-cloudflare/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,24 @@ import { makeCloudflarePlugins } from "./plugins";
import { createD1ExecutorDb } from "./db/d1";
import { cloudflareAccessIdentityLayer } from "./auth/cloudflare-access";
import {
CloudflareCodeExecutorProvider,
makeCloudflareCodeExecutorProvider,
makeCloudflareHostConfig,
makeCloudflarePluginsProvider,
} from "./execution";
import { ErrorCaptureLive } from "./observability";
import { cloudflareAccountMiddleware } from "./account/account-provider";
import { makeCloudflareMcpSeams } from "./mcp";
import { preloadQuickJs } from "./quickjs";

// ===========================================================================
// The Cloudflare host, as ONE `ExecutorApp.make` call — the 4th app alongside
// cloud / self-host / local, differing only by the injected Layers.
//
// The whole scenario in 60 seconds: Cloudflare Access is the identity (validate
// the Cf-Access-Jwt-Assertion JWT — no Better Auth, no WorkOS, no app login),
// D1 is the SQLite store (same FumaDB assembly as self-host), QuickJS is the
// in-process code substrate, no billing, single-tenant. `diff` against
// host-selfhost/src/app.ts is three injected Layers: identity, db, plugins/config.
// D1 is the SQLite store (same FumaDB assembly as self-host), the dynamic-worker
// executor (Worker Loaders, the `LOADER` binding) is the code substrate (same as
// cloud), no billing, single-tenant. `diff` against host-selfhost/src/app.ts is
// the injected Layers: identity, db, plugins/config, code executor.
//
// Built per isolate (async) so the D1 schema bring-up happens once at first
// fetch; `env` arrives with that fetch (a Worker has no module-scope bindings),
Expand All @@ -36,10 +36,6 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => {
const config = loadConfig(env);
const plugins = makeCloudflarePlugins(config.secretKey);

// Load the Workers-compatible (WASM-inlined) QuickJS variant before any
// executor is built — the default variant can't fetch its .wasm on Workers.
await preloadQuickJs();

// Open + idempotently bring up the D1 schema once (the long-lived handle the
// per-request scoped executor reads through the DbProvider seam).
const dbHandle = await createD1ExecutorDb(env.DB, env.BLOBS);
Expand All @@ -53,7 +49,7 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => {
providers: {
identity: identityLayer,
db: dbProviderLayer(Effect.succeed(dbHandle)),
engine: { codeExecutor: CloudflareCodeExecutorProvider }, // decorator defaults to no-op
engine: { codeExecutor: makeCloudflareCodeExecutorProvider(env.LOADER) }, // decorator defaults to no-op
plugins: {
provider: makeCloudflarePluginsProvider(config),
config: makeCloudflareHostConfig(config),
Expand All @@ -63,8 +59,8 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => {
// auth context; `me` reflects the Access principal. Members/keys are
// Access-managed, so the rest of the surface is stubbed.
account: cloudflareAccountMiddleware(config),
// The MCP serving envelope: Access-JWT auth + the shared in-process session
// store over the QuickJS engine.
// The MCP serving envelope: Access-JWT auth + the Durable Object session
// store over the dynamic-worker engine.
mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter },
},
extensions: {
Expand Down
3 changes: 3 additions & 0 deletions apps/host-cloudflare/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export interface CloudflareEnv {
* session (the DO id IS the session id), so a session survives across the
* Worker's stateless isolates. */
readonly MCP_SESSION: DurableObjectNamespace;
/** Worker Loader binding for the dynamic-worker code substrate: the
* sandbox-isolate loader. Each code execution loads a fresh workerd isolate. */
readonly LOADER: WorkerLoader;
/** Zero Trust team domain, e.g. `your-team.cloudflareaccess.com`. */
readonly ACCESS_TEAM_DOMAIN: string;
/** The Access application's AUD tag (the JWT audience to verify). */
Expand Down
39 changes: 26 additions & 13 deletions apps/host-cloudflare/src/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,41 @@ import {
PluginsProvider,
type ExecutorDbHandle,
} from "@executor-js/api/server";
import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker";

import type { CloudflareConfig } from "./config";
import { makeCloudflarePlugins } from "./plugins";

// ---------------------------------------------------------------------------
// Cloudflare execution-stack seams — the same shape as self-host (QuickJS code
// substrate, no-op engine decorator), with the plugins + host config built from
// the per-request `env`-derived config rather than process.env.
// Cloudflare execution-stack seams. The plugins + host config are built from the
// per-request `env`-derived config rather than process.env, with a no-op engine
// decorator (no billing).
//
// QuickJS-wasm is the default code substrate because it runs in a single Worker
// with no extra binding. When Cloudflare's dynamic Worker Loader leaves closed
// beta, swap CodeExecutorProvider for the dynamic-worker executor (cloud's) —
// it's a one-Layer change behind this same seam.
// The code substrate is the dynamic-worker executor (the same one cloud uses):
// each execution loads a fresh workerd isolate through the `LOADER` Worker Loader
// binding. Unlike cloud, which reads the ambient `cloudflare:workers` env, the
// host threads `env.LOADER` explicitly through the seam (the host's deliberate
// pattern: a Worker receives its bindings per request, so providers close over
// `env` rather than a module-scope import).
// ---------------------------------------------------------------------------

export { makeExecutionStack } from "@executor-js/api/server";
export { EngineDecoratorNoop };

export const CloudflareCodeExecutorProvider: Layer.Layer<CodeExecutorProvider> = Layer.sync(
CodeExecutorProvider,
() => makeQuickJsExecutor(),
);
export const makeCloudflareCodeExecutorProvider = (
loader: WorkerLoader,
): Layer.Layer<CodeExecutorProvider> => {
if (!loader) {
// The dynamic-worker executor only touches `loader` on the first `execute`,
// so a missing binding would otherwise surface as an opaque "undefined" defect
// mid-execution. Fail at app boot with an actionable message instead.
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: the Worker cannot execute code without the LOADER binding
throw new Error(
"Missing LOADER binding: add `worker_loaders` to wrangler.jsonc (Worker Loaders requires a paid Workers plan).",
);
}
return Layer.sync(CodeExecutorProvider, () => makeDynamicWorkerExecutor({ loader }));
};

export const makeCloudflarePluginsProvider = (
config: CloudflareConfig,
Expand All @@ -58,13 +70,14 @@ export const makeCloudflareHostConfig = (config: CloudflareConfig): Layer.Layer<
export const makeCloudflareExecutionStackLayer = (
config: CloudflareConfig,
dbHandle: ExecutorDbHandle,
loader: WorkerLoader,
): Layer.Layer<
DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator
> =>
Layer.mergeAll(
dbProviderLayer(Effect.succeed(dbHandle)),
makeCloudflarePluginsProvider(config),
makeCloudflareHostConfig(config),
CloudflareCodeExecutorProvider,
makeCloudflareCodeExecutorProvider(loader),
EngineDecoratorNoop,
);
10 changes: 4 additions & 6 deletions apps/host-cloudflare/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config";
import { createD1ExecutorDb } from "../db/d1";
import { makeCloudflareExecutionStackLayer, makeExecutionStack } from "../execution";
import { preloadQuickJs } from "../quickjs";

// ---------------------------------------------------------------------------
// Cloudflare (self-host) MCP Session Durable Object — the host-cloudflare
Expand All @@ -24,7 +23,7 @@ import { preloadQuickJs } from "../quickjs";
// `end` disposal contract.
// - resolveSessionMeta → single-tenant: the org is fixed in config, so no
// lookup — just stamp the configured org name.
// - buildMcpServer → the QuickJS execution stack + the MCP tool server.
// - buildMcpServer → the dynamic-worker execution stack + the MCP tool server.
// host-cf has no OTel/Sentry, so it keeps the base's default no-op telemetry +
// error seams. Replacing the prior in-memory store with this DO is what fixes
// `tools/list` failing across Worker isolates (a session created on one isolate
Expand Down Expand Up @@ -72,14 +71,13 @@ export class McpSessionDO extends McpSessionDOBase<CfSessionDbHandle> {
const config = this.cfConfig;
const self = this;
return Effect.gen(function* () {
// QuickJS-WASM must be loaded before the executor layer builds it (the
// default variant can't fetch its .wasm on Workers). Idempotent per isolate.
yield* Effect.promise(() => preloadQuickJs());
const { engine } = yield* makeExecutionStack(
sessionMeta.userId,
sessionMeta.organizationId,
sessionMeta.organizationName,
).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle)));
).pipe(
Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle, self.cfEnv.LOADER)),
);
// Browser elicitation mode (the base owns the approval store + the HTTP
// approval RPCs): a gated execution pauses and returns an approvalUrl into
// the console resume page. The URL origin is the create request's origin
Expand Down
Binary file removed apps/host-cloudflare/src/quickjs-engine.wasm
Binary file not shown.
35 changes: 0 additions & 35 deletions apps/host-cloudflare/src/quickjs.ts

This file was deleted.

32 changes: 26 additions & 6 deletions apps/host-cloudflare/src/worker.e2e.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ import { unstable_dev, type Unstable_DevWorker } from "wrangler";

// ---------------------------------------------------------------------------
// End-to-end test for the Cloudflare host: boots the REAL worker on workerd via
// Miniflare (wrangler `unstable_dev`) with a local D1 + R2, dev-auth on. This is
// the only test that exercises the CF-specific stack together — D1 schema
// bring-up, the R2-backed blob seam (multi-MB spec storage), QuickJS-WASM
// execution, and the MCP envelope — through the actual HTTP surface.
// Miniflare (wrangler `unstable_dev`) with a local D1 + R2 + Worker Loaders,
// dev-auth on. This is the only test that exercises the CF-specific stack
// together, through the actual HTTP surface: D1 schema bring-up, the R2-backed
// blob seam (multi-MB spec storage), the dynamic-worker code substrate (Worker
// Loaders via the `LOADER` binding), and the MCP envelope.
// ---------------------------------------------------------------------------

const dir = fileURLToPath(new URL(".", import.meta.url));
Expand Down Expand Up @@ -60,7 +61,7 @@ describe("cloudflare host e2e (workerd/miniflare)", () => {
await worker?.stop();
});

it("executes TypeScript via /api/executions (QuickJS on workerd)", async () => {
it("executes TypeScript via /api/executions (dynamic-worker on workerd)", async () => {
const res = await worker.fetch("/api/executions", {
method: "POST",
headers: { "content-type": "application/json" },
Expand All @@ -77,6 +78,25 @@ describe("cloudflare host e2e (workerd/miniflare)", () => {
expect(body.text).toBe("42");
}, 60_000);

it("runs code using a Node API (Buffer), proving nodejs_compat in the dynamic-worker isolate", async () => {
// A capability the prior QuickJS-WASM substrate could NOT provide: the
// dynamic-worker isolate boots with `nodejs_compat`, so `Buffer` (and the
// node:* builtins) are available. QuickJS-WASM had no node compat, so this
// doubles as a regression guard that the engine really is dynamic-worker.
const res = await worker.fetch("/api/executions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
code: 'export default Buffer.from("hello").toString("base64")',
}),
});
expect(res.status).toBe(200);
const body = (await res.json()) as { status: string; text: string; isError: boolean };
expect(body.status).toBe("completed");
expect(body.isError).toBe(false);
expect(body.text).toBe("aGVsbG8=");
}, 60_000);

it("adds a LARGE OpenAPI source — exercises the R2 blob seam (~1MB spec) + createMany batching (>100 tools)", async () => {
// Synthesize a spec big enough to (a) far exceed D1's per-value cap if it
// were inlined — proving the spec text really lands in the R2 blob seam —
Expand Down Expand Up @@ -216,7 +236,7 @@ describe("cloudflare host e2e (workerd/miniflare)", () => {
expect(toolNames).toContain("execute");
}, 60_000);

it("invokes the execute tool over MCP (initialize → tools/call → QuickJS)", async () => {
it("invokes the execute tool over MCP (initialize → tools/call → dynamic-worker)", async () => {
const accept = "application/json, text/event-stream";
const rpc = (sessionId: string | null, body: unknown) =>
worker.fetch("/mcp", {
Expand Down
Loading