WIP: M3 PWA/offline implementation, M4 auth (backend + frontend), D-013 faster-whisper

- M3: hand-rolled service worker (precache + runtime /ort caching), precache
  manifest injection, SW registration/update toast, manifest + generated icons,
  production build script, offline indicator in ModelPicker (D-005/D-007)
- M4 backend: bun:sqlite users/sessions (D-009), Passport local strategy behind
  connect-compat adapter (D-008), /api/auth routes wired into dev+prod serving,
  Bun.password Argon2id, 38 auth tests
- M4 frontend: AuthForms + useAuth + framework-free authClient, session-aware
  header, anonymous-first fail-silent offline handling, 18 client tests
- D-013: server engine (M5) will use faster-whisper via Python sidecar;
  browser side stays Transformers.js (CTranslate2 has no WASM build)
- bun test: 83 pass / tsc --noEmit clean
- Note: M3 doc closeout (WorkBreakdown/Architecture/tests/offline.md) still in
  flight from a background agent; follow-up commit will finish docs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Fable 5 committed 2026-07-10 04:02:28 +02:00
1 parent eb72a2e646
commit 9f39a82982
38 files changed
+3857 -246

No files matched your search

+174
View File
@@ -0,0 +1,174 @@
/**
* Auth API client (M4). Deliberately framework-free: validation is pure and
* the fetch layer is injectable, so `bun test` can exercise everything without
* a DOM or a running server (tests/auth-client.test.ts). React state lives in
* `useAuth` (src/lib/useAuth.ts).
*
* Contract (mirrored by src/server): all endpoints exchange JSON and errors
* arrive as `{ error: string }`. The session cookie is HttpOnly, so
* `GET /api/auth/me` is the ONLY way to learn the current auth state.
*/
export interface AuthUser {
id: number | string;
username: string;
}
/** Minimal fetch signature so tests can inject a mock. */
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
// Bound lazily via globalThis: bare `fetch` references passed around as values
// can throw "Illegal invocation" in some browsers.
const defaultFetch: FetchLike = (input, init) => globalThis.fetch(input, init);
// --- Client-side validation ---------------------------------------------
// Mirrors the server rules for instant feedback; the server stays
// authoritative and its `{ error }` messages are surfaced on 4xx regardless.
export const USERNAME_MIN_LENGTH = 3;
export const USERNAME_MAX_LENGTH = 32;
export const USERNAME_PATTERN = /^[a-zA-Z0-9_.-]+$/;
export const PASSWORD_MIN_LENGTH = 8;
/** Returns a human-readable problem with the username, or null when valid. */
export function validateUsername(username: string): string | null {
if (
username.length < USERNAME_MIN_LENGTH ||
username.length > USERNAME_MAX_LENGTH
) {
return `Username must be ${USERNAME_MIN_LENGTH}–${USERNAME_MAX_LENGTH} characters.`;
}
if (!USERNAME_PATTERN.test(username)) {
return "Username may only contain letters, digits, and _ . -";
}
return null;
}
/** Returns a human-readable problem with the password, or null when valid. */
export function validatePassword(password: string): string | null {
if (password.length < PASSWORD_MIN_LENGTH) {
return `Password must be at least ${PASSWORD_MIN_LENGTH} characters.`;
}
return null;
}
// --- API requests ---------------------------------------------------------
function isAuthUser(value: unknown): value is AuthUser {
if (typeof value !== "object" || value === null) return false;
const candidate = value as { id?: unknown; username?: unknown };
return (
(typeof candidate.id === "number" || typeof candidate.id === "string") &&
typeof candidate.username === "string"
);
}
/** Pull `{ error }` out of a 4xx/5xx body, falling back to a generic line. */
async function readErrorMessage(
response: Response,
fallback: string,
): Promise<string> {
try {
const body: unknown = await response.json();
if (
typeof body === "object" &&
body !== null &&
typeof (body as { error?: unknown }).error === "string"
) {
return (body as { error: string }).error;
}
} catch {
// Non-JSON error body (proxy page, empty 500) — use the fallback.
}
return fallback;
}
/**
* POST credentials to an auth endpoint and return the signed-in user.
* Throws an `Error` whose message is safe to show in the UI.
*/
async function requestUser(
fetchFn: FetchLike,
path: string,
credentials: { username: string; password: string },
): Promise<AuthUser> {
let response: Response;
try {
response = await fetchFn(path, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentials),
});
} catch {
throw new Error("Could not reach the server. Check your connection.");
}
if (!response.ok) {
throw new Error(
await readErrorMessage(response, `Request failed (${response.status}).`),
);
}
const payload: unknown = await response.json().catch(() => null);
const user = (payload as { user?: unknown } | null)?.user;
if (!isAuthUser(user)) {
throw new Error("The server returned an unexpected response.");
}
return user;
}
/** POST /api/auth/login → user, or throws with the server's error message. */
export function login(
username: string,
password: string,
fetchFn: FetchLike = defaultFetch,
): Promise<AuthUser> {
return requestUser(fetchFn, "/api/auth/login", { username, password });
}
/** POST /api/auth/register → user (auto signed in), or throws (400/409). */
export function register(
username: string,
password: string,
fetchFn: FetchLike = defaultFetch,
): Promise<AuthUser> {
return requestUser(fetchFn, "/api/auth/register", { username, password });
}
/**
* GET /api/auth/me → the current user, or null when anonymous. NEVER rejects:
* the app is offline-first and /api/* is network-only in the service worker,
* so 401s, network failures, and malformed bodies all resolve to anonymous
* without surfacing an error (Architecture.md §5 anonymous-first invariant).
*/
export async function fetchCurrentUser(
fetchFn: FetchLike = defaultFetch,
): Promise<AuthUser | null> {
try {
const response = await fetchFn("/api/auth/me", {
method: "GET",
credentials: "same-origin",
});
if (!response.ok) return null;
const payload: unknown = await response.json();
const user = (payload as { user?: unknown } | null)?.user;
return isAuthUser(user) ? user : null;
} catch {
return null;
}
}
/**
* POST /api/auth/logout (→ 204). Swallows failures: the caller clears local
* state either way, and an unreachable server just means the cookie expires
* on its own.
*/
export async function logout(fetchFn: FetchLike = defaultFetch): Promise<void> {
try {
await fetchFn("/api/auth/logout", {
method: "POST",
credentials: "same-origin",
});
} catch {
// Offline — nothing to do.
}
}
+60
View File
@@ -0,0 +1,60 @@
/**
* "Is this model already downloaded?" helper (Decisions.md D-005).
*
* Transformers.js v3 persists Hub downloads in the browser Cache API (Cache
* Storage, cache name "transformers-cache"). We inspect the cached request
* URLs and match them against catalog model ids. Used by `ModelPicker` for the
* downloaded badge (M2) and reused by the offline indicator (M3).
*
* `matchCachedModels` is pure so it can be unit-tested; only
* `getDownloadedModels` touches the `caches` global.
*/
/** Cache name used by Transformers.js v3 (`env.cacheName` default). */
export const TRANSFORMERS_CACHE_NAME = "transformers-cache";
/**
* Pure matcher: which of `modelIds` have at least one cached ONNX weight file?
*
* Cached URLs look like
* `https://huggingface.co/<modelId>/resolve/main/onnx/<file>.onnx`. We require
* an `.onnx` hit (not just tokenizer/config JSON) so a model whose weights
* never finished downloading does not show as available.
*/
export function matchCachedModels(
urls: Iterable<string>,
modelIds: readonly string[],
): Set<string> {
const downloaded = new Set<string>();
for (const url of urls) {
if (!url.split("?")[0]?.endsWith(".onnx")) continue;
for (const modelId of modelIds) {
if (!downloaded.has(modelId) && url.includes(`/${modelId}/`)) {
downloaded.add(modelId);
}
}
}
return downloaded;
}
/**
* Query the browser Cache API for models with cached weights. Returns an
* empty set where Cache Storage is unavailable (non-secure contexts, tests).
*/
export async function getDownloadedModels(
modelIds: readonly string[],
): Promise<Set<string>> {
if (typeof caches === "undefined") return new Set();
try {
const urls: string[] = [];
// Match any transformers-owned cache, in case the name is ever versioned.
for (const name of await caches.keys()) {
if (!name.includes("transformers")) continue;
const cache = await caches.open(name);
for (const request of await cache.keys()) urls.push(request.url);
}
return matchCachedModels(urls, modelIds);
} catch {
return new Set();
}
}
+260 -12
View File
@@ -1,20 +1,268 @@
/**
* Model catalog (Decisions.md D-006).
* Curated Whisper model catalog (Decisions.md D-006).
*
* M1 ships a single hardcoded model. The curated catalog — tiny → large,
* English-only vs multilingual, dtype defaults per device, size estimates —
* lands in M2.
* Pure, typed data — no browser globals — so it is unit-testable with
* `bun test` and shareable with the server engine (M5). ONNX conversions come
* from the `onnx-community` Hub org (the recommended source for
* Transformers.js v3+).
*
* TODO(M2): curated `MODELS` catalog with sizes/dtypes/languages (D-006).
* dtype defaults per device follow the community-known-good combinations:
* - WASM: q8 for small models, q4 for the large ones (download size).
* - WebGPU: fp16 encoder + q4 merged decoder for the big models; fp32 for
* tiny/base where fp16 has known accuracy issues.
* `verified` is only true for combinations we have actually run in a browser;
* the rest are pending manual testing (WorkBreakdown M2).
*/
import type { InferenceDevice } from "../worker/protocol";
import type {
DevicePreference,
DtypeSpec,
InferenceDevice,
WhisperDtype,
WhisperTask,
} from "../worker/protocol";
// ---------------------------------------------------------------------------
// Catalog types
// ---------------------------------------------------------------------------
/** Dtype default for one device, with verification status (D-006). */
export interface DtypeDefault {
dtype: DtypeSpec;
/** True only if this exact combination has been run in a real browser. */
verified: boolean;
/** Why this combination was chosen / caveats. */
note: string;
}
export interface WhisperModel {
/** Hugging Face Hub id, e.g. "onnx-community/whisper-tiny.en". */
id: string;
/** Human-readable name for the picker. */
displayName: string;
/** False for `.en` (English-only) variants. */
multilingual: boolean;
/** Encoder parameter count, in millions (drives size estimates). */
encoderParamsM: number;
/** Decoder parameter count, in millions (drives size estimates). */
decoderParamsM: number;
/** Per-device dtype defaults (D-004 / D-006). */
defaults: Record<InferenceDevice, DtypeDefault>;
}
// ---------------------------------------------------------------------------
// Size estimation (approximate, shown before download — D-006)
// ---------------------------------------------------------------------------
/** Approximate stored bytes per parameter for each quantization level. */
export const BYTES_PER_PARAM: Record<WhisperDtype, number> = {
fp32: 4,
fp16: 2,
q8: 1.05, // int8 weights + scales/zero-points overhead
q4: 0.6, // 4-bit blocks + per-block scales; some tensors stay higher precision
};
/** Tokenizer + configs + preprocessor, roughly constant per model. */
const AUX_FILES_BYTES = 3_000_000;
/** Approximate total download size for a model at a given dtype spec. */
export function estimateDownloadBytes(
model: WhisperModel,
dtype: DtypeSpec,
): number {
const encDtype = typeof dtype === "string" ? dtype : dtype.encoder_model;
const decDtype = typeof dtype === "string" ? dtype : dtype.decoder_model_merged;
return Math.round(
model.encoderParamsM * 1e6 * BYTES_PER_PARAM[encDtype] +
model.decoderParamsM * 1e6 * BYTES_PER_PARAM[decDtype] +
AUX_FILES_BYTES,
);
}
/** "487 MB" / "1.6 GB" style formatting for size estimates. */
export function formatModelSize(bytes: number): string {
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
return `${Math.round(bytes / 1e6)} MB`;
}
// ---------------------------------------------------------------------------
// dtype defaults (community-known-good; see file header)
// ---------------------------------------------------------------------------
const WASM_Q8: DtypeDefault = {
dtype: "q8",
verified: false,
note: "q8 is the Transformers.js WASM default; good quality/size trade-off.",
};
const WASM_Q4: DtypeDefault = {
dtype: "q4",
verified: false,
note: "q4 keeps the download manageable for larger models on WASM.",
};
/** fp16 has known accuracy issues for the small Whisper encoders on WebGPU. */
const WEBGPU_FP32: DtypeDefault = {
dtype: { encoder_model: "fp32", decoder_model_merged: "fp32" },
verified: false,
note: "fp32 both parts: fp16 encoders are known-bad for tiny/base on WebGPU.",
};
const WEBGPU_FP16_Q4: DtypeDefault = {
dtype: { encoder_model: "fp16", decoder_model_merged: "q4" },
verified: false,
note: "fp16 encoder + q4 merged decoder — the common working WebGPU setup.",
};
// ---------------------------------------------------------------------------
// The catalog (D-006: curated, not exhaustive)
// ---------------------------------------------------------------------------
export const MODELS: readonly WhisperModel[] = [
{
id: "onnx-community/whisper-tiny.en",
displayName: "Whisper Tiny (English)",
multilingual: false,
encoderParamsM: 8,
decoderParamsM: 31,
defaults: {
// Verified in-browser during M1 (WASM, Transformers.js default q8).
wasm: { ...WASM_Q8, verified: true },
webgpu: WEBGPU_FP32,
},
},
{
id: "onnx-community/whisper-tiny",
displayName: "Whisper Tiny",
multilingual: true,
encoderParamsM: 8,
decoderParamsM: 31,
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP32 },
},
{
id: "onnx-community/whisper-base.en",
displayName: "Whisper Base (English)",
multilingual: false,
encoderParamsM: 20,
decoderParamsM: 54,
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP32 },
},
{
id: "onnx-community/whisper-base",
displayName: "Whisper Base",
multilingual: true,
encoderParamsM: 20,
decoderParamsM: 54,
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP32 },
},
{
id: "onnx-community/whisper-small.en",
displayName: "Whisper Small (English)",
multilingual: false,
encoderParamsM: 88,
decoderParamsM: 156,
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP16_Q4 },
},
{
id: "onnx-community/whisper-small",
displayName: "Whisper Small",
multilingual: true,
encoderParamsM: 88,
decoderParamsM: 156,
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP16_Q4 },
},
{
id: "onnx-community/whisper-medium.en",
displayName: "Whisper Medium (English)",
multilingual: false,
encoderParamsM: 306,
decoderParamsM: 463,
defaults: { wasm: WASM_Q4, webgpu: WEBGPU_FP16_Q4 },
},
{
id: "onnx-community/whisper-medium",
displayName: "Whisper Medium",
multilingual: true,
encoderParamsM: 306,
decoderParamsM: 463,
defaults: { wasm: WASM_Q4, webgpu: WEBGPU_FP16_Q4 },
},
{
id: "onnx-community/whisper-large-v3-turbo",
displayName: "Whisper Large v3 Turbo",
multilingual: true,
encoderParamsM: 635,
decoderParamsM: 174,
defaults: { wasm: WASM_Q4, webgpu: WEBGPU_FP16_Q4 },
},
];
export function getModel(id: string): WhisperModel | undefined {
return MODELS.find((model) => model.id === id);
}
/** Default dtype for a model on a given device. Unknown ids fall back to q8. */
export function resolveDefaultDtype(
modelId: string,
device: InferenceDevice,
): DtypeSpec {
return getModel(modelId)?.defaults[device].dtype ?? "q8";
}
/** Approximate download size using the model's default dtype for `device`. */
export function estimateDefaultDownloadBytes(
model: WhisperModel,
device: InferenceDevice,
): number {
return estimateDownloadBytes(model, model.defaults[device].dtype);
}
// ---------------------------------------------------------------------------
// Defaults & language list
// ---------------------------------------------------------------------------
/**
* The one model M1 supports. `onnx-community` is the recommended org for
* Transformers.js v3+ ONNX conversions.
*/
export const DEFAULT_MODEL_ID = "onnx-community/whisper-tiny.en";
/** M1 runs WASM only; WebGPU detection arrives in M2 (D-004). */
export const DEFAULT_DEVICE: InferenceDevice = "wasm";
/** UI default: let the worker probe WebGPU and fall back to WASM (D-004). */
export const DEFAULT_DEVICE_PREFERENCE: DevicePreference = "auto";
export const DEFAULT_TASK: WhisperTask = "transcribe";
/** Sentinel for "let Whisper detect the language". */
export const AUTO_LANGUAGE = "auto";
/**
* Curated language list for multilingual models (Whisper language codes).
* Not exhaustive — Whisper supports ~100; extend on demand.
*/
export const WHISPER_LANGUAGES: readonly { code: string; label: string }[] = [
{ code: "en", label: "English" },
{ code: "da", label: "Danish" },
{ code: "de", label: "German" },
{ code: "es", label: "Spanish" },
{ code: "fr", label: "French" },
{ code: "it", label: "Italian" },
{ code: "nl", label: "Dutch" },
{ code: "no", label: "Norwegian" },
{ code: "sv", label: "Swedish" },
{ code: "fi", label: "Finnish" },
{ code: "is", label: "Icelandic" },
{ code: "pl", label: "Polish" },
{ code: "pt", label: "Portuguese" },
{ code: "ro", label: "Romanian" },
{ code: "cs", label: "Czech" },
{ code: "el", label: "Greek" },
{ code: "hu", label: "Hungarian" },
{ code: "uk", label: "Ukrainian" },
{ code: "ru", label: "Russian" },
{ code: "tr", label: "Turkish" },
{ code: "ar", label: "Arabic" },
{ code: "he", label: "Hebrew" },
{ code: "hi", label: "Hindi" },
{ code: "id", label: "Indonesian" },
{ code: "vi", label: "Vietnamese" },
{ code: "th", label: "Thai" },
{ code: "zh", label: "Chinese" },
{ code: "ja", label: "Japanese" },
{ code: "ko", label: "Korean" },
];
+57
View File
@@ -0,0 +1,57 @@
import { useCallback, useEffect, useState } from "react";
import {
fetchCurrentUser,
login as loginRequest,
logout as logoutRequest,
register as registerRequest,
type AuthUser,
} from "./authClient";
export interface AuthState {
/** The signed-in user, or null while anonymous (the default). */
user: AuthUser | null;
/** Rejects with a UI-safe Error message on failure (e.g. 401). */
login: (username: string, password: string) => Promise<void>;
/** Rejects with a UI-safe Error message on failure (400/409). */
register: (username: string, password: string) => Promise<void>;
/** Always resolves; local state is cleared even if the server is down. */
logout: () => Promise<void>;
}
/**
* Session state for the M4 auth UI. The session cookie is HttpOnly, so the
* only source of truth is GET /api/auth/me, probed once on mount.
* `fetchCurrentUser` never rejects — 401 and network failure (offline, server
* down) both resolve to anonymous, keeping the anonymous-first invariant:
* a failing /me must not surface any error.
*/
export function useAuth(): AuthState {
const [user, setUser] = useState<AuthUser | null>(null);
useEffect(() => {
let cancelled = false;
fetchCurrentUser().then((current) => {
// Guard against StrictMode double-mounts / fast unmounts.
if (!cancelled && current) setUser(current);
});
return () => {
cancelled = true;
};
}, []);
const login = useCallback(async (username: string, password: string) => {
setUser(await loginRequest(username, password));
}, []);
const register = useCallback(async (username: string, password: string) => {
// Register signs the user in immediately (the 201 sets the cookie).
setUser(await registerRequest(username, password));
}, []);
const logout = useCallback(async () => {
await logoutRequest();
setUser(null);
}, []);
return { user, login, register, logout };
}