From 9ff1e866df6f6d36412f38307487534f6f4d23a9 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Thu, 21 May 2026 02:12:36 +0200 Subject: [PATCH] feat: provision cloudflare workers and pin db pool per isolate --- app/(auth)/sign-in/page.tsx | 28 ++- app/(auth)/sign-up/page.tsx | 32 ++-- cloudflare-env.d.ts | 12 +- components/providers/RealtimeBridge.tsx | 4 + lib/auth.ts | 14 +- lib/db/_driver.workers.ts | 51 ++++-- lib/db/connection.ts | 55 +++--- lib/db/request-scope.node.ts | 17 -- lib/db/request-scope.workers.ts | 205 +--------------------- package.json | 8 +- plugins/claude-code/.mcp.json | 6 +- plugins/codex/.mcp.json | 4 + plugins/cursor/mcp.json | 3 + plugins/gemini/gemini-extension.json | 3 + scripts/assert-deploy-ready.ts | 149 +++++++++++----- scripts/postbuild-cf.ts | 49 ------ scripts/server-only-shim.js | 15 ++ tests/db/connection.cloudflare.test.ts | 49 ++---- tests/db/request-scope.test.ts | 224 ------------------------ tsconfig.json | 2 +- worker-cf.ts | 18 ++ wrangler.jsonc | 106 +++++++---- 22 files changed, 394 insertions(+), 660 deletions(-) delete mode 100644 scripts/postbuild-cf.ts create mode 100644 scripts/server-only-shim.js delete mode 100644 tests/db/request-scope.test.ts create mode 100644 worker-cf.ts diff --git a/app/(auth)/sign-in/page.tsx b/app/(auth)/sign-in/page.tsx index c441d90..c28dad2 100644 --- a/app/(auth)/sign-in/page.tsx +++ b/app/(auth)/sign-in/page.tsx @@ -42,14 +42,26 @@ export default function SignInPage() {

- New to Mymir?{" "} - - Create an account - + {process.env.DEPLOY_TARGET === "cloudflare" ? ( + + Sign-ups are invite-only + + ) : ( + <> + New to Mymir?{" "} + + Create an account + + + )}

} diff --git a/app/(auth)/sign-up/page.tsx b/app/(auth)/sign-up/page.tsx index 8730ece..5cc1557 100644 --- a/app/(auth)/sign-up/page.tsx +++ b/app/(auth)/sign-up/page.tsx @@ -5,15 +5,16 @@ import { AuthHero } from "@/components/auth/AuthHero"; import { SocialButtons } from "@/components/auth/SocialButtons"; import { SignUpForm } from "@/components/auth/SignUpForm"; +const SIGNUPS_DISABLED = process.env.DEPLOY_TARGET === "cloudflare"; + /** - * Sign-up page — mirrors the sign-in two-column shell with the - * registration form. Same disabled-with-tooltip social buttons (no - * backend providers wired) and the same static hero on the right. - * - * Post-create the user lands on `/`; `requireMembership` then forwards - * to `/onboarding/team` because a fresh account has zero memberships. + * Sign-up page. Renders the registration form on self-host and an + * "invite only" notice on the hosted Cloudflare deploy. Post-create the + * user lands on `/`; `requireMembership` forwards to `/onboarding/team` + * because a fresh account has zero memberships. * - * @returns Server-rendered auth shell composing the sign-up form. + * @returns Server-rendered auth shell with form or invite-only notice + * depending on `DEPLOY_TARGET`. */ export default function SignUpPage() { return ( @@ -25,21 +26,26 @@ export default function SignUpPage() { className="text-[26px] font-semibold text-text-primary" style={{ letterSpacing: "-0.01em", lineHeight: 1.15 }} > - Create an account. + {SIGNUPS_DISABLED ? "Invite only." : "Create an account."}

- Your project graph and decision history live here. Connect agents - through MCP from your CLI once you’re in. + {SIGNUPS_DISABLED + ? "Mymir is in a closed beta. New accounts are opening soon — until then, sign-ups are invite-only." + : "Your project graph and decision history live here. Connect agents through MCP from your CLI once you’re in."}

- - + {SIGNUPS_DISABLED ? null : ( + <> + + + + )}

- Already have an account?{" "} + {SIGNUPS_DISABLED ? "Already invited?" : "Already have an account?"}{" "} ; - NEXT_CACHE_DO_QUEUE: DurableObjectNamespace; - WORKER_SELF_REFERENCE: Service; + MYMIR_BROKER: DurableObjectNamespace; + NEXT_CACHE_DO_QUEUE: DurableObjectNamespace; + WORKER_SELF_REFERENCE: Service; } declare namespace Cloudflare { interface GlobalProps { - mainModule: typeof import("./.open-next/worker"); + mainModule: typeof import("./worker-cf"); durableNamespaces: "MymirBroker" | "DOQueueHandler"; } interface Env extends __BaseEnv_CloudflareEnv {} diff --git a/components/providers/RealtimeBridge.tsx b/components/providers/RealtimeBridge.tsx index b420aeb..46d93ab 100644 --- a/components/providers/RealtimeBridge.tsx +++ b/components/providers/RealtimeBridge.tsx @@ -80,7 +80,11 @@ export function RealtimeBridge() { es = new EventSource("/api/events"); es.onmessage = (msg) => handle(msg.data); es.onopen = () => { + const isReconnect = backoff !== INITIAL_BACKOFF_MS; backoff = INITIAL_BACKOFF_MS; + if (isReconnect) { + qc.invalidateQueries(); + } }; es.onerror = () => { es?.close(); diff --git a/lib/auth.ts b/lib/auth.ts index 91c3740..936b2e2 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -10,6 +10,16 @@ import { ac, owner, admin, member as memberRole } from "@/lib/auth/permissions"; import { findOrgMemberUserIdsAsAdmin } from "@/lib/data/membership"; import { grantOrgAccess, revokeOrgAccess } from "@/lib/realtime/access"; +const IS_CLOUDFLARE = process.env.DEPLOY_TARGET === "cloudflare"; + +if (IS_CLOUDFLARE && !process.env.BETTER_AUTH_URL) { + throw new Error( + "BETTER_AUTH_URL is required on the Cloudflare deploy target. " + + "Without it, Better-auth's trustedOrigins falls back to [] and CSRF " + + "protection accepts any origin. Set it in wrangler.jsonc env.production.vars.", + ); +} + /** * Better Auth server instance with email/password auth and * organization-based team management. Adapts the `neon_auth` schema via @@ -24,6 +34,8 @@ export const auth = betterAuth({ emailAndPassword: { enabled: true, revokeSessionsOnPasswordReset: true, + // Invite-only on hosted Cloudflare; self-host stays open. + disableSignUp: IS_CLOUDFLARE, }, session: { expiresIn: 60 * 60 * 24 * 7, // 7 days @@ -43,7 +55,7 @@ export const auth = betterAuth({ ? [process.env.BETTER_AUTH_URL] : [], advanced: { - useSecureCookies: process.env.NODE_ENV === "production", + useSecureCookies: process.env.NODE_ENV === "production" || IS_CLOUDFLARE, defaultCookieAttributes: { httpOnly: true, sameSite: "lax", diff --git a/lib/db/_driver.workers.ts b/lib/db/_driver.workers.ts index 33353ff..36f6e16 100644 --- a/lib/db/_driver.workers.ts +++ b/lib/db/_driver.workers.ts @@ -9,19 +9,37 @@ import type { AppDb, AuthDb, DbBundle } from "./_driver.node"; export type { AppDb, AuthDb, DbBundle, ClosablePool } from "./_driver.node"; /** - * Per-request Neon Pool tuning. `max: 1` because each pool's lifetime is a - * single request (created in `withRequestDbCore`, ended via - * `ctx.waitUntil(pool.end())`); a larger cap would open extra WebSocket - * connections to Neon that never resolve before teardown. `idleTimeoutMillis` - * is omitted on purpose for the same reason — idle reaping cannot fire - * inside a single request lifetime and the default is sufficient. + * Per-isolate Neon Pool tuning. The Pool is shared across requests within + * one isolate; each connection is single-use (`maxUses: 1`) so the + * "WebSocket cannot outlive a single request" constraint is honored at the + * connection level — a connection serves one query then is destroyed. + * + * `max` caps concurrent open connections per isolate. The pattern is the + * one recommended by OpenNext (https://opennext.js.org/cloudflare/howtos/db). + */ +const NEON_OPTS = { max: 5, maxUses: 1 } as const; + +/** + * Attach a Pool-level error listener so unhandled idle-client errors from + * `pg` do not surface as `EventEmitter` uncaught events (which terminate + * the isolate and drop every in-flight request). Logged only; the Pool + * itself recovers by creating a fresh connection on the next `connect()`. + * + * @param pool - Neon pool to instrument. + * @param role - Role tag included in the log prefix. + * @returns The same pool, with the listener attached. */ -const NEON_OPTS = { max: 1 } as const; +function attachPoolErrorLogger

(pool: P, role: string): P { + pool.on("error", (err: unknown) => { + console.error(`[db:${role}] neon pool background error`, err); + }); + return pool; +} /** * Build the application Drizzle client backed by `@neondatabase/serverless`. - * Creates a fresh `NeonPool` per call; callers MUST close it via - * `ctx.waitUntil(pool.end())` once the request completes. + * The underlying `NeonPool` is reused across requests within an isolate; + * each query opens a fresh single-use connection (`maxUses: 1`). * * @returns Pool + Drizzle instance bound to the public schema. * @throws Error when `DATABASE_URL` is unset. @@ -33,7 +51,10 @@ export function buildAppPool(): DbBundle { "DATABASE_URL is required for the app runtime connection (app_user role).", ); } - const pool = new NeonPool({ connectionString: url, ...NEON_OPTS }); + const pool = attachPoolErrorLogger( + new NeonPool({ connectionString: url, ...NEON_OPTS }), + "app", + ); return { pool, db: drizzleNeon(pool, { schema: appSchema }) as unknown as AppDb, @@ -54,7 +75,10 @@ export function buildAuthPool(): DbBundle { "(DML on neon_auth.*, no public-schema access).", ); } - const pool = new NeonPool({ connectionString: url, ...NEON_OPTS }); + const pool = attachPoolErrorLogger( + new NeonPool({ connectionString: url, ...NEON_OPTS }), + "auth", + ); return { pool, db: drizzleNeon(pool, { schema: authSchema }) as unknown as AuthDb, @@ -74,7 +98,10 @@ export function buildServicePool(): DbBundle { "DATABASE_SERVICE_ROLE_URL is required for service-role data access", ); } - const pool = new NeonPool({ connectionString: url, ...NEON_OPTS }); + const pool = attachPoolErrorLogger( + new NeonPool({ connectionString: url, ...NEON_OPTS }), + "service", + ); return { pool, db: drizzleNeon(pool, { schema: appSchema }) as unknown as AppDb, diff --git a/lib/db/connection.ts b/lib/db/connection.ts index ca239fc..a2a43ac 100644 --- a/lib/db/connection.ts +++ b/lib/db/connection.ts @@ -6,7 +6,6 @@ import { buildAuthPool, buildServicePool, } from "@/lib/db/_driver"; -import { autoSeedRequestDb } from "@/lib/db/request-scope"; import type { AppDb, AuthDb } from "@/lib/db/_driver.node"; export type { AppDb, AuthDb } from "@/lib/db/_driver.node"; @@ -56,35 +55,47 @@ export interface RequestScopedDb { /** * AsyncLocalStorage frame populated by the Workers request-scope helper. - * On self-host the frame is never entered; the global-cached singletons - * built lazily on first proxy access are used instead. + * + * Pinned to a `globalThis` slot keyed by `Symbol.for(...)` so every bundle + * that imports this module observes the same instance — on Workers the + * final artifact contains two copies of this file (one bundled by Next's + * webpack into `.open-next/worker.js`, another by wrangler's esbuild from + * `worker-cf.ts`), and without the global pin each would instantiate its + * own `AsyncLocalStorage`. OpenNext's `init.js` uses the same pattern for + * `__cloudflare-context__`. */ -export const requestDbStore = new AsyncLocalStorage(); +const REQUEST_DB_STORE_KEY = Symbol.for("@mymir/db/requestDbStore"); +const symbolKeyedGlobal = globalThis as Record; +if (!symbolKeyedGlobal[REQUEST_DB_STORE_KEY]) { + symbolKeyedGlobal[REQUEST_DB_STORE_KEY] = + new AsyncLocalStorage(); +} +export const requestDbStore = symbolKeyedGlobal[ + REQUEST_DB_STORE_KEY +] as AsyncLocalStorage; type GlobalKey = "__mymirAppDb" | "__mymirAuthDb" | "__mymirServiceRoleDb"; /** - * Resolve the active Drizzle client for a role: prefer the AsyncLocalStorage - * frame, fall back per-target (Workers lazy-seeds, self-host caches on - * `globalThis`). + * Resolve the active Drizzle client for a role. * - * On Cloudflare Workers, when the proxy is first read in a request without - * an active `withRequestDb` frame, the {@link autoSeedRequestDb} hook builds - * the three role pools, registers `ctx.waitUntil(pool.end())` for teardown, - * and seeds the ALS store for the remainder of the request's async tree. The - * `globalThis` cache is never consulted on Workers because the Neon - * serverless `Pool`'s WebSocket cannot span requests. + * Both deploy targets use a `globalThis`-cached singleton built lazily on + * first read. On self-host the Node `Pool` warms up once and serves every + * request via standard pg pooling. On Cloudflare Workers the Neon Pool is + * configured with `maxUses: 1` (see `lib/db/_driver.workers.ts`), so each + * connection is single-use even though the Pool instance persists across + * requests within an isolate — the "WebSocket cannot outlive a request" + * constraint is honored at the connection level, not the Pool level. * - * On self-host the seeder is a stub (throws); the `globalThis` cache holds - * a single warm pool per role for the lifetime of the Node process. + * The {@link requestDbStore} ALS frame is consulted first for explicit + * scoping (e.g. tests that want to inject sentinel clients via + * `requestDbStore.run`). Production fetch paths simply hit the cache. * - * @param key - Which role to read from the request-scope bundle. - * @param globalKey - Matching `globalThis.__mymir*` slot for self-host. + * @param key - Which role to read from the request-scope bundle (used only + * when an ALS frame is explicitly active). + * @param globalKey - Matching `globalThis.__mymir*` slot. * @param builder - Factory invoked at most once to populate the slot. * @returns Drizzle instance for the role. - * @throws Error on Workers when the seeder cannot register teardown (no - * active fetch context, e.g. scheduled handler) — see - * `autoSeedRequestDb` for the remediation. */ function getScopedOrGlobal( key: keyof RequestScopedDb, @@ -93,10 +104,6 @@ function getScopedOrGlobal( ): TDb { const scoped = requestDbStore.getStore(); if (scoped) return scoped[key] as TDb; - if (process.env.DEPLOY_TARGET === "cloudflare") { - const seeded = autoSeedRequestDb(); - return seeded[key] as TDb; - } const cached = globalThis[globalKey]; if (cached) return cached as TDb; const built = builder().db; diff --git a/lib/db/request-scope.node.ts b/lib/db/request-scope.node.ts index d2f87d6..8d6bb2e 100644 --- a/lib/db/request-scope.node.ts +++ b/lib/db/request-scope.node.ts @@ -1,5 +1,4 @@ import "server-only"; -import type { RequestScopedDb } from "./connection"; /** * Self-host no-op for the per-request DB seeding helper. @@ -16,19 +15,3 @@ import type { RequestScopedDb } from "./connection"; export async function withRequestDb(fn: () => Promise): Promise { return fn(); } - -/** - * Self-host stub for {@link autoSeedRequestDb}. The connection proxy uses - * the globalThis fallback on self-host and never reaches the lazy seeder, - * so this body must never execute. Throwing rather than no-op keeps a - * misrouted call surfacing instead of silently corrupting state. - * - * @throws Always. - */ -export function autoSeedRequestDb(): RequestScopedDb { - throw new Error( - "autoSeedRequestDb is Workers-only; the self-host build should never " + - "call it (the connection proxy uses the globalThis cache). If you " + - "see this on self-host, the webpack alias indirection is broken.", - ); -} diff --git a/lib/db/request-scope.workers.ts b/lib/db/request-scope.workers.ts index 9782ae4..b2dcee9 100644 --- a/lib/db/request-scope.workers.ts +++ b/lib/db/request-scope.workers.ts @@ -1,208 +1,17 @@ import "server-only"; -import { getCloudflareContext } from "@opennextjs/cloudflare"; -import type { AppDb, AuthDb, ClosablePool, DbBundle } from "./_driver.node"; -import { - buildAppPool, - buildAuthPool, - buildServicePool, -} from "./_driver.workers"; -import { - type AppUserConn, - type RequestScopedDb, - type ServiceRoleConn, - requestDbStore, -} from "./connection"; - -/** - * Minimal `ctx.waitUntil` shape used by {@link withRequestDbCore}. Defined - * locally so the file does not depend on `@cloudflare/workers-types` - * (forbidden by `eslint.config.mjs`: pulling its ambient declarations - * clobbers DOM `Request`/`Response`). - */ -interface RequestCtx { - waitUntil: (promise: Promise) => void; -} - -/** - * Bundle of per-role pool factories injected into {@link withRequestDbCore}. - * The production wrapper passes the real `_driver.workers` builders; tests - * substitute fakes that return sentinel `db` handles and instrumented - * `pool.end()` implementations. - */ -export interface PoolBuilders { - buildAppPool: () => DbBundle; - buildAuthPool: () => DbBundle; - buildServicePool: () => DbBundle; -} - -/** - * Build the three role pools, closing any already-created pool if a later - * builder throws. Returns a {@link RequestScopedDb} ready for ALS seeding - * plus the underlying pools so the caller can schedule `pool.end()` after - * the body completes. - * - * @param builders - Per-role pool factories. - * @returns Pools plus the matching `db` clients keyed by role. - * @throws Whatever a builder throws, with previously-built pools already - * scheduled for teardown via {@link closePoolsAfterError}. - */ -function buildRolePools(builders: PoolBuilders): { - pools: ClosablePool[]; - scoped: RequestScopedDb; -} { - const opened: ClosablePool[] = []; - try { - const app = builders.buildAppPool(); - opened.push(app.pool); - const auth = builders.buildAuthPool(); - opened.push(auth.pool); - const service = builders.buildServicePool(); - opened.push(service.pool); - return { - pools: opened, - scoped: { - appDb: app.db as AppUserConn, - authDb: auth.db, - serviceRoleDb: service.db as ServiceRoleConn, - }, - }; - } catch (err) { - closePoolsAfterError(opened); - throw err; - } -} - /** - * Close pools that were opened before a later builder threw. Fire-and-forget - * by design: if `end()` rejects the only sane outlet is the log, since the - * surrounding control flow is already unwinding the original failure. + * Workers-side counterpart to {@link ./request-scope.node:withRequestDb}. * - * @param pools - Pools to close. - */ -function closePoolsAfterError(pools: ClosablePool[]): void { - for (const pool of pools) { - void pool.end().catch((err) => { - console.error("[db] pool cleanup after build failure failed", err); - }); - } -} - -/** - * Schedule `pool.end()` for every role pool through a single `ctx.waitUntil` - * registration so one promise tracks the entire teardown. Halves the - * `waitUntil` bookkeeping vs registering three independent promises and - * keeps the response-blocking budget tight. - * - * @param ctx - Cloudflare execution context. - * @param pools - Pools opened for this request. - */ -function schedulePoolTeardown(ctx: RequestCtx, pools: ClosablePool[]): void { - ctx.waitUntil( - Promise.allSettled(pools.map((pool) => pool.end())).then((results) => { - for (const result of results) { - if (result.status === "rejected") { - console.error("[db] pool end failed", result.reason); - } - } - }), - ); -} - -/** - * Testable core of {@link withRequestDb}. Takes the Cloudflare execution - * context and the three pool factories as inputs so unit tests can - * exercise the lifecycle without booting OpenNext or the Neon driver. - * - * Builds fresh Drizzle clients for the three roles via - * {@link buildRolePools} (which guarantees no leaked pools if a later - * builder throws), runs `fn` inside an AsyncLocalStorage frame so the - * proxy exports in `./connection.ts` resolve to those clients, and - * schedules `pool.end()` for every pool via a single `ctx.waitUntil` so - * socket teardown does not block the response and only one promise is - * tracked. - * - * @param ctx - Cloudflare execution context exposing `waitUntil`. - * @param builders - Per-role pool factories. - * @param fn - The request-handler body. - * @returns Whatever `fn` returns. - */ -export async function withRequestDbCore( - ctx: RequestCtx, - builders: PoolBuilders, - fn: () => Promise, -): Promise { - const { pools, scoped } = buildRolePools(builders); - try { - return await requestDbStore.run(scoped, fn); - } finally { - schedulePoolTeardown(ctx, pools); - } -} - -/** - * Wrap a request-scoped operation with per-request Pool lifecycle. - * - * Cloudflare Workers cannot persist WebSocket connections beyond a single - * request, so the Neon `Pool` for each role must be created inside the - * handler and closed before the response is fully delivered. Thin wrapper - * over {@link withRequestDbCore} that wires in the live Cloudflare context - * and the real Neon pool builders from `./_driver.workers`. + * The Workers DB pool is a per-isolate singleton with single-use + * connections (`maxUses: 1`), so request scoping happens at the + * connection level inside `lib/db/_driver.workers.ts`; this wrapper is a + * pass-through identical to the Node sibling. Kept exported so callers + * compile against both targets without target-specific imports. * * @param fn - The request-handler body. * @returns Whatever `fn` returns. */ export async function withRequestDb(fn: () => Promise): Promise { - const { ctx } = getCloudflareContext(); - return withRequestDbCore( - ctx, - { buildAppPool, buildAuthPool, buildServicePool }, - fn, - ); -} - -/** - * Lazy auto-seed entry point for the {@link requestDbStore} proxy fallback. - * - * Called from `./connection.ts:getScopedOrGlobal` on Cloudflare Workers - * when a route reads `appDb` / `authDb` / `serviceRoleDb` outside an - * explicit {@link withRequestDb} frame. Builds the three role pools, - * registers a single `ctx.waitUntil(pool.end())` for teardown, then uses - * `AsyncLocalStorage.enterWith` to seed the frame for the rest of the - * request's async tree. - * - * Each Workers fetch invocation runs in its own root async context, so - * `enterWith` cannot leak the seeded store across requests. Callers that - * still want explicit scoping (e.g. background tasks that share a fetch - * handler) can wrap with {@link withRequestDb} directly. - * - * @returns The seeded scope (also entered into the ALS frame). - * @throws Error when `getCloudflareContext()` is unavailable — running on - * Workers without an active fetch context (scheduled handler, DO alarm, - * etc.) means we cannot register `pool.end()` for teardown, and - * silently seeding without teardown would leak sockets across - * invocations. The caller is expected to wrap with {@link withRequestDb} - * explicitly in those contexts. - */ -export function autoSeedRequestDb(): RequestScopedDb { - let ctx: RequestCtx; - try { - ctx = getCloudflareContext({ async: false }).ctx; - } catch (err) { - throw new Error( - "autoSeedRequestDb: Cloudflare execution context is unavailable " + - `(${err instanceof Error ? err.message : String(err)}). ` + - "This usually means a non-fetch Workers handler (scheduled, alarm, " + - "queue consumer) accessed appDb/authDb/serviceRoleDb without " + - "wrapping the body in withRequestDb(() => ...).", - ); - } - const { pools, scoped } = buildRolePools({ - buildAppPool, - buildAuthPool, - buildServicePool, - }); - schedulePoolTeardown(ctx, pools); - requestDbStore.enterWith(scoped); - return scoped; + return fn(); } diff --git a/package.json b/package.json index a2d9786..9558f1e 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,10 @@ "build": "next build --webpack", "postbuild": "bun run scripts/postbuild.ts", "start": "node --env-file-if-exists=.env.local scripts/start.mjs", - "build:cf": "DEPLOY_TARGET=cloudflare next build --webpack && DEPLOY_TARGET=cloudflare opennextjs-cloudflare build && bun run scripts/postbuild-cf.ts", - "preview:cf": "DEPLOY_TARGET=cloudflare next build --webpack && DEPLOY_TARGET=cloudflare opennextjs-cloudflare build && bun run scripts/postbuild-cf.ts && DEPLOY_TARGET=cloudflare opennextjs-cloudflare preview", - "deploy:cf": "bun run scripts/assert-deploy-ready.ts && DEPLOY_TARGET=cloudflare next build --webpack && DEPLOY_TARGET=cloudflare opennextjs-cloudflare build && bun run scripts/postbuild-cf.ts && DEPLOY_TARGET=cloudflare opennextjs-cloudflare deploy --env production", - "cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts", + "build:cf": "DEPLOY_TARGET=cloudflare next build --webpack && DEPLOY_TARGET=cloudflare opennextjs-cloudflare build", + "preview:cf": "DEPLOY_TARGET=cloudflare next build --webpack && DEPLOY_TARGET=cloudflare opennextjs-cloudflare build && DEPLOY_TARGET=cloudflare opennextjs-cloudflare preview --env production", + "deploy:cf": "bun run scripts/assert-deploy-ready.ts && DEPLOY_TARGET=cloudflare next build --webpack && DEPLOY_TARGET=cloudflare opennextjs-cloudflare build && DEPLOY_TARGET=cloudflare opennextjs-cloudflare deploy --env production", + "cf-typegen": "wrangler types --env production --env-interface CloudflareEnv cloudflare-env.d.ts", "pretest": "[ -n \"$TEST_DATABASE_URL\" ] || bun run db:test:up", "test": "bun test", "test:db": "bun test tests/db tests/data tests/auth", diff --git a/plugins/claude-code/.mcp.json b/plugins/claude-code/.mcp.json index a53d10f..c9ad9bd 100644 --- a/plugins/claude-code/.mcp.json +++ b/plugins/claude-code/.mcp.json @@ -2,7 +2,11 @@ "mcpServers": { "mymir": { "type": "http", - "url": "${MYMIR_URL:-http://localhost:3000}/api/mcp" + "url": "${MYMIR_URL:-https://app.mymir.dev}/api/mcp" + }, + "mymir-local": { + "type": "http", + "url": "http://localhost:3000/api/mcp" } } } diff --git a/plugins/codex/.mcp.json b/plugins/codex/.mcp.json index 81caae1..a0bc25f 100644 --- a/plugins/codex/.mcp.json +++ b/plugins/codex/.mcp.json @@ -1,6 +1,10 @@ { "mcpServers": { "mymir": { + "type": "http", + "url": "https://app.mymir.dev/api/mcp" + }, + "mymir-local": { "type": "http", "url": "http://localhost:3000/api/mcp" } diff --git a/plugins/cursor/mcp.json b/plugins/cursor/mcp.json index 6dd1c6d..8f19995 100644 --- a/plugins/cursor/mcp.json +++ b/plugins/cursor/mcp.json @@ -1,6 +1,9 @@ { "mcpServers": { "mymir": { + "url": "https://app.mymir.dev/api/mcp" + }, + "mymir-local": { "url": "http://localhost:3000/api/mcp" } } diff --git a/plugins/gemini/gemini-extension.json b/plugins/gemini/gemini-extension.json index bfe55a2..f261ba8 100644 --- a/plugins/gemini/gemini-extension.json +++ b/plugins/gemini/gemini-extension.json @@ -4,6 +4,9 @@ "description": "Persistent context network for coding projects. Tracks tasks, dependencies, and decisions across sessions.", "mcpServers": { "mymir": { + "httpUrl": "https://app.mymir.dev/api/mcp" + }, + "mymir-local": { "httpUrl": "http://localhost:3000/api/mcp" } } diff --git a/scripts/assert-deploy-ready.ts b/scripts/assert-deploy-ready.ts index 24d8bc0..dc620ef 100644 --- a/scripts/assert-deploy-ready.ts +++ b/scripts/assert-deploy-ready.ts @@ -1,16 +1,23 @@ /** - * Pre-deploy guard: reject `bun run deploy:cf` while `wrangler.jsonc` still - * carries placeholder binding IDs. MYMR-165 provisions the real Cloudflare - * resources; until that lands, every `deploy:cf` should fail loudly here - * rather than ship a Worker bound to non-existent KV / D1 / R2 resources. + * Pre-deploy guard: reject `bun run deploy:cf` while the production + * configuration in `wrangler.jsonc` is not ready. `deploy:cf` targets + * `--env production`; all production bindings live under `env.production` + * so the guard inspects that section (top-level config is reserved for + * `wrangler dev` and intentionally has no real binding IDs, which makes + * an accidental `wrangler deploy` without `--env` fail fast instead of + * misbinding production resources). * * Checked invariants: - * - No KV namespace `id` is all zeros. - * - No D1 `database_id` is the zero UUID. - * - No R2 binding still references the `mymir-placeholder-*` bucket name. - * - `BROKER_DO_SECRET` is registered as a Wrangler secret in the target - * environment (set via `wrangler secret put`). Cannot be checked from - * `wrangler.jsonc` alone, so the script shells out to `wrangler secret list`. + * - `env.production` exists. + * - No KV namespace `id` in `env.production` is all zeros. + * - No D1 `database_id` in `env.production` is the zero UUID. + * - No R2 binding in `env.production` references a `mymir-placeholder-*` bucket. + * - Every required production secret is registered in the production + * Wrangler env: BROKER_DO_SECRET (broker DO HMAC key), BETTER_AUTH_SECRET + * (Better-auth signing key), DATABASE_URL / DATABASE_SERVICE_ROLE_URL / + * DATABASE_AUTH_URL (Neon connection strings for the three DB roles). + * Cannot be checked from `wrangler.jsonc` alone, so the script shells out + * to `wrangler secret list --env production`. * * Run from the `deploy:cf` script chain. Exits with code 1 on any failure * and prints a remediation hint. @@ -38,11 +45,14 @@ interface R2Binding { binding: string; bucket_name: string; } -interface WranglerConfig { +interface WranglerEnvBindings { kv_namespaces?: KvBinding[]; d1_databases?: D1Binding[]; r2_buckets?: R2Binding[]; } +interface WranglerConfig extends WranglerEnvBindings { + env?: { production?: WranglerEnvBindings }; +} /** * Strip `// line` and `/* block *\/` comments so the JSONC config parses @@ -69,61 +79,122 @@ async function readWranglerConfig(): Promise { return JSON.parse(stripJsonc(raw)) as WranglerConfig; } -const failures: string[] = []; +/** + * Print the accumulated failures and abort with exit code 1. + * + * @param failures - Non-empty list of failure messages. + */ +function abortWithFailures(failures: string[]): never { + console.error("\nDeploy aborted — wrangler.jsonc is not production-ready:\n"); + for (const f of failures) console.error(` - ${f}`); + console.error(""); + process.exit(1); +} const cfg = await readWranglerConfig(); +const prod = cfg.env?.production; +if (!prod) { + abortWithFailures([ + "No 'env.production' section found in wrangler.jsonc. " + + "'deploy:cf' targets '--env production' and the production bindings must live under that section.", + ]); +} + +const failures: string[] = []; -for (const kv of cfg.kv_namespaces ?? []) { +for (const kv of prod.kv_namespaces ?? []) { if (kv.id === ZERO_KV_ID) { failures.push( - `KV namespace "${kv.binding}" still has placeholder id ${ZERO_KV_ID}. ` + + `KV namespace "${kv.binding}" in env.production still has placeholder id ${ZERO_KV_ID}. ` + `Provision via 'wrangler kv namespace create' then patch wrangler.jsonc.`, ); } } -for (const d1 of cfg.d1_databases ?? []) { +for (const d1 of prod.d1_databases ?? []) { if (d1.database_id === ZERO_D1_ID) { failures.push( - `D1 database "${d1.binding}" still has placeholder database_id ${ZERO_D1_ID}. ` + + `D1 database "${d1.binding}" in env.production still has placeholder database_id ${ZERO_D1_ID}. ` + `Provision via 'wrangler d1 create ${d1.database_name ?? d1.binding}'.`, ); } } -for (const r2 of cfg.r2_buckets ?? []) { +for (const r2 of prod.r2_buckets ?? []) { if (PLACEHOLDER_BUCKET_RE.test(r2.bucket_name)) { failures.push( - `R2 binding "${r2.binding}" still references placeholder bucket "${r2.bucket_name}". ` + + `R2 binding "${r2.binding}" in env.production still references placeholder bucket "${r2.bucket_name}". ` + `Provision via 'wrangler r2 bucket create' then patch wrangler.jsonc.`, ); } } -const proc = Bun.spawnSync({ - cmd: ["bunx", "wrangler", "secret", "list", "--env", "production"], - stdout: "pipe", - stderr: "pipe", -}); -const secretListStdout = proc.stdout.toString(); -if (proc.exitCode !== 0) { - failures.push( - `Failed to enumerate Wrangler secrets in 'production' env. ` + - `stderr: ${proc.stderr.toString().trim() || "(empty)"}`, - ); -} else if (!secretListStdout.includes("BROKER_DO_SECRET")) { - failures.push( - `BROKER_DO_SECRET is not registered in the 'production' Wrangler env. ` + - `Set it via 'wrangler secret put BROKER_DO_SECRET --env production'. ` + - `Generate a value with 'openssl rand -base64 48'.`, - ); +const REQUIRED_SECRETS = [ + "BROKER_DO_SECRET", + "BETTER_AUTH_SECRET", + "DATABASE_URL", + "DATABASE_SERVICE_ROLE_URL", + "DATABASE_AUTH_URL", +] as const; + +interface WranglerSecretEntry { + name: string; + type: string; +} + +/** + * Resolve the production secret names registered with Wrangler. + * + * Parses `wrangler secret list --env production`'s JSON output and returns + * the exact secret names — substring matching against raw stdout (the + * previous approach) would treat a future `DATABASE_URL_BACKUP` as + * satisfying the `DATABASE_URL` check. + * + * @returns Set of secret names registered in the production env, or null + * when the wrangler invocation failed (with reason appended to the + * failure list out-of-band). + */ +function listProductionSecretNames(failures: string[]): Set | null { + const cmdResult = Bun.spawnSync({ + cmd: ["bunx", "wrangler", "secret", "list", "--env", "production"], + stdout: "pipe", + stderr: "pipe", + }); + if (cmdResult.exitCode !== 0) { + failures.push( + `Failed to enumerate Wrangler secrets in 'production' env. ` + + `stderr: ${cmdResult.stderr.toString().trim() || "(empty)"}`, + ); + return null; + } + const stdout = cmdResult.stdout.toString().trim(); + try { + const parsed = JSON.parse(stdout) as WranglerSecretEntry[]; + return new Set(parsed.map((s) => s.name)); + } catch (err) { + failures.push( + `Could not parse 'wrangler secret list' output as JSON. ` + + `Error: ${err instanceof Error ? err.message : String(err)}. ` + + `Raw stdout (first 200 chars): ${stdout.slice(0, 200)}`, + ); + return null; + } +} + +const presentSecrets = listProductionSecretNames(failures); +if (presentSecrets) { + for (const name of REQUIRED_SECRETS) { + if (!presentSecrets.has(name)) { + failures.push( + `${name} is not registered in the 'production' Wrangler env. ` + + `Set it via 'wrangler secret put ${name} --env production'.`, + ); + } + } } if (failures.length > 0) { - console.error("\nDeploy aborted — wrangler.jsonc is not production-ready:\n"); - for (const f of failures) console.error(` - ${f}`); - console.error(""); - process.exit(1); + abortWithFailures(failures); } console.log("Deploy guard: wrangler.jsonc bindings + secrets look healthy."); diff --git a/scripts/postbuild-cf.ts b/scripts/postbuild-cf.ts deleted file mode 100644 index e08b6ce..0000000 --- a/scripts/postbuild-cf.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Cloudflare post-build glue: add Mymir-specific Durable Object exports - * to the OpenNext worker entrypoint. - * - * OpenNext's CLI generates `.open-next/worker.js` from a fixed template - * that exports only its own built-in Durable Objects (DOQueueHandler, - * DOShardedTagCache, BucketCachePurge). The CLI does not expose an - * extension hook for user-defined DOs that are referenced from - * `wrangler.jsonc`. This script bundles `lib/realtime/broker-do.ts` and - * appends the export to `worker.js` so the `MYMIR_BROKER` binding - * resolves at startup. - */ -import path from "node:path"; -import fs from "node:fs/promises"; - -const ROOT = path.resolve(import.meta.dir, ".."); -const OUT = path.join(ROOT, ".open-next"); -const WORKER = path.join(OUT, "worker.js"); -const DO_DIR = path.join(OUT, ".build", "durable-objects"); -const DO_OUT = path.join(DO_DIR, "mymir-broker.js"); -const DO_SRC = path.join(ROOT, "lib/realtime/broker-do.ts"); - -await fs.mkdir(DO_DIR, { recursive: true }); - -const result = await Bun.build({ - entrypoints: [DO_SRC], - outdir: DO_DIR, - naming: "mymir-broker.js", - format: "esm", - target: "browser", - external: ["cloudflare:workers"], - minify: false, -}); - -if (!result.success) { - for (const log of result.logs) console.error(log); - throw new Error("Failed to bundle MymirBroker Durable Object"); -} - -const workerSource = await fs.readFile(WORKER, "utf8"); -const EXPORT_LINE = `export { MymirBroker } from "./.build/durable-objects/mymir-broker.js";\n`; -if (workerSource.includes('from "./.build/durable-objects/mymir-broker.js"')) { - console.log("worker.js already exports MymirBroker — skipping patch"); -} else { - await fs.writeFile(WORKER, `${workerSource.trimEnd()}\n${EXPORT_LINE}`); - console.log("Patched worker.js to export MymirBroker"); -} - -console.log(`Mymir Durable Object bundled at ${path.relative(ROOT, DO_OUT)}`); diff --git a/scripts/server-only-shim.js b/scripts/server-only-shim.js new file mode 100644 index 0000000..69b1f51 --- /dev/null +++ b/scripts/server-only-shim.js @@ -0,0 +1,15 @@ +/** + * Shim for the `server-only` package on the Cloudflare Workers bundle. + * + * `server-only/index.js` throws at import time so that bundlers building + * client-side code error out. Next.js's webpack picks the package's + * `react-server` export condition (`empty.js` — a no-op), but wrangler's + * esbuild does not honor that condition, so it resolves to the throwing + * default and crashes the worker at startup with code 10021. + * + * Workers always run server-side, so the guard the package provides is + * meaningless here. This file replaces `server-only` with a no-op via the + * `alias` field in `wrangler.jsonc`. + * + * Aliased from: `"alias": { "server-only": "./scripts/server-only-shim.js" }`. + */ diff --git a/tests/db/connection.cloudflare.test.ts b/tests/db/connection.cloudflare.test.ts index 01901f7..c730a89 100644 --- a/tests/db/connection.cloudflare.test.ts +++ b/tests/db/connection.cloudflare.test.ts @@ -1,26 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; /** - * Cloudflare branch of `getScopedOrGlobal` in `lib/db/connection.ts`. The - * proxy must delegate to `autoSeedRequestDb` (from the `request-scope` - * alias indirection) when `DEPLOY_TARGET === "cloudflare"` and no ALS - * frame is active. The seeder is mocked at module-import time so this - * file does not need a live Cloudflare execution context. + * Cloudflare branch of `getScopedOrGlobal` in `lib/db/connection.ts`. + * + * Since the B2 refactor (MYMR-165 follow-up), the Workers path uses the + * same `globalThis`-cached singleton as self-host; the Pool is a + * per-isolate singleton with `maxUses: 1` connections. The legacy + * per-request ALS auto-seed has been removed. + * + * `requestDbStore.run(...)` still wins when an explicit ALS frame is + * active — used by tests that inject sentinels and by any future + * background callers that want explicit scoping. */ -const seedCalls: number[] = []; - mock.module("@/lib/db/request-scope", () => ({ withRequestDb: async (fn: () => Promise): Promise => fn(), - autoSeedRequestDb: () => { - seedCalls.push(seedCalls.length); - const scope = { - appDb: { marker: "auto-app" }, - authDb: { marker: "auto-auth" }, - serviceRoleDb: { marker: "auto-service" }, - }; - return scope as never; - }, })); describe("getScopedOrGlobal on Cloudflare", () => { @@ -29,7 +23,10 @@ describe("getScopedOrGlobal on Cloudflare", () => { beforeEach(() => { originalTarget = process.env.DEPLOY_TARGET; process.env.DEPLOY_TARGET = "cloudflare"; - seedCalls.length = 0; + delete (globalThis as { __mymirAppDb?: unknown }).__mymirAppDb; + delete (globalThis as { __mymirAuthDb?: unknown }).__mymirAuthDb; + delete (globalThis as { __mymirServiceRoleDb?: unknown }) + .__mymirServiceRoleDb; }); afterEach(() => { @@ -40,22 +37,8 @@ describe("getScopedOrGlobal on Cloudflare", () => { } }); - it("auto-seeds via autoSeedRequestDb when no ALS frame is active", async () => { - const { appDb, authDb, serviceRoleDb } = await import( - "@/lib/db/connection" - ); - - expect((appDb as unknown as { marker: string }).marker).toBe("auto-app"); - expect((authDb as unknown as { marker: string }).marker).toBe("auto-auth"); - expect((serviceRoleDb as unknown as { marker: string }).marker).toBe( - "auto-service", - ); - expect(seedCalls.length).toBeGreaterThan(0); - }); - - it("does not invoke autoSeedRequestDb when an ALS frame is already active", async () => { + it("resolves to the seeded scope when an ALS frame is active", async () => { const { appDb, requestDbStore } = await import("@/lib/db/connection"); - seedCalls.length = 0; const sentinel = { marker: "explicit-app" }; requestDbStore.run( @@ -70,7 +53,5 @@ describe("getScopedOrGlobal on Cloudflare", () => { ); }, ); - - expect(seedCalls.length).toBe(0); }); }); diff --git a/tests/db/request-scope.test.ts b/tests/db/request-scope.test.ts deleted file mode 100644 index cb6f188..0000000 --- a/tests/db/request-scope.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; -import type { AppDb, AuthDb, DbBundle } from "@/lib/db/_driver.node"; -import { requestDbStore } from "@/lib/db/connection"; -import { - type PoolBuilders, - withRequestDbCore, -} from "@/lib/db/request-scope.workers"; - -interface RecordedCtx { - waitUntil: ReturnType; - awaited: () => Promise; -} - -/** - * Build a stub `ExecutionContext` that records every `waitUntil` argument. - * The `awaited()` accessor settles each captured promise so individual - * tests can assert on lifecycle errors without keeping a dangling - * rejection. - * - * @returns Recording context plus a helper that drains the captured promises. - */ -function makeRecordingCtx(): RecordedCtx { - const captured: Promise[] = []; - const waitUntil = mock((p: Promise) => { - captured.push(p); - }); - return { - waitUntil, - awaited: () => Promise.all(captured.map((p) => p.catch((err) => err))), - }; -} - -interface StubPool { - end: ReturnType; -} - -/** - * Build a `PoolBuilders` triple whose `db` handles are sentinel objects - * and whose `pool.end()` implementations are configurable per role. - * - * @param ends - Optional per-role overrides for `pool.end()`. - * @returns Builder triple plus references to the underlying mocks. - */ -function makeBuilders(ends?: { - app?: () => Promise; - auth?: () => Promise; - service?: () => Promise; -}): { - builders: PoolBuilders; - pools: { app: StubPool; auth: StubPool; service: StubPool }; - sentinels: { app: object; auth: object; service: object }; -} { - const appPool: StubPool = { - end: mock(ends?.app ?? (async () => undefined)), - }; - const authPool: StubPool = { - end: mock(ends?.auth ?? (async () => undefined)), - }; - const servicePool: StubPool = { - end: mock(ends?.service ?? (async () => undefined)), - }; - - const appSentinel = { role: "app" }; - const authSentinel = { role: "auth" }; - const serviceSentinel = { role: "service" }; - - const builders: PoolBuilders = { - buildAppPool: () => - ({ - pool: appPool, - db: appSentinel as unknown as AppDb, - }) as DbBundle, - buildAuthPool: () => - ({ - pool: authPool, - db: authSentinel as unknown as AuthDb, - }) as DbBundle, - buildServicePool: () => - ({ - pool: servicePool, - db: serviceSentinel as unknown as AppDb, - }) as DbBundle, - }; - - return { - builders, - pools: { app: appPool, auth: authPool, service: servicePool }, - sentinels: { - app: appSentinel, - auth: authSentinel, - service: serviceSentinel, - }, - }; -} - -describe("withRequestDbCore", () => { - let unhandled: Array<{ reason: unknown }>; - let originalHandler: NodeJS.UnhandledRejectionListener[]; - - beforeEach(() => { - unhandled = []; - originalHandler = process.listeners("unhandledRejection"); - process.removeAllListeners("unhandledRejection"); - process.on("unhandledRejection", (reason) => { - unhandled.push({ reason }); - }); - }); - - afterEach(() => { - process.removeAllListeners("unhandledRejection"); - for (const handler of originalHandler) { - process.on("unhandledRejection", handler); - } - }); - - it("seeds the ALS frame with the three role clients", async () => { - const ctx = makeRecordingCtx(); - const { builders, sentinels } = makeBuilders(); - - await withRequestDbCore(ctx, builders, async () => { - const store = requestDbStore.getStore(); - expect(store).toBeDefined(); - expect(store?.appDb).toBe(sentinels.app as never); - expect(store?.authDb).toBe(sentinels.auth as never); - expect(store?.serviceRoleDb).toBe(sentinels.service as never); - }); - }); - - it("schedules pool.end() via a single ctx.waitUntil on success", async () => { - const ctx = makeRecordingCtx(); - const { builders, pools } = makeBuilders(); - - await withRequestDbCore(ctx, builders, async () => "ok"); - - expect(ctx.waitUntil).toHaveBeenCalledTimes(1); - await ctx.awaited(); - expect(pools.app.end).toHaveBeenCalledTimes(1); - expect(pools.auth.end).toHaveBeenCalledTimes(1); - expect(pools.service.end).toHaveBeenCalledTimes(1); - }); - - it("propagates fn() errors and still closes every pool", async () => { - const ctx = makeRecordingCtx(); - const { builders, pools } = makeBuilders(); - const sentinelError = new Error("handler exploded"); - - await expect( - withRequestDbCore(ctx, builders, async () => { - throw sentinelError; - }), - ).rejects.toBe(sentinelError); - - expect(ctx.waitUntil).toHaveBeenCalledTimes(1); - await ctx.awaited(); - expect(pools.app.end).toHaveBeenCalledTimes(1); - expect(pools.auth.end).toHaveBeenCalledTimes(1); - expect(pools.service.end).toHaveBeenCalledTimes(1); - }); - - it("swallows pool.end() rejections into console.error without unhandled rejections", async () => { - const ctx = makeRecordingCtx(); - const { builders } = makeBuilders({ - app: async () => { - throw new Error("socket teardown failed"); - }, - }); - - const errorSpy = mock((..._args: unknown[]) => undefined); - const originalError = console.error; - console.error = errorSpy as unknown as typeof console.error; - - try { - await withRequestDbCore(ctx, builders, async () => "ok"); - await ctx.awaited(); - } finally { - console.error = originalError; - } - - await new Promise((r) => setTimeout(r, 0)); - expect(unhandled).toHaveLength(0); - const calls = errorSpy.mock.calls; - expect( - calls.some( - (args) => - typeof args[0] === "string" && - (args[0] as string).startsWith("[db] pool end failed"), - ), - ).toBe(true); - }); - - it("closes earlier pools when a later builder throws", async () => { - const ctx = makeRecordingCtx(); - const appEnd = mock(async () => undefined); - const authEnd = mock(async () => undefined); - const sentinelError = new Error("service builder boom"); - - const builders = { - buildAppPool: () => - ({ - pool: { end: appEnd }, - db: {} as never, - }) as never, - buildAuthPool: () => - ({ - pool: { end: authEnd }, - db: {} as never, - }) as never, - buildServicePool: () => { - throw sentinelError; - }, - }; - - await expect( - withRequestDbCore(ctx, builders, async () => "ok"), - ).rejects.toBe(sentinelError); - - // Eager fire-and-forget cleanup for app + auth; service was never built. - await new Promise((r) => setTimeout(r, 0)); - expect(appEnd).toHaveBeenCalledTimes(1); - expect(authEnd).toHaveBeenCalledTimes(1); - // No `ctx.waitUntil` because the body never ran; cleanup is direct. - expect(ctx.waitUntil).toHaveBeenCalledTimes(0); - }); -}); diff --git a/tsconfig.json b/tsconfig.json index 77002e1..9688722 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,5 +30,5 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": ["node_modules", "cloudflare-env.d.ts"] + "exclude": ["node_modules", "cloudflare-env.d.ts", ".open-next"] } diff --git a/worker-cf.ts b/worker-cf.ts new file mode 100644 index 0000000..b30bea2 --- /dev/null +++ b/worker-cf.ts @@ -0,0 +1,18 @@ +/** + * Cloudflare Workers entry point for Mymir's OpenNext build. + * + * Re-exports the Durable Object classes wrangler instantiates at runtime + * (`MymirBroker`, `DOQueueHandler`) and delegates `fetch` to the + * OpenNext-generated handler. + */ +import { MymirBroker } from "./lib/realtime/broker-do"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment -- generated entry; tsc resolves it but @ts-expect-error trips "unused directive". +// @ts-ignore generated by `opennextjs-cloudflare build`; resolved at bundle time. +import { default as openNextHandler } from "./.open-next/worker.js"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment -- generated entry, see above. +// @ts-ignore generated by `opennextjs-cloudflare build`. +export { DOQueueHandler } from "./.open-next/worker.js"; +export { MymirBroker }; + +export default openNextHandler; diff --git a/wrangler.jsonc b/wrangler.jsonc index 782c1b9..0420332 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,6 +1,6 @@ { "$schema": "node_modules/wrangler/config-schema.json", - "main": ".open-next/worker.js", + "main": "./worker-cf.ts", "name": "mymir", "compatibility_date": "2026-05-16", "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], @@ -8,36 +8,82 @@ "directory": ".open-next/assets", "binding": "ASSETS" }, - "services": [{ "binding": "WORKER_SELF_REFERENCE", "service": "mymir" }], - "kv_namespaces": [ - { "binding": "AUTH_KV", "id": "00000000000000000000000000000000" } - ], - "r2_buckets": [ - { - "binding": "NEXT_INC_CACHE_R2_BUCKET", - "bucket_name": "mymir-placeholder-cache" - } - ], - "d1_databases": [ - { - "binding": "NEXT_TAG_CACHE_D1", - "database_id": "00000000-0000-0000-0000-000000000000", - "database_name": "mymir-tag-cache" - } - ], - "durable_objects": { - "bindings": [ - { "name": "MYMIR_BROKER", "class_name": "MymirBroker" }, - { "name": "NEXT_CACHE_DO_QUEUE", "class_name": "DOQueueHandler" } - ] - }, - "migrations": [ - { - "tag": "v1", - "new_sqlite_classes": ["MymirBroker", "DOQueueHandler"] - } - ], "vars": { "DEPLOY_TARGET": "cloudflare" + }, + "alias": { + "server-only": "./scripts/server-only-shim.js" + }, + "env": { + "production": { + "name": "mymir-production", + "assets": { + "directory": ".open-next/assets", + "binding": "ASSETS" + }, + "services": [ + { "binding": "WORKER_SELF_REFERENCE", "service": "mymir-production" } + ], + "kv_namespaces": [ + { "binding": "AUTH_KV", "id": "839e8280f3bf47dd95c0a5c4c244033c" } + ], + "r2_buckets": [ + { + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "mymir-isr-cache" + } + ], + "d1_databases": [ + { + "binding": "NEXT_TAG_CACHE_D1", + "database_id": "a4b3426f-ba2e-4358-936d-594697c16908", + "database_name": "mymir-tag-cache" + } + ], + "durable_objects": { + "bindings": [ + { "name": "MYMIR_BROKER", "class_name": "MymirBroker" }, + { "name": "NEXT_CACHE_DO_QUEUE", "class_name": "DOQueueHandler" } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["MymirBroker", "DOQueueHandler"] + } + ], + "ratelimits": [ + { + "name": "RATE_LIMIT_API", + "namespace_id": "1001", + "simple": { "limit": 100, "period": 60 } + }, + { + "name": "RATE_LIMIT_AUTH", + "namespace_id": "1002", + "simple": { "limit": 5, "period": 60 } + } + ], + "routes": [{ "pattern": "app.mymir.dev", "custom_domain": true }], + "observability": { + "enabled": true, + "head_sampling_rate": 1, + "logs": { + "enabled": true, + "head_sampling_rate": 1, + "persist": true, + "invocation_logs": true + }, + "traces": { + "enabled": true, + "persist": true, + "head_sampling_rate": 1 + } + }, + "vars": { + "DEPLOY_TARGET": "cloudflare", + "BETTER_AUTH_URL": "https://app.mymir.dev" + } + } } }