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:
1 parent
eb72a2e646
commit
9f39a82982
38 files changed
+3857
-246
No files matched your search
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Production build (`bun run build`) — Decisions.md D-007.
|
||||
*
|
||||
* Emits a self-contained `dist/`:
|
||||
* 1. `bun build src/index.html` → hashed app-shell assets
|
||||
* 2. inference worker bundle at the stable /inference.worker.js URL
|
||||
* 3. manifest + icons + onnxruntime .wasm/.mjs (dist/ort/, offline inference)
|
||||
* 4. sw.js with the precache manifest + a content-hash version injected
|
||||
*
|
||||
* `bun run start` (NODE_ENV=production bun src/index.ts) then serves dist/
|
||||
* verbatim, so the URLs the service worker precached are exactly the URLs
|
||||
* the server responds to.
|
||||
*/
|
||||
|
||||
import { cp, readdir, rm } from "node:fs/promises";
|
||||
import { join, relative } from "node:path";
|
||||
import { injectPrecacheManifest, precachePathsFromOutputs } from "../src/pwa/precache";
|
||||
import { ensureIcons, ICON_SIZES, ICONS_DIR } from "./generate-icons";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..");
|
||||
const DIST = join(ROOT, "dist");
|
||||
|
||||
await rm(DIST, { recursive: true, force: true });
|
||||
|
||||
// Icons before the shell build — index.html references src/pwa/icons/ (the
|
||||
// HTML loader resolves the file at bundle time) and they aren't checked in.
|
||||
await ensureIcons(ICONS_DIR);
|
||||
|
||||
// 1. App shell (hashed assets).
|
||||
const shellBuild = await Bun.build({
|
||||
entrypoints: [join(ROOT, "src/index.html")],
|
||||
outdir: DIST,
|
||||
target: "browser",
|
||||
minify: true,
|
||||
sourcemap: "linked",
|
||||
define: { "process.env.NODE_ENV": '"production"' },
|
||||
env: "BUN_PUBLIC_*",
|
||||
});
|
||||
|
||||
// 2. Inference worker at its stable URL (Bun does not follow `new Worker`
|
||||
// graphs — same mechanism as the dev server in src/index.ts).
|
||||
const workerBuild = await Bun.build({
|
||||
entrypoints: [join(ROOT, "src/worker/inference.worker.ts")],
|
||||
target: "browser",
|
||||
format: "esm",
|
||||
minify: true,
|
||||
});
|
||||
const workerScript = await workerBuild.outputs[0]!.text();
|
||||
await Bun.write(join(DIST, "inference.worker.js"), workerScript);
|
||||
|
||||
// 3. Static PWA assets + onnxruntime wasm binaries (offline inference — the
|
||||
// worker loads them from /ort/ instead of the jsdelivr CDN). The manifest and
|
||||
// icons are also emitted as hashed shell assets (index.html links them), but
|
||||
// the manifest's internal icon URLs point at these fixed /icons/ paths.
|
||||
await cp(join(ROOT, "src/pwa/manifest.webmanifest"), join(DIST, "manifest.webmanifest"));
|
||||
for (const size of ICON_SIZES) {
|
||||
await cp(join(ICONS_DIR, `icon-${size}.png`), join(DIST, `icons/icon-${size}.png`));
|
||||
}
|
||||
const ortDist = join(ROOT, "node_modules/onnxruntime-web/dist");
|
||||
const ortFiles = (await readdir(ortDist)).filter((name) =>
|
||||
/^ort-wasm-simd-threaded(\.jsep|\.jspi|\.asyncify)?\.(wasm|mjs)$/.test(name),
|
||||
);
|
||||
for (const name of ortFiles) {
|
||||
await cp(join(ortDist, name), join(DIST, "ort", name));
|
||||
}
|
||||
|
||||
// 4. Service worker with the injected precache manifest (D-007). Icons,
|
||||
// manifest, and the worker are unhashed, so the cache version hashes their
|
||||
// contents (the hashed shell assets are covered by their names).
|
||||
const swBuild = await Bun.build({
|
||||
entrypoints: [join(ROOT, "src/pwa/sw.ts")],
|
||||
target: "browser",
|
||||
format: "esm",
|
||||
// Placeholder string literals must survive verbatim (src/pwa/precache.ts).
|
||||
minify: false,
|
||||
});
|
||||
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`),
|
||||
],
|
||||
);
|
||||
const version = Bun.hash(
|
||||
JSON.stringify(precachePaths) + swSource + workerScript,
|
||||
).toString(36);
|
||||
await Bun.write(join(DIST, "sw.js"), injectPrecacheManifest(swSource, precachePaths, version));
|
||||
|
||||
console.log(`Built dist/ (cache version ${version}); precached app shell:`);
|
||||
for (const path of precachePaths) console.log(` ${path}`);
|
||||
console.log(` + ${ortFiles.length} onnxruntime files in /ort/ (runtime-cached)`);
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* PWA icon generator — writes simple solid-background PNGs (192 & 512 px)
|
||||
* with a hand-rolled PNG encoder (no image dependencies, D-001). The glyph —
|
||||
* a ring on the app's dark background — stays well inside the maskable safe
|
||||
* zone, so the icons declare `purpose: "any maskable"` in the manifest.
|
||||
*
|
||||
* Run directly (`bun scripts/generate-icons.ts`) or via `ensureIcons()` from
|
||||
* src/index.ts (dev) and scripts/build.ts (production).
|
||||
*/
|
||||
|
||||
import { deflateSync } from "node:zlib";
|
||||
import { join } from "node:path";
|
||||
|
||||
export const ICON_SIZES = [192, 512] as const;
|
||||
|
||||
// App theme (src/index.css / manifest.webmanifest).
|
||||
const BACKGROUND: readonly [number, number, number] = [0x16, 0x18, 0x1d];
|
||||
const ACCENT: readonly [number, number, number] = [0x7a, 0xa2, 0xf7];
|
||||
|
||||
// --- Minimal PNG encoding (8-bit RGBA, filter 0) -------------------------
|
||||
|
||||
const CRC_TABLE = new Uint32Array(256).map((_, n) => {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
return c >>> 0;
|
||||
});
|
||||
|
||||
function crc32(bytes: Uint8Array): number {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of bytes) crc = CRC_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function pngChunk(type: string, data: Uint8Array): Uint8Array {
|
||||
const chunk = new Uint8Array(12 + data.length);
|
||||
const view = new DataView(chunk.buffer);
|
||||
view.setUint32(0, data.length);
|
||||
for (let i = 0; i < 4; i++) chunk[4 + i] = type.charCodeAt(i);
|
||||
chunk.set(data, 8);
|
||||
view.setUint32(8 + data.length, crc32(chunk.subarray(4, 8 + data.length)));
|
||||
return chunk;
|
||||
}
|
||||
|
||||
/** Encode an RGBA pixel buffer as a PNG file. */
|
||||
function encodePng(size: number, rgba: Uint8Array): Uint8Array {
|
||||
const ihdr = new Uint8Array(13);
|
||||
const ihdrView = new DataView(ihdr.buffer);
|
||||
ihdrView.setUint32(0, size);
|
||||
ihdrView.setUint32(4, size);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 6; // color type: RGBA
|
||||
// compression 0, filter 0, interlace 0
|
||||
|
||||
// Scanlines, each prefixed with filter byte 0.
|
||||
const raw = new Uint8Array(size * (size * 4 + 1));
|
||||
for (let y = 0; y < size; y++) {
|
||||
raw.set(rgba.subarray(y * size * 4, (y + 1) * size * 4), y * (size * 4 + 1) + 1);
|
||||
}
|
||||
|
||||
const signature = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const idat = new Uint8Array(deflateSync(raw));
|
||||
return Uint8Array.from([
|
||||
...signature,
|
||||
...pngChunk("IHDR", ihdr),
|
||||
...pngChunk("IDAT", idat),
|
||||
...pngChunk("IEND", new Uint8Array(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
// --- Icon artwork ---------------------------------------------------------
|
||||
|
||||
/** 0→1 coverage across a ~1px band, for cheap antialiasing. */
|
||||
function edge(distance: number): number {
|
||||
return Math.min(1, Math.max(0, distance + 0.5));
|
||||
}
|
||||
|
||||
export function encodeIcon(size: number): Uint8Array {
|
||||
const rgba = new Uint8Array(size * size * 4);
|
||||
const center = (size - 1) / 2;
|
||||
const outer = size * 0.3;
|
||||
const inner = size * 0.17;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const d = Math.hypot(x - center, y - center);
|
||||
// Ring: inside outer radius but outside inner radius.
|
||||
const cover = edge(outer - d) * edge(d - inner);
|
||||
const offset = (y * size + x) * 4;
|
||||
for (let c = 0; c < 3; c++) {
|
||||
rgba[offset + c] = Math.round(
|
||||
BACKGROUND[c]! + (ACCENT[c]! - BACKGROUND[c]!) * cover,
|
||||
);
|
||||
}
|
||||
rgba[offset + 3] = 255; // opaque — maskable icons need a full background
|
||||
}
|
||||
}
|
||||
return encodePng(size, rgba);
|
||||
}
|
||||
|
||||
/** Directory the manifest's icon URLs are served from in dev. */
|
||||
export const ICONS_DIR = join(import.meta.dir, "../src/pwa/icons");
|
||||
|
||||
/** Generate any missing icons into `dir` (idempotent). */
|
||||
export async function ensureIcons(dir: string = ICONS_DIR): Promise<void> {
|
||||
for (const size of ICON_SIZES) {
|
||||
const path = join(dir, `icon-${size}.png`);
|
||||
if (await Bun.file(path).exists()) continue;
|
||||
await Bun.write(path, encodeIcon(size));
|
||||
console.log(`Wrote ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await ensureIcons();
|
||||
}
|
||||
Reference in new issue
Block a user