cursedbelt-core 2.8.0 → 2.9.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.
@@ -13,7 +13,9 @@
13
13
  * - `install` — precache the shell and the config's `precache` list, then `skipWaiting`.
14
14
  * - `activate` — delete every cache named `<cachePrefix>*` that is not this version's,
15
15
  * then `clients.claim()`.
16
- * - `fetch` — same-origin static ASSETS cache-first; document NAVIGATIONS
16
+ * - `fetch` — same-origin static ASSETS: cache-first when the NAME carries a content
17
+ * hash, otherwise revalidated with the network and the cache as the
18
+ * offline fallback (see {@link isContentHashed}); document NAVIGATIONS
17
19
  * network-first, falling back offline to the exact URL, then the same
18
20
  * path ignoring the query, then the shell. Everything else is left on
19
21
  * the network: `respondWith` is never called for it.
@@ -44,7 +46,7 @@
44
46
  export type ShellRoute =
45
47
  /** A document navigation — network-first, cached-document fallback offline. */
46
48
  'navigate'
47
- /** A same-origin static asset (JS/CSS/WASM/font/image/JSON) — cache-first. */
49
+ /** A same-origin static asset (JS/CSS/WASM/font/image/JSON) — cache-first if content-hashed, else revalidated. */
48
50
  | 'asset'
49
51
  /** An auth-gated API call — never cached, never answered from a cache. */
50
52
  | 'api'
