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
1 change: 1 addition & 0 deletions apps/host-cloudflare/.gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
dist/
.dev-db/
.dev.vars
.wrangler/
wrangler.preview.jsonc
35 changes: 34 additions & 1 deletion apps/host-cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ 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 |
| **db** | D1 (SQLite) by default; Neon/Postgres via Hyperdrive (opt-in) |
| **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) |
Expand Down Expand Up @@ -77,6 +77,39 @@ running `wrangler dev` if you need live data.
`ENABLE_DEV_AUTH` is a dev-only escape hatch — never set it in a deployed
environment (it disables the Access gate).

## Neon Postgres (opt-in; D1 is the default)

D1 (SQLite) is the default db seam: zero external dependencies, auto-provisioned
by `wrangler deploy`, co-located with the Worker. That is the right default for
this single-tenant template. Swap to Neon Postgres over Cloudflare Hyperdrive
when you need real interactive transactions, no bound-parameter cap, large
values without the ~1-2MB D1 ceiling, or to scale past D1's 10GB limit. The
Worker auto-selects Postgres when a Hyperdrive binding is present, with no code
change (see `src/db/index.ts`).

```bash
# 1. Create a Neon project (https://neon.tech) and copy its connection string.
# 2. Create a Hyperdrive config pointing at it:
bunx wrangler hyperdrive create executor-pg \
--connection-string="postgresql://USER:PASS@EP.neon.tech/neondb?sslmode=require"
# 3. In wrangler.jsonc, uncomment the "hyperdrive" block and set "id" to the id
# printed above. (Leave the d1_databases block; it just goes unused.)
# 4. Deploy. The schema is provisioned automatically on first boot (runtime
# ensure, same code path as D1), so there is no separate migration step.
bun run deploy
```

Local dev against Postgres (PGlite stands in for Neon, no Docker):

```bash
bun run dev:db # PGlite over a socket at postgresql://…@127.0.0.1:5433/postgres
bun run dev # wrangler dev reads HYPERDRIVE.localConnectionString
```

The R2 `BLOBS` bucket stays useful under Postgres (large blob offload) and is
optional either way. Migrating existing data from a live D1 deployment to
Postgres is out of scope: a fresh Postgres database starts empty.

## Notes

