344 lines
14 KiB
TypeScript
344 lines
14 KiB
TypeScript
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 { 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<string, string> = {
|
|
"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<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, server: any): Promise<Response | undefined> {
|
|
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,
|
|
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<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, 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<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 (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"})`,
|
|
);
|