cursedbelt-core 2.1.0 → 2.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.
- package/dist/core/activity/index.d.ts +2 -2
- package/dist/core/activity/index.js +1 -1
- package/dist/core/activity/model.d.ts +2 -2
- package/dist/core/analytics/index.d.ts +2 -2
- package/dist/core/analytics/index.js +2 -2
- package/dist/core/category/index.d.ts +3 -3
- package/dist/core/category/index.js +2 -2
- package/dist/core/category/memoryStore.d.ts +1 -1
- package/dist/core/category/memoryStore.js +1 -1
- package/dist/core/category/tree.d.ts +1 -1
- package/dist/core/colorScheme/index.d.ts +1 -1
- package/dist/core/colorScheme/index.js +1 -1
- package/dist/core/folder-tree/actions.d.ts +1 -1
- package/dist/core/folder-tree/actions.js +1 -1
- package/dist/core/folder-tree/index.d.ts +3 -3
- package/dist/core/folder-tree/index.js +3 -3
- package/dist/core/folder-tree/tree.js +1 -1
- package/dist/core/master-lock/index.d.ts +4 -4
- package/dist/core/master-lock/index.js +4 -4
- package/dist/core/master-lock/wire.d.ts +1 -1
- package/dist/core/media/ffmpeg.d.ts +1 -1
- package/dist/core/media/format.d.ts +1 -1
- package/dist/core/media/index.d.ts +12 -12
- package/dist/core/media/index.js +11 -11
- package/dist/core/media/mediaItem.d.ts +1 -1
- package/dist/core/media/renderPlan.d.ts +1 -1
- package/dist/core/media/renderPlan.js +1 -1
- package/dist/core/media/validate.d.ts +1 -1
- package/dist/core/media/validate.js +1 -1
- package/dist/core/navigation/coverageTest.d.ts +1 -1
- package/dist/core/navigation/coverageTest.js +1 -1
- package/dist/core/navigation/index.d.ts +6 -6
- package/dist/core/navigation/index.js +6 -6
- package/dist/core/navigation/migrateNavPreferences.d.ts +1 -1
- package/dist/core/navigation/navigationSelectors.d.ts +1 -1
- package/dist/core/navigation/navigationSelectors.js +1 -1
- package/dist/core/navigation/searchCatalogue.d.ts +1 -1
- package/dist/core/offline/shellWorker.d.ts +185 -0
- package/dist/core/offline/shellWorker.js +200 -0
- package/dist/core/palette/fuzzyRank.d.ts +1 -1
- package/dist/core/palette/index.d.ts +3 -3
- package/dist/core/palette/index.js +3 -3
- package/dist/core/sharing/index.d.ts +2 -2
- package/dist/core/sharing/index.js +1 -1
- package/dist/core/tokens/registry.d.ts +2 -2
- package/dist/core/tokens/registry.js +1 -1
- package/dist/core/uiScale/index.d.ts +2 -2
- package/dist/core/uiScale/index.js +2 -2
- package/dist/core/uiScale/uiScaleStore.js +1 -1
- package/dist/core/wire/__fixtures__/rowsMarshalWorker.js +1 -1
- package/dist/core/wire/index.d.ts +4 -4
- package/dist/core/wire/index.js +4 -4
- package/dist/core/wire/ingress.d.ts +1 -1
- package/dist/core/wire/ingress.js +3 -3
- package/dist/core/wire/marshalWorker.d.ts +1 -1
- package/dist/core/wire/marshalWorker.js +2 -2
- package/dist/core/wire/marshalling.js +1 -1
- package/dist/ctgr/chunkAccumulator.d.ts +1 -1
- package/dist/ctgr/chunkAccumulator.js +2 -2
- package/dist/ctgr/chunkStore.d.ts +1 -1
- package/dist/ctgr/chunkStore.js +2 -2
- package/dist/ctgr/codec.d.ts +1 -1
- package/dist/ctgr/codec.js +5 -5
- package/dist/ctgr/index.d.ts +9 -9
- package/dist/ctgr/index.js +9 -9
- package/dist/ctgr/v0compat.d.ts +1 -1
- package/dist/ctgr/v0compat.js +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/testing/index.d.ts +3 -3
- package/dist/testing/index.js +3 -3
- package/package.json +8 -2
- package/src/config/appConfig.spec.ts +1 -1
- package/src/core/activity/index.ts +2 -2
- package/src/core/activity/model.ts +2 -2
- package/src/core/analytics/index.ts +2 -2
- package/src/core/analytics/range.spec.ts +1 -1
- package/src/core/analytics/schedule.spec.ts +1 -1
- package/src/core/category/category.spec.ts +1 -1
- package/src/core/category/index.ts +3 -3
- package/src/core/category/memoryStore.ts +2 -2
- package/src/core/category/tree.ts +1 -1
- package/src/core/color.spec.ts +1 -1
- package/src/core/colorScheme/colorScheme.spec.ts +1 -1
- package/src/core/colorScheme/index.ts +1 -1
- package/src/core/download.spec.ts +1 -1
- package/src/core/duplicates/index.spec.ts +1 -1
- package/src/core/events/ccEventBus.spec.ts +1 -1
- package/src/core/file-tree/fileTreeModel.spec.ts +1 -1
- package/src/core/folder-tree/actions.spec.ts +2 -2
- package/src/core/folder-tree/actions.ts +2 -2
- package/src/core/folder-tree/index.ts +3 -3
- package/src/core/folder-tree/path.spec.ts +1 -1
- package/src/core/folder-tree/tree.spec.ts +1 -1
- package/src/core/folder-tree/tree.ts +1 -1
- package/src/core/master-lock/index.ts +4 -4
- package/src/core/master-lock/kdf.spec.ts +2 -2
- package/src/core/master-lock/presence.spec.ts +1 -1
- package/src/core/master-lock/wire.spec.ts +1 -1
- package/src/core/master-lock/wire.ts +1 -1
- package/src/core/media/bgRemoval.spec.ts +1 -1
- package/src/core/media/ffmpeg.spec.ts +2 -2
- package/src/core/media/ffmpeg.ts +1 -1
- package/src/core/media/format.spec.ts +1 -1
- package/src/core/media/format.ts +1 -1
- package/src/core/media/index.ts +12 -12
- package/src/core/media/mediaItem.spec.ts +2 -2
- package/src/core/media/mediaItem.ts +1 -1
- package/src/core/media/renderPlan.spec.ts +1 -1
- package/src/core/media/renderPlan.ts +2 -2
- package/src/core/media/segment.spec.ts +1 -1
- package/src/core/media/size.spec.ts +1 -1
- package/src/core/media/trim.spec.ts +1 -1
- package/src/core/media/validate.spec.ts +2 -2
- package/src/core/media/validate.ts +2 -2
- package/src/core/media/videoCuts.spec.ts +1 -1
- package/src/core/navigation/coverageTest.ts +2 -2
- package/src/core/navigation/index.ts +6 -6
- package/src/core/navigation/migrateNavPreferences.ts +1 -1
- package/src/core/navigation/navigation.spec.ts +1 -1
- package/src/core/navigation/navigationSelectors.ts +1 -1
- package/src/core/navigation/searchCatalogue.ts +1 -1
- package/src/core/net/reconnectSchedule.spec.ts +1 -1
- package/src/core/offline/shellWorker.spec.ts +392 -0
- package/src/core/offline/shellWorker.ts +346 -0
- package/src/core/offline/shellWorker.webworker.spec.ts +80 -0
- package/src/core/palette/fuzzyRank.ts +1 -1
- package/src/core/palette/index.ts +3 -3
- package/src/core/palette/palette.spec.ts +4 -4
- package/src/core/schema/projection.spec.ts +1 -1
- package/src/core/sharing/index.ts +2 -2
- package/src/core/sharing/model.spec.ts +1 -1
- package/src/core/tokens/registry.spec.ts +1 -1
- package/src/core/tokens/registry.ts +2 -2
- package/src/core/uiScale/index.ts +2 -2
- package/src/core/uiScale/uiScale.spec.ts +1 -1
- package/src/core/uiScale/uiScaleStore.ts +1 -1
- package/src/core/wire/__fixtures__/rowsMarshalWorker.ts +1 -1
- package/src/core/wire/index.ts +4 -4
- package/src/core/wire/ingress.spec.ts +2 -2
- package/src/core/wire/ingress.ts +3 -3
- package/src/core/wire/marshalWorker.spec.ts +2 -2
- package/src/core/wire/marshalWorker.ts +2 -2
- package/src/core/wire/marshalling.spec.ts +1 -1
- package/src/core/wire/marshalling.ts +1 -1
- package/src/core/wire/wireHub.spec.ts +1 -1
- package/src/ctgr/chunkAccumulator.ts +3 -3
- package/src/ctgr/chunkStore.spec.ts +2 -2
- package/src/ctgr/chunkStore.ts +3 -3
- package/src/ctgr/codec.spec.ts +4 -4
- package/src/ctgr/codec.ts +5 -5
- package/src/ctgr/index.ts +9 -9
- package/src/ctgr/v0compat.spec.ts +2 -2
- package/src/ctgr/v0compat.ts +3 -3
- package/src/index.ts +2 -2
- package/src/leafSubpathsImportNothing.spec.ts +5 -0
- package/src/testTmpRoot.spec.ts +1 -1
- package/src/testing/index.ts +3 -3
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// shared by HeaderSearch and (task 14) the command palette. The palette upgrades the
|
|
5
5
|
// *ranking* to uFuzzy; this is the dependency-free baseline both can fall back to.
|
|
6
6
|
|
|
7
|
-
import type { SearchCatalogueEntry } from './navigationSelectors';
|
|
7
|
+
import type { SearchCatalogueEntry } from './navigationSelectors.js';
|
|
8
8
|
|
|
9
9
|
/** A query hit with its computed relevance score (higher = better). */
|
|
10
10
|
export interface CatalogueHit extends SearchCatalogueEntry {
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
createReconnectSchedule,
|
|
4
4
|
RECONNECT_CAP_MS,
|
|
5
5
|
retriesAreJittered,
|
|
6
|
-
} from './reconnectSchedule';
|
|
6
|
+
} from './reconnectSchedule.js';
|
|
7
7
|
|
|
8
8
|
describe('reconnect backoff with full jitter (J5-2)', () => {
|
|
9
9
|
it('doubles the ceiling per attempt up to the cap', () => {
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
classifyRequest,
|
|
4
|
+
installShellWorker,
|
|
5
|
+
mayFallBackToShell,
|
|
6
|
+
type RequestFacts,
|
|
7
|
+
resolveShellWorkerConfig,
|
|
8
|
+
type ShellExtendableEvent,
|
|
9
|
+
type ShellFetchEvent,
|
|
10
|
+
type ShellWorkerCache,
|
|
11
|
+
type ShellWorkerConfig,
|
|
12
|
+
type ShellWorkerRequest,
|
|
13
|
+
type ShellWorkerRequestInfo,
|
|
14
|
+
type ShellWorkerResponse,
|
|
15
|
+
type ShellWorkerScope,
|
|
16
|
+
shouldCache,
|
|
17
|
+
} from './shellWorker.js';
|
|
18
|
+
|
|
19
|
+
/** roms' worker, expressed as a config — the two special cases are the point. */
|
|
20
|
+
const ROMS: ShellWorkerConfig = {
|
|
21
|
+
cachePrefix: 'roms-shell-',
|
|
22
|
+
version: 'v3',
|
|
23
|
+
precache: ['/', '/emulator-frame.html', '/emulator-runtime.js'],
|
|
24
|
+
passthroughPrefixes: ['/_nes-roms/'],
|
|
25
|
+
noShellFallback: ['/emulator-frame.html'],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const facts = (pathname: string, over: Partial<RequestFacts> = {}): RequestFacts => ({
|
|
29
|
+
method: 'GET',
|
|
30
|
+
sameOrigin: true,
|
|
31
|
+
pathname,
|
|
32
|
+
isNavigate: false,
|
|
33
|
+
...over,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('classifyRequest — roms parity', () => {
|
|
37
|
+
const table: Array<[string, RequestFacts, string]> = [
|
|
38
|
+
['non-GET is passthrough', facts('/app.js', { method: 'POST' }), 'passthrough'],
|
|
39
|
+
['cross-origin is passthrough', facts('/app.js', { sameOrigin: false }), 'passthrough'],
|
|
40
|
+
['/api/ is api', facts('/api/saves'), 'api'],
|
|
41
|
+
['/api/ wins over the .json asset extension', facts('/api/library.json'), 'api'],
|
|
42
|
+
['/api/ wins over a navigation', facts('/api/me', { isNavigate: true }), 'api'],
|
|
43
|
+
['roms ROM bytes are passthrough (config)', facts('/_nes-roms/mario.nes'), 'passthrough'],
|
|
44
|
+
['roms ROM path as a .json is still passthrough', facts('/_nes-roms/index.json'), 'passthrough'],
|
|
45
|
+
['a navigation is navigate', facts('/library/nes', { isNavigate: true }), 'navigate'],
|
|
46
|
+
['the frame document is a navigation', facts('/emulator-frame.html', { isNavigate: true }), 'navigate'],
|
|
47
|
+
['hashed JS is an asset', facts('/assets/index-abc123.js'), 'asset'],
|
|
48
|
+
['WASM is an asset', facts('/cores/snes9x.wasm'), 'asset'],
|
|
49
|
+
['a font is an asset', facts('/fonts/inter.woff2'), 'asset'],
|
|
50
|
+
['upper-case extension is an asset', facts('/icons/LOGO.PNG'), 'asset'],
|
|
51
|
+
['an extensionless non-navigation is passthrough', facts('/healthz'), 'passthrough'],
|
|
52
|
+
['a .nes file outside the ROM path is passthrough', facts('/other/mario.nes'), 'passthrough'],
|
|
53
|
+
];
|
|
54
|
+
for (const [name, input, route] of table) {
|
|
55
|
+
it(name, () => expect(classifyRequest(input, ROMS)).toBe(route));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
it('without the passthrough config the ROM path would be an asset — the config is what decides', () => {
|
|
59
|
+
expect(classifyRequest(facts('/_nes-roms/index.json'), {})).toBe('asset');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('/api/ stays api even when a config lists only other API prefixes', () => {
|
|
63
|
+
expect(classifyRequest(facts('/api/me'), { apiPrefixes: ['/rpc/'] })).toBe('api');
|
|
64
|
+
expect(classifyRequest(facts('/rpc/call.json'), { apiPrefixes: ['/rpc/'] })).toBe('api');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('a custom asset regex replaces the default', () => {
|
|
68
|
+
expect(classifyRequest(facts('/a.js'), { assetExtensions: /\.css$/ })).toBe('passthrough');
|
|
69
|
+
expect(classifyRequest(facts('/a.css'), { assetExtensions: /\.css$/ })).toBe('asset');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('shouldCache / mayFallBackToShell', () => {
|
|
74
|
+
it('caches assets and navigations, never the API or passthrough', () => {
|
|
75
|
+
expect(shouldCache('asset')).toBe(true);
|
|
76
|
+
expect(shouldCache('navigate')).toBe(true);
|
|
77
|
+
expect(shouldCache('api')).toBe(false);
|
|
78
|
+
expect(shouldCache('passthrough')).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('refuses the shell to roms’ frame and allows it everywhere else', () => {
|
|
82
|
+
expect(mayFallBackToShell('/emulator-frame.html', ROMS)).toBe(false);
|
|
83
|
+
expect(mayFallBackToShell('/library/nes', ROMS)).toBe(true);
|
|
84
|
+
expect(mayFallBackToShell('/emulator-frame.html', {})).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('resolveShellWorkerConfig — the refusals', () => {
|
|
89
|
+
it('names the cache prefix+version and always precaches the shell', () => {
|
|
90
|
+
const resolved = resolveShellWorkerConfig({ cachePrefix: 'music-shell-', version: 'v1', precache: ['/player.js'] });
|
|
91
|
+
expect(resolved.cacheName).toBe('music-shell-v1');
|
|
92
|
+
expect(resolved.precache).toEqual(['/', '/player.js']);
|
|
93
|
+
expect(resolved.apiPrefixes).toEqual(['/api/']);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('keeps roms’ precache as written when the shell is already listed', () => {
|
|
97
|
+
expect(resolveShellWorkerConfig(ROMS).precache).toEqual(ROMS.precache);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('refuses an empty prefix — activate would delete every cache on the origin', () => {
|
|
101
|
+
expect(() => resolveShellWorkerConfig({ ...ROMS, cachePrefix: '' })).toThrow(/cachePrefix/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('refuses to precache anything under an API prefix', () => {
|
|
105
|
+
expect(() => resolveShellWorkerConfig({ ...ROMS, precache: ['/', '/api/me'] })).toThrow(/API is never cached/);
|
|
106
|
+
expect(() => resolveShellWorkerConfig({ ...ROMS, apiPrefixes: ['/rpc/'], precache: ['/rpc/x'] })).toThrow(
|
|
107
|
+
/API is never cached/,
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('refuses a relative precache entry and a shell that may not be its own fallback', () => {
|
|
112
|
+
expect(() => resolveShellWorkerConfig({ ...ROMS, precache: ['app.js'] })).toThrow(/starting with "\/"/);
|
|
113
|
+
expect(() => resolveShellWorkerConfig({ ...ROMS, noShellFallback: ['/'] })).toThrow(/noShellFallback/);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ── A fake worker scope ─────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
const ORIGIN = 'https://app.test';
|
|
120
|
+
|
|
121
|
+
class FakeResponse implements ShellWorkerResponse {
|
|
122
|
+
constructor(
|
|
123
|
+
readonly body: string,
|
|
124
|
+
readonly ok = true,
|
|
125
|
+
) {}
|
|
126
|
+
clone(): FakeResponse {
|
|
127
|
+
return new FakeResponse(this.body, this.ok);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const req = (path: string, over: Partial<ShellWorkerRequest> = {}): ShellWorkerRequest => ({
|
|
132
|
+
url: path.startsWith('http') ? path : `${ORIGIN}${path}`,
|
|
133
|
+
method: 'GET',
|
|
134
|
+
mode: 'cors',
|
|
135
|
+
...over,
|
|
136
|
+
});
|
|
137
|
+
const nav = (path: string): ShellWorkerRequest => req(path, { mode: 'navigate' });
|
|
138
|
+
|
|
139
|
+
const urlOf = (info: ShellWorkerRequestInfo): string =>
|
|
140
|
+
typeof info === 'string' ? new URL(info, ORIGIN).href : 'href' in info ? info.href : info.url;
|
|
141
|
+
|
|
142
|
+
const stripSearch = (url: string): string => {
|
|
143
|
+
const u = new URL(url);
|
|
144
|
+
return `${u.origin}${u.pathname}`;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A worker scope backed by Maps: `server` is what the network answers when `online`,
|
|
149
|
+
* `cacheStore` is CacheStorage. Every network call and every cache write is recorded,
|
|
150
|
+
* so a test can prove a request was NEVER touched, not merely answered correctly.
|
|
151
|
+
*/
|
|
152
|
+
const makeWorld = () => {
|
|
153
|
+
const server = new Map<string, FakeResponse>();
|
|
154
|
+
const cacheStore = new Map<string, Map<string, FakeResponse>>();
|
|
155
|
+
const networkCalls: string[] = [];
|
|
156
|
+
const cacheWrites: string[] = [];
|
|
157
|
+
const listeners: Record<string, (event: never) => void> = {};
|
|
158
|
+
let online = true;
|
|
159
|
+
let skipped = false;
|
|
160
|
+
let claimed = false;
|
|
161
|
+
|
|
162
|
+
const network = async (url: string): Promise<FakeResponse> => {
|
|
163
|
+
networkCalls.push(url);
|
|
164
|
+
if (!online) throw new TypeError('Failed to fetch');
|
|
165
|
+
return server.get(url) ?? new FakeResponse('not found', false);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const openCache = (name: string): ShellWorkerCache => {
|
|
169
|
+
let entries = cacheStore.get(name);
|
|
170
|
+
if (!entries) {
|
|
171
|
+
entries = new Map();
|
|
172
|
+
cacheStore.set(name, entries);
|
|
173
|
+
}
|
|
174
|
+
const bucket = entries;
|
|
175
|
+
return {
|
|
176
|
+
async addAll(paths) {
|
|
177
|
+
for (const path of paths) {
|
|
178
|
+
const res = await network(`${ORIGIN}${path}`);
|
|
179
|
+
if (!res.ok) throw new TypeError(`addAll: ${path} answered not-ok`);
|
|
180
|
+
bucket.set(`${ORIGIN}${path}`, res);
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
async put(request, response) {
|
|
184
|
+
cacheWrites.push(urlOf(request));
|
|
185
|
+
bucket.set(urlOf(request), response as FakeResponse);
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const scope: ShellWorkerScope = {
|
|
191
|
+
addEventListener(type: string, listener: (event: never) => void) {
|
|
192
|
+
listeners[type] = listener;
|
|
193
|
+
},
|
|
194
|
+
caches: {
|
|
195
|
+
open: async (name) => openCache(name),
|
|
196
|
+
keys: async () => [...cacheStore.keys()],
|
|
197
|
+
delete: async (name) => cacheStore.delete(name),
|
|
198
|
+
async match(request, options) {
|
|
199
|
+
const raw = urlOf(request);
|
|
200
|
+
for (const entries of cacheStore.values()) {
|
|
201
|
+
const exact = entries.get(raw);
|
|
202
|
+
if (exact) return exact;
|
|
203
|
+
if (options?.ignoreSearch) {
|
|
204
|
+
for (const [key, value] of entries) if (stripSearch(key) === stripSearch(raw)) return value;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return undefined;
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
clients: {
|
|
211
|
+
async claim() {
|
|
212
|
+
claimed = true;
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
location: { origin: ORIGIN },
|
|
216
|
+
fetch: (request) => network(urlOf(request)),
|
|
217
|
+
async skipWaiting() {
|
|
218
|
+
skipped = true;
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const extendable = async (type: 'install' | 'activate'): Promise<void> => {
|
|
223
|
+
const waits: Promise<unknown>[] = [];
|
|
224
|
+
const event: ShellExtendableEvent = { waitUntil: (p) => void waits.push(p) };
|
|
225
|
+
(listeners[type] as (e: ShellExtendableEvent) => void)(event);
|
|
226
|
+
await Promise.all(waits);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/** Dispatch a fetch. `response` is undefined when the worker did not call respondWith. */
|
|
230
|
+
const dispatch = (request: ShellWorkerRequest) => {
|
|
231
|
+
const waits: Promise<unknown>[] = [];
|
|
232
|
+
let response: Promise<ShellWorkerResponse> | undefined;
|
|
233
|
+
const event: ShellFetchEvent = {
|
|
234
|
+
request,
|
|
235
|
+
waitUntil: (p) => void waits.push(p),
|
|
236
|
+
respondWith: (r) => {
|
|
237
|
+
response = r;
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
(listeners.fetch as (e: ShellFetchEvent) => void)(event);
|
|
241
|
+
return {
|
|
242
|
+
response,
|
|
243
|
+
/** Resolve the response, then let every deferred cache write land. */
|
|
244
|
+
settled: async () => {
|
|
245
|
+
const body = response ? await response : undefined;
|
|
246
|
+
await Promise.all(waits);
|
|
247
|
+
return body as FakeResponse | undefined;
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
scope,
|
|
254
|
+
server,
|
|
255
|
+
cacheStore,
|
|
256
|
+
networkCalls,
|
|
257
|
+
cacheWrites,
|
|
258
|
+
extendable,
|
|
259
|
+
dispatch,
|
|
260
|
+
goOffline: () => {
|
|
261
|
+
online = false;
|
|
262
|
+
},
|
|
263
|
+
get skipped() {
|
|
264
|
+
return skipped;
|
|
265
|
+
},
|
|
266
|
+
get claimed() {
|
|
267
|
+
return claimed;
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/** A roms-shaped world, installed while online. */
|
|
273
|
+
const installedRoms = async () => {
|
|
274
|
+
const world = makeWorld();
|
|
275
|
+
world.server.set(`${ORIGIN}/`, new FakeResponse('<shell>'));
|
|
276
|
+
world.server.set(`${ORIGIN}/emulator-frame.html`, new FakeResponse('<frame>'));
|
|
277
|
+
world.server.set(`${ORIGIN}/emulator-runtime.js`, new FakeResponse('runtime'));
|
|
278
|
+
installShellWorker(world.scope, ROMS);
|
|
279
|
+
await world.extendable('install');
|
|
280
|
+
return world;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
describe('installShellWorker — install and activate', () => {
|
|
284
|
+
it('precaches the configured paths into prefix+version and skips waiting', async () => {
|
|
285
|
+
const world = await installedRoms();
|
|
286
|
+
expect([...(world.cacheStore.get('roms-shell-v3')?.keys() ?? [])]).toEqual([
|
|
287
|
+
`${ORIGIN}/`,
|
|
288
|
+
`${ORIGIN}/emulator-frame.html`,
|
|
289
|
+
`${ORIGIN}/emulator-runtime.js`,
|
|
290
|
+
]);
|
|
291
|
+
expect(world.skipped).toBe(true);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('does not skip waiting when the precache fails — the old worker keeps serving', async () => {
|
|
295
|
+
const world = makeWorld(); // nothing on the server: addAll rejects
|
|
296
|
+
installShellWorker(world.scope, ROMS);
|
|
297
|
+
await expect(world.extendable('install')).rejects.toThrow(/answered not-ok/);
|
|
298
|
+
expect(world.skipped).toBe(false);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('deletes old versions of its own prefix on activate, keeps everything else, and claims', async () => {
|
|
302
|
+
const world = await installedRoms();
|
|
303
|
+
world.cacheStore.set('roms-shell-v2', new Map());
|
|
304
|
+
world.cacheStore.set('roms-shell-v1', new Map());
|
|
305
|
+
world.cacheStore.set('roms-rom-digests', new Map()); // the app's own cache
|
|
306
|
+
await world.extendable('activate');
|
|
307
|
+
expect([...world.cacheStore.keys()].sort()).toEqual(['roms-rom-digests', 'roms-shell-v3']);
|
|
308
|
+
expect(world.claimed).toBe(true);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe('installShellWorker — fetch', () => {
|
|
313
|
+
it('serves an online navigation from the network and caches it', async () => {
|
|
314
|
+
const world = await installedRoms();
|
|
315
|
+
world.server.set(`${ORIGIN}/library/nes`, new FakeResponse('<library>'));
|
|
316
|
+
const body = await world.dispatch(nav('/library/nes')).settled();
|
|
317
|
+
expect(body?.body).toBe('<library>');
|
|
318
|
+
expect(world.cacheWrites).toContain(`${ORIGIN}/library/nes`);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('falls back to the SHELL for an offline navigation nothing cached', async () => {
|
|
322
|
+
const world = await installedRoms();
|
|
323
|
+
world.goOffline();
|
|
324
|
+
const body = await world.dispatch(nav('/library/snes?sort=recent')).settled();
|
|
325
|
+
expect(body?.body).toBe('<shell>');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('answers the frame offline from its own cached copy, ignoring the query', async () => {
|
|
329
|
+
const world = await installedRoms();
|
|
330
|
+
world.goOffline();
|
|
331
|
+
const body = await world.dispatch(nav('/emulator-frame.html?system=nes')).settled();
|
|
332
|
+
expect(body?.body).toBe('<frame>');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('🔴 never hands the SHELL to a noShellFallback path — it rejects instead (the 2026-08-12 incident)', async () => {
|
|
336
|
+
const world = makeWorld();
|
|
337
|
+
world.server.set(`${ORIGIN}/`, new FakeResponse('<shell>'));
|
|
338
|
+
// The 2026-08-12 shape: the frame is NOT precached, the shell is.
|
|
339
|
+
installShellWorker(world.scope, { ...ROMS, precache: ['/'] });
|
|
340
|
+
await world.extendable('install');
|
|
341
|
+
world.goOffline();
|
|
342
|
+
const { response } = world.dispatch(nav('/emulator-frame.html?system=nes'));
|
|
343
|
+
expect(response).toBeDefined();
|
|
344
|
+
await expect(response as Promise<ShellWorkerResponse>).rejects.toThrow('Failed to fetch');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('rethrows the network error when offline with nothing cached at all, rather than resolving anything', async () => {
|
|
348
|
+
const world = makeWorld();
|
|
349
|
+
installShellWorker(world.scope, ROMS); // never installed: no shell in any cache
|
|
350
|
+
world.goOffline();
|
|
351
|
+
await expect(world.dispatch(nav('/library/nes')).response as Promise<ShellWorkerResponse>).rejects.toThrow(
|
|
352
|
+
'Failed to fetch',
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('serves assets cache-first and caches only an ok response', async () => {
|
|
357
|
+
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();
|
|
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
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('🔴 never intercepts, reads or caches /api/ — even with a stale copy planted in the cache', async () => {
|
|
369
|
+
const world = await installedRoms();
|
|
370
|
+
world.cacheStore.get('roms-shell-v3')?.set(`${ORIGIN}/api/me`, new FakeResponse('{"user":"revoked"}'));
|
|
371
|
+
for (const request of [req('/api/me'), nav('/api/me'), req('/api/library.json')]) {
|
|
372
|
+
const { response } = world.dispatch(request);
|
|
373
|
+
expect(response, `${request.url} (${request.mode}) must not be answered by the worker`).toBeUndefined();
|
|
374
|
+
}
|
|
375
|
+
world.goOffline();
|
|
376
|
+
expect(world.dispatch(req('/api/me')).response).toBeUndefined();
|
|
377
|
+
expect(world.networkCalls.filter((u) => u.includes('/api/'))).toEqual([]);
|
|
378
|
+
expect(world.cacheWrites.filter((u) => u.includes('/api/'))).toEqual([]);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('leaves non-GET, cross-origin and passthrough-prefixed requests to the browser', async () => {
|
|
382
|
+
const world = await installedRoms();
|
|
383
|
+
for (const request of [
|
|
384
|
+
req('/assets/app.js', { method: 'POST' }),
|
|
385
|
+
req('https://cdn.other.test/lib.js'),
|
|
386
|
+
req('/_nes-roms/mario.nes'),
|
|
387
|
+
req('/healthz'),
|
|
388
|
+
]) {
|
|
389
|
+
expect(world.dispatch(request).response, request.url).toBeUndefined();
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
});
|