subpath feature

This commit is contained in:
Lucas Winther
2026-07-10 10:13:37 +02:00
parent 2d668b3fb8
commit 076efc2101
11 changed files with 32 additions and 31 deletions
+3 -3
View File
@@ -79,9 +79,9 @@ const swSource = await swBuild.outputs[0]!.text();
const precachePaths = precachePathsFromOutputs( const precachePaths = precachePathsFromOutputs(
shellBuild.outputs.map((output) => relative(DIST, output.path)), shellBuild.outputs.map((output) => relative(DIST, output.path)),
[ [
"/inference.worker.js", "inference.worker.js",
"/manifest.webmanifest", "manifest.webmanifest",
...ICON_SIZES.map((size) => `/icons/icon-${size}.png`), ...ICON_SIZES.map((size) => `icons/icon-${size}.png`),
], ],
); );
const version = Bun.hash( const version = Bun.hash(
+1 -1
View File
@@ -120,7 +120,7 @@ export function App() {
// main thread (Architecture.md §5). The worker script is bundled by the // main thread (Architecture.md §5). The worker script is bundled by the
// server at startup and served at a stable URL (see src/index.ts) because // server at startup and served at a stable URL (see src/index.ts) because
// Bun's bundler does not follow `new Worker(new URL(...))` graphs yet. // Bun's bundler does not follow `new Worker(new URL(...))` graphs yet.
const worker = new Worker("/inference.worker.js", { type: "module" }); const worker = new Worker("inference.worker.js", { type: "module" });
worker.addEventListener("message", (event: MessageEvent<unknown>) => { worker.addEventListener("message", (event: MessageEvent<unknown>) => {
const message = event.data; const message = event.data;
if (!isWorkerToUiMessage(message)) return; if (!isWorkerToUiMessage(message)) return;
+4 -4
View File
@@ -122,7 +122,7 @@ export function login(
password: string, password: string,
fetchFn: FetchLike = defaultFetch, fetchFn: FetchLike = defaultFetch,
): Promise<AuthUser> { ): Promise<AuthUser> {
return requestUser(fetchFn, "/api/auth/login", { username, password }); return requestUser(fetchFn, "api/auth/login", { username, password });
} }
/** POST /api/auth/register → user (auto signed in), or throws (400/409). */ /** POST /api/auth/register → user (auto signed in), or throws (400/409). */
@@ -131,7 +131,7 @@ export function register(
password: string, password: string,
fetchFn: FetchLike = defaultFetch, fetchFn: FetchLike = defaultFetch,
): Promise<AuthUser> { ): Promise<AuthUser> {
return requestUser(fetchFn, "/api/auth/register", { username, password }); return requestUser(fetchFn, "api/auth/register", { username, password });
} }
/** Result of the startup /me probe (fetchAuthStatus). */ /** Result of the startup /me probe (fetchAuthStatus). */
@@ -158,7 +158,7 @@ export async function fetchAuthStatus(
fetchFn: FetchLike = defaultFetch, fetchFn: FetchLike = defaultFetch,
): Promise<AuthStatus> { ): Promise<AuthStatus> {
try { try {
const response = await fetchFn("/api/auth/me", { const response = await fetchFn("api/auth/me", {
method: "GET", method: "GET",
credentials: "same-origin", credentials: "same-origin",
}); });
@@ -179,7 +179,7 @@ export async function fetchAuthStatus(
*/ */
export async function logout(fetchFn: FetchLike = defaultFetch): Promise<void> { export async function logout(fetchFn: FetchLike = defaultFetch): Promise<void> {
try { try {
await fetchFn("/api/auth/logout", { await fetchFn("api/auth/logout", {
method: "POST", method: "POST",
credentials: "same-origin", credentials: "same-origin",
}); });
+2 -2
View File
@@ -263,7 +263,7 @@ export class ServerEngine implements Engine {
let response: Response; let response: Response;
try { try {
response = await this.fetchFn(`/api/transcribe?${params}`, { response = await this.fetchFn(`api/transcribe?${params}`, {
method: "POST", method: "POST",
credentials: "same-origin", credentials: "same-origin",
headers: { "Content-Type": "application/octet-stream" }, headers: { "Content-Type": "application/octet-stream" },
@@ -327,7 +327,7 @@ export async function fetchServerEngineStatus(
fetchFn: FetchLike = defaultFetch, fetchFn: FetchLike = defaultFetch,
): Promise<ServerEngineStatus> { ): Promise<ServerEngineStatus> {
try { try {
const response = await fetchFn("/api/transcribe/status", { const response = await fetchFn("api/transcribe/status", {
method: "GET", method: "GET",
credentials: "same-origin", credentials: "same-origin",
}); });
+5 -5
View File
@@ -2,21 +2,21 @@
"name": "Whisper Web — on-device speech-to-text", "name": "Whisper Web — on-device speech-to-text",
"short_name": "Whisper Web", "short_name": "Whisper Web",
"description": "Speech-to-text that runs entirely in your browser via OpenAI Whisper. Works offline once installed with a downloaded model.", "description": "Speech-to-text that runs entirely in your browser via OpenAI Whisper. Works offline once installed with a downloaded model.",
"id": "/", "id": "./",
"start_url": "/", "start_url": "./",
"scope": "/", "scope": "./",
"display": "standalone", "display": "standalone",
"theme_color": "#16181d", "theme_color": "#16181d",
"background_color": "#16181d", "background_color": "#16181d",
"icons": [ "icons": [
{ {
"src": "/icons/icon-192.png", "src": "icons/icon-192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "any maskable" "purpose": "any maskable"
}, },
{ {
"src": "/icons/icon-512.png", "src": "icons/icon-512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "any maskable" "purpose": "any maskable"
+1 -1
View File
@@ -27,7 +27,7 @@ export function precachePathsFromOutputs(
for (const outputPath of outputPaths) { for (const outputPath of outputPaths) {
const normalized = outputPath.replaceAll("\\", "/").replace(/^\.?\//, ""); const normalized = outputPath.replaceAll("\\", "/").replace(/^\.?\//, "");
if (normalized.endsWith(".map")) continue; if (normalized.endsWith(".map")) continue;
paths.add(normalized === "index.html" ? "/" : `/${normalized}`); paths.add(normalized === "index.html" ? "./" : normalized);
} }
for (const extra of extraPaths) paths.add(extra); for (const extra of extraPaths) paths.add(extra);
return [...paths].sort(); return [...paths].sort();
+1 -1
View File
@@ -31,7 +31,7 @@ export function registerServiceWorker(options: RegisterOptions): void {
if (container.controller) options.onControlled?.(); if (container.controller) options.onControlled?.();
container container
.register("/sw.js") .register("sw.js")
.then((registration) => { .then((registration) => {
const notify = (worker: ServiceWorker) => { const notify = (worker: ServiceWorker) => {
options.onUpdateReady(() => { options.onUpdateReady(() => {
+5 -4
View File
@@ -18,6 +18,7 @@
// In dev src/index.ts injects an empty list + "dev"; in production // In dev src/index.ts injects an empty list + "dev"; in production
// scripts/build.ts injects the hashed dist asset list + a content hash. // scripts/build.ts injects the hashed dist asset list + a content hash.
const PRECACHE: string[] = JSON.parse("__PRECACHE_MANIFEST__") as string[]; const PRECACHE: string[] = JSON.parse("__PRECACHE_MANIFEST__") as string[];
const PRECACHE_URLS = PRECACHE.map(path => new URL(path, sw.location.href).pathname);
const VERSION = "__CACHE_VERSION__"; const VERSION = "__CACHE_VERSION__";
const SHELL_CACHE = `whisper-shell-${VERSION}`; const SHELL_CACHE = `whisper-shell-${VERSION}`;
@@ -118,19 +119,19 @@ sw.addEventListener("fetch", (event) => {
if (url.origin !== sw.location.origin) return; if (url.origin !== sw.location.origin) return;
// API is network-only (D-007) — auth and the server engine must never be // API is network-only (D-007) — auth and the server engine must never be
// served stale from cache. // served stale from cache.
if (url.pathname.startsWith("/api/")) return; if (url.pathname.includes("/api/")) return;
if (url.pathname.startsWith("/ort/")) { if (url.pathname.includes("/ort/")) {
event.respondWith(cacheFirst(RUNTIME_CACHE, request)); event.respondWith(cacheFirst(RUNTIME_CACHE, request));
return; return;
} }
if (PRECACHE.includes(url.pathname)) { if (PRECACHE_URLS.includes(url.pathname)) {
event.respondWith(cacheFirst(SHELL_CACHE, request)); event.respondWith(cacheFirst(SHELL_CACHE, request));
return; return;
} }
// Anything else (dev's unhashed shell, navigations to deep links): // Anything else (dev's unhashed shell, navigations to deep links):
// network-first with cache fallback; navigations fall back to the shell. // network-first with cache fallback; navigations fall back to the shell.
event.respondWith(networkFirst(request, request.mode === "navigate" ? "/" : null)); event.respondWith(networkFirst(request, request.mode === "navigate" ? "./" : null));
}); });
export {}; export {};
+1 -1
View File
@@ -31,7 +31,7 @@ import {
// them at /ort/* (src/index.ts; copied into dist/ort by scripts/build.ts) // them at /ort/* (src/index.ts; copied into dist/ort by scripts/build.ts)
// and the service worker runtime-caches them cache-first (src/pwa/sw.ts). // and the service worker runtime-caches them cache-first (src/pwa/sw.ts).
if (env.backends.onnx.wasm) { if (env.backends.onnx.wasm) {
env.backends.onnx.wasm.wasmPaths = "/ort/"; env.backends.onnx.wasm.wasmPaths = "ort/";
} }
// `lib: ["DOM"]` types `self` as Window; give it the worker-scope shape we use. // `lib: ["DOM"]` types `self` as Window; give it the worker-scope shape we use.
+4 -4
View File
@@ -69,7 +69,7 @@ describe("fetchAuthStatus (fail-silent /me)", () => {
const { fetch, calls } = mockFetch(jsonResponse(200, { user: USER })); const { fetch, calls } = mockFetch(jsonResponse(200, { user: USER }));
expect(await fetchAuthStatus(fetch)).toEqual({ enabled: true, user: USER }); expect(await fetchAuthStatus(fetch)).toEqual({ enabled: true, user: USER });
// The cookie is HttpOnly — it must ride along via same-origin credentials. // The cookie is HttpOnly — it must ride along via same-origin credentials.
expect(calls[0]?.input).toBe("/api/auth/me"); expect(calls[0]?.input).toBe("api/auth/me");
expect(calls[0]?.init?.credentials).toBe("same-origin"); expect(calls[0]?.init?.credentials).toBe("same-origin");
}); });
@@ -105,7 +105,7 @@ describe("login", () => {
expect(await login("alice", "hunter22", fetch)).toEqual(USER); expect(await login("alice", "hunter22", fetch)).toEqual(USER);
const call = calls[0]; const call = calls[0];
expect(call?.input).toBe("/api/auth/login"); expect(call?.input).toBe("api/auth/login");
expect(call?.init?.method).toBe("POST"); expect(call?.init?.method).toBe("POST");
expect(call?.init?.credentials).toBe("same-origin"); expect(call?.init?.credentials).toBe("same-origin");
expect( expect(
@@ -145,7 +145,7 @@ describe("register", () => {
test("returns the user on 201", async () => { test("returns the user on 201", async () => {
const { fetch, calls } = mockFetch(jsonResponse(201, { user: USER })); const { fetch, calls } = mockFetch(jsonResponse(201, { user: USER }));
expect(await register("alice", "hunter22", fetch)).toEqual(USER); expect(await register("alice", "hunter22", fetch)).toEqual(USER);
expect(calls[0]?.input).toBe("/api/auth/register"); expect(calls[0]?.input).toBe("api/auth/register");
}); });
test("surfaces {error} on 409 (username taken)", async () => { test("surfaces {error} on 409 (username taken)", async () => {
@@ -172,7 +172,7 @@ describe("logout", () => {
test("POSTs to /api/auth/logout with credentials", async () => { test("POSTs to /api/auth/logout with credentials", async () => {
const { fetch, calls } = mockFetch(new Response(null, { status: 204 })); const { fetch, calls } = mockFetch(new Response(null, { status: 204 }));
await logout(fetch); await logout(fetch);
expect(calls[0]?.input).toBe("/api/auth/logout"); expect(calls[0]?.input).toBe("api/auth/logout");
expect(calls[0]?.init?.method).toBe("POST"); expect(calls[0]?.init?.method).toBe("POST");
expect(calls[0]?.init?.credentials).toBe("same-origin"); expect(calls[0]?.init?.credentials).toBe("same-origin");
}); });
+5 -5
View File
@@ -11,15 +11,15 @@ describe("precachePathsFromOutputs", () => {
expect( expect(
precachePathsFromOutputs( precachePathsFromOutputs(
["index.html", "chunk-abc123.js", "chunk-abc123.js.map", "./index-def456.css"], ["index.html", "chunk-abc123.js", "chunk-abc123.js.map", "./index-def456.css"],
["/inference.worker.js"], ["inference.worker.js"],
), ),
).toEqual(["/", "/chunk-abc123.js", "/index-def456.css", "/inference.worker.js"]); ).toEqual(["./", "chunk-abc123.js", "index-def456.css", "inference.worker.js"]);
}); });
test("deduplicates and sorts", () => { test("deduplicates and sorts", () => {
expect(precachePathsFromOutputs(["a.js", "a.js"], ["/a.js", "/"])).toEqual([ expect(precachePathsFromOutputs(["a.js", "a.js"], ["a.js", "./"])).toEqual([
"/", "./",
"/a.js", "a.js",
]); ]);
}); });
}); });