- The QuickJS engine WASM is vendored into `src/quickjs-engine.wasm` (Workers
Expand Down
4 changes: 4 additions & 0 deletions apps/host-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"deploy": "vite build && wrangler deploy",
"dev": "wrangler dev",
"dev:web": "vite dev",
"dev:db": "bun run scripts/dev-db.ts",
"typecheck": "tsgo --noEmit",
"cf-typegen": "wrangler types",
"deploy:setup": "bash scripts/deploy.sh",
Expand Down Expand Up @@ -38,13 +39,16 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"jose": "^5.9.6",
"postgres": "^3.4.9",
"quickjs-emscripten-core": "0.31.0",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250410.0",
"@effect/vitest": "catalog:",
"@electric-sql/pglite": "^0.4.4",
"@electric-sql/pglite-socket": "^0.1.4",
"@executor-js/vite-plugin": "workspace:*",
"@tailwindcss/vite": "catalog:",
"@tanstack/router-plugin": "^1.167.12",
Expand Down
101 changes: 101 additions & 0 deletions apps/host-cloudflare/scripts/dev-db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// ---------------------------------------------------------------------------
// Local dev Postgres via PGlite (no Docker, no install)
// ---------------------------------------------------------------------------
//
// Exposes an in-process PGlite instance over a TCP socket so Hyperdrive's
// `localConnectionString` can connect to it like a real Postgres server, for
// exercising the opt-in Postgres seam (src/db/postgres.ts) under `wrangler dev`.
//
// Unlike apps/cloud (which applies generated drizzle-kit migrations), the
// Cloudflare host brings its schema up at RUNTIME via
// `ensureDrizzleRuntimeSchemaFromTables`, so this script does the same here:
// the dev DB and the deployed Worker share one schema bring-up code path.

import { execSync } from "node:child_process";
import { setTimeout as sleep } from "node:timers/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { PGlite } from "@electric-sql/pglite";
import { PGLiteSocketServer } from "@electric-sql/pglite-socket";
import { drizzle } from "drizzle-orm/pglite";
import {
createDrizzleRuntimeSchemaFromTables,
ensureDrizzleRuntimeSchemaFromTables,
} from "@executor-js/fumadb/adapters/drizzle";
import { collectTables } from "@executor-js/api/server";

import { CLOUDFLARE_NAMESPACE, CLOUDFLARE_SCHEMA_VERSION } from "../src/config";

const __dirname = dirname(fileURLToPath(import.meta.url));
// Port + data dir default to the dev values but are env-overridable so a second
// throwaway instance can run alongside `bun dev`.
const PORT = Number(process.env.DEV_DB_PORT ?? 5433);
const DB_PATH = process.env.DEV_DB_PATH
? resolve(process.env.DEV_DB_PATH)
: resolve(__dirname, "../.dev-db");

// Reap any orphan dev-db from a previous run that didn't shut down cleanly,
// otherwise the new instance can't bind to PORT.
function reapStaleDevDb() {
const out = execSync(`lsof -ti tcp:${PORT} -sTCP:LISTEN 2>/dev/null || true`, {
encoding: "utf8",
});
const pids = out.trim().split("\n").filter(Boolean);
if (pids.length === 0) return false;

for (const pid of pids) {
const cmd = execSync(`ps -p ${pid} -o args= 2>/dev/null || true`, {
Comment thread
amondnet marked this conversation as resolved.
encoding: "utf8",
}).trim();
// The PID can exit between `lsof` and `ps` (the stale instance shutting down
// on its own): an empty cmd means it is already gone and the port is free, so
// skip it rather than misreading it as an unexpected process and bailing.
if (cmd === "") continue;
if (!cmd.includes("dev-db.ts")) {
console.error(`[dev-db] Port ${PORT} is held by an unexpected process (pid ${pid}): ${cmd}`);
console.error(`[dev-db] Refusing to kill it. Free the port and retry.`);
process.exit(1);
}
console.log(`[dev-db] Reaping stale dev-db (pid ${pid})`);
// process.kill (a Node built-in, no subshell) instead of `kill -KILL`;
// tolerate ESRCH if the process exited between the check above and here.
try {
process.kill(Number(pid), "SIGKILL");
} catch {
// already gone
}
}
return true;
}

if (reapStaleDevDb()) {
// Give the kernel a beat to release the socket before we try to bind.
await sleep(200);
}

console.log(`[dev-db] Starting PGlite at ${DB_PATH}`);
const db = await PGlite.create(DB_PATH);

console.log("[dev-db] Ensuring runtime schema (provider=postgresql)");
const options = {
tables: collectTables(),
namespace: CLOUDFLARE_NAMESPACE,
version: CLOUDFLARE_SCHEMA_VERSION,
provider: "postgresql" as const,
};
const schema = createDrizzleRuntimeSchemaFromTables(options);
await ensureDrizzleRuntimeSchemaFromTables(drizzle(db, { schema }), options);

const server = new PGLiteSocketServer({ db, port: PORT, host: "127.0.0.1" });
await server.start();
console.log(`[dev-db] Listening on postgresql://postgres:postgres@127.0.0.1:${PORT}/postgres`);

const shutdown = async () => {
console.log("\n[dev-db] Shutting down");
await server.stop();
await db.close();
process.exit(0);
};

process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
73 changes: 46 additions & 27 deletions apps/host-cloudflare/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { Effect } from "effect";
import { HttpEffect, HttpRouter } from "effect/unstable/http";

import { dbProviderLayer, ExecutorApp, textFailureStrategy } from "@executor-js/api/server";
import { ExecutorApp, textFailureStrategy } from "@executor-js/api/server";

import { loadConfig, type CloudflareEnv } from "./config";
import { makeCloudflarePlugins } from "./plugins";
import { createD1ExecutorDb } from "./db/d1";
import { selectDbSeam } from "./db";
import { cloudflareAccessIdentityLayer } from "./auth/cloudflare-access";
import {
CloudflareCodeExecutorProvider,
Expand Down Expand Up @@ -40,33 +39,36 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => {
// 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);
// The db seam: D1 by default (one memoized handle); a Hyperdrive binding /
// DATABASE_URL switches it to Postgres, where the schema is brought up once
// here and each request gets a fresh connection in its own fiber scope via
// `requestScoped` (Cloudflare forbids sharing a socket across requests).
const seam = await selectDbSeam(env);
const identityLayer = cloudflareAccessIdentityLayer(config);
// MCP runs through the `MCP_SESSION` Durable Object (cross-isolate sessions);
// each session DO opens its own D1 handle, so it takes `env`, not `dbHandle`.
// each session DO opens its own db handle, so it takes `env`, not a handle.
const mcp = makeCloudflareMcpSeams(config, env);

const { appLayer, toWebHandler } = ExecutorApp.make({
plugins,
providers: {
identity: identityLayer,
db: dbProviderLayer(Effect.succeed(dbHandle)),
engine: { codeExecutor: CloudflareCodeExecutorProvider }, // decorator defaults to no-op
plugins: {
provider: makeCloudflarePluginsProvider(config),
config: makeCloudflareHostConfig(config),
},
errorCapture: ErrorCaptureLive,
// The account API (`/api/account/*`) backs the shared multiplayer shell's
// 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.
mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter },
// Everything but the db provider + (Postgres-only) request scope is identical
// across the two seams.
const commonProviders = {
identity: identityLayer,
engine: { codeExecutor: CloudflareCodeExecutorProvider }, // decorator defaults to no-op
plugins: {
provider: makeCloudflarePluginsProvider(config),
config: makeCloudflareHostConfig(config),
},
errorCapture: ErrorCaptureLive,
// The account API (`/api/account/*`) backs the shared multiplayer shell's
// 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.
mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter },
};
const common = {
plugins,
extensions: {
routes: [
// Browser approval of paused MCP executions: the console resume page
Expand All @@ -75,9 +77,26 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => {
HttpRouter.add("*", "/api/mcp-sessions/*", HttpEffect.fromWebHandler(mcp.approvalHandler)),
],
},
config: { mountPrefix: "/api", failure: textFailureStrategy },
config: { mountPrefix: "/api" as const, failure: textFailureStrategy },
boot: identityLayer,
});
};

// Postgres provides its per-request connection through `requestScoped` so the
// socket's acquire/release spans the whole request fiber; D1 needs no request
// scope. Two `make` calls (rather than a conditional field) keep the residual
// types exact: Postgres's `db` carries a `CfPgConnection` requirement that
// only `requestScoped` satisfies.
const { appLayer, toWebHandler } =
seam.kind === "postgres"
? ExecutorApp.make({
...common,
providers: { ...commonProviders, db: seam.db },
requestScoped: seam.requestScoped,
})
: ExecutorApp.make({
...common,
providers: { ...commonProviders, db: seam.db },
});

return { appLayer, toWebHandler };
};
19 changes: 18 additions & 1 deletion apps/host-cloudflare/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { D1Database, DurableObjectNamespace, R2Bucket } from "@cloudflare/workers-types";
import type {
D1Database,
DurableObjectNamespace,
Hyperdrive,
R2Bucket,
} from "@cloudflare/workers-types";

import { isValidOrgSlug } from "@executor-js/api";
import { missingPublicOriginWarning, resolvePublicOrigin } from "@executor-js/sdk/public-origin";
Expand All @@ -21,6 +26,18 @@ export interface CloudflareEnv {
readonly DB: D1Database;
/** R2 bucket binding — holds values too large for a D1 row (~1-2MB cap). */
readonly BLOBS?: R2Bucket;
/** Optional Hyperdrive binding for Neon/Postgres. When present the db seam
* switches from D1 to Postgres-over-Hyperdrive (see `src/db/index.ts`).
* Provision with `wrangler hyperdrive create` and add the binding to
* `wrangler.jsonc`. Absent by default, so D1 stays the zero-setup default. */
readonly HYPERDRIVE?: Hyperdrive;
/** Direct Postgres connection string. Only used when
* `EXECUTOR_DIRECT_DATABASE_URL === "true"` (keeps Hyperdrive the default
* path); also serves as the fallback when a Hyperdrive binding is absent. */
readonly DATABASE_URL?: string;
/** Set to "true" to connect to `DATABASE_URL` directly instead of through
* Hyperdrive. For local dev / admin scripts only; mirrors `apps/cloud`. */
readonly EXECUTOR_DIRECT_DATABASE_URL?: string;
/** MCP session Durable Object namespace — one addressable isolate per MCP
* session (the DO id IS the session id), so a session survives across the
* Worker's stateless isolates. */
Expand Down
13 changes: 13 additions & 0 deletions apps/host-cloudflare/src/db/data-migrations.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Effect } from "effect";
import type { Sql } from "postgres";
import type { D1Database, D1DatabaseSession, R2Bucket } from "@cloudflare/workers-types";

import {
Expand Down Expand Up @@ -88,3 +89,15 @@ export const runCloudflareDataMigrations = (
Effect.runPromise(
runSqliteDataMigrations(d1DataMigrationClient(db), cloudflareDataMigrations(bucket)),
);

// Postgres (Neon) path, see ./postgres.ts. A fresh Postgres deployment has no
// legacy D1 data, so there is nothing to migrate. The existing data migration
// (the Google OpenAPI ownership move + its R2 blob copy) is SQLite-specific
// (`json_extract`/`json_type`, D1 sessions) and only relevant to deployments
// that previously ran on D1. Moving existing data D1 -> Postgres is out of
// scope (a manual export/import); if a Postgres-native data migration is ever
// needed, add a Postgres ledger here modelled on apps/cloud's code migrations.
export const runCloudflarePostgresDataMigrations = async (
_sql: Sql,
_bucket: R2Bucket | undefined,
): Promise<readonly string[]> => [];
Loading