From ea38ad9cb7a97029f1e30dedefba67dadf9cfbe7 Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Fri, 28 Aug 2026 16:38:29 +0800 Subject: [PATCH] feat(imf): persist verified models in the browser Cache API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browsers kept verified bytes in memory only — every page reload redownloaded the whole model. The registry now stores verified zips in a named Cache and re-verifies the sha256 against the index on every use; corrupted entries are deleted and refetched. Node hosts are unchanged (filesystem cache). Download-once per browser. --- src/ml/imf/registry.ts | 36 ++++++++++++++++++ test/imf.test.ts | 83 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/src/ml/imf/registry.ts b/src/ml/imf/registry.ts index 87bb4d4..a2e1600 100644 --- a/src/ml/imf/registry.ts +++ b/src/ml/imf/registry.ts @@ -140,6 +140,30 @@ export interface ResolvedZip { path?: string } +/** Browser Cache API persistence: verified model bytes survive page + * reloads, so a model downloads once per browser. Node hosts persist + * to the filesystem instead; both paths re-verify against the index + * sha256 on every use — the cache is never trusted blindly. */ +const CACHE_NAME = "interscript-imf-models-v1" + +// Minimal structural types — the DOM lib isn't in this package's tsconfig +interface BrowserCache { + match(request: string): Promise + put(request: string, response: Response): Promise + delete(request: string): Promise +} + +declare const caches: { open(name: string): Promise } | undefined + +function cacheKey(modelId: string, filename: string): string { + return `https://imf.interscript.org/cache/${modelId}/${filename}` +} + +async function browserCache(): Promise { + if (typeof caches === "undefined") return undefined + return await caches.open(CACHE_NAME) +} + export async function resolve(modelId: string, indexUrl?: string): Promise { const source = indexUrl ?? process.env["SECRYST_INDEX"] ?? DEFAULT_INDEX_URL const entries = await fetchIndex(source) @@ -157,6 +181,16 @@ export async function resolve(modelId: string, indexUrl?: string): Promise { rmSync(dir, { recursive: true, force: true }) } }) + + it("persists verified models in the browser Cache API (download once)", async () => { + const { createHash } = await import("node:crypto") + const { createServer } = await import("node:http") + const sha = createHash("sha256").update(fixtureZip).digest("hex") + let channelUp = true + let indexBody = "" + const server = createServer((req, res) => { + if (req.url === "/index.yaml") { + res.writeHead(200, { "content-type": "text/yaml" }) + res.end(indexBody) + return + } + if (req.url === "/index.yaml.sha256") { + const digest = createHash("sha256").update(indexBody).digest("hex") + res.writeHead(200) + res.end(`${digest} index.yaml\n`) + return + } + if (req.url === "/tiny.zip") { + if (!channelUp) { + res.writeHead(404) + res.end("gone") + return + } + res.writeHead(200) + res.end(fixtureZip) + return + } + res.writeHead(404) + res.end() + }) + await new Promise((r) => server.listen(0, "127.0.0.1", r)) + const port = (server.address() as { port: number }).port + const indexUrl = `http://127.0.0.1:${port}/index.yaml` + indexBody = `version: 1\nmodels:\n tiny-1.0:\n filename: tiny.zip\n url: http://127.0.0.1:${port}/tiny.zip\n sha256: ${sha}\n` + + const store = new Map() + const fakeCache = { + async match(req: RequestInfo) { + const key = String(req instanceof Request ? req.url : req) + return store.get(key)?.clone() + }, + async put(req: RequestInfo, res: Response) { + const key = String(req instanceof Request ? req.url : req) + store.set(key, res.clone()) + }, + async delete(req: RequestInfo) { + const key = String(req instanceof Request ? req.url : req) + return store.delete(key) + }, + } + const g = globalThis as Record + g["caches"] = { open: async () => fakeCache } + // simulate a browser host: no Node fs, so the Cache API path runs + const versions = process.versions as { node?: string } + const realNode = versions.node + delete versions.node + try { + process.env["SECRYST_CACHE"] = undefined + const first = await resolve("tiny-1.0", indexUrl) + expect([...first.bytes]).toEqual([...fixtureZip]) + expect(store.size).toBe(1) + + // channel dies; the cached copy serves, still sha-verified + channelUp = false + const second = await resolve("tiny-1.0", indexUrl) + expect([...second.bytes]).toEqual([...fixtureZip]) + + // a corrupted cache entry falls through to a fresh download + channelUp = true + const key = store.keys().next().value as string + store.set(key, new Response(new Uint8Array([1, 2, 3]))) + const third = await resolve("tiny-1.0", indexUrl) + expect([...third.bytes]).toEqual([...fixtureZip]) + } finally { + if (realNode !== undefined) versions.node = realNode + delete g["caches"] + delete process.env["SECRYST_CACHE"] + await new Promise((r) => server.close(() => r())) + } + }) + it("DEFAULT_INDEX_URL pins a GitHub Release asset, never raw", async () => { const { DEFAULT_INDEX_URL } = await import("../src/ml/imf/registry.js") expect(DEFAULT_INDEX_URL).toMatch(