Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/ml/imf/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response | undefined>
put(request: string, response: Response): Promise<void>
delete(request: string): Promise<boolean>
}

declare const caches: { open(name: string): Promise<BrowserCache> } | undefined

function cacheKey(modelId: string, filename: string): string {
return `https://imf.interscript.org/cache/${modelId}/${filename}`
}

async function browserCache(): Promise<BrowserCache | undefined> {
if (typeof caches === "undefined") return undefined
return await caches.open(CACHE_NAME)
}

export async function resolve(modelId: string, indexUrl?: string): Promise<ResolvedZip> {
const source = indexUrl ?? process.env["SECRYST_INDEX"] ?? DEFAULT_INDEX_URL
const entries = await fetchIndex(source)
Expand All @@ -157,6 +181,16 @@ export async function resolve(modelId: string, indexUrl?: string): Promise<Resol
if ((await sha256Hex(cached)) === entry.sha256) return { bytes: cached, path: target }
}

const cache = await browserCache()
if (cache) {
const hit = await cache.match(cacheKey(modelId, entry.filename))
if (hit) {
const cached = new Uint8Array(await hit.arrayBuffer())
if ((await sha256Hex(cached)) === entry.sha256) return { bytes: cached }
await cache.delete(cacheKey(modelId, entry.filename))
}
}

if (entry.parts?.length) {
if (fs) {
const { createHash } = await import("node:crypto")
Expand Down Expand Up @@ -197,6 +231,7 @@ export async function resolve(modelId: string, indexUrl?: string): Promise<Resol
`assembled ${entry.filename} sha256 mismatch: got ${actual}, index says ${entry.sha256}`,
)
}
if (cache) await cache.put(cacheKey(modelId, entry.filename), new Response(bytes))
return { bytes }
}

Expand All @@ -217,5 +252,6 @@ export async function resolve(modelId: string, indexUrl?: string): Promise<Resol
fs.renameSync(tmp, target)
return { bytes, path: target }
}
if (cache) await cache.put(cacheKey(modelId, entry.filename), new Response(bytes))
return { bytes }
}
83 changes: 83 additions & 0 deletions test/imf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,89 @@ describe("registry", () => {
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<void>((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<string, Response>()
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<string, unknown>
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<void>((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(
Expand Down
Loading