Files
whisper_web/src/index.ts
T

200 lines
7.9 KiB
TypeScript

import { serve } from "bun";
import { join, normalize, sep } from "node:path";
import { ensureIcons, ICONS_DIR } from "../scripts/generate-icons.ts";
import { injectPrecacheManifest } from "./pwa/precache.ts";
import { auth } from "./server/auth.ts";
import { AUTH_ENABLED } from "./server/flags.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 });
// 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<string, string> = {
".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<Response> {
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<Response> {
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): Promise<Response> {
const { pathname } = new URL(request.url);
// Runtime feature flag (D-015): with auth disabled, the endpoints don't
// exist — the client's /me probe reads the 404 as "feature off".
if (!AUTH_ENABLED && pathname.startsWith("/api/auth/")) return NOT_FOUND();
switch (`${request.method} ${pathname}`) {
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);
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<typeof serve>[0];
async function productionServeOptions(): Promise<ServeOptions> {
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) {
const url = new URL(request.url);
// API routes are dynamic (M4/M5) and network-only in the SW (D-007).
if (url.pathname.startsWith("/api/")) return handleApiRequest(request);
const relative = url.pathname === "/" ? "index.html" : decodeURIComponent(url.pathname.slice(1));
const path = normalize(join(dist, relative));
if (!path.startsWith(dist + sep)) 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 response;
// SPA fallback: serve the app shell for unknown navigations.
return 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")`; the service
* worker registers as "/sw.js".
*/
async function bundleEntrypoint(relativePath: string): Promise<string> {
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,
});
const artifact = build.outputs[0];
if (!artifact) throw new Error(`Failed to bundle src/${relativePath}`);
return artifact.text();
}
async function developmentServeOptions(): Promise<ServeOptions> {
// src/pwa/icons/*.png are generated, not checked in; index.html links them,
// so they must exist before the HTML import bundles it.
await ensureIcons();
const { default: index } = await import("./index.html");
const workerScript = await bundleEntrypoint("./worker/inference.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: {
"/inference.worker.js": new Response(workerScript, { headers: scriptHeaders }),
"/sw.js": new Response(swScript, {
// The browser's SW update check must always see fresh bytes.
headers: { ...scriptHeaders, "Cache-Control": "no-cache" },
}),
"/manifest.webmanifest": () =>
serveFile(join(import.meta.dir, "pwa/manifest.webmanifest")),
"/icons/:file": (request) => {
const name = request.params.file ?? "";
return /^icon-\d+\.png$/.test(name)
? serveFile(join(ICONS_DIR, name))
: NOT_FOUND();
},
"/ort/:file": (request) => serveOrtFile(ORT_DIST, request.params.file ?? ""),
// API routes go through the same dispatcher production uses.
"/api/*": handleApiRequest,
// Serve the bundled React app for all other routes.
"/*": index,
},
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;
const server = serve(options);
console.log(
`Server running at ${server.url} (${IS_PRODUCTION ? "production, serving dist/" : "development"})`,
);