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
|
|
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
|
|
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.
|
|
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-
|
|
359
|
-
expect((await world.dispatch(req('/assets/app-
|
|
360
|
-
expect((await world.dispatch(req('/assets/missing.js')).settled())?.ok).toBe(false);
|
|
361
|
-
expect(world.cacheWrites).toEqual([`${ORIGIN}/assets/app-
|
|
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-
|
|
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
|
|
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.
|