@emdzej/bimmerz-vfs 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,271 @@
1
+ /**
2
+ * `CachedHttpDirectory` — drop-in replacement for `HttpDirectory`
3
+ * that persists fetched bytes (index + files) in OPFS (preferred)
4
+ * or IndexedDB.
5
+ *
6
+ * Why a wrapper, not changes to `HttpDirectory`: the base class is
7
+ * the right minimum surface for read-only HTTP. Caching is an
8
+ * orthogonal concern that some consumers don't want (development,
9
+ * one-shot CLI tools). Keeping the two separate also means a bug in
10
+ * the cache layer can't poison the simple path.
11
+ *
12
+ * What gets cached:
13
+ *
14
+ * • The `index.json` per directory.
15
+ * • The body of every `file(name).arrayBuffer()` call.
16
+ *
17
+ * Staleness policy: **stale-while-revalidate.**
18
+ *
19
+ * • First read of any URL: do a normal fetch, store body + ETag +
20
+ * Last-Modified + the `Content-Type`, return.
21
+ * • Subsequent reads inside `maxAgeMs` (default 5 min): serve
22
+ * cached bytes immediately, no network at all.
23
+ * • Subsequent reads OUTSIDE `maxAgeMs`: serve cached bytes
24
+ * immediately AND kick off a background conditional GET (`If-
25
+ * None-Match` / `If-Modified-Since`). When the server replies:
26
+ * - `304 Not Modified` → bump `validatedAt`. Body untouched.
27
+ * - `200 OK` → write the new body to cache. The next call
28
+ * then sees the fresh bytes.
29
+ * • Explicit `revalidate(file)` forces an immediate conditional
30
+ * fetch and returns once the network response settles.
31
+ *
32
+ * The first-after-update read returns the OLD bytes. That's the
33
+ * standard SWR tradeoff — favours latency over freshness, lets
34
+ * the dashboard render instantly on every load.
35
+ */
36
+ import { openCacheBackend, } from './cache.js';
37
+ /* ── Module-level cached backends ──────────────────────────────── */
38
+ /* When the user doesn't pass `backend`, we share one across all
39
+ `CachedHttpDirectory` instances per namespace. Saves opening
40
+ multiple OPFS handles to the same directory. */
41
+ const backendCache = new Map();
42
+ function getBackend(namespace, prefer) {
43
+ const key = `${prefer ?? 'auto'}:${namespace}`;
44
+ let p = backendCache.get(key);
45
+ if (!p) {
46
+ p = openCacheBackend({ namespace, prefer });
47
+ backendCache.set(key, p);
48
+ }
49
+ return p;
50
+ }
51
+ /* ── CachedHttpFile ────────────────────────────────────────────── */
52
+ /** `VirtualFile` backed by a cache lookup + conditional fetch. */
53
+ export class CachedHttpFile {
54
+ name;
55
+ size;
56
+ #url;
57
+ #fetch;
58
+ #backend;
59
+ #cacheKey;
60
+ #maxAgeMs;
61
+ constructor(args) {
62
+ this.name = args.name;
63
+ this.size = args.size;
64
+ this.#url = args.url;
65
+ this.#fetch = args.fetch;
66
+ this.#backend = args.backend;
67
+ this.#cacheKey = args.cacheKey;
68
+ this.#maxAgeMs = args.maxAgeMs;
69
+ }
70
+ async arrayBuffer() {
71
+ const backend = await this.#backend;
72
+ const cached = await backend.get(this.#cacheKey);
73
+ const now = Date.now();
74
+ if (cached) {
75
+ /* Fresh enough — serve directly. */
76
+ if (now - cached.meta.validatedAt < this.#maxAgeMs) {
77
+ return cached.bytes;
78
+ }
79
+ /* Stale — kick off a conditional fetch in the background and
80
+ return the cached bytes immediately. The next call will see
81
+ the result. */
82
+ void this.#revalidateInBackground(cached);
83
+ return cached.bytes;
84
+ }
85
+ /* Cold miss. Network fetch and store. */
86
+ const fresh = await this.#fetchAndStore();
87
+ return fresh.bytes;
88
+ }
89
+ async #fetchAndStore(prev) {
90
+ const headers = {};
91
+ if (prev?.meta.etag)
92
+ headers['If-None-Match'] = prev.meta.etag;
93
+ if (prev?.meta.lastModified)
94
+ headers['If-Modified-Since'] = prev.meta.lastModified;
95
+ const res = await this.#fetch(this.#url, { headers });
96
+ const now = Date.now();
97
+ if (res.status === 304 && prev) {
98
+ /* Server says cached body is still fresh. Update timestamp,
99
+ leave the bytes alone. */
100
+ const updatedMeta = { ...prev.meta, validatedAt: now };
101
+ const entry = { bytes: prev.bytes, meta: updatedMeta };
102
+ const backend = await this.#backend;
103
+ await backend.put(this.#cacheKey, entry);
104
+ return entry;
105
+ }
106
+ if (!res.ok) {
107
+ /* Treat any non-success as "keep the cached entry, surface the
108
+ error". When we have no cached entry, throw. */
109
+ if (prev)
110
+ return prev;
111
+ throw new Error(`HTTP ${res.status} ${res.statusText}: ${this.#url}`);
112
+ }
113
+ const bytes = await res.arrayBuffer();
114
+ const meta = {
115
+ url: this.#url,
116
+ etag: res.headers.get('etag') ?? undefined,
117
+ lastModified: res.headers.get('last-modified') ?? undefined,
118
+ contentType: res.headers.get('content-type') ?? undefined,
119
+ size: bytes.byteLength,
120
+ storedAt: now,
121
+ validatedAt: now,
122
+ };
123
+ const entry = { bytes, meta };
124
+ const backend = await this.#backend;
125
+ await backend.put(this.#cacheKey, entry);
126
+ return entry;
127
+ }
128
+ async #revalidateInBackground(prev) {
129
+ try {
130
+ await this.#fetchAndStore(prev);
131
+ }
132
+ catch {
133
+ /* Background failures don't bubble — the cached entry stays
134
+ valid until the next attempt. */
135
+ }
136
+ }
137
+ /** Force a fresh conditional fetch and resolve once the cache is
138
+ * consistent with the server. */
139
+ async revalidate() {
140
+ const backend = await this.#backend;
141
+ const cached = await backend.get(this.#cacheKey);
142
+ await this.#fetchAndStore(cached ?? undefined);
143
+ }
144
+ }
145
+ /* ── CachedHttpDirectory ───────────────────────────────────────── */
146
+ export class CachedHttpDirectory {
147
+ name;
148
+ #baseUrl;
149
+ #indexFile;
150
+ #fetch;
151
+ #backend;
152
+ #namespace;
153
+ #cacheKey;
154
+ #maxAgeMs;
155
+ /** Lazily populated on first access. Unlike `HttpDirectory`,
156
+ * we DON'T memo the parsed index — we re-read from the cache so
157
+ * background-revalidation updates take effect immediately. */
158
+ #indexUrl;
159
+ constructor(baseUrl, options = {}) {
160
+ this.#baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
161
+ this.#indexFile = options.indexFile ?? 'index.json';
162
+ this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
163
+ this.name = this.#baseUrl.split('/').filter(Boolean).at(-1) ?? '';
164
+ this.#namespace = options.namespace ?? defaultNamespace(this.#baseUrl);
165
+ this.#cacheKey = options.cacheKey ?? ((url) => url);
166
+ this.#maxAgeMs = options.maxAgeMs ?? 5 * 60 * 1000;
167
+ this.#backend = options.backend
168
+ ? Promise.resolve(options.backend)
169
+ : getBackend(this.#namespace, options.preferBackend);
170
+ this.#indexUrl = `${this.#baseUrl}/${this.#indexFile}`;
171
+ }
172
+ async #loadIndex() {
173
+ const indexFile = new CachedHttpFile({
174
+ name: this.#indexFile,
175
+ size: 0,
176
+ url: this.#indexUrl,
177
+ fetch: this.#fetch,
178
+ backend: this.#backend,
179
+ cacheKey: this.#cacheKey(this.#indexUrl),
180
+ maxAgeMs: this.#maxAgeMs,
181
+ });
182
+ const bytes = await indexFile.arrayBuffer();
183
+ const text = new TextDecoder('utf-8').decode(bytes);
184
+ return JSON.parse(text);
185
+ }
186
+ async file(name) {
187
+ const index = await this.#loadIndex();
188
+ const target = name.toLowerCase();
189
+ const entry = index.find((e) => (e.type === 'file' || e.type === 'link') && e.fullName === target);
190
+ if (!entry)
191
+ return null;
192
+ const url = `${this.#baseUrl}/${entry.originalFullName}`;
193
+ return new CachedHttpFile({
194
+ name: entry.originalFullName,
195
+ size: entry.size,
196
+ url,
197
+ fetch: this.#fetch,
198
+ backend: this.#backend,
199
+ cacheKey: this.#cacheKey(url),
200
+ maxAgeMs: this.#maxAgeMs,
201
+ });
202
+ }
203
+ async dir(name) {
204
+ const index = await this.#loadIndex();
205
+ const target = name.toLowerCase();
206
+ const entry = index.find((e) => e.type === 'dir' && e.fullName === target);
207
+ if (!entry)
208
+ return null;
209
+ /* Pass the SAME backend down — nested directories share one
210
+ namespace. Their cache keys still distinguish entries via the
211
+ full URL. */
212
+ return new CachedHttpDirectory(`${this.#baseUrl}/${entry.originalFullName}`, {
213
+ indexFile: this.#indexFile,
214
+ fetch: this.#fetch,
215
+ backend: await this.#backend,
216
+ namespace: this.#namespace,
217
+ cacheKey: this.#cacheKey,
218
+ maxAgeMs: this.#maxAgeMs,
219
+ });
220
+ }
221
+ async entries() {
222
+ const index = await this.#loadIndex();
223
+ const result = [];
224
+ for (const entry of index) {
225
+ if (entry.type === 'file' || entry.type === 'link') {
226
+ result.push({ kind: 'file', name: entry.originalFullName, size: entry.size });
227
+ }
228
+ else if (entry.type === 'dir') {
229
+ result.push({ kind: 'dir', name: entry.originalFullName });
230
+ }
231
+ }
232
+ return result;
233
+ }
234
+ /* ── CacheControl ───────────────────────────────────────── */
235
+ async stats() {
236
+ const backend = await this.#backend;
237
+ const sz = await backend.size();
238
+ return { backend: backend.kind, entries: sz.entries, totalBytes: sz.totalBytes };
239
+ }
240
+ async clear() {
241
+ const backend = await this.#backend;
242
+ await backend.clear();
243
+ }
244
+ async revalidate(path) {
245
+ /* `path` is interpreted relative to baseUrl. Normalises leading
246
+ slashes so callers can pass `"foo.bin"` or `"/foo.bin"`. */
247
+ const trimmed = path.replace(/^\/+/, '');
248
+ const url = `${this.#baseUrl}/${trimmed}`;
249
+ const file = new CachedHttpFile({
250
+ name: trimmed,
251
+ size: 0,
252
+ url,
253
+ fetch: this.#fetch,
254
+ backend: this.#backend,
255
+ cacheKey: this.#cacheKey(url),
256
+ maxAgeMs: this.#maxAgeMs,
257
+ });
258
+ await file.revalidate();
259
+ }
260
+ async revalidateIndex() {
261
+ return this.revalidate(this.#indexFile);
262
+ }
263
+ }
264
+ function defaultNamespace(baseUrl) {
265
+ /* Strip scheme + double slashes so two URLs that differ only in
266
+ `http://` vs `https://` still share a namespace — same content
267
+ under two ingress paths is the common case for embedded
268
+ dongles served over both ports. */
269
+ return baseUrl.replace(/^https?:\/\//, '');
270
+ }
271
+ //# sourceMappingURL=cached-http.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cached-http.js","sourceRoot":"","sources":["../src/cached-http.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Tests for `CachedHttpDirectory` + `CachedHttpFile` against the
3
+ * in-memory cache backend. OPFS and IDB live behind their own
4
+ * abstraction (`CacheBackend`) — once those persist, the
5
+ * directory's behaviour is the same.
6
+ *
7
+ * Each test wires a mock `fetch` that records calls + responds with
8
+ * controllable status / headers / body, so we can simulate 200, 304,
9
+ * 404, and stale-while-revalidate cycles deterministically.
10
+ */
11
+ export {};
12
+ //# sourceMappingURL=cached-http.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cached-http.test.d.ts","sourceRoot":"","sources":["../src/cached-http.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG"}
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Tests for `CachedHttpDirectory` + `CachedHttpFile` against the
3
+ * in-memory cache backend. OPFS and IDB live behind their own
4
+ * abstraction (`CacheBackend`) — once those persist, the
5
+ * directory's behaviour is the same.
6
+ *
7
+ * Each test wires a mock `fetch` that records calls + responds with
8
+ * controllable status / headers / body, so we can simulate 200, 304,
9
+ * 404, and stale-while-revalidate cycles deterministically.
10
+ */
11
+ import { describe, expect, it } from 'vitest';
12
+ import { CachedHttpDirectory, CachedHttpFile } from './cached-http.js';
13
+ import { MemoryCacheBackend } from './cache.js';
14
+ function bytes(values) {
15
+ return new Uint8Array(values).buffer;
16
+ }
17
+ function makeFetch(routes, spy) {
18
+ return async (input, init) => {
19
+ const url = input.toString();
20
+ const headers = {};
21
+ if (init?.headers) {
22
+ for (const [k, v] of Object.entries(init.headers)) {
23
+ headers[k.toLowerCase()] = v;
24
+ }
25
+ }
26
+ spy?.calls.push({ url, headers: { ...headers } });
27
+ const route = routes.get(url);
28
+ if (!route || route.status === 404) {
29
+ return new Response(null, { status: 404, statusText: 'Not Found' });
30
+ }
31
+ /* If the caller set conditional + an If-None-Match that matches
32
+ our etag, return 304. */
33
+ if (route.conditional && route.etag && headers['if-none-match'] === route.etag) {
34
+ return new Response(null, { status: 304, statusText: 'Not Modified' });
35
+ }
36
+ const responseHeaders = { 'Content-Type': 'application/octet-stream' };
37
+ if (route.etag)
38
+ responseHeaders['ETag'] = route.etag;
39
+ if (route.lastModified)
40
+ responseHeaders['Last-Modified'] = route.lastModified;
41
+ return new Response(route.body ?? '', {
42
+ status: route.status ?? 200,
43
+ headers: responseHeaders,
44
+ });
45
+ };
46
+ }
47
+ /* Index fixture used across tests. */
48
+ const BASE = 'http://test.local/install';
49
+ function buildIndex() {
50
+ return JSON.stringify([
51
+ { type: 'file', name: 'ms43', fullName: 'ms43.ipo', originalName: 'MS43', originalFullName: 'MS43.IPO', size: 512 },
52
+ { type: 'file', name: 'readme', fullName: 'readme.txt', originalName: 'README', originalFullName: 'README.TXT', size: 64 },
53
+ { type: 'dir', name: 'sub', fullName: 'sub', originalName: 'SUB', originalFullName: 'SUB', size: 0 },
54
+ ]);
55
+ }
56
+ function makeBackend() {
57
+ return new MemoryCacheBackend('test-ns');
58
+ }
59
+ /* ── CachedHttpFile ─────────────────────────────────────────────── */
60
+ describe('CachedHttpFile', () => {
61
+ it('fetches once and serves the cached bytes thereafter', async () => {
62
+ const routes = new Map([
63
+ [`${BASE}/MS43.IPO`, { body: bytes([1, 2, 3, 4]), etag: '"a1"' }],
64
+ ]);
65
+ const spy = { calls: [] };
66
+ const backend = makeBackend();
67
+ const f = new CachedHttpFile({
68
+ name: 'MS43.IPO', size: 4,
69
+ url: `${BASE}/MS43.IPO`,
70
+ fetch: makeFetch(routes, spy),
71
+ backend: Promise.resolve(backend),
72
+ cacheKey: `${BASE}/MS43.IPO`,
73
+ maxAgeMs: 60_000,
74
+ });
75
+ const first = new Uint8Array(await f.arrayBuffer());
76
+ expect(Array.from(first)).toEqual([1, 2, 3, 4]);
77
+ expect(spy.calls.length).toBe(1);
78
+ const second = new Uint8Array(await f.arrayBuffer());
79
+ expect(Array.from(second)).toEqual([1, 2, 3, 4]);
80
+ /* Still one fetch — second read came from the cache. */
81
+ expect(spy.calls.length).toBe(1);
82
+ });
83
+ it('revalidates against the server with If-None-Match and accepts 304', async () => {
84
+ const routes = new Map([
85
+ [`${BASE}/MS43.IPO`, { body: bytes([1, 2, 3, 4]), etag: '"a1"', conditional: true }],
86
+ ]);
87
+ const spy = { calls: [] };
88
+ const backend = makeBackend();
89
+ const f = new CachedHttpFile({
90
+ name: 'MS43.IPO', size: 4,
91
+ url: `${BASE}/MS43.IPO`,
92
+ fetch: makeFetch(routes, spy),
93
+ backend: Promise.resolve(backend),
94
+ cacheKey: `${BASE}/MS43.IPO`,
95
+ maxAgeMs: 60_000,
96
+ });
97
+ await f.arrayBuffer();
98
+ await f.revalidate();
99
+ /* Second call should be a conditional GET that the mock returns
100
+ 304 to — body unchanged. */
101
+ expect(spy.calls.length).toBe(2);
102
+ expect(spy.calls[1].headers['if-none-match']).toBe('"a1"');
103
+ /* The cache entry still holds the original bytes. */
104
+ const cached = await backend.get(`${BASE}/MS43.IPO`);
105
+ expect(cached?.bytes.byteLength).toBe(4);
106
+ });
107
+ it('replaces cached bytes when the server returns 200 with new content', async () => {
108
+ const routes = new Map([
109
+ [`${BASE}/MS43.IPO`, { body: bytes([1, 2]), etag: '"a1"' }],
110
+ ]);
111
+ const spy = { calls: [] };
112
+ const backend = makeBackend();
113
+ const f = new CachedHttpFile({
114
+ name: 'MS43.IPO', size: 2,
115
+ url: `${BASE}/MS43.IPO`,
116
+ fetch: makeFetch(routes, spy),
117
+ backend: Promise.resolve(backend),
118
+ cacheKey: `${BASE}/MS43.IPO`,
119
+ maxAgeMs: 60_000,
120
+ });
121
+ await f.arrayBuffer();
122
+ /* Mutate the route — new content + new etag, NOT conditional. */
123
+ routes.set(`${BASE}/MS43.IPO`, { body: bytes([9, 9, 9]), etag: '"a2"' });
124
+ await f.revalidate();
125
+ const cached = await backend.get(`${BASE}/MS43.IPO`);
126
+ expect(Array.from(new Uint8Array(cached.bytes))).toEqual([9, 9, 9]);
127
+ expect(cached.meta.etag).toBe('"a2"');
128
+ });
129
+ it('serves cached bytes immediately when over maxAgeMs and revalidates in background', async () => {
130
+ const routes = new Map([
131
+ [`${BASE}/MS43.IPO`, { body: bytes([1, 2, 3]), etag: '"a1"', conditional: true }],
132
+ ]);
133
+ const spy = { calls: [] };
134
+ const backend = makeBackend();
135
+ const f = new CachedHttpFile({
136
+ name: 'MS43.IPO', size: 3,
137
+ url: `${BASE}/MS43.IPO`,
138
+ fetch: makeFetch(routes, spy),
139
+ backend: Promise.resolve(backend),
140
+ cacheKey: `${BASE}/MS43.IPO`,
141
+ maxAgeMs: 0, // always stale
142
+ });
143
+ /* Cold miss → 1 fetch. */
144
+ await f.arrayBuffer();
145
+ expect(spy.calls.length).toBe(1);
146
+ /* Next call: cached bytes are returned synchronously, but the
147
+ maxAge=0 policy schedules a background revalidate. */
148
+ const buf = new Uint8Array(await f.arrayBuffer());
149
+ expect(Array.from(buf)).toEqual([1, 2, 3]);
150
+ /* Flush microtasks. */
151
+ await new Promise((r) => setTimeout(r, 0));
152
+ /* Background fetch went out — total 2 calls. */
153
+ expect(spy.calls.length).toBe(2);
154
+ expect(spy.calls[1].headers['if-none-match']).toBe('"a1"');
155
+ });
156
+ it('throws on cold-miss 404 with no cache entry', async () => {
157
+ const routes = new Map();
158
+ const backend = makeBackend();
159
+ const f = new CachedHttpFile({
160
+ name: 'X', size: 0,
161
+ url: `${BASE}/missing`,
162
+ fetch: makeFetch(routes),
163
+ backend: Promise.resolve(backend),
164
+ cacheKey: `${BASE}/missing`,
165
+ maxAgeMs: 60_000,
166
+ });
167
+ await expect(f.arrayBuffer()).rejects.toThrow(/404/);
168
+ });
169
+ it('keeps cached bytes when revalidation returns an error', async () => {
170
+ const routes = new Map([
171
+ [`${BASE}/MS43.IPO`, { body: bytes([1, 2, 3, 4]), etag: '"a1"' }],
172
+ ]);
173
+ const backend = makeBackend();
174
+ const f = new CachedHttpFile({
175
+ name: 'MS43.IPO', size: 4,
176
+ url: `${BASE}/MS43.IPO`,
177
+ fetch: makeFetch(routes),
178
+ backend: Promise.resolve(backend),
179
+ cacheKey: `${BASE}/MS43.IPO`,
180
+ maxAgeMs: 60_000,
181
+ });
182
+ await f.arrayBuffer();
183
+ /* Mutate the route to return 500. revalidate() should NOT throw
184
+ (we have a cached entry) and the cache should be intact. */
185
+ routes.set(`${BASE}/MS43.IPO`, { status: 500 });
186
+ await f.revalidate();
187
+ const cached = await backend.get(`${BASE}/MS43.IPO`);
188
+ expect(cached).not.toBeNull();
189
+ expect(Array.from(new Uint8Array(cached.bytes))).toEqual([1, 2, 3, 4]);
190
+ });
191
+ });
192
+ /* ── CachedHttpDirectory ──────────────────────────────────────── */
193
+ describe('CachedHttpDirectory', () => {
194
+ it('caches the index and reuses it across file/dir lookups', async () => {
195
+ const routes = new Map([
196
+ [`${BASE}/index.json`, { body: buildIndex(), etag: '"i1"' }],
197
+ [`${BASE}/MS43.IPO`, { body: bytes([0xAA]) }],
198
+ ]);
199
+ const spy = { calls: [] };
200
+ const dir = new CachedHttpDirectory(BASE, {
201
+ fetch: makeFetch(routes, spy),
202
+ backend: makeBackend(),
203
+ });
204
+ await dir.entries();
205
+ await dir.file('MS43.IPO');
206
+ await dir.entries();
207
+ /* Index fetched once; MS43.IPO not yet fetched (file() only
208
+ returns a CachedHttpFile, doesn't read bytes). */
209
+ const indexCalls = spy.calls.filter((c) => c.url.endsWith('/index.json')).length;
210
+ expect(indexCalls).toBe(1);
211
+ });
212
+ it('clear() wipes the namespace', async () => {
213
+ const routes = new Map([
214
+ [`${BASE}/index.json`, { body: buildIndex() }],
215
+ [`${BASE}/MS43.IPO`, { body: bytes([0xAA, 0xBB]) }],
216
+ ]);
217
+ const backend = makeBackend();
218
+ const dir = new CachedHttpDirectory(BASE, {
219
+ fetch: makeFetch(routes),
220
+ backend,
221
+ });
222
+ const f = await dir.file('MS43.IPO');
223
+ await f.arrayBuffer();
224
+ let stats = await dir.stats();
225
+ expect(stats.entries).toBeGreaterThan(0);
226
+ await dir.clear();
227
+ stats = await dir.stats();
228
+ expect(stats.entries).toBe(0);
229
+ });
230
+ it('stats() reports backend kind + total bytes', async () => {
231
+ const routes = new Map([
232
+ [`${BASE}/index.json`, { body: buildIndex() }],
233
+ [`${BASE}/MS43.IPO`, { body: new ArrayBuffer(1000) }],
234
+ ]);
235
+ const dir = new CachedHttpDirectory(BASE, {
236
+ fetch: makeFetch(routes),
237
+ backend: makeBackend(),
238
+ });
239
+ const f = await dir.file('MS43.IPO');
240
+ await f.arrayBuffer();
241
+ const stats = await dir.stats();
242
+ expect(stats.backend).toBe('memory');
243
+ expect(stats.entries).toBe(2); // index + MS43.IPO
244
+ expect(stats.totalBytes).toBeGreaterThanOrEqual(1000);
245
+ });
246
+ it('cacheKey hook canonicalises URLs with query strings', async () => {
247
+ /* Same content under two URLs that differ only by a signed-token
248
+ query string. With the cacheKey hook stripping the query,
249
+ both should hit the same cache entry. */
250
+ const body = bytes([1, 2, 3]);
251
+ const routes = new Map([
252
+ [`${BASE}/MS43.IPO?t=abc`, { body }],
253
+ [`${BASE}/MS43.IPO?t=def`, { body }],
254
+ ]);
255
+ const spy = { calls: [] };
256
+ const backend = makeBackend();
257
+ const fetchFn = makeFetch(routes, spy);
258
+ const cacheKey = (url) => url.replace(/\?.*$/, '');
259
+ const a = new CachedHttpFile({
260
+ name: 'a', size: 3,
261
+ url: `${BASE}/MS43.IPO?t=abc`,
262
+ fetch: fetchFn,
263
+ backend: Promise.resolve(backend),
264
+ cacheKey: cacheKey(`${BASE}/MS43.IPO?t=abc`),
265
+ maxAgeMs: 60_000,
266
+ });
267
+ const b = new CachedHttpFile({
268
+ name: 'b', size: 3,
269
+ url: `${BASE}/MS43.IPO?t=def`,
270
+ fetch: fetchFn,
271
+ backend: Promise.resolve(backend),
272
+ cacheKey: cacheKey(`${BASE}/MS43.IPO?t=def`),
273
+ maxAgeMs: 60_000,
274
+ });
275
+ await a.arrayBuffer();
276
+ await b.arrayBuffer();
277
+ /* Only one fetch — the second resolved from cache because the
278
+ cache keys are equal after the hook normalised away the token. */
279
+ expect(spy.calls.length).toBe(1);
280
+ });
281
+ it('revalidate(path) updates a cached entry on demand', async () => {
282
+ const routes = new Map([
283
+ [`${BASE}/index.json`, { body: buildIndex() }],
284
+ [`${BASE}/MS43.IPO`, { body: bytes([1]), etag: '"a1"' }],
285
+ ]);
286
+ const backend = makeBackend();
287
+ const dir = new CachedHttpDirectory(BASE, {
288
+ fetch: makeFetch(routes),
289
+ backend,
290
+ });
291
+ const f = await dir.file('MS43.IPO');
292
+ await f.arrayBuffer();
293
+ /* Server-side update. */
294
+ routes.set(`${BASE}/MS43.IPO`, { body: bytes([2, 2]), etag: '"a2"' });
295
+ await dir.revalidate('MS43.IPO');
296
+ const cached = await backend.get(`${BASE}/MS43.IPO`);
297
+ expect(Array.from(new Uint8Array(cached.bytes))).toEqual([2, 2]);
298
+ expect(cached.meta.etag).toBe('"a2"');
299
+ });
300
+ it('revalidateIndex() refetches the index', async () => {
301
+ const routes = new Map([
302
+ [`${BASE}/index.json`, { body: buildIndex(), etag: '"i1"' }],
303
+ ]);
304
+ const spy = { calls: [] };
305
+ const dir = new CachedHttpDirectory(BASE, {
306
+ fetch: makeFetch(routes, spy),
307
+ backend: makeBackend(),
308
+ });
309
+ await dir.entries();
310
+ await dir.revalidateIndex();
311
+ const indexCalls = spy.calls.filter((c) => c.url.endsWith('/index.json')).length;
312
+ expect(indexCalls).toBe(2);
313
+ });
314
+ });
315
+ /* ── MemoryCacheBackend (used to back the tests; sanity-check it
316
+ * separately so failures here don't masquerade as CachedHttpFile
317
+ * bugs). ─────────────────────────────────────────────────────── */
318
+ describe('MemoryCacheBackend', () => {
319
+ it('round-trips put + get', async () => {
320
+ const b = makeBackend();
321
+ await b.put('k', {
322
+ bytes: bytes([1, 2, 3]),
323
+ meta: { url: 'http://x', size: 3, storedAt: 1, validatedAt: 1 },
324
+ });
325
+ const e = await b.get('k');
326
+ expect(e).not.toBeNull();
327
+ expect(Array.from(new Uint8Array(e.bytes))).toEqual([1, 2, 3]);
328
+ });
329
+ it('isolates namespaces', async () => {
330
+ const a = new MemoryCacheBackend('a');
331
+ const b = new MemoryCacheBackend('b');
332
+ await a.put('k', {
333
+ bytes: new ArrayBuffer(0),
334
+ meta: { url: 'u', size: 0, storedAt: 0, validatedAt: 0 },
335
+ });
336
+ expect((await b.get('k'))).toBeNull();
337
+ });
338
+ it('clear(prefix) only removes matching keys', async () => {
339
+ const b = makeBackend();
340
+ await b.put('foo/a', { bytes: new ArrayBuffer(0), meta: { url: 'u', size: 0, storedAt: 0, validatedAt: 0 } });
341
+ await b.put('foo/b', { bytes: new ArrayBuffer(0), meta: { url: 'u', size: 0, storedAt: 0, validatedAt: 0 } });
342
+ await b.put('bar/c', { bytes: new ArrayBuffer(0), meta: { url: 'u', size: 0, storedAt: 0, validatedAt: 0 } });
343
+ await b.clear('foo/');
344
+ const keys = await b.keys();
345
+ expect(keys).toEqual(['bar/c']);
346
+ });
347
+ });
348
+ //# sourceMappingURL=cached-http.test.js.map