/** * Bun runtime adapter — `Bun.serve`, `bun:sqlite`, `Bun.password`. * * Uses Bun native `routes` for HTTP paths the engine can express, with * `fetch` as the fallback. Every request passes Host / Origin validation * before the app pipeline (console §10.1). */ import { Database } from "bun:sqlite"; import { createBunCrypto, createEnv, createFiles, createTimers } from "./primitives.ts"; import { secureFetch } from "./security.ts"; import { APP_PORT, type FetchApp, type Runtime, type ServeOptions, type ServerHandle, } from "./types.ts"; /** Bun adapter — extends {@link Runtime} with native sqlite. */ export interface BunRuntime extends Runtime { readonly name: "bun"; /** * Open a `bun:sqlite` database (native binding). * * @param filename - Path, `":memory:"`, or omit for memory * @param options - `bun:sqlite` open options */ sqlite(filename?: string, options?: ConstructorParameters[1]): Database; } /** * Create the Bun runtime adapter. */ export function createBunRuntime(): BunRuntime { return { name: "bun", timers: createTimers(), crypto: createBunCrypto(), env: createEnv(), files: createFiles(), sqlite(filename = ":memory:", options) { return options === undefined ? new Database(filename) : new Database(filename, options); }, serve(app, options) { return listenBun(app, options); }, }; } /** * True when a path can be registered on Bun.serve `routes` * (static segments and `:param` only). * * @param path - Route path pattern */ export function isBunNativePath(path: string): boolean { if (!path.startsWith("/")) return false; // Reject optional params, regex, wildcards Bun's object routes may not mirror 1:1 if (/[*?{}()]/.test(path)) return false; if (/\/:[^/]+\?/.test(path)) return false; return true; } /** * Methods Bun.serve `routes` accepts. QUERY (RFC 10008) is not among * them — registering it throws `ERR_INVALID_ARG_TYPE`. Those verbs stay * on the `fetch` fallback, which the app router already handles. * * @param method - HTTP method */ export function isBunNativeMethod(method: string): boolean { return ( method === "GET" || method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE" || method === "HEAD" || method === "OPTIONS" ); } /** Bun.serve `fetch` / route handler — second arg is the live server. */ type ServeFetch = ( req: Request, server: { timeout(request: Request, seconds: number): void }, ) => Response | Promise; type MethodHandlers = Partial>; /** * Disable the 10s idle timeout for SSE so a quiet token stream is not reset. * * @param inner - App fetch */ function holdSseIdle(inner: (request: Request) => Promise): ServeFetch { return async (req, server) => { const res = await inner(req); const ct = (res.headers.get("content-type") ?? "").split(";")[0]?.trim() ?? ""; if (/^text\/event-stream$/i.test(ct)) { server.timeout(req, 0); } return res; }; } /** * Build Bun.serve `routes` from app HTTP bindings. * Each handler runs the secured fetch pipeline (identical behaviour). * * @param app - Application * @param fetchHandler - Secured fetch */ export function buildBunRoutes( app: FetchApp, fetchHandler: (request: Request) => Promise, ): Record { const routes: Record = {}; const bindings = app.bindings ?? []; for (const b of bindings) { if (b.trigger.kind !== "http") continue; const path = b.trigger.path; const method = b.trigger.method; if (typeof path !== "string" || typeof method !== "string") continue; if (!isBunNativePath(path) || !isBunNativeMethod(method)) continue; const methods = routes[path] ?? (routes[path] = {}); methods[method] = holdSseIdle(fetchHandler); } return routes; } function isBunInvalidRoutesError(err: unknown): boolean { if (!(err instanceof TypeError)) return false; const code = (err as { code?: unknown }).code; return code === "ERR_INVALID_ARG_TYPE" || err.message.includes("'routes' expects"); } /** * Open {@link Bun.serve}. Native `routes` is best-effort — Bun rejects * verbs it does not know (QUERY) with a long HTML-bundling example. * Fall back to `fetch` only; do not surface that dump. * * @param options - Listen options */ export function serveBunHttp(options: { readonly port: number; readonly hostname: string; readonly fetch: (request: Request) => Response | Promise; readonly routes?: Record; readonly id?: string; readonly maxRequestBodySize?: number; }): ReturnType { const fetch = holdSseIdle( typeof options.fetch === "function" ? (req: Request) => Promise.resolve(options.fetch(req)) : options.fetch, ); const base = { port: options.port, hostname: options.hostname, maxRequestBodySize: options.maxRequestBodySize ?? 1_048_576, fetch, ...(options.id !== undefined ? { id: options.id } : {}), }; const table = options.routes; if (table === undefined || Object.keys(table).length === 0) { return Bun.serve(base); } try { return Bun.serve({ ...base, routes: table }); } catch (err) { if (!isBunInvalidRoutesError(err)) throw err; return Bun.serve(base); } } function listenBun(app: FetchApp, options?: ServeOptions): ServerHandle { const port = options?.port ?? APP_PORT; const hostname = options?.hostname ?? "127.0.0.1"; const fetchHandler = secureFetch((req) => app.fetch(req), options, hostname); const routes = buildBunRoutes(app, fetchHandler); const server = serveBunHttp({ port, hostname, fetch: fetchHandler, routes, ...(options?.id !== undefined ? { id: options.id } : {}), ...(options?.maxRequestBodySize !== undefined ? { maxRequestBodySize: options.maxRequestBodySize } : {}), }); const closeIdle = ( server as typeof server & { closeIdleConnections(): void } ).closeIdleConnections.bind(server); const onPressure = (): void => { closeIdle(); }; const proc = process as NodeJS.EventEmitter; proc.on("memoryPressure", onPressure); const boundPort = server.port ?? port; const boundHost = server.hostname ?? hostname; const url = new URL(`http://${formatHostForUrl(boundHost)}:${boundPort}/`); return { url, port: boundPort, hostname: boundHost, fetch: fetchHandler, stop(closeActiveConnections = false) { proc.off("memoryPressure", onPressure); return server.stop(closeActiveConnections); }, }; } /** * Format a hostname for use in a URL (bracket IPv6). * * @param host - Hostname */ function formatHostForUrl(host: string): string { if (host.includes(":") && !host.startsWith("[")) return `[${host}]`; return host; }