import { serve } from "bun"; import { tmpdir } from "node:os"; import { join, normalize, sep } from "node:path"; import { ensureFonts } from "../scripts/fonts.ts"; import { ensureIcons, ICONS_DIR } from "../scripts/generate-icons.ts"; import { ortAliasPlugin } from "../scripts/ortAlias.ts"; import { injectPrecacheManifest } from "./pwa/precache.ts"; import { auth } from "./server/auth.ts"; import { AUTH_ENABLED } from "./server/flags.ts"; import { MAX_UPLOAD_BYTES, parseLanguageAndTask, sidecar, transcribe } from "./server/transcribe.ts"; import { liveWebSocket } from "./server/live.ts"; import { getFasterWhisperModel } from "./lib/models.ts"; const ROOT = join(import.meta.dir, ".."); const IS_PRODUCTION = process.env.NODE_ENV === "production"; // --------------------------------------------------------------------------- // Shared static helpers // --------------------------------------------------------------------------- const NOT_FOUND = () => new Response("Not Found", { status: 404 }); /** * Cross-origin isolation (D-023): COOP + COEP make `crossOriginIsolated` * true in the browser, which unlocks SharedArrayBuffer — without it, * onnxruntime-web silently runs its WASM backend on a SINGLE thread and * browser inference is several times slower than it should be. Applied to * every response: only documents/workers consume them, and they are harmless * on assets and API responses. Cross-origin model downloads keep working — * the Hugging Face Hub is fetched with CORS, which `require-corp` allows. */ const ISOLATION_HEADERS: Record = { "Cross-Origin-Opener-Policy": "same-origin", "Cross-Origin-Embedder-Policy": "require-corp", }; function withIsolationHeaders(response: Response): Response { try { for (const [name, value] of Object.entries(ISOLATION_HEADERS)) { response.headers.set(name, value); } return response; } catch { // Responses produced by fetch() have immutable headers — rebuild. const copy = new Response(response.body, response); for (const [name, value] of Object.entries(ISOLATION_HEADERS)) { copy.headers.set(name, value); } return copy; } } // onnxruntime-web fetches its .wasm binaries at runtime — from the jsdelivr // CDN by default, which would break offline. The worker points // `env.backends.onnx.wasm.wasmPaths` at /ort/ instead (see // src/worker/inference.worker.ts), we serve the files from node_modules // here (production: copied into dist/ort by scripts/build.ts), and the // service worker runtime-caches them cache-first. const ORT_DIST = join(ROOT, "node_modules/onnxruntime-web/dist"); export const ORT_FILE_RE = /^ort-wasm-simd-threaded(\.jsep|\.jspi|\.asyncify)?\.(wasm|mjs)$/; const CONTENT_TYPES: Record = { ".wasm": "application/wasm", ".mjs": "text/javascript; charset=utf-8", ".webmanifest": "application/manifest+json", }; function contentTypeFor(path: string): string | undefined { const dot = path.lastIndexOf("."); return dot === -1 ? undefined : CONTENT_TYPES[path.slice(dot)]; } async function serveFile(path: string, cacheControl?: string): Promise { const file = Bun.file(path); if (!(await file.exists())) return NOT_FOUND(); const headers = new Headers(); const type = contentTypeFor(path); if (type) headers.set("Content-Type", type); if (cacheControl) headers.set("Cache-Control", cacheControl); return new Response(file, { headers }); } async function serveOrtFile(dir: string, name: string): Promise { if (!ORT_FILE_RE.test(name)) return NOT_FOUND(); return serveFile(join(dir, name)); } // --------------------------------------------------------------------------- // API dispatcher — shared by development and production so /api/* behaves // identically in both serve modes (the SW treats it as network-only, D-007). // Auth endpoints are M4 (src/server/auth.ts); /api/transcribe joins in M5. // --------------------------------------------------------------------------- async function handleApiRequest(request: Request, server: any): Promise { const { pathname } = new URL(request.url); const apiIndex = pathname.indexOf("/api/"); const apiPath = apiIndex !== -1 ? pathname.slice(apiIndex) : pathname; // Runtime feature flag (D-015): with auth disabled, the endpoints don't // exist — the client's /me probe reads the 404 as "feature off". The // server engine (M5) is session-gated, so it disappears along with auth. if ( !AUTH_ENABLED && (apiPath.startsWith("/api/auth/") || apiPath.startsWith("/api/transcribe")) ) { return NOT_FOUND(); } switch (`${request.method} ${apiPath}`) { case "POST /api/auth/register": return auth.register(request); case "POST /api/auth/login": return auth.login(request); case "POST /api/auth/logout": return auth.logout(request); case "GET /api/auth/me": return auth.me(request); case "GET /api/transcribe/status": // The first probe spawns the sidecar and waits for faster-whisper to // import (READY_TIMEOUT_MS = 60 s) — give it headroom over Bun's 10 s // default idle timeout. server.timeout(request, 90); return transcribe.status(request); case "POST /api/transcribe": // Long-running ndjson stream (D-021): quiet stretches (model // load/download, slow decode windows) exceed Bun's 10 s idle timeout, // which would reset the connection mid-transcription. Heartbeat lines // (transcribe.ts) cover proxies; disable Bun's timer entirely here. server.timeout(request, 0); return transcribe.post(request); case "GET /api/transcribe/live": { const user = auth.getSessionUser(request); if (!user) return new Response("Unauthorized", { status: 401 }); const url = new URL(request.url); const modelId = url.searchParams.get("model"); if (!modelId) return new Response("Missing model", { status: 400 }); const fasterWhisperModel = getFasterWhisperModel(modelId); if (!fasterWhisperModel) return new Response("Unknown model", { status: 400 }); // Same validation as POST /api/transcribe (D-030-adjacent: never forward // an unvalidated task/language string into the sidecar protocol just // because this entry point is a WebSocket, not JSON over HTTP). const options = parseLanguageAndTask(url); if (typeof options === "string") return new Response(options, { status: 400 }); // Fail fast like the HTTP route (D-013 "degrade gracefully") instead of // opening a socket that can only report unavailability once the first // segment reaches the sidecar. const status = await sidecar.status(); if (!status.available) { return new Response(status.reason ?? "server engine unavailable", { status: 503 }); } const success = server.upgrade(request, { data: { user, model: fasterWhisperModel, language: options.language, task: options.task, prompt: options.prompt, tempDir: tmpdir(), }, }); if (success) return undefined; return new Response("WebSocket upgrade failed", { status: 500 }); } default: return NOT_FOUND(); } } // --------------------------------------------------------------------------- // Production: serve the prebuilt dist/ directory (created by `bun run build`) // so the URLs match the hashed asset paths precached by dist/sw.js (D-007). // --------------------------------------------------------------------------- type ServeOptions = Parameters[0]; async function productionServeOptions(): Promise { const dist = join(ROOT, "dist"); if (!(await Bun.file(join(dist, "sw.js")).exists())) { throw new Error("dist/ is missing or incomplete — run `bun run build` first."); } return { development: false, async fetch(request, server) { const url = new URL(request.url); // API routes are dynamic (M4/M5) and network-only in the SW (D-007). if (url.pathname.includes("/api/")) { const response = await handleApiRequest(request, server); if (response) return withIsolationHeaders(response); return new Response("Upgrading...", { status: 101 }); // actually Bun handles the real response, but TypeScript requires a return } let relative = url.pathname === "/" ? "index.html" : decodeURIComponent(url.pathname.slice(1)); if (relative.endsWith("/")) relative += "index.html"; let path = normalize(join(dist, relative)); // Auto-strip proxy subpaths for static assets while (relative.includes("/") && !(await Bun.file(path).exists())) { relative = relative.substring(relative.indexOf("/") + 1); path = normalize(join(dist, relative)); } if (!path.startsWith(dist + sep) && path !== dist) return NOT_FOUND(); // traversal guard const response = await serveFile( path, // Hashed chunks are immutable; everything else must revalidate so the // SW update flow sees new versions. /(^|\/)(chunk|index)-[a-z0-9]+\./.test(relative) ? "public, max-age=31536000, immutable" : "no-cache", ); if (response.status !== 404) return withIsolationHeaders(response); // SPA fallback: serve the app shell for unknown navigations. return withIsolationHeaders(await serveFile(join(dist, "index.html"), "no-cache")); }, }; } // --------------------------------------------------------------------------- // Development: HTML imports + on-the-fly bundles // --------------------------------------------------------------------------- /** * Bun's bundler does not currently follow `new Worker(...)` graphs (verified * with Bun 1.3: both `bun build` and the dev server leave the call untouched), * so worker-like entrypoints are bundled at startup and served from stable * URLs. The UI spawns `new Worker("/inference.worker.js")` (and * "/translate.worker.js" for transcript translation, D-WS4b); the service * worker registers as "/sw.js". */ async function bundleEntrypoint(relativePath: string): Promise { const build = await Bun.build({ entrypoints: [join(import.meta.dir, relativePath)], target: "browser", format: "esm", // Never minify: sw.ts relies on its placeholder string literals surviving // bundling verbatim (src/pwa/precache.ts), and dev builds favor debuggability. minify: false, // One onnxruntime in the worker bundle (D-WS7a); harmless for sw.ts. plugins: [ortAliasPlugin], }); const artifact = build.outputs[0]; if (!artifact) throw new Error(`Failed to bundle src/${relativePath}`); return artifact.text(); } async function developmentServeOptions(): Promise { // src/pwa/icons/*.png are generated (and checked in); index.html links them, // so regenerate any missing ones before the HTML import bundles it. await ensureIcons(); // Likewise src/fonts/ (self-hosted Inter, scripts/fonts.ts) for index.css. await ensureFonts(); const { default: index } = await import("./index.html"); const workerScript = await bundleEntrypoint("./worker/inference.worker.ts"); // Transcript translation runs in its own worker (D-WS4b). const translateWorkerScript = await bundleEntrypoint("./worker/translate.worker.ts"); // Dev SW: empty precache list — assets are unhashed here, so the shell is // runtime-cached network-first instead (see src/pwa/sw.ts). const swScript = injectPrecacheManifest( await bundleEntrypoint("./pwa/sw.ts"), [], "dev", ); const scriptHeaders = { "Content-Type": "text/javascript; charset=utf-8" }; return { routes: { // The HTML import is an HTMLBundle, which Bun only serves as a route // value — returning it from fetch() throws "Expected a Response object". // It is mounted on an internal path so "/" can flow through fetch() // below, which re-serves it with the COOP/COEP isolation headers // (D-023) that Bun cannot attach to an HTMLBundle route directly. "/__shell": index, }, // Fallback for everything the route table doesn't match, including // subpath-proxied requests (/proxy/api/..., /proxy/sw.js, ...). async fetch(request, server) { const url = new URL(request.url); const pathname = url.pathname; if (pathname.includes("/api/")) { const response = await handleApiRequest(request, server); if (response) return withIsolationHeaders(response); return new Response("Upgrading...", { status: 101 }); } if (pathname.endsWith("/inference.worker.js")) { return withIsolationHeaders(new Response(workerScript, { headers: scriptHeaders })); } if (pathname.endsWith("/translate.worker.js")) { return withIsolationHeaders( new Response(translateWorkerScript, { headers: scriptHeaders }), ); } if (pathname.endsWith("/sw.js")) { return withIsolationHeaders( new Response(swScript, { headers: { ...scriptHeaders, "Cache-Control": "no-cache" } }), ); } if (pathname.endsWith("/manifest.webmanifest")) { return withIsolationHeaders(await serveFile(join(import.meta.dir, "pwa/manifest.webmanifest"))); } const iconsMatch = pathname.match(/\/icons\/(icon-\d+\.png)$/); if (iconsMatch?.[1]) { return withIsolationHeaders(await serveFile(join(ICONS_DIR, iconsMatch[1]))); } const ortMatch = pathname.match(/\/ort\/(.*(?:wasm|mjs))$/); if (ortMatch?.[1]) { return withIsolationHeaders(await serveOrtFile(ORT_DIST, ortMatch[1])); } // The app shell ("/" and SPA fallback for unknown navigations, // including subpath ones). The HTMLBundle itself can only live in // `routes`, so re-enter the server at "/__shell" to let it produce the // dev-bundled HTML, then stamp the isolation headers on the document. return withIsolationHeaders(await fetch(new URL("/__shell", server.url))); }, development: { // Enable browser hot reloading in development hmr: true, // Echo console logs from the browser to the server console: true, }, }; } // --------------------------------------------------------------------------- const options = IS_PRODUCTION ? await productionServeOptions() : await developmentServeOptions(); options.port = process.env.PORT || 3000; // Bun's default cap is 128 MB, which would 4xx a /api/transcribe upload of // more than ~35 minutes of raw PCM before the route's own limit applies. options.maxRequestBodySize = MAX_UPLOAD_BYTES; options.websocket = liveWebSocket; const server = serve(options); console.log( `Server running at ${server.url} (${IS_PRODUCTION ? "production, serving dist/" : "development"})`, );