@@ -110,6 +112,23 @@ export interface ResolvedShellWorkerConfig {
110
112
  /** roms' `ASSET_EXT`, verbatim. */
111
113
  export declare const DEFAULT_ASSET_EXT: RegExp;
112
114
  /** Always an API prefix, whatever the config says. */
115
+ /**
116
+ * A built file whose NAME carries its content hash — Vite's `index-BcHBclrL.js` — so
117
+ * the bytes under that name can never change, and cache-first is exact.
118
+ *
119
+ * 🔴 Every other asset is REVALIDATED (network, then the cache when offline), because
120
+ * cache-first on a fixed name is "the first copy this device ever saw, for ever":
121
+ * the cache is named by `version`, which an app bumps by hand, and nothing else
122
+ * evicts an entry. Measured on roms 2026-09-27: its emulator cores
123
+ * (`/vendor/cores/melonds_libretro.js`) and frame runtime (`/emulator-runtime.js`)
124
+ * are fixed names under a `v4` cache, so the DS audio fix deployed that day — and
125
+ * every core or runtime fix before it — could never reach a device that had already
126
+ * played the console. The edge answers those names `no-cache` with an ETag, so a
127
+ * revalidation is a 304 and no body.
128
+ */
129
+ export declare const CONTENT_HASHED_NAME: RegExp;
130
+ /** Whether this pathname's last segment is a content-hashed build output. */
131
+ export declare function isContentHashed(pathname: string): boolean;
113
132
  export declare const ALWAYS_API_PREFIX = "/api/";
114
133
  /**
115
134
  * Classify a request. Order matters and is roms' order: non-GET and cross-origin
@@ -13,7 +13,9 @@
13
13
  * - `install` — precache the shell and the config's `precache` list, then `skipWaiting`.
14
14
  * - `activate` — delete every cache named `<cachePrefix>*` that is not this version's,
15
15
  * then `clients.claim()`.
16
- * - `fetch` — same-origin static ASSETS cache-first; document NAVIGATIONS
16
+ * - `fetch` — same-origin static ASSETS: cache-first when the NAME carries a content
17
+ * hash, otherwise revalidated with the network and the cache as the
18
+ * offline fallback (see {@link isContentHashed}); document NAVIGATIONS
17
19
  * network-first, falling back offline to the exact URL, then the same
18
20
  * path ignoring the query, then the shell. Everything else is left on
19
21
  * the network: `respondWith` is never called for it.
@@ -43,6 +45,25 @@
43
45
  /** roms' `ASSET_EXT`, verbatim. */
44
46
  export const DEFAULT_ASSET_EXT = /\.(?:js|mjs|css|wasm|woff2?|ttf|png|jpe?g|webp|svg|ico|json)$/i;
45
47
  /** Always an API prefix, whatever the config says. */
48
+ /**
49
+ * A built file whose NAME carries its content hash — Vite's `index-BcHBclrL.js` — so
50
+ * the bytes under that name can never change, and cache-first is exact.
51
+ *
52
+ * 🔴 Every other asset is REVALIDATED (network, then the cache when offline), because
53
+ * cache-first on a fixed name is "the first copy this device ever saw, for ever":
54
+ * the cache is named by `version`, which an app bumps by hand, and nothing else
55
+ * evicts an entry. Measured on roms 2026-09-27: its emulator cores
56
+ * (`/vendor/cores/melonds_libretro.js`) and frame runtime (`/emulator-runtime.js`)
57
+ * are fixed names under a `v4` cache, so the DS audio fix deployed that day — and
58
+ * every core or runtime fix before it — could never reach a device that had already
59
+ * played the console. The edge answers those names `no-cache` with an ETag, so a
60
+ * revalidation is a 304 and no body.
61
+ */
62
+ export const CONTENT_HASHED_NAME = /-[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$/;
63
+ /** Whether this pathname's last segment is a content-hashed build output. */
64
+ export function isContentHashed(pathname) {
65
+ return CONTENT_HASHED_NAME.test(pathname);
66
+ }
46
67
  export const ALWAYS_API_PREFIX = '/api/';
47
68
  const apiPrefixesOf = (config) => {
48
69
  const extra = config.apiPrefixes ?? [];
@@ -143,6 +164,26 @@ export function installShellWorker(scope, config) {
143
164
  store(event, 'asset', response);
144
165
  return response;
145
166
  };
167
+ // A fixed-name asset: the network's answer when it has a good one (stored for
168
+ // offline), else the cached copy — offline, or an edge error — else whatever the
169
+ // network said. Never the cache first: see `isContentHashed`.
170
+ const revalidate = async (event) => {
171
+ let response;
172
+ try {
173
+ response = await scope.fetch(event.request);
174
+ }
175
+ catch (error) {
176
+ const cached = await caches.match(event.request);
177
+ if (cached)
178
+ return cached;
179
+ throw error;
180
+ }
181
+ if (response.ok) {
182
+ store(event, 'asset', response);
183
+ return response;
184
+ }
185
+ return (await caches.match(event.request)) ?? response;
186
+ };
146
187
  const networkFirst = async (event) => {
147
188
  const { request } = event;
148
189
  try {
@@ -190,7 +231,7 @@ export function installShellWorker(scope, config) {
190
231
  isNavigate: event.request.mode === 'navigate',
191
232
  }, resolved);
192
233
  if (route === 'asset')
193
- event.respondWith(cacheFirst(event));
234
+ event.respondWith(isContentHashed(url.pathname) ? cacheFirst(event) : revalidate(event));
194
235
  else if (route === 'navigate')
195
236
  event.respondWith(networkFirst(event));
196
237
  // `api` and `passthrough`: no respondWith, so the browser goes to the network as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-core",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "Framework-agnostic core of the cursedbelt split — isomorphic domain logic. cwip below it, cursedbelt-server and cursedbelt (react) above it.",
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'bun:test';
2
2
  import {
3
3
  classifyRequest,
4
4
  installShellWorker,
5
+ isContentHashed,
5
6
  mayFallBackToShell,
6
7
  type RequestFacts,
7
8
  resolveShellWorkerConfig,
@@ -353,16 +354,50 @@ describe('installShellWorker — fetch', () => {
353
354
  );
354
355
  });
355
356
 
356
- it('serves assets cache-first and caches only an ok response', async () => {
357
+ it('serves CONTENT-HASHED assets cache-first and caches only an ok response', async () => {
357
358
  const world = await installedRoms();
358
- world.server.set(`${ORIGIN}/assets/app-1.js`, new FakeResponse('app'));
359
- expect((await world.dispatch(req('/assets/app-1.js')).settled())?.body).toBe('app');
360
- expect((await world.dispatch(req('/assets/missing.js')).settled())?.ok).toBe(false);
361
- expect(world.cacheWrites).toEqual([`${ORIGIN}/assets/app-1.js`]);
362
- world.goOffline();
359
+ world.server.set(`${ORIGIN}/assets/app-BcHBclrL.js`, new FakeResponse('app'));
360
+ expect((await world.dispatch(req('/assets/app-BcHBclrL.js')).settled())?.body).toBe('app');
361
+ expect((await world.dispatch(req('/assets/missing-DeadBeef.js')).settled())?.ok).toBe(false);
362
+ expect(world.cacheWrites).toEqual([`${ORIGIN}/assets/app-BcHBclrL.js`]);
363
363
  const calls = world.networkCalls.length;
364
- expect((await world.dispatch(req('/assets/app-1.js')).settled())?.body).toBe('app');
365
- expect(world.networkCalls.length).toBe(calls); // from the cache, not the network
364
+ expect((await world.dispatch(req('/assets/app-BcHBclrL.js')).settled())?.body).toBe('app');
365
+ expect(world.networkCalls.length).toBe(calls); // from the cache, not the network — even online
366
+ });
367
+
368
+ it('🔴 a FIXED-NAME asset reaches the device when its bytes change — the roms core that never updated (2026-09-27)', async () => {
369
+ const world = await installedRoms();
370
+ const core = `${ORIGIN}/vendor/cores/melonds_libretro.js`;
371
+ world.server.set(core, new FakeResponse('old driver'));
372
+ expect((await world.dispatch(req('/vendor/cores/melonds_libretro.js')).settled())?.body).toBe('old driver');
373
+ world.server.set(core, new FakeResponse('new driver')); // a deploy
374
+ expect((await world.dispatch(req('/vendor/cores/melonds_libretro.js')).settled())?.body).toBe('new driver');
375
+ // …and the runtime precached at install is refreshed the same way.
376
+ world.server.set(`${ORIGIN}/emulator-runtime.js`, new FakeResponse('runtime 2'));
377
+ expect((await world.dispatch(req('/emulator-runtime.js')).settled())?.body).toBe('runtime 2');
378
+ // Offline, the LATEST copy answers, not the first one.
379
+ world.goOffline();
380
+ expect((await world.dispatch(req('/vendor/cores/melonds_libretro.js')).settled())?.body).toBe('new driver');
381
+ expect((await world.dispatch(req('/emulator-runtime.js')).settled())?.body).toBe('runtime 2');
382
+ });
383
+
384
+ it('a fixed-name asset the edge errors on is served from the cache; with no copy, the error stands', async () => {
385
+ const world = await installedRoms();
386
+ world.server.set(`${ORIGIN}/controller-ds.webp`, new FakeResponse('pad'));
387
+ await world.dispatch(req('/controller-ds.webp')).settled();
388
+ world.server.set(`${ORIGIN}/controller-ds.webp`, new FakeResponse('bad gateway', false));
389
+ expect((await world.dispatch(req('/controller-ds.webp')).settled())?.body).toBe('pad');
390
+ expect((await world.dispatch(req('/favicon.svg')).settled())?.ok).toBe(false);
391
+ world.goOffline();
392
+ await expect(world.dispatch(req('/favicon.svg')).response as Promise<ShellWorkerResponse>).rejects.toThrow('Failed to fetch');
393
+ });
394
+
395
+ it('isContentHashed: Vite names are, fixed names are not', () => {
396
+ expect(isContentHashed('/assets/index-BcHBclrL.js')).toBe(true);
397
+ expect(isContentHashed('/assets/ps2-C7Rs0q_e.css')).toBe(true);
398
+ expect(isContentHashed('/vendor/cores/melonds_libretro.wasm')).toBe(false);
399
+ expect(isContentHashed('/emulator-runtime.js')).toBe(false);
400
+ expect(isContentHashed('/controller-ds.webp')).toBe(false);
366
401
  });
367
402
 
368
403
  it('🔴 never intercepts, reads or caches /api/ — even with a stale copy planted in the cache', async () => {
@@ -13,7 +13,9 @@
13
13
  * - `install` — precache the shell and the config's `precache` list, then `skipWaiting`.
14
14
  * - `activate` — delete every cache named `<cachePrefix>*` that is not this version's,
15
15
  * then `clients.claim()`.
16
- * - `fetch` — same-origin static ASSETS cache-first; document NAVIGATIONS
16
+ * - `fetch` — same-origin static ASSETS: cache-first when the NAME carries a content
17
+ * hash, otherwise revalidated with the network and the cache as the
18
+ * offline fallback (see {@link isContentHashed}); document NAVIGATIONS
17
19
  * network-first, falling back offline to the exact URL, then the same
18
20
  * path ignoring the query, then the shell. Everything else is left on
19
21
  * the network: `respondWith` is never called for it.
@@ -45,7 +47,7 @@
45
47
  export type ShellRoute =
46
48
  /** A document navigation — network-first, cached-document fallback offline. */
47
49
  | 'navigate'
48
- /** A same-origin static asset (JS/CSS/WASM/font/image/JSON) — cache-first. */
50
+ /** A same-origin static asset (JS/CSS/WASM/font/image/JSON) — cache-first if content-hashed, else revalidated. */
49
51
  | 'asset'
50
52
  /** An auth-gated API call — never cached, never answered from a cache. */
51
53
  | 'api'
@@ -116,6 +118,27 @@ export interface ResolvedShellWorkerConfig {
116
118
  export const DEFAULT_ASSET_EXT = /\.(?:js|mjs|css|wasm|woff2?|ttf|png|jpe?g|webp|svg|ico|json)$/i;
117
119
 
118
120
  /** Always an API prefix, whatever the config says. */
121
+ /**
122
+ * A built file whose NAME carries its content hash — Vite's `index-BcHBclrL.js` — so
123
+ * the bytes under that name can never change, and cache-first is exact.
124
+ *
125
+ * 🔴 Every other asset is REVALIDATED (network, then the cache when offline), because
126
+ * cache-first on a fixed name is "the first copy this device ever saw, for ever":
127
+ * the cache is named by `version`, which an app bumps by hand, and nothing else
128
+ * evicts an entry. Measured on roms 2026-09-27: its emulator cores
129
+ * (`/vendor/cores/melonds_libretro.js`) and frame runtime (`/emulator-runtime.js`)
130
+ * are fixed names under a `v4` cache, so the DS audio fix deployed that day — and
131
+ * every core or runtime fix before it — could never reach a device that had already
132
+ * played the console. The edge answers those names `no-cache` with an ETag, so a
133
+ * revalidation is a 304 and no body.
134
+ */
135
+ export const CONTENT_HASHED_NAME = /-[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$/;
136
+
137
+ /** Whether this pathname's last segment is a content-hashed build output. */
138
+ export function isContentHashed(pathname: string): boolean {
139
+ return CONTENT_HASHED_NAME.test(pathname);
140
+ }
141
+
119
142
  export const ALWAYS_API_PREFIX = '/api/';
120
143
 
121
144
  const apiPrefixesOf = (config: Pick<ShellWorkerConfig, 'apiPrefixes'>): readonly string[] => {
@@ -279,6 +302,25 @@ export function installShellWorker(scope: ShellWorkerScope, config: ShellWorkerC
279
302
  return response;
280
303
  };
281
304
 
305
+ // A fixed-name asset: the network's answer when it has a good one (stored for
306
+ // offline), else the cached copy — offline, or an edge error — else whatever the
307
+ // network said. Never the cache first: see `isContentHashed`.
308
+ const revalidate = async (event: ShellFetchEvent): Promise<ShellWorkerResponse> => {
309
+ let response: ShellWorkerResponse;
310
+ try {
311
+ response = await scope.fetch(event.request);
312
+ } catch (error) {
313
+ const cached = await caches.match(event.request);
314
+ if (cached) return cached;
315
+ throw error;
316
+ }
317
+ if (response.ok) {
318
+ store(event, 'asset', response);
319
+ return response;
320
+ }
321
+ return (await caches.match(event.request)) ?? response;
322
+ };
323
+
282
324
  const networkFirst = async (event: ShellFetchEvent): Promise<ShellWorkerResponse> => {
283
325
  const { request } = event;
284
326
  try {
@@ -336,7 +378,7 @@ export function installShellWorker(scope: ShellWorkerScope, config: ShellWorkerC
336
378
  },
337
379
  resolved,
338
380
  );
339
- if (route === 'asset') event.respondWith(cacheFirst(event));
381
+ if (route === 'asset') event.respondWith(isContentHashed(url.pathname) ? cacheFirst(event) : revalidate(event));
340
382
  else if (route === 'navigate') event.respondWith(networkFirst(event));
341
383
  // `api` and `passthrough`: no respondWith, so the browser goes to the network as
342
384
  // if this worker did not exist. Never a cache read, never a cache write.