cursedbelt-core 2.1.1 → 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/offline/shellWorker.d.ts +185 -0
- package/dist/core/offline/shellWorker.js +200 -0
- package/package.json +7 -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/leafSubpathsImportNothing.spec.ts +5 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app-shell service worker, once, for every app that serves its shell offline
|
|
3
|
+
* (task 2073, 2026-09-22).
|
|
4
|
+
*
|
|
5
|
+
* `apps/roms` wrote this by hand as plain JS in `public/sw.js`, with a TypeScript
|
|
6
|
+
* twin in `src/offline/swRouting.ts` held in step by a comment saying "keep the two
|
|
7
|
+
* in step". `apps/music` needed the same worker, and a third hand copy is how a fix
|
|
8
|
+
* lands in one app and not the others. This module is the one body; an app's
|
|
9
|
+
* `src/sw.ts` passes a config and adds whatever is genuinely its own (background
|
|
10
|
+
* sync, an outbox, a digest-keyed media cache) as further listeners beside it.
|
|
11
|
+
*
|
|
12
|
+
* What it does, and it is deliberately all it does:
|
|
13
|
+
* - `install` — precache the shell and the config's `precache` list, then `skipWaiting`.
|
|
14
|
+
* - `activate` — delete every cache named `<cachePrefix>*` that is not this version's,
|
|
15
|
+
* then `clients.claim()`.
|
|
16
|
+
* - `fetch` — same-origin static ASSETS cache-first; document NAVIGATIONS
|
|
17
|
+
* network-first, falling back offline to the exact URL, then the same
|
|
18
|
+
* path ignoring the query, then the shell. Everything else is left on
|
|
19
|
+
* the network: `respondWith` is never called for it.
|
|
20
|
+
*
|
|
21
|
+
* 🔴 `/api/` is NEVER cached and NEVER served from a cache. It is auth-gated, and a
|
|
22
|
+
* browser whose session has since been revoked must not read yesterday's copy of a
|
|
23
|
+
* private response out of its own cache. That is not a default a config can switch
|
|
24
|
+
* off: `apiPrefixes` ADDS prefixes to `/api/`, it cannot remove it, a precache entry
|
|
25
|
+
* under an API prefix is refused when the worker is installed, and `shouldCache`
|
|
26
|
+
* refuses to store an `api` route even if a caller asks.
|
|
27
|
+
*
|
|
28
|
+
* 🔴 `noShellFallback` exists because of the 2026-08-12 frame incident in roms. A game
|
|
29
|
+
* boots by loading `/emulator-frame.html` into a sandboxed iframe — a NAVIGATION, so it
|
|
30
|
+
* went network-first, and offline with nothing cached under that path the fallback
|
|
31
|
+
* handed it the APP SHELL. The iframe rendered a second copy of the library where the
|
|
32
|
+
* game should be: no canvas, no error, no clue. Any document that is not the shell (an
|
|
33
|
+
* iframe, a popup, a print view) must be listed there, and must be in `precache` if it
|
|
34
|
+
* is meant to work offline at all — an offline miss on it then REJECTS, which is the
|
|
35
|
+
* honest answer, rather than resolving the wrong document.
|
|
36
|
+
*
|
|
37
|
+
* Imports nothing at runtime (`leafSubpathsImportNothing.spec.ts` holds it to that):
|
|
38
|
+
* it is bundled into a service worker, where every byte ships on every install.
|
|
39
|
+
* `scope` is typed structurally so this compiles without the WebWorker lib, is testable
|
|
40
|
+
* with a fake, and still accepts the real `self` — `shellWorker.webworker.spec.ts`
|
|
41
|
+
* typechecks exactly that against `lib.webworker.d.ts`.
|
|
42
|
+
*/
|
|
43
|
+
/** Where a request goes. */
|
|
44
|
+
export type ShellRoute =
|
|
45
|
+
/** A document navigation — network-first, cached-document fallback offline. */
|
|
46
|
+
'navigate'
|
|
47
|
+
/** A same-origin static asset (JS/CSS/WASM/font/image/JSON) — cache-first. */
|
|
48
|
+
| 'asset'
|
|
49
|
+
/** An auth-gated API call — never cached, never answered from a cache. */
|
|
50
|
+
| 'api'
|
|
51
|
+
/** Everything else (non-GET, cross-origin, `passthroughPrefixes`) — untouched. */
|
|
52
|
+
| 'passthrough';
|
|
53
|
+
/** The facts about a request that classification reads — nothing else. */
|
|
54
|
+
export interface RequestFacts {
|
|
55
|
+
method: string;
|
|
56
|
+
/** Same origin as the worker (`new URL(request.url).origin === location.origin`). */
|
|
57
|
+
sameOrigin: boolean;
|
|
58
|
+
/** URL pathname, no query. */
|
|
59
|
+
pathname: string;
|
|
60
|
+
/** `request.mode === 'navigate'`. */
|
|
61
|
+
isNavigate: boolean;
|
|
62
|
+
}
|
|
63
|
+
export interface ShellWorkerConfig {
|
|
64
|
+
/**
|
|
65
|
+
* Every cache this worker owns is named `<cachePrefix><version>`, and `activate`
|
|
66
|
+
* deletes every OTHER cache starting with `cachePrefix`. So the prefix must be one
|
|
67
|
+
* nothing else in the app shares — `roms-shell-`, not `roms-`, or the app's own
|
|
68
|
+
* digest-keyed caches go with it.
|
|
69
|
+
*/
|
|
70
|
+
cachePrefix: string;
|
|
71
|
+
/** Bump to evict the previous shell on the next activate. */
|
|
72
|
+
version: string;
|
|
73
|
+
/**
|
|
74
|
+
* Same-origin paths fetched into the cache at install. `shellPath` is always
|
|
75
|
+
* precached whether listed or not — a shell fallback with no shell is no fallback.
|
|
76
|
+
*/
|
|
77
|
+
precache: readonly string[];
|
|
78
|
+
/**
|
|
79
|
+
* Extra API prefixes. `/api/` is ALWAYS one of them and cannot be removed — see the
|
|
80
|
+
* header. Default: `['/api/']`.
|
|
81
|
+
*/
|
|
82
|
+
apiPrefixes?: readonly string[];
|
|
83
|
+
/**
|
|
84
|
+
* Same-origin prefixes the worker must leave alone even for GET assets — e.g. roms'
|
|
85
|
+
* `/_nes-roms/`, whose bytes the app caches itself under a content digest.
|
|
86
|
+
*/
|
|
87
|
+
passthroughPrefixes?: readonly string[];
|
|
88
|
+
/**
|
|
89
|
+
* Exact pathnames that must NEVER be answered with the shell offline (roms'
|
|
90
|
+
* `/emulator-frame.html`). An offline miss on one rejects instead. See the header.
|
|
91
|
+
*/
|
|
92
|
+
noShellFallback?: readonly string[];
|
|
93
|
+
/** Which same-origin pathnames count as static assets. Default: `DEFAULT_ASSET_EXT`. */
|
|
94
|
+
assetExtensions?: RegExp;
|
|
95
|
+
/** The app shell's path, the navigation fallback. Default: `'/'`. */
|
|
96
|
+
shellPath?: string;
|
|
97
|
+
}
|
|
98
|
+
/** A config with every default applied and every refusal already checked. */
|
|
99
|
+
export interface ResolvedShellWorkerConfig {
|
|
100
|
+
cachePrefix: string;
|
|
101
|
+
version: string;
|
|
102
|
+
cacheName: string;
|
|
103
|
+
precache: readonly string[];
|
|
104
|
+
apiPrefixes: readonly string[];
|
|
105
|
+
passthroughPrefixes: readonly string[];
|
|
106
|
+
noShellFallback: readonly string[];
|
|
107
|
+
assetExtensions: RegExp;
|
|
108
|
+
shellPath: string;
|
|
109
|
+
}
|
|
110
|
+
/** roms' `ASSET_EXT`, verbatim. */
|
|
111
|
+
export declare const DEFAULT_ASSET_EXT: RegExp;
|
|
112
|
+
/** Always an API prefix, whatever the config says. */
|
|
113
|
+
export declare const ALWAYS_API_PREFIX = "/api/";
|
|
114
|
+
/**
|
|
115
|
+
* Classify a request. Order matters and is roms' order: non-GET and cross-origin
|
|
116
|
+
* first, then the API (so an API path that happens to end in `.json` is still `api`),
|
|
117
|
+
* then the app's own passthrough prefixes, then navigation, then assets.
|
|
118
|
+
*/
|
|
119
|
+
export declare function classifyRequest(facts: RequestFacts, config?: Pick<ShellWorkerConfig, 'apiPrefixes' | 'passthroughPrefixes' | 'assetExtensions'>): ShellRoute;
|
|
120
|
+
/** Whether a response on this route may be written to the cache. Never `api`. */
|
|
121
|
+
export declare function shouldCache(route: ShellRoute): boolean;
|
|
122
|
+
/** May an offline miss on this navigation be answered with the shell? */
|
|
123
|
+
export declare function mayFallBackToShell(pathname: string, config?: Pick<ShellWorkerConfig, 'noShellFallback'>): boolean;
|
|
124
|
+
/**
|
|
125
|
+
* Apply defaults and refuse the configs that would break a promise in the header.
|
|
126
|
+
* `installShellWorker` calls this; it is exported so an app's own test can prove its
|
|
127
|
+
* config is accepted without standing up a worker.
|
|
128
|
+
*/
|
|
129
|
+
export declare function resolveShellWorkerConfig(config: ShellWorkerConfig): ResolvedShellWorkerConfig;
|
|
130
|
+
export interface ShellWorkerRequest {
|
|
131
|
+
readonly url: string;
|
|
132
|
+
readonly method: string;
|
|
133
|
+
readonly mode: string;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The structural mirror of `RequestInfo | URL` — a request, a string, or a URL-like —
|
|
137
|
+
* which is what the real `fetch`, `Cache` and `CacheStorage` accept. It has to be the
|
|
138
|
+
* whole union: with `ShellWorkerRequest` alone the real `self` is refused, because a
|
|
139
|
+
* `URL` is not a request in either direction (measured by the webworker spec).
|
|
140
|
+
*/
|
|
141
|
+
export type ShellWorkerRequestInfo = ShellWorkerRequest | string | {
|
|
142
|
+
readonly href: string;
|
|
143
|
+
};
|
|
144
|
+
export interface ShellWorkerResponse {
|
|
145
|
+
readonly ok: boolean;
|
|
146
|
+
clone(): ShellWorkerResponse;
|
|
147
|
+
}
|
|
148
|
+
export interface ShellWorkerCache {
|
|
149
|
+
addAll(requests: string[]): Promise<void>;
|
|
150
|
+
put(request: ShellWorkerRequestInfo, response: ShellWorkerResponse): Promise<void>;
|
|
151
|
+
}
|
|
152
|
+
export interface ShellWorkerCacheStorage {
|
|
153
|
+
open(cacheName: string): Promise<ShellWorkerCache>;
|
|
154
|
+
keys(): Promise<string[]>;
|
|
155
|
+
delete(cacheName: string): Promise<boolean>;
|
|
156
|
+
match(request: ShellWorkerRequestInfo, options?: {
|
|
157
|
+
ignoreSearch?: boolean;
|
|
158
|
+
}): Promise<ShellWorkerResponse | undefined>;
|
|
159
|
+
}
|
|
160
|
+
export interface ShellExtendableEvent {
|
|
161
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
162
|
+
}
|
|
163
|
+
export interface ShellFetchEvent extends ShellExtendableEvent {
|
|
164
|
+
readonly request: ShellWorkerRequest;
|
|
165
|
+
respondWith(response: Promise<ShellWorkerResponse>): void;
|
|
166
|
+
}
|
|
167
|
+
export interface ShellWorkerScope {
|
|
168
|
+
addEventListener(type: 'install', listener: (event: ShellExtendableEvent) => void): void;
|
|
169
|
+
addEventListener(type: 'activate', listener: (event: ShellExtendableEvent) => void): void;
|
|
170
|
+
addEventListener(type: 'fetch', listener: (event: ShellFetchEvent) => void): void;
|
|
171
|
+
readonly caches: ShellWorkerCacheStorage;
|
|
172
|
+
readonly clients: {
|
|
173
|
+
claim(): Promise<void>;
|
|
174
|
+
};
|
|
175
|
+
readonly location: {
|
|
176
|
+
readonly origin: string;
|
|
177
|
+
};
|
|
178
|
+
fetch(request: ShellWorkerRequestInfo): Promise<ShellWorkerResponse>;
|
|
179
|
+
skipWaiting(): Promise<void>;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Wire `install`, `activate` and `fetch` onto `scope` (the worker's `self`).
|
|
183
|
+
* Returns the resolved config, so an app can read `cacheName` for its own use.
|
|
184
|
+
*/
|
|
185
|
+
export declare function installShellWorker(scope: ShellWorkerScope, config: ShellWorkerConfig): ResolvedShellWorkerConfig;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app-shell service worker, once, for every app that serves its shell offline
|
|
3
|
+
* (task 2073, 2026-09-22).
|
|
4
|
+
*
|
|
5
|
+
* `apps/roms` wrote this by hand as plain JS in `public/sw.js`, with a TypeScript
|
|
6
|
+
* twin in `src/offline/swRouting.ts` held in step by a comment saying "keep the two
|
|
7
|
+
* in step". `apps/music` needed the same worker, and a third hand copy is how a fix
|
|
8
|
+
* lands in one app and not the others. This module is the one body; an app's
|
|
9
|
+
* `src/sw.ts` passes a config and adds whatever is genuinely its own (background
|
|
10
|
+
* sync, an outbox, a digest-keyed media cache) as further listeners beside it.
|
|
11
|
+
*
|
|
12
|
+
* What it does, and it is deliberately all it does:
|
|
13
|
+
* - `install` — precache the shell and the config's `precache` list, then `skipWaiting`.
|
|
14
|
+
* - `activate` — delete every cache named `<cachePrefix>*` that is not this version's,
|
|
15
|
+
* then `clients.claim()`.
|
|
16
|
+
* - `fetch` — same-origin static ASSETS cache-first; document NAVIGATIONS
|
|
17
|
+
* network-first, falling back offline to the exact URL, then the same
|
|
18
|
+
* path ignoring the query, then the shell. Everything else is left on
|
|
19
|
+
* the network: `respondWith` is never called for it.
|
|
20
|
+
*
|
|
21
|
+
* 🔴 `/api/` is NEVER cached and NEVER served from a cache. It is auth-gated, and a
|
|
22
|
+
* browser whose session has since been revoked must not read yesterday's copy of a
|
|
23
|
+
* private response out of its own cache. That is not a default a config can switch
|
|
24
|
+
* off: `apiPrefixes` ADDS prefixes to `/api/`, it cannot remove it, a precache entry
|
|
25
|
+
* under an API prefix is refused when the worker is installed, and `shouldCache`
|
|
26
|
+
* refuses to store an `api` route even if a caller asks.
|
|
27
|
+
*
|
|
28
|
+
* 🔴 `noShellFallback` exists because of the 2026-08-12 frame incident in roms. A game
|
|
29
|
+
* boots by loading `/emulator-frame.html` into a sandboxed iframe — a NAVIGATION, so it
|
|
30
|
+
* went network-first, and offline with nothing cached under that path the fallback
|
|
31
|
+
* handed it the APP SHELL. The iframe rendered a second copy of the library where the
|
|
32
|
+
* game should be: no canvas, no error, no clue. Any document that is not the shell (an
|
|
33
|
+
* iframe, a popup, a print view) must be listed there, and must be in `precache` if it
|
|
34
|
+
* is meant to work offline at all — an offline miss on it then REJECTS, which is the
|
|
35
|
+
* honest answer, rather than resolving the wrong document.
|
|
36
|
+
*
|
|
37
|
+
* Imports nothing at runtime (`leafSubpathsImportNothing.spec.ts` holds it to that):
|
|
38
|
+
* it is bundled into a service worker, where every byte ships on every install.
|
|
39
|
+
* `scope` is typed structurally so this compiles without the WebWorker lib, is testable
|
|
40
|
+
* with a fake, and still accepts the real `self` — `shellWorker.webworker.spec.ts`
|
|
41
|
+
* typechecks exactly that against `lib.webworker.d.ts`.
|
|
42
|
+
*/
|
|
43
|
+
/** roms' `ASSET_EXT`, verbatim. */
|
|
44
|
+
export const DEFAULT_ASSET_EXT = /\.(?:js|mjs|css|wasm|woff2?|ttf|png|jpe?g|webp|svg|ico|json)$/i;
|
|
45
|
+
/** Always an API prefix, whatever the config says. */
|
|
46
|
+
export const ALWAYS_API_PREFIX = '/api/';
|
|
47
|
+
const apiPrefixesOf = (config) => {
|
|
48
|
+
const extra = config.apiPrefixes ?? [];
|
|
49
|
+
return extra.includes(ALWAYS_API_PREFIX) ? extra : [ALWAYS_API_PREFIX, ...extra];
|
|
50
|
+
};
|
|
51
|
+
const startsWithAny = (pathname, prefixes) => prefixes.some((prefix) => pathname.startsWith(prefix));
|
|
52
|
+
/**
|
|
53
|
+
* Classify a request. Order matters and is roms' order: non-GET and cross-origin
|
|
54
|
+
* first, then the API (so an API path that happens to end in `.json` is still `api`),
|
|
55
|
+
* then the app's own passthrough prefixes, then navigation, then assets.
|
|
56
|
+
*/
|
|
57
|
+
export function classifyRequest(facts, config = {}) {
|
|
58
|
+
if (facts.method !== 'GET')
|
|
59
|
+
return 'passthrough';
|
|
60
|
+
if (!facts.sameOrigin)
|
|
61
|
+
return 'passthrough';
|
|
62
|
+
if (startsWithAny(facts.pathname, apiPrefixesOf(config)))
|
|
63
|
+
return 'api';
|
|
64
|
+
if (startsWithAny(facts.pathname, config.passthroughPrefixes ?? []))
|
|
65
|
+
return 'passthrough';
|
|
66
|
+
if (facts.isNavigate)
|
|
67
|
+
return 'navigate';
|
|
68
|
+
if ((config.assetExtensions ?? DEFAULT_ASSET_EXT).test(facts.pathname))
|
|
69
|
+
return 'asset';
|
|
70
|
+
return 'passthrough';
|
|
71
|
+
}
|
|
72
|
+
/** Whether a response on this route may be written to the cache. Never `api`. */
|
|
73
|
+
export function shouldCache(route) {
|
|
74
|
+
return route === 'asset' || route === 'navigate';
|
|
75
|
+
}
|
|
76
|
+
/** May an offline miss on this navigation be answered with the shell? */
|
|
77
|
+
export function mayFallBackToShell(pathname, config = {}) {
|
|
78
|
+
return !(config.noShellFallback ?? []).includes(pathname);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Apply defaults and refuse the configs that would break a promise in the header.
|
|
82
|
+
* `installShellWorker` calls this; it is exported so an app's own test can prove its
|
|
83
|
+
* config is accepted without standing up a worker.
|
|
84
|
+
*/
|
|
85
|
+
export function resolveShellWorkerConfig(config) {
|
|
86
|
+
if (config.cachePrefix.trim() === '') {
|
|
87
|
+
// An empty prefix makes EVERY cache on the origin "an old version of ours", and
|
|
88
|
+
// activate would delete the app's other caches with it.
|
|
89
|
+
throw new Error('shell worker: cachePrefix must not be empty — activate deletes every cache it prefixes');
|
|
90
|
+
}
|
|
91
|
+
if (config.version.trim() === '')
|
|
92
|
+
throw new Error('shell worker: version must not be empty');
|
|
93
|
+
const shellPath = config.shellPath ?? '/';
|
|
94
|
+
const apiPrefixes = apiPrefixesOf(config);
|
|
95
|
+
const precache = config.precache.includes(shellPath) ? [...config.precache] : [shellPath, ...config.precache];
|
|
96
|
+
for (const path of precache) {
|
|
97
|
+
if (!path.startsWith('/')) {
|
|
98
|
+
throw new Error(`shell worker: precache entry "${path}" must be a same-origin path starting with "/"`);
|
|
99
|
+
}
|
|
100
|
+
if (startsWithAny(path, apiPrefixes)) {
|
|
101
|
+
throw new Error(`shell worker: precache entry "${path}" is under an API prefix — the API is never cached`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (config.noShellFallback?.includes(shellPath)) {
|
|
105
|
+
throw new Error(`shell worker: the shell path "${shellPath}" cannot also be in noShellFallback`);
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
cachePrefix: config.cachePrefix,
|
|
109
|
+
version: config.version,
|
|
110
|
+
cacheName: `${config.cachePrefix}${config.version}`,
|
|
111
|
+
precache,
|
|
112
|
+
apiPrefixes,
|
|
113
|
+
passthroughPrefixes: config.passthroughPrefixes ?? [],
|
|
114
|
+
noShellFallback: config.noShellFallback ?? [],
|
|
115
|
+
assetExtensions: config.assetExtensions ?? DEFAULT_ASSET_EXT,
|
|
116
|
+
shellPath,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Wire `install`, `activate` and `fetch` onto `scope` (the worker's `self`).
|
|
121
|
+
* Returns the resolved config, so an app can read `cacheName` for its own use.
|
|
122
|
+
*/
|
|
123
|
+
export function installShellWorker(scope, config) {
|
|
124
|
+
const resolved = resolveShellWorkerConfig(config);
|
|
125
|
+
const { caches } = scope;
|
|
126
|
+
// Written after the response is handed back, like roms' `cache.put` — but kept
|
|
127
|
+
// alive with `waitUntil` so the worker is not stopped mid-write, and swallowed on
|
|
128
|
+
// failure: a full quota must not turn a good network response into an error.
|
|
129
|
+
const store = (event, route, response) => {
|
|
130
|
+
if (!shouldCache(route) || !response.ok)
|
|
131
|
+
return;
|
|
132
|
+
const copy = response.clone();
|
|
133
|
+
event.waitUntil(caches
|
|
134
|
+
.open(resolved.cacheName)
|
|
135
|
+
.then((cache) => cache.put(event.request, copy))
|
|
136
|
+
.catch(() => undefined));
|
|
137
|
+
};
|
|
138
|
+
const cacheFirst = async (event) => {
|
|
139
|
+
const cached = await caches.match(event.request);
|
|
140
|
+
if (cached)
|
|
141
|
+
return cached;
|
|
142
|
+
const response = await scope.fetch(event.request);
|
|
143
|
+
store(event, 'asset', response);
|
|
144
|
+
return response;
|
|
145
|
+
};
|
|
146
|
+
const networkFirst = async (event) => {
|
|
147
|
+
const { request } = event;
|
|
148
|
+
try {
|
|
149
|
+
const response = await scope.fetch(request);
|
|
150
|
+
store(event, 'navigate', response);
|
|
151
|
+
return response;
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
// Offline. The exact URL first, then the same path ignoring the query — roms'
|
|
155
|
+
// frame is `/emulator-frame.html?system=nes`, and one cached copy answers for
|
|
156
|
+
// every console.
|
|
157
|
+
const cached = (await caches.match(request)) ?? (await caches.match(request, { ignoreSearch: true }));
|
|
158
|
+
if (cached)
|
|
159
|
+
return cached;
|
|
160
|
+
if (mayFallBackToShell(new URL(request.url).pathname, resolved)) {
|
|
161
|
+
const shell = await caches.match(resolved.shellPath);
|
|
162
|
+
if (shell)
|
|
163
|
+
return shell;
|
|
164
|
+
}
|
|
165
|
+
// Nothing honest to serve: reject, and the browser shows its own offline page
|
|
166
|
+
// rather than a document that is not the one asked for.
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
scope.addEventListener('install', (event) => {
|
|
171
|
+
event.waitUntil(caches
|
|
172
|
+
.open(resolved.cacheName)
|
|
173
|
+
.then((cache) => cache.addAll([...resolved.precache]))
|
|
174
|
+
.then(() => scope.skipWaiting()));
|
|
175
|
+
});
|
|
176
|
+
scope.addEventListener('activate', (event) => {
|
|
177
|
+
event.waitUntil(caches
|
|
178
|
+
.keys()
|
|
179
|
+
.then((keys) => Promise.all(keys
|
|
180
|
+
.filter((key) => key.startsWith(resolved.cachePrefix) && key !== resolved.cacheName)
|
|
181
|
+
.map((key) => caches.delete(key))))
|
|
182
|
+
.then(() => scope.clients.claim()));
|
|
183
|
+
});
|
|
184
|
+
scope.addEventListener('fetch', (event) => {
|
|
185
|
+
const url = new URL(event.request.url);
|
|
186
|
+
const route = classifyRequest({
|
|
187
|
+
method: event.request.method,
|
|
188
|
+
sameOrigin: url.origin === scope.location.origin,
|
|
189
|
+
pathname: url.pathname,
|
|
190
|
+
isNavigate: event.request.mode === 'navigate',
|
|
191
|
+
}, resolved);
|
|
192
|
+
if (route === 'asset')
|
|
193
|
+
event.respondWith(cacheFirst(event));
|
|
194
|
+
else if (route === 'navigate')
|
|
195
|
+
event.respondWith(networkFirst(event));
|
|
196
|
+
// `api` and `passthrough`: no respondWith, so the browser goes to the network as
|
|
197
|
+
// if this worker did not exist. Never a cache read, never a cache write.
|
|
198
|
+
});
|
|
199
|
+
return resolved;
|
|
200
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.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.",
|
|
@@ -251,6 +251,12 @@
|
|
|
251
251
|
"source": "./src/core/net/reconnectSchedule.ts",
|
|
252
252
|
"import": "./dist/core/net/reconnectSchedule.js"
|
|
253
253
|
},
|
|
254
|
+
"./offline/shell-worker": {
|
|
255
|
+
"types": "./dist/core/offline/shellWorker.d.ts",
|
|
256
|
+
"bun": "./src/core/offline/shellWorker.ts",
|
|
257
|
+
"source": "./src/core/offline/shellWorker.ts",
|
|
258
|
+
"import": "./dist/core/offline/shellWorker.js"
|
|
259
|
+
},
|
|
254
260
|
"./palette": {
|
|
255
261
|
"types": "./dist/core/palette/index.d.ts",
|
|
256
262
|
"bun": "./src/core/palette/index.ts",
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app-shell service worker, once, for every app that serves its shell offline
|
|
3
|
+
* (task 2073, 2026-09-22).
|
|
4
|
+
*
|
|
5
|
+
* `apps/roms` wrote this by hand as plain JS in `public/sw.js`, with a TypeScript
|
|
6
|
+
* twin in `src/offline/swRouting.ts` held in step by a comment saying "keep the two
|
|
7
|
+
* in step". `apps/music` needed the same worker, and a third hand copy is how a fix
|
|
8
|
+
* lands in one app and not the others. This module is the one body; an app's
|
|
9
|
+
* `src/sw.ts` passes a config and adds whatever is genuinely its own (background
|
|
10
|
+
* sync, an outbox, a digest-keyed media cache) as further listeners beside it.
|
|
11
|
+
*
|
|
12
|
+
* What it does, and it is deliberately all it does:
|
|
13
|
+
* - `install` — precache the shell and the config's `precache` list, then `skipWaiting`.
|
|
14
|
+
* - `activate` — delete every cache named `<cachePrefix>*` that is not this version's,
|
|
15
|
+
* then `clients.claim()`.
|
|
16
|
+
* - `fetch` — same-origin static ASSETS cache-first; document NAVIGATIONS
|
|
17
|
+
* network-first, falling back offline to the exact URL, then the same
|
|
18
|
+
* path ignoring the query, then the shell. Everything else is left on
|
|
19
|
+
* the network: `respondWith` is never called for it.
|
|
20
|
+
*
|
|
21
|
+
* 🔴 `/api/` is NEVER cached and NEVER served from a cache. It is auth-gated, and a
|
|
22
|
+
* browser whose session has since been revoked must not read yesterday's copy of a
|
|
23
|
+
* private response out of its own cache. That is not a default a config can switch
|
|
24
|
+
* off: `apiPrefixes` ADDS prefixes to `/api/`, it cannot remove it, a precache entry
|
|
25
|
+
* under an API prefix is refused when the worker is installed, and `shouldCache`
|
|
26
|
+
* refuses to store an `api` route even if a caller asks.
|
|
27
|
+
*
|
|
28
|
+
* 🔴 `noShellFallback` exists because of the 2026-08-12 frame incident in roms. A game
|
|
29
|
+
* boots by loading `/emulator-frame.html` into a sandboxed iframe — a NAVIGATION, so it
|
|
30
|
+
* went network-first, and offline with nothing cached under that path the fallback
|
|
31
|
+
* handed it the APP SHELL. The iframe rendered a second copy of the library where the
|
|
32
|
+
* game should be: no canvas, no error, no clue. Any document that is not the shell (an
|
|
33
|
+
* iframe, a popup, a print view) must be listed there, and must be in `precache` if it
|
|
34
|
+
* is meant to work offline at all — an offline miss on it then REJECTS, which is the
|
|
35
|
+
* honest answer, rather than resolving the wrong document.
|
|
36
|
+
*
|
|
37
|
+
* Imports nothing at runtime (`leafSubpathsImportNothing.spec.ts` holds it to that):
|
|
38
|
+
* it is bundled into a service worker, where every byte ships on every install.
|
|
39
|
+
* `scope` is typed structurally so this compiles without the WebWorker lib, is testable
|
|
40
|
+
* with a fake, and still accepts the real `self` — `shellWorker.webworker.spec.ts`
|
|
41
|
+
* typechecks exactly that against `lib.webworker.d.ts`.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** Where a request goes. */
|
|
45
|
+
export type ShellRoute =
|
|
46
|
+
/** A document navigation — network-first, cached-document fallback offline. */
|
|
47
|
+
| 'navigate'
|
|
48
|
+
/** A same-origin static asset (JS/CSS/WASM/font/image/JSON) — cache-first. */
|
|
49
|
+
| 'asset'
|
|
50
|
+
/** An auth-gated API call — never cached, never answered from a cache. */
|
|
51
|
+
| 'api'
|
|
52
|
+
/** Everything else (non-GET, cross-origin, `passthroughPrefixes`) — untouched. */
|
|
53
|
+
| 'passthrough';
|
|
54
|
+
|
|
55
|
+
/** The facts about a request that classification reads — nothing else. */
|
|
56
|
+
export interface RequestFacts {
|
|
57
|
+
method: string;
|
|
58
|
+
/** Same origin as the worker (`new URL(request.url).origin === location.origin`). */
|
|
59
|
+
sameOrigin: boolean;
|
|
60
|
+
/** URL pathname, no query. */
|
|
61
|
+
pathname: string;
|
|
62
|
+
/** `request.mode === 'navigate'`. */
|
|
63
|
+
isNavigate: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ShellWorkerConfig {
|
|
67
|
+
/**
|
|
68
|
+
* Every cache this worker owns is named `<cachePrefix><version>`, and `activate`
|
|
69
|
+
* deletes every OTHER cache starting with `cachePrefix`. So the prefix must be one
|
|
70
|
+
* nothing else in the app shares — `roms-shell-`, not `roms-`, or the app's own
|
|
71
|
+
* digest-keyed caches go with it.
|
|
72
|
+
*/
|
|
73
|
+
cachePrefix: string;
|
|
74
|
+
/** Bump to evict the previous shell on the next activate. */
|
|
75
|
+
version: string;
|
|
76
|
+
/**
|
|
77
|
+
* Same-origin paths fetched into the cache at install. `shellPath` is always
|
|
78
|
+
* precached whether listed or not — a shell fallback with no shell is no fallback.
|
|
79
|
+
*/
|
|
80
|
+
precache: readonly string[];
|
|
81
|
+
/**
|
|
82
|
+
* Extra API prefixes. `/api/` is ALWAYS one of them and cannot be removed — see the
|
|
83
|
+
* header. Default: `['/api/']`.
|
|
84
|
+
*/
|
|
85
|
+
apiPrefixes?: readonly string[];
|
|
86
|
+
/**
|
|
87
|
+
* Same-origin prefixes the worker must leave alone even for GET assets — e.g. roms'
|
|
88
|
+
* `/_nes-roms/`, whose bytes the app caches itself under a content digest.
|
|
89
|
+
*/
|
|
90
|
+
passthroughPrefixes?: readonly string[];
|
|
91
|
+
/**
|
|
92
|
+
* Exact pathnames that must NEVER be answered with the shell offline (roms'
|
|
93
|
+
* `/emulator-frame.html`). An offline miss on one rejects instead. See the header.
|
|
94
|
+
*/
|
|
95
|
+
noShellFallback?: readonly string[];
|
|
96
|
+
/** Which same-origin pathnames count as static assets. Default: `DEFAULT_ASSET_EXT`. */
|
|
97
|
+
assetExtensions?: RegExp;
|
|
98
|
+
/** The app shell's path, the navigation fallback. Default: `'/'`. */
|
|
99
|
+
shellPath?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A config with every default applied and every refusal already checked. */
|
|
103
|
+
export interface ResolvedShellWorkerConfig {
|
|
104
|
+
cachePrefix: string;
|
|
105
|
+
version: string;
|
|
106
|
+
cacheName: string;
|
|
107
|
+
precache: readonly string[];
|
|
108
|
+
apiPrefixes: readonly string[];
|
|
109
|
+
passthroughPrefixes: readonly string[];
|
|
110
|
+
noShellFallback: readonly string[];
|
|
111
|
+
assetExtensions: RegExp;
|
|
112
|
+
shellPath: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** roms' `ASSET_EXT`, verbatim. */
|
|
116
|
+
export const DEFAULT_ASSET_EXT = /\.(?:js|mjs|css|wasm|woff2?|ttf|png|jpe?g|webp|svg|ico|json)$/i;
|
|
117
|
+
|
|
118
|
+
/** Always an API prefix, whatever the config says. */
|
|
119
|
+
export const ALWAYS_API_PREFIX = '/api/';
|
|
120
|
+
|
|
121
|
+
const apiPrefixesOf = (config: Pick<ShellWorkerConfig, 'apiPrefixes'>): readonly string[] => {
|
|
122
|
+
const extra = config.apiPrefixes ?? [];
|
|
123
|
+
return extra.includes(ALWAYS_API_PREFIX) ? extra : [ALWAYS_API_PREFIX, ...extra];
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const startsWithAny = (pathname: string, prefixes: readonly string[]): boolean =>
|
|
127
|
+
prefixes.some((prefix) => pathname.startsWith(prefix));
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Classify a request. Order matters and is roms' order: non-GET and cross-origin
|
|
131
|
+
* first, then the API (so an API path that happens to end in `.json` is still `api`),
|
|
132
|
+
* then the app's own passthrough prefixes, then navigation, then assets.
|
|
133
|
+
*/
|
|
134
|
+
export function classifyRequest(
|
|
135
|
+
facts: RequestFacts,
|
|
136
|
+
config: Pick<ShellWorkerConfig, 'apiPrefixes' | 'passthroughPrefixes' | 'assetExtensions'> = {},
|
|
137
|
+
): ShellRoute {
|
|
138
|
+
if (facts.method !== 'GET') return 'passthrough';
|
|
139
|
+
if (!facts.sameOrigin) return 'passthrough';
|
|
140
|
+
if (startsWithAny(facts.pathname, apiPrefixesOf(config))) return 'api';
|
|
141
|
+
if (startsWithAny(facts.pathname, config.passthroughPrefixes ?? [])) return 'passthrough';
|
|
142
|
+
if (facts.isNavigate) return 'navigate';
|
|
143
|
+
if ((config.assetExtensions ?? DEFAULT_ASSET_EXT).test(facts.pathname)) return 'asset';
|
|
144
|
+
return 'passthrough';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Whether a response on this route may be written to the cache. Never `api`. */
|
|
148
|
+
export function shouldCache(route: ShellRoute): boolean {
|
|
149
|
+
return route === 'asset' || route === 'navigate';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** May an offline miss on this navigation be answered with the shell? */
|
|
153
|
+
export function mayFallBackToShell(pathname: string, config: Pick<ShellWorkerConfig, 'noShellFallback'> = {}): boolean {
|
|
154
|
+
return !(config.noShellFallback ?? []).includes(pathname);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Apply defaults and refuse the configs that would break a promise in the header.
|
|
159
|
+
* `installShellWorker` calls this; it is exported so an app's own test can prove its
|
|
160
|
+
* config is accepted without standing up a worker.
|
|
161
|
+
*/
|
|
162
|
+
export function resolveShellWorkerConfig(config: ShellWorkerConfig): ResolvedShellWorkerConfig {
|
|
163
|
+
if (config.cachePrefix.trim() === '') {
|
|
164
|
+
// An empty prefix makes EVERY cache on the origin "an old version of ours", and
|
|
165
|
+
// activate would delete the app's other caches with it.
|
|
166
|
+
throw new Error('shell worker: cachePrefix must not be empty — activate deletes every cache it prefixes');
|
|
167
|
+
}
|
|
168
|
+
if (config.version.trim() === '') throw new Error('shell worker: version must not be empty');
|
|
169
|
+
const shellPath = config.shellPath ?? '/';
|
|
170
|
+
const apiPrefixes = apiPrefixesOf(config);
|
|
171
|
+
const precache = config.precache.includes(shellPath) ? [...config.precache] : [shellPath, ...config.precache];
|
|
172
|
+
for (const path of precache) {
|
|
173
|
+
if (!path.startsWith('/')) {
|
|
174
|
+
throw new Error(`shell worker: precache entry "${path}" must be a same-origin path starting with "/"`);
|
|
175
|
+
}
|
|
176
|
+
if (startsWithAny(path, apiPrefixes)) {
|
|
177
|
+
throw new Error(`shell worker: precache entry "${path}" is under an API prefix — the API is never cached`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (config.noShellFallback?.includes(shellPath)) {
|
|
181
|
+
throw new Error(`shell worker: the shell path "${shellPath}" cannot also be in noShellFallback`);
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
cachePrefix: config.cachePrefix,
|
|
185
|
+
version: config.version,
|
|
186
|
+
cacheName: `${config.cachePrefix}${config.version}`,
|
|
187
|
+
precache,
|
|
188
|
+
apiPrefixes,
|
|
189
|
+
passthroughPrefixes: config.passthroughPrefixes ?? [],
|
|
190
|
+
noShellFallback: config.noShellFallback ?? [],
|
|
191
|
+
assetExtensions: config.assetExtensions ?? DEFAULT_ASSET_EXT,
|
|
192
|
+
shellPath,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── The structural slice of ServiceWorkerGlobalScope this worker touches ──────────
|
|
197
|
+
// Method syntax throughout, on purpose: TypeScript checks method parameters
|
|
198
|
+
// bivariantly, which is what lets the real `self` (whose `Request`/`Response` are
|
|
199
|
+
// far wider than these) satisfy them.
|
|
200
|
+
|
|
201
|
+
export interface ShellWorkerRequest {
|
|
202
|
+
readonly url: string;
|
|
203
|
+
readonly method: string;
|
|
204
|
+
readonly mode: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The structural mirror of `RequestInfo | URL` — a request, a string, or a URL-like —
|
|
209
|
+
* which is what the real `fetch`, `Cache` and `CacheStorage` accept. It has to be the
|
|
210
|
+
* whole union: with `ShellWorkerRequest` alone the real `self` is refused, because a
|
|
211
|
+
* `URL` is not a request in either direction (measured by the webworker spec).
|
|
212
|
+
*/
|
|
213
|
+
export type ShellWorkerRequestInfo = ShellWorkerRequest | string | { readonly href: string };
|
|
214
|
+
|
|
215
|
+
export interface ShellWorkerResponse {
|
|
216
|
+
readonly ok: boolean;
|
|
217
|
+
clone(): ShellWorkerResponse;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface ShellWorkerCache {
|
|
221
|
+
addAll(requests: string[]): Promise<void>;
|
|
222
|
+
put(request: ShellWorkerRequestInfo, response: ShellWorkerResponse): Promise<void>;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface ShellWorkerCacheStorage {
|
|
226
|
+
open(cacheName: string): Promise<ShellWorkerCache>;
|
|
227
|
+
keys(): Promise<string[]>;
|
|
228
|
+
delete(cacheName: string): Promise<boolean>;
|
|
229
|
+
match(request: ShellWorkerRequestInfo, options?: { ignoreSearch?: boolean }): Promise<ShellWorkerResponse | undefined>;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface ShellExtendableEvent {
|
|
233
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface ShellFetchEvent extends ShellExtendableEvent {
|
|
237
|
+
readonly request: ShellWorkerRequest;
|
|
238
|
+
respondWith(response: Promise<ShellWorkerResponse>): void;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface ShellWorkerScope {
|
|
242
|
+
addEventListener(type: 'install', listener: (event: ShellExtendableEvent) => void): void;
|
|
243
|
+
addEventListener(type: 'activate', listener: (event: ShellExtendableEvent) => void): void;
|
|
244
|
+
addEventListener(type: 'fetch', listener: (event: ShellFetchEvent) => void): void;
|
|
245
|
+
readonly caches: ShellWorkerCacheStorage;
|
|
246
|
+
readonly clients: { claim(): Promise<void> };
|
|
247
|
+
readonly location: { readonly origin: string };
|
|
248
|
+
fetch(request: ShellWorkerRequestInfo): Promise<ShellWorkerResponse>;
|
|
249
|
+
skipWaiting(): Promise<void>;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Wire `install`, `activate` and `fetch` onto `scope` (the worker's `self`).
|
|
254
|
+
* Returns the resolved config, so an app can read `cacheName` for its own use.
|
|
255
|
+
*/
|
|
256
|
+
export function installShellWorker(scope: ShellWorkerScope, config: ShellWorkerConfig): ResolvedShellWorkerConfig {
|
|
257
|
+
const resolved = resolveShellWorkerConfig(config);
|
|
258
|
+
const { caches } = scope;
|
|
259
|
+
|
|
260
|
+
// Written after the response is handed back, like roms' `cache.put` — but kept
|
|
261
|
+
// alive with `waitUntil` so the worker is not stopped mid-write, and swallowed on
|
|
262
|
+
// failure: a full quota must not turn a good network response into an error.
|
|
263
|
+
const store = (event: ShellFetchEvent, route: ShellRoute, response: ShellWorkerResponse): void => {
|
|
264
|
+
if (!shouldCache(route) || !response.ok) return;
|
|
265
|
+
const copy = response.clone();
|
|
266
|
+
event.waitUntil(
|
|
267
|
+
caches
|
|
268
|
+
.open(resolved.cacheName)
|
|
269
|
+
.then((cache) => cache.put(event.request, copy))
|
|
270
|
+
.catch(() => undefined),
|
|
271
|
+
);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const cacheFirst = async (event: ShellFetchEvent): Promise<ShellWorkerResponse> => {
|
|
275
|
+
const cached = await caches.match(event.request);
|
|
276
|
+
if (cached) return cached;
|
|
277
|
+
const response = await scope.fetch(event.request);
|
|
278
|
+
store(event, 'asset', response);
|
|
279
|
+
return response;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const networkFirst = async (event: ShellFetchEvent): Promise<ShellWorkerResponse> => {
|
|
283
|
+
const { request } = event;
|
|
284
|
+
try {
|
|
285
|
+
const response = await scope.fetch(request);
|
|
286
|
+
store(event, 'navigate', response);
|
|
287
|
+
return response;
|
|
288
|
+
} catch (error) {
|
|
289
|
+
// Offline. The exact URL first, then the same path ignoring the query — roms'
|
|
290
|
+
// frame is `/emulator-frame.html?system=nes`, and one cached copy answers for
|
|
291
|
+
// every console.
|
|
292
|
+
const cached = (await caches.match(request)) ?? (await caches.match(request, { ignoreSearch: true }));
|
|
293
|
+
if (cached) return cached;
|
|
294
|
+
if (mayFallBackToShell(new URL(request.url).pathname, resolved)) {
|
|
295
|
+
const shell = await caches.match(resolved.shellPath);
|
|
296
|
+
if (shell) return shell;
|
|
297
|
+
}
|
|
298
|
+
// Nothing honest to serve: reject, and the browser shows its own offline page
|
|
299
|
+
// rather than a document that is not the one asked for.
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
scope.addEventListener('install', (event) => {
|
|
305
|
+
event.waitUntil(
|
|
306
|
+
caches
|
|
307
|
+
.open(resolved.cacheName)
|
|
308
|
+
.then((cache) => cache.addAll([...resolved.precache]))
|
|
309
|
+
.then(() => scope.skipWaiting()),
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
scope.addEventListener('activate', (event) => {
|
|
314
|
+
event.waitUntil(
|
|
315
|
+
caches
|
|
316
|
+
.keys()
|
|
317
|
+
.then((keys) =>
|
|
318
|
+
Promise.all(
|
|
319
|
+
keys
|
|
320
|
+
.filter((key) => key.startsWith(resolved.cachePrefix) && key !== resolved.cacheName)
|
|
321
|
+
.map((key) => caches.delete(key)),
|
|
322
|
+
),
|
|
323
|
+
)
|
|
324
|
+
.then(() => scope.clients.claim()),
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
scope.addEventListener('fetch', (event) => {
|
|
329
|
+
const url = new URL(event.request.url);
|
|
330
|
+
const route = classifyRequest(
|
|
331
|
+
{
|
|
332
|
+
method: event.request.method,
|
|
333
|
+
sameOrigin: url.origin === scope.location.origin,
|
|
334
|
+
pathname: url.pathname,
|
|
335
|
+
isNavigate: event.request.mode === 'navigate',
|
|
336
|
+
},
|
|
337
|
+
resolved,
|
|
338
|
+
);
|
|
339
|
+
if (route === 'asset') event.respondWith(cacheFirst(event));
|
|
340
|
+
else if (route === 'navigate') event.respondWith(networkFirst(event));
|
|
341
|
+
// `api` and `passthrough`: no respondWith, so the browser goes to the network as
|
|
342
|
+
// if this worker did not exist. Never a cache read, never a cache write.
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
return resolved;
|
|
346
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `ShellWorkerScope` is structural so the module typechecks without the WebWorker lib
|
|
6
|
+
* and can be tested with a fake. The promise that makes that worth anything is the
|
|
7
|
+
* other half: an app's `src/sw.ts` passes the REAL `self` and it must compile. Nothing
|
|
8
|
+
* in this repo's own tsconfig can see that (it carries lib DOM, not WebWorker), so this
|
|
9
|
+
* spec typechecks a two-file fixture against `lib.webworker.d.ts` with no DOM at all:
|
|
10
|
+
*
|
|
11
|
+
* - `sw.ts` passes a `ServiceWorkerGlobalScope` — must be clean;
|
|
12
|
+
* - `control.ts` passes a scope with no `caches` — must be RED, or tsc was not
|
|
13
|
+
* checking anything and the clean result above is vacuous.
|
|
14
|
+
*
|
|
15
|
+
* It also proves `shellWorker.ts` itself compiles with no DOM lib, which is the lib a
|
|
16
|
+
* service-worker build has.
|
|
17
|
+
*/
|
|
18
|
+
const REPO = fileURLToPath(new URL('../../..', import.meta.url));
|
|
19
|
+
const SOURCE = fileURLToPath(new URL('./shellWorker.ts', import.meta.url));
|
|
20
|
+
|
|
21
|
+
const typecheck = async (): Promise<string> => {
|
|
22
|
+
const root = `${process.env.TMPDIR ?? '/tmp'}/shell-worker-webworker-${process.pid}`;
|
|
23
|
+
// Bun.write, NOT node:fs — this repo's test preload virtualizes node:fs, and the
|
|
24
|
+
// spawned tsc would read a directory the mock never wrote.
|
|
25
|
+
await Bun.write(
|
|
26
|
+
`${root}/tsconfig.json`,
|
|
27
|
+
JSON.stringify({
|
|
28
|
+
compilerOptions: {
|
|
29
|
+
target: 'ESNext',
|
|
30
|
+
module: 'ESNext',
|
|
31
|
+
moduleResolution: 'bundler',
|
|
32
|
+
lib: ['ESNext', 'WebWorker'],
|
|
33
|
+
types: [],
|
|
34
|
+
strict: true,
|
|
35
|
+
noEmit: true,
|
|
36
|
+
skipLibCheck: true,
|
|
37
|
+
allowImportingTsExtensions: true,
|
|
38
|
+
},
|
|
39
|
+
files: ['sw.ts', 'control.ts'],
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
await Bun.write(
|
|
43
|
+
`${root}/sw.ts`,
|
|
44
|
+
`import { installShellWorker } from ${JSON.stringify(SOURCE)};
|
|
45
|
+
declare const sw: ServiceWorkerGlobalScope;
|
|
46
|
+
installShellWorker(sw, { cachePrefix: 'app-shell-', version: 'v1', precache: ['/'] });
|
|
47
|
+
`,
|
|
48
|
+
);
|
|
49
|
+
await Bun.write(
|
|
50
|
+
`${root}/control.ts`,
|
|
51
|
+
`import { installShellWorker } from ${JSON.stringify(SOURCE)};
|
|
52
|
+
declare const sw: ServiceWorkerGlobalScope;
|
|
53
|
+
const { caches: _dropped, ...noCaches } = sw;
|
|
54
|
+
installShellWorker(noCaches, { cachePrefix: 'app-shell-', version: 'v1', precache: ['/'] });
|
|
55
|
+
`,
|
|
56
|
+
);
|
|
57
|
+
const child = Bun.spawnSync([process.execPath, `${REPO}/node_modules/typescript/bin/tsc`, '-p', root], {
|
|
58
|
+
stdout: 'pipe',
|
|
59
|
+
stderr: 'pipe',
|
|
60
|
+
});
|
|
61
|
+
return `${child.stdout.toString()}${child.stderr.toString()}`;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const output = await typecheck();
|
|
65
|
+
const errorsIn = (file: string): string[] =>
|
|
66
|
+
output.split('\n').filter((line) => line.includes(`${file}(`) && line.includes('error TS'));
|
|
67
|
+
|
|
68
|
+
describe('shell worker against lib.webworker', () => {
|
|
69
|
+
it('rejects a scope with no caches (negative control — tsc really checked)', () => {
|
|
70
|
+
const control = errorsIn('control.ts');
|
|
71
|
+
expect(control.length, `tsc output:\n${output}`).toBeGreaterThan(0);
|
|
72
|
+
// Red for the RIGHT reason — the missing member, not a broken fixture.
|
|
73
|
+
expect(output, `tsc output:\n${output}`).toContain("Property 'caches' is missing");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('accepts the real ServiceWorkerGlobalScope, and the module compiles without DOM', () => {
|
|
77
|
+
expect(errorsIn('sw.ts'), `tsc output:\n${output}`).toEqual([]);
|
|
78
|
+
expect(errorsIn('shellWorker.ts'), `tsc output:\n${output}`).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -108,6 +108,11 @@ const LEAVES = [
|
|
|
108
108
|
/** Wire-format types + the method⇄abbreviation maps. Trivially isomorphic on purpose. */
|
|
109
109
|
evaluates: 'METHOD_TO_ABBREV',
|
|
110
110
|
},
|
|
111
|
+
{
|
|
112
|
+
subpath: './offline/shell-worker',
|
|
113
|
+
/** Bundled into an app's service worker, where every byte ships on every install. */
|
|
114
|
+
evaluates: 'installShellWorker',
|
|
115
|
+
},
|
|
111
116
|
] as const;
|
|
112
117
|
|
|
113
118
|
/**
|