/** * Cloudflare Hyperdrive runtime adapter - RUNTIME ENTRY * * Hyperdrive pools and accelerates connections to an existing PostgreSQL * (or PostgreSQL-compatible, e.g. PlanetScale Postgres) database, letting a * Worker reach it over Cloudflare's network with connection pooling and * query caching. * * Connection lifecycle on Workers * -------------------------------- * A Worker isolate handles many requests, but a database connection (a TCP * socket) is bound to the request that opened it — it cannot be reused by a * later request. A module-global `pg.Pool` therefore breaks: the first request * works, then subsequent requests reusing the isolate's stale pool hang or * error with "Cannot perform I/O on behalf of a different request". * * So this adapter is request-scoped: `createRequestScopedDb` builds a fresh * `pg.Pool` + Kysely for each request and closes it once the response body has * finished streaming. EmDash's middleware stashes that per-request Kysely in * ALS, and the runtime/loader db getters prefer it over the singleton — so all * request-path queries use a connection opened in the current request. * * `createDialect` still builds the per-isolate singleton Kysely, used only for * cold-start migrations (which run inside the first request, so the socket is * valid there). Everything else resolves the connection from ALS at use-time: * the request path through the runtime/loader db getters, and the background and * plugin paths (Cron Trigger sweep, plugin hook contexts, media providers) * through resolvers threaded by the core runtime. The Cron Trigger handler opens * its own event-scoped connection for the sweep. So no warm-isolate path reuses * the singleton's request-bound socket across events. * * Optional split caching (`cachedBinding`) * ---------------------------------------- * Hyperdrive's query cache is unsafe to leave on for the whole app because the * admin, setup, and any read-after-write path can be served a stale pre-write * result within the cache TTL. But anonymous reads of **public-site paths** — * no session, no write, not under `/_emdash` — can tolerate a short staleness * window, so when a `cachedBinding` (pointing at a cache-enabled Hyperdrive * config over the same database) is configured, those requests route through * it and everything else stays on the primary uncached binding: every * authenticated request, every write, and every request under `/_emdash` * (admin, setup, auth, internal APIs) — including anonymous GETs such as the * post-setup status check, which must observe a write made moments earlier. * Migrations and the per-isolate singleton always use the primary binding. * Omit `cachedBinding` and the adapter behaves exactly as before. * * Known limitation — sandboxed plugins are D1-only. The sandbox plugin bridge * (a Durable Object) talks to a D1 binding directly, independent of the * configured adapter, so sandboxed plugins are not available on a Hyperdrive * deployment. This is a pre-existing bridge constraint, unrelated to connection * scoping; tracked in https://github.com/emdash-cms/emdash/issues/1623. * * This module imports directly from cloudflare:workers to access the binding. * Do NOT import it at config time — use { hyperdrive } from * "@emdash-cms/cloudflare" instead. * * Requirements (set in the consuming site's wrangler config): * - `compatibility_flags: ["nodejs_compat"]` * - `compatibility_date >= "2024-09-23"` * - `pg >= 8.16.3` installed in the site */ import { env, waitUntil } from "cloudflare:workers"; import { kyselyLogOption } from "emdash/database/instrumentation"; import { type Dialect, Kysely, PostgresDialect } from "kysely"; // `pg` is provided by the consuming site (an optional peer of `emdash`); it is // kept external from this package's bundle. import { Pool } from "pg"; /** * Hyperdrive configuration (runtime type — matches the config-time type in * index.ts). */ interface HyperdriveConfig { binding: string; max?: number; /** * Optional binding for a cache-enabled Hyperdrive config over the same * database. When set, anonymous read requests route through it; all * authenticated requests and all writes stay on `binding`. */ cachedBinding?: string; } /** * Minimal shape of a Hyperdrive binding. Workers inject `connectionString` * (and the discrete parts) at runtime; we only need the string for pg. */ interface HyperdriveBinding { connectionString: string; } const DEFAULT_MAX = 5; /** * Build a fresh node-postgres Pool for the given connection string. * * Hyperdrive owns the real pool to the origin; the in-Worker pool just feeds * connections to the current request. The per-request pool is closed * explicitly once the response has streamed (see `close()` in * `createRequestScopedDb`), so no idle-reaper is needed. */ function createPool(connectionString: string, max: number): Pool { return new Pool({ connectionString, max, // Disable pg's idle-reaper timer. In workerd a socket is owned by the // request that opened it; a background timer set in one request that // later fires and touches that socket performs I/O "on behalf of a // different request", which workerd hangs on. Pools are torn down // explicitly instead, so the reaper is unnecessary. idleTimeoutMillis: 0, }); } /** * Create a PostgreSQL dialect backed by a Hyperdrive binding. * * Used for the per-isolate singleton Kysely, which serves cold-start migrations * only. The request path reads through `createRequestScopedDb`, and the * background/plugin paths resolve an event-scoped connection from ALS, so * neither reuses this singleton's request-bound socket across events. */ export function createDialect(config: HyperdriveConfig): Dialect { const binding = requireBinding(config); // Cold-start migrations are sequential, so a single connection is enough, // and keeping it to 1 leaves the bulk of Hyperdrive's connection budget for // the per-request pools. return new PostgresDialect({ pool: createPool(binding.connectionString, 1) }); } /** * A cookie interface minimally compatible with Astro's AstroCookies. Declared * here (not imported from astro) so this module stays free of astro types. */ interface CookieJar { get(name: string): { value: string } | undefined; set(name: string, value: string, options: Record): void; } export interface RequestScopedDbOpts { config: HyperdriveConfig; isAuthenticated: boolean; isWrite: boolean; cookies: CookieJar; url: URL; } export interface RequestScopedDb { /** Per-request Kysely instance backed by a fresh pg Pool. */ db: Kysely; /** * No per-request state to persist (Hyperdrive routes and caches itself, so * there are no bookmark cookies). Kept to satisfy the adapter contract. */ commit: () => void; /** * Close the per-request pool. The middleware calls this once the response * body has fully streamed — not before — because Astro streams HTML and the * Live loader issues queries while the body streams; tearing the pool down * any earlier yields "driver has already been destroyed". Draining is handed * to `waitUntil` so it never blocks, while the socket stays valid for the * whole request it was opened in. */ close: () => void; } /** * Create a fresh, request-scoped Kysely backed by its own pg Pool. EmDash * middleware calls this once per request, stashes `db` in ALS for the duration * of next(), then closes it once the response body has streamed. * * Hyperdrive itself routes reads/writes and handles caching, so this adapter * does not need bookmark cookies or read-replica constraints — every request * gets an equivalent connection. */ export function createRequestScopedDb(opts: RequestScopedDbOpts): RequestScopedDb | null { const bindingName = selectBindingName(opts.config, { isAuthenticated: opts.isAuthenticated, isWrite: opts.isWrite, url: opts.url, }); let binding = getBinding(bindingName); // If the cached binding was selected but isn't present at runtime (e.g. // misconfigured wrangler), fall back to the primary binding rather than // dropping to the singleton — the primary is the safe, always-correct choice. if (!binding?.connectionString && bindingName !== opts.config.binding) { binding = getBinding(opts.config.binding); } // No binding at runtime: fall back to the singleton path (which will throw // a descriptive error if the binding is genuinely missing). if (!binding?.connectionString) return null; const pool = createPool(binding.connectionString, opts.config.max ?? DEFAULT_MAX); const db = new Kysely({ dialect: new PostgresDialect({ pool }), // Mirror the D1 adapter and the runtime singleton: route per-request // queries through the instrumentation logger so db.* Server-Timing // counters and EMDASH_QUERY_LOG capture Hyperdrive queries too. The // singleton built by createDialect gets this from core (it wraps the // dialect in a logged Kysely), but this request-scoped Kysely is built // here, so it must opt in itself. log: kyselyLogOption(), }); let closed = false; return { db, // No bookmark/cookie state for Hyperdrive. commit() {}, close() { if (closed) return; closed = true; // Destroy the Kysely (and its pool) once the body has streamed. // waitUntil keeps the isolate alive to drain without delaying the // response. The socket was opened in this request and is closed within // it, so there's no cross-request I/O. waitUntil( db.destroy().catch((error: unknown) => { console.error("[emdash][hyperdrive] failed to close request pool:", error); }), ); }, }; } /** * EmDash's admin base path. Requests under this prefix (the admin UI, the * setup wizard, auth, and every internal `/_emdash/api/*` route) must never be * served from the query cache — they include read-after-write-sensitive reads * that are issued *anonymously* (e.g. the post-setup `GET /_emdash/api/setup/ * status` runs before any session exists). Only genuinely public site reads * are safe to cache. */ const EMDASH_BASE_PATH = "/_emdash"; function isEmDashInternalPath(url: URL): boolean { return url.pathname === EMDASH_BASE_PATH || url.pathname.startsWith(`${EMDASH_BASE_PATH}/`); } /** * Decide which binding a given request should use. * * The cache-enabled binding (when configured) is used only for **anonymous * reads of public-site paths** — the one class of request that tolerates a * short staleness window. Everything else uses the primary uncached binding to * preserve read-after-write consistency: * * - authenticated requests (editors/authors) → primary; * - writes (`POST`/`PUT`/`DELETE`) → primary; * - **any request under `/_emdash`** (admin, setup, auth, internal APIs), * even an anonymous GET → primary. The setup-status and login-state reads * are anonymous GETs that must see writes made moments earlier; routing them * to the cache would loop the setup wizard and show stale auth state. * * Pure (no I/O) so the routing rule can be unit-tested directly. */ export function selectBindingName( config: HyperdriveConfig, opts: { isAuthenticated: boolean; isWrite: boolean; url: URL }, ): string { if ( config.cachedBinding && !opts.isAuthenticated && !opts.isWrite && !isEmDashInternalPath(opts.url) ) { return config.cachedBinding; } return config.binding; } function getBinding(bindingName: string): HyperdriveBinding | null { // eslint-disable-next-line typescript/no-unsafe-type-assertion -- Worker binding accessed from untyped env object const binding = (env as Record)[bindingName] as HyperdriveBinding | undefined; return binding ?? null; } function requireBinding(config: HyperdriveConfig): HyperdriveBinding { // Migrations and the per-isolate singleton always use the primary binding — // never the cache-enabled one. const binding = getBinding(config.binding); if (!binding) { const example = JSON.stringify( { hyperdrive: [{ binding: config.binding, id: "" }] }, null, 2, ); throw new Error( `Hyperdrive binding "${config.binding}" not found in environment. ` + `Check your wrangler.jsonc configuration:\n\n${example}\n\n` + `Hyperdrive also requires compatibility_flags: ["nodejs_compat"] and ` + `compatibility_date >= "2024-09-23".`, ); } if (!binding.connectionString) { throw new Error( `Hyperdrive binding "${config.binding}" is present but has no connectionString. ` + `Ensure the binding points at a valid Hyperdrive configuration.`, ); } return binding; }