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
|
@@ -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
|
+
});
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// too, so "mandelbrot" still finds "Fractal".
|
|
19
19
|
|
|
20
20
|
import uFuzzy from '@leeoniya/ufuzzy';
|
|
21
|
-
import type { NavItem } from '../navigation';
|
|
21
|
+
import type { NavItem } from '../navigation/index.js';
|
|
22
22
|
|
|
23
23
|
// One singleton instance — uFuzzy compiles regexes from its options on construction,
|
|
24
24
|
// so re-creating it per query would be wasteful. `intraMode: 1` = contiguous match.
|
|
@@ -7,16 +7,16 @@
|
|
|
7
7
|
export {
|
|
8
8
|
fuzzyRankItems,
|
|
9
9
|
searchInitials,
|
|
10
|
-
} from './fuzzyRank';
|
|
10
|
+
} from './fuzzyRank.js';
|
|
11
11
|
export {
|
|
12
12
|
formatHotkey,
|
|
13
13
|
isApplePlatform,
|
|
14
14
|
matchHotkey,
|
|
15
15
|
type ParsedHotkey,
|
|
16
16
|
parseHotkey,
|
|
17
|
-
} from './hotkeys';
|
|
17
|
+
} from './hotkeys.js';
|
|
18
18
|
export {
|
|
19
19
|
type ShortcutEntry,
|
|
20
20
|
ShortcutRegistry,
|
|
21
21
|
shortcutRegistry,
|
|
22
|
-
} from './ShortcutRegistry';
|
|
22
|
+
} from './ShortcutRegistry.js';
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from 'bun:test';
|
|
2
|
-
import type { NavItem } from '../navigation';
|
|
3
|
-
import { fuzzyRankItems, searchInitials } from './fuzzyRank';
|
|
4
|
-
import { formatHotkey, matchHotkey, parseHotkey } from './hotkeys';
|
|
5
|
-
import { ShortcutRegistry } from './ShortcutRegistry';
|
|
2
|
+
import type { NavItem } from '../navigation/index.js';
|
|
3
|
+
import { fuzzyRankItems, searchInitials } from './fuzzyRank.js';
|
|
4
|
+
import { formatHotkey, matchHotkey, parseHotkey } from './hotkeys.js';
|
|
5
|
+
import { ShortcutRegistry } from './ShortcutRegistry.js';
|
|
6
6
|
|
|
7
7
|
// ── fuzzyRank ────────────────────────────────────────────────────────────────
|
|
8
8
|
|
|
@@ -17,7 +17,7 @@ export {
|
|
|
17
17
|
sameSubject,
|
|
18
18
|
SHARE_ACTIONS,
|
|
19
19
|
strongerAccess,
|
|
20
|
-
} from "./model";
|
|
20
|
+
} from "./model.js";
|
|
21
21
|
export type {
|
|
22
22
|
ShareAccess,
|
|
23
23
|
ShareAction,
|
|
@@ -30,4 +30,4 @@ export type {
|
|
|
30
30
|
ShareSubject,
|
|
31
31
|
ShareSubjectType,
|
|
32
32
|
ShareViewer,
|
|
33
|
-
} from "./model";
|
|
33
|
+
} from "./model.js";
|
|
@@ -55,13 +55,13 @@ export interface TokenDef<S extends z.ZodType = z.ZodType> {
|
|
|
55
55
|
* They used to be a second, divergent declaration of the same names — see the
|
|
56
56
|
* unification note on `CCWireMarshaller` (05 §B1).
|
|
57
57
|
*/
|
|
58
|
-
import type { CCWireMarshaller } from '../wire/marshalling';
|
|
58
|
+
import type { CCWireMarshaller } from '../wire/marshalling.js';
|
|
59
59
|
|
|
60
60
|
export {
|
|
61
61
|
type CCWireMarshaller,
|
|
62
62
|
MARSHALLER_WORKER_OFFLOAD_BYTES,
|
|
63
63
|
requiresWorkerOffload,
|
|
64
|
-
} from '../wire/marshalling';
|
|
64
|
+
} from '../wire/marshalling.js';
|
|
65
65
|
|
|
66
66
|
export type TokenRegistryIssueCode =
|
|
67
67
|
| 'token-collision'
|
|
@@ -16,7 +16,7 @@ export {
|
|
|
16
16
|
type UiScaleLevel,
|
|
17
17
|
type UiScaleLevelId,
|
|
18
18
|
uiScaleToFontSize,
|
|
19
|
-
} from './scale';
|
|
19
|
+
} from './scale.js';
|
|
20
20
|
export {
|
|
21
21
|
applyUiScaleToDocument,
|
|
22
22
|
createUiScaleStore,
|
|
@@ -29,4 +29,4 @@ export {
|
|
|
29
29
|
type UiScaleStore,
|
|
30
30
|
type UiScaleStoreOptions,
|
|
31
31
|
uiScaleStore,
|
|
32
|
-
} from './uiScaleStore';
|
|
32
|
+
} from './uiScaleStore.js';
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* `localStorage` + the `<html>` element.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { clampUiScale, DEFAULT_UI_SCALE, stepUiScale, uiScaleToFontSize } from './scale';
|
|
12
|
+
import { clampUiScale, DEFAULT_UI_SCALE, stepUiScale, uiScaleToFontSize } from './scale.js';
|
|
13
13
|
|
|
14
14
|
/** The slice of the Web Storage API the store needs (so a test can fake it). */
|
|
15
15
|
export interface UiScaleStorage {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* and the worker imports it.
|
|
9
9
|
*/
|
|
10
10
|
import { z } from 'zod';
|
|
11
|
-
import { serveMarshalWorker } from '../marshalWorker';
|
|
11
|
+
import { serveMarshalWorker } from '../marshalWorker.js';
|
|
12
12
|
|
|
13
13
|
const schema = z.object({ rows: z.array(z.object({ id: z.string() })) });
|
|
14
14
|
|
package/src/core/wire/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ export {
|
|
|
24
24
|
marshalInbound,
|
|
25
25
|
payloadBytes,
|
|
26
26
|
requiresWorkerOffload,
|
|
27
|
-
} from './marshalling';
|
|
27
|
+
} from './marshalling.js';
|
|
28
28
|
|
|
29
29
|
export {
|
|
30
30
|
createMarshalWorkerClient,
|
|
@@ -36,14 +36,14 @@ export {
|
|
|
36
36
|
type MarshalWorkerRequest,
|
|
37
37
|
type MarshalWorkerResponse,
|
|
38
38
|
serveMarshalWorker,
|
|
39
|
-
} from './marshalWorker';
|
|
39
|
+
} from './marshalWorker.js';
|
|
40
40
|
|
|
41
41
|
export {
|
|
42
42
|
createMarshalledIngress,
|
|
43
43
|
INGRESS_TOKEN_UNKNOWN,
|
|
44
44
|
type MarshalledIngress,
|
|
45
45
|
type MarshalledIngressOptions,
|
|
46
|
-
} from './ingress';
|
|
46
|
+
} from './ingress.js';
|
|
47
47
|
|
|
48
48
|
export {
|
|
49
49
|
createInstanceRuntime,
|
|
@@ -53,4 +53,4 @@ export {
|
|
|
53
53
|
type WireEnvelope,
|
|
54
54
|
type WireHub,
|
|
55
55
|
type WireHubOptions,
|
|
56
|
-
} from './wireHub';
|
|
56
|
+
} from './wireHub.js';
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
import { describe, expect, it } from 'bun:test';
|
|
12
12
|
import { Hono } from 'hono';
|
|
13
13
|
import { z } from 'zod';
|
|
14
|
-
import { createMarshalledIngress, INGRESS_TOKEN_UNKNOWN } from './ingress';
|
|
15
|
-
import { type CCWireMarshaller, createMarshallerRegistry } from './marshalling';
|
|
14
|
+
import { createMarshalledIngress, INGRESS_TOKEN_UNKNOWN } from './ingress.js';
|
|
15
|
+
import { type CCWireMarshaller, createMarshallerRegistry } from './marshalling.js';
|
|
16
16
|
|
|
17
17
|
const schema = z.object({ rows: z.array(z.object({ id: z.string() })) });
|
|
18
18
|
const WORKER_ENTRY = new URL('./__fixtures__/rowsMarshalWorker.ts', import.meta.url);
|
package/src/core/wire/ingress.ts
CHANGED
|
@@ -50,14 +50,14 @@
|
|
|
50
50
|
* A CSV import is the same three lines with a different marshaller and a
|
|
51
51
|
* different service call. Neither needs to know that a worker exists.
|
|
52
52
|
*/
|
|
53
|
-
import { AppError } from '../../shared/errors';
|
|
54
|
-
import { createMarshalWorkerClient, type MarshalWorkerClient } from './marshalWorker';
|
|
53
|
+
import { AppError } from '../../shared/errors.js';
|
|
54
|
+
import { createMarshalWorkerClient, type MarshalWorkerClient } from './marshalWorker.js';
|
|
55
55
|
import {
|
|
56
56
|
type CCWireMarshaller,
|
|
57
57
|
marshalInbound,
|
|
58
58
|
type MarshallerRegistry,
|
|
59
59
|
type MarshalWorker,
|
|
60
|
-
} from './marshalling';
|
|
60
|
+
} from './marshalling.js';
|
|
61
61
|
|
|
62
62
|
/** No marshaller is registered for the token an ingress was asked to feed. */
|
|
63
63
|
export const INGRESS_TOKEN_UNKNOWN = 'INGRESS_TOKEN_UNKNOWN';
|
|
@@ -15,8 +15,8 @@ import {
|
|
|
15
15
|
createMarshalWorkerClient,
|
|
16
16
|
MARSHALLER_WORKER_FAILED,
|
|
17
17
|
MARSHALLER_WORKER_TIMEOUT,
|
|
18
|
-
} from './marshalWorker';
|
|
19
|
-
import { type CCWireMarshaller, marshalInbound, requiresWorkerOffload } from './marshalling';
|
|
18
|
+
} from './marshalWorker.js';
|
|
19
|
+
import { type CCWireMarshaller, marshalInbound, requiresWorkerOffload } from './marshalling.js';
|
|
20
20
|
|
|
21
21
|
const schema = z.object({ rows: z.array(z.object({ id: z.string() })) });
|
|
22
22
|
const WORKER_ENTRY = new URL('./__fixtures__/rowsMarshalWorker.ts', import.meta.url);
|
|
@@ -33,13 +33,13 @@
|
|
|
33
33
|
* inside the server branch — a static import would put a Node builtin in the
|
|
34
34
|
* module graph of every client bundle that touches wire code.
|
|
35
35
|
*/
|
|
36
|
-
import { AppError } from '../../shared/errors';
|
|
36
|
+
import { AppError } from '../../shared/errors.js';
|
|
37
37
|
import {
|
|
38
38
|
type CCWireMarshaller,
|
|
39
39
|
MARSHALLER_SCHEMA_VIOLATION,
|
|
40
40
|
MARSHALLER_VALIDATION_FAILED,
|
|
41
41
|
type MarshalWorker,
|
|
42
|
-
} from './marshalling';
|
|
42
|
+
} from './marshalling.js';
|
|
43
43
|
|
|
44
44
|
export const MARSHALLER_WORKER_FAILED = 'MARSHALLER_WORKER_FAILED';
|
|
45
45
|
export const MARSHALLER_WORKER_TIMEOUT = 'MARSHALLER_WORKER_TIMEOUT';
|