Skip to content

Commit ea38ad9

Browse files
committed
feat(imf): persist verified models in the browser Cache API
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.
1 parent de77301 commit ea38ad9

2 files changed

Lines changed: 119 additions & 0 deletions

File tree

‎src/ml/imf/registry.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,30 @@ export interface ResolvedZip {
140140
path?: string
141141
}
142142

143+
/** Browser Cache API persistence: verified model bytes survive page
144+
* reloads, so a model downloads once per browser. Node hosts persist
145+
* to the filesystem instead; both paths re-verify against the index
146+
* sha256 on every use — the cache is never trusted blindly. */
147+
const CACHE_NAME = "interscript-imf-models-v1"
148+
149+
// Minimal structural types — the DOM lib isn't in this package's tsconfig
150+
interface BrowserCache {
151+
match(request: string): Promise<Response | undefined>
152+
put(request: string, response: Response): Promise<void>
153+
delete(request: string): Promise<boolean>
154+
}
155+
156+
declare const caches: { open(name: string): Promise<BrowserCache> } | undefined
157+
158+
function cacheKey(modelId: string, filename: string): string {
159+
return `https://imf.interscript.org/cache/${modelId}/${filename}`
160+
}
161+
162+
async function browserCache(): Promise<BrowserCache | undefined> {
163+
if (typeof caches === "undefined") return undefined
164+
return await caches.open(CACHE_NAME)
165+
}
166+
143167
export async function resolve(modelId: string, indexUrl?: string): Promise<ResolvedZip> {
144168
const source = indexUrl ?? process.env["SECRYST_INDEX"] ?? DEFAULT_INDEX_URL
145169
const entries = await fetchIndex(source)
@@ -157,6 +181,16 @@ export async function resolve(modelId: string, indexUrl?: string): Promise<Resol
157181
if ((await sha256Hex(cached)) === entry.sha256) return { bytes: cached, path: target }
158182
}
159183

184+
const cache = await browserCache()
185+
if (cache) {
186+
const hit = await cache.match(cacheKey(modelId, entry.filename))
187+
if (hit) {
188+
const cached = new Uint8Array(await hit.arrayBuffer())
189+
if ((await sha256Hex(cached)) === entry.sha256) return { bytes: cached }
190+
await cache.delete(cacheKey(modelId, entry.filename))
191+
}
192+
}
193+
160194
if (entry.parts?.length) {
161195
if (fs) {
162196
const { createHash } = await import("node:crypto")
@@ -197,6 +231,7 @@ export async function resolve(modelId: string, indexUrl?: string): Promise<Resol
197231
`assembled ${entry.filename} sha256 mismatch: got ${actual}, index says ${entry.sha256}`,
198232
)
199233
}
234+
if (cache) await cache.put(cacheKey(modelId, entry.filename), new Response(bytes))
200235
return { bytes }
201236
}
202237

@@ -217,5 +252,6 @@ export async function resolve(modelId: string, indexUrl?: string): Promise<Resol
217252
fs.renameSync(tmp, target)
218253
return { bytes, path: target }
219254
}
255+
if (cache) await cache.put(cacheKey(modelId, entry.filename), new Response(bytes))
220256
return { bytes }
221257
}

‎test/imf.test.ts‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,89 @@ describe("registry", () => {
158158
rmSync(dir, { recursive: true, force: true })
159159
}
160160
})
161+
162+
it("persists verified models in the browser Cache API (download once)", async () => {
163+
const { createHash } = await import("node:crypto")
164+
const { createServer } = await import("node:http")
165+
const sha = createHash("sha256").update(fixtureZip).digest("hex")
166+
let channelUp = true
167+
let indexBody = ""
168+
const server = createServer((req, res) => {
169+
if (req.url === "/index.yaml") {
170+
res.writeHead(200, { "content-type": "text/yaml" })
171+
res.end(indexBody)
172+
return
173+
}
174+
if (req.url === "/index.yaml.sha256") {
175+
const digest = createHash("sha256").update(indexBody).digest("hex")
176+
res.writeHead(200)
177+
res.end(`${digest} index.yaml\n`)
178+
return
179+
}
180+
if (req.url === "/tiny.zip") {
181+
if (!channelUp) {
182+
res.writeHead(404)
183+
res.end("gone")
184+
return
185+
}
186+
res.writeHead(200)
187+
res.end(fixtureZip)
188+
return
189+
}
190+
res.writeHead(404)
191+
res.end()
192+
})
193+
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r))
194+
const port = (server.address() as { port: number }).port
195+
const indexUrl = `http://127.0.0.1:${port}/index.yaml`
196+
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`
197+
198+
const store = new Map<string, Response>()
199+
const fakeCache = {
200+
async match(req: RequestInfo) {
201+
const key = String(req instanceof Request ? req.url : req)
202+
return store.get(key)?.clone()
203+
},
204+
async put(req: RequestInfo, res: Response) {
205+
const key = String(req instanceof Request ? req.url : req)
206+
store.set(key, res.clone())
207+
},
208+
async delete(req: RequestInfo) {
209+
const key = String(req instanceof Request ? req.url : req)
210+
return store.delete(key)
211+
},
212+
}
213+
const g = globalThis as Record<string, unknown>
214+
g["caches"] = { open: async () => fakeCache }
215+
// simulate a browser host: no Node fs, so the Cache API path runs
216+
const versions = process.versions as { node?: string }
217+
const realNode = versions.node
218+
delete versions.node
219+
try {
220+
process.env["SECRYST_CACHE"] = undefined
221+
const first = await resolve("tiny-1.0", indexUrl)
222+
expect([...first.bytes]).toEqual([...fixtureZip])
223+
expect(store.size).toBe(1)
224+
225+
// channel dies; the cached copy serves, still sha-verified
226+
channelUp = false
227+
const second = await resolve("tiny-1.0", indexUrl)
228+
expect([...second.bytes]).toEqual([...fixtureZip])
229+
230+
// a corrupted cache entry falls through to a fresh download
231+
channelUp = true
232+
const key = store.keys().next().value as string
233+
store.set(key, new Response(new Uint8Array([1, 2, 3])))
234+
const third = await resolve("tiny-1.0", indexUrl)
235+
expect([...third.bytes]).toEqual([...fixtureZip])
236+
} finally {
237+
if (realNode !== undefined) versions.node = realNode
238+
delete g["caches"]
239+
delete process.env["SECRYST_CACHE"]
240+
await new Promise<void>((r) => server.close(() => r()))
241+
}
242+
})
243+
161244
it("DEFAULT_INDEX_URL pins a GitHub Release asset, never raw", async () => {
162245
const { DEFAULT_INDEX_URL } = await import("../src/ml/imf/registry.js")
163246
expect(DEFAULT_INDEX_URL).toMatch(

0 commit comments

Comments
 (0)