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(
shellBuild.outputs.map((output) => relative(DIST, output.path)),
[
"/inference.worker.js",
"/manifest.webmanifest",
...ICON_SIZES.map((size) => `/icons/icon-${size}.png`),
"inference.worker.js",
"manifest.webmanifest",
...ICON_SIZES.map((size) => `icons/icon-${size}.png`),
],
);
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
// 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.
const worker = new Worker("/inference.worker.js", { type: "module" });
const worker = new Worker("inference.worker.js", { type: "module" });
worker.addEventListener("message", (event: MessageEvent<unknown>) => {
const message = event.data;
if (!isWorkerToUiMessage(message)) return;
+4 -4
View File
@@ -122,7 +122,7 @@ export function login(
password: string,
fetchFn: FetchLike = defaultFetch,
): 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). */
@@ -131,7 +131,7 @@ export function register(
password: string,
fetchFn: FetchLike = defaultFetch,
): 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). */
@@ -158,7 +158,7 @@ export async function fetchAuthStatus(
fetchFn: FetchLike = defaultFetch,
): Promise<AuthStatus> {
try {
const response = await fetchFn("/api/auth/me", {
const response = await fetchFn("api/auth/me", {
method: "GET",
credentials: "same-origin",
});
@@ -179,7 +179,7 @@ export async function fetchAuthStatus(
*/
export async function logout(fetchFn: FetchLike = defaultFetch): Promise<void> {
try {
await fetchFn("/api/auth/logout", {
await fetchFn("api/auth/logout", {
method: "POST",
credentials: "same-origin",
});
+2 -2
View File
@@ -263,7 +263,7 @@ export class ServerEngine implements Engine {
let response: Response;
try {
response = await this.fetchFn(`/api/transcribe?${params}`, {
response = await this.fetchFn(`api/transcribe?${params}`, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/octet-stream" },
@@ -327,7 +327,7 @@ export async function fetchServerEngineStatus(
fetchFn: FetchLike = defaultFetch,
): Promise<ServerEngineStatus> {
try {
const response = await fetchFn("/api/transcribe/status", {
const response = await fetchFn("api/transcribe/status", {
method: "GET",
credentials: "same-origin",
});
+5 -5
View File
@@ -2,21 +2,21 @@
"name": "Whisper Web — on-device speech-to-text",
"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.",
"id": "/",
"start_url": "/",
"scope": "/",
"id": "./",
"start_url": "./",
"scope": "./",
"display": "standalone",
"theme_color": "#16181d",
"background_color": "#16181d",
"icons": [
{
"src": "/icons/icon-192.png",
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
+1 -1
View File
@@ -27,7 +27,7 @@ export function precachePathsFromOutputs(
for (const outputPath of outputPaths) {
const normalized = outputPath.replaceAll("\\", "/").replace(/^\.?\//, "");
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);
return [...paths].sort();
+1 -1
View File
@@ -31,7 +31,7 @@ export function registerServiceWorker(options: RegisterOptions): void {
if (container.controller) options.onControlled?.();
container
.register("/sw.js")
.register("sw.js")
.then((registration) => {
const notify = (worker: ServiceWorker) => {
options.onUpdateReady(() => {
+5 -4
View File
@@ -18,6 +18,7 @@
// In dev src/index.ts injects an empty list + "dev"; in production
// scripts/build.ts injects the hashed dist asset list + a content hash.
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 SHELL_CACHE = `whisper-shell-${VERSION}`;
@@ -118,19 +119,19 @@ sw.addEventListener("fetch", (event) => {
if (url.origin !== sw.location.origin) return;
// API is network-only (D-007) — auth and the server engine must never be
// 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));
return;
}
if (PRECACHE.includes(url.pathname)) {
if (PRECACHE_URLS.includes(url.pathname)) {
event.respondWith(cacheFirst(SHELL_CACHE, request));
return;
}
// Anything else (dev's unhashed shell, navigations to deep links):
// 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 {};
+1 -1
View File
@@ -31,7 +31,7 @@ import {
// 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).
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.
+4 -4
View File
@@ -69,7 +69,7 @@ describe("fetchAuthStatus (fail-silent /me)", () => {
const { fetch, calls } = mockFetch(jsonResponse(200, { user: USER }));
expect(await fetchAuthStatus(fetch)).toEqual({ enabled: true, user: USER });
// 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");
});
@@ -105,7 +105,7 @@ describe("login", () => {
expect(await login("alice", "hunter22", fetch)).toEqual(USER);
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?.credentials).toBe("same-origin");
expect(
@@ -145,7 +145,7 @@ describe("register", () => {
test("returns the user on 201", async () => {
const { fetch, calls } = mockFetch(jsonResponse(201, { user: 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 () => {
@@ -172,7 +172,7 @@ describe("logout", () => {
test("POSTs to /api/auth/logout with credentials", async () => {
const { fetch, calls } = mockFetch(new Response(null, { status: 204 }));
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?.credentials).toBe("same-origin");
});
+5 -5
View File
@@ -11,15 +11,15 @@ describe("precachePathsFromOutputs", () => {
expect(
precachePathsFromOutputs(
["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", () => {
expect(precachePathsFromOutputs(["a.js", "a.js"], ["/a.js", "/"])).toEqual([
"/",
"/a.js",
expect(precachePathsFromOutputs(["a.js", "a.js"], ["a.js", "./"])).toEqual([
"./",
"a.js",
]);
});
});