babylonjs-toolkit 9.30.0 → 9.34.1
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/lib/babylon.toolkit.d.ts +28563 -430
- package/lib/babylon.toolkit.js +1 -1
- package/lib/index.d.ts +28563 -430
- package/lib/workers/bt-content-worker.js +1180 -0
- package/package.json +15 -15
|
@@ -0,0 +1,1180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Babylon Toolkit content service worker (PWA Content Installer).
|
|
3
|
+
*
|
|
4
|
+
* The worker is the only network gateway for files listed in a content manifest (plan decision D14): every request for a
|
|
5
|
+
* listed file comes through here, is answered from Cache Storage when its hash is stored, and otherwise is downloaded once
|
|
6
|
+
* per hash, verified against the manifest's SHA-256 and stored while the page reads the same bytes. The page-side
|
|
7
|
+
* `TOOLKIT.ContentInstaller` decides what to install and when; this worker never decides what to install (D3), never
|
|
8
|
+
* downloads in its `install` event, and only knows Cache Storage, the protocol messages and the manifests.
|
|
9
|
+
*
|
|
10
|
+
* Storage (D15): `bt-content-files-v1` holds decoded file bytes keyed by hash; `bt-content-state-v1` holds the roots index,
|
|
11
|
+
* root states and manifests (written by the leader page) and the client pins (written here). A page keeps the version it
|
|
12
|
+
* first requested (D16 pins); `collect-garbage` deletes manifests, pins and files that no kept version needs.
|
|
13
|
+
*
|
|
14
|
+
* Plain modern JavaScript with no imports and no build step: hosts serve this file as it is.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Cache holding stored files by hash. */
|
|
18
|
+
const CONTENT_FILES_CACHE = "bt-content-files-v1";
|
|
19
|
+
/** Cache holding manifests, root states, the roots index and client pins. */
|
|
20
|
+
const CONTENT_STATE_CACHE = "bt-content-state-v1";
|
|
21
|
+
/** Path prefix of every synthetic store key on this origin. */
|
|
22
|
+
const STORE_KEY_PREFIX = "/__bt-content__/";
|
|
23
|
+
/** Query parameter marking an installer download; its value is the manifest version to resolve against. */
|
|
24
|
+
const INSTALL_MARKER_PARAMETER = "bt-content-install";
|
|
25
|
+
/** Query parameter carrying the installer's attempt number (0 first); above 0 the worker bypasses the HTTP cache. */
|
|
26
|
+
const ATTEMPT_PARAMETER = "bt-content-attempt";
|
|
27
|
+
/** Broadcast channel shared by the worker and every page. */
|
|
28
|
+
const BROADCAST_CHANNEL_NAME = "bt-content-installer";
|
|
29
|
+
|
|
30
|
+
/** Store-key folder of file entries (`files/<hash>`). */
|
|
31
|
+
const FILES_FOLDER = "files/";
|
|
32
|
+
/** Store-key folder of root states (`roots/<encodeURIComponent(rootKey)>`). */
|
|
33
|
+
const ROOTS_FOLDER = "roots/";
|
|
34
|
+
/** Store-key folder of manifests (`manifests/<encodeURIComponent(rootKey)>/<version>`). */
|
|
35
|
+
const MANIFESTS_FOLDER = "manifests/";
|
|
36
|
+
/** Store-key folder of client pins (`pins/<encodeURIComponent(clientId)>`). */
|
|
37
|
+
const PINS_FOLDER = "pins/";
|
|
38
|
+
/** Store key name of the roots index (a JSON array of root keys). */
|
|
39
|
+
const ROOTS_INDEX_NAME = "roots-index";
|
|
40
|
+
/** Install policy under which pass-through requests are not stored. */
|
|
41
|
+
const INSTALL_POLICY_OFF = "off";
|
|
42
|
+
/** Matches one byte range: `bytes=start-end`, `bytes=start-` or `bytes=-suffix`. */
|
|
43
|
+
const SINGLE_RANGE_PATTERN = /^bytes=(\d*)-(\d*)$/;
|
|
44
|
+
/** HTTP status of a full answer from the store. */
|
|
45
|
+
const STATUS_OK = 200;
|
|
46
|
+
/** HTTP status of a ranged answer from the store. */
|
|
47
|
+
const STATUS_PARTIAL_CONTENT = 206;
|
|
48
|
+
/** HTTP status of the empty answer to an installer download the worker could not store (see `fallbackResponse`). */
|
|
49
|
+
const STATUS_NO_CONTENT = 204;
|
|
50
|
+
/** Header of that empty answer, naming it as a download that was not stored. */
|
|
51
|
+
const NOT_STORED_HEADER = "X-Bt-Content-Not-Stored";
|
|
52
|
+
/** HTTP status of a Range header the stored file cannot satisfy. */
|
|
53
|
+
const STATUS_RANGE_NOT_SATISFIABLE = 416;
|
|
54
|
+
|
|
55
|
+
/** Root states by root key (the absolute manifest URL), read from the roots index; reloaded on `roots-changed`. */
|
|
56
|
+
const rootsByKey = new Map();
|
|
57
|
+
/** Parsed manifests by `rootKey + "\n" + version`. */
|
|
58
|
+
const manifestsByKey = new Map();
|
|
59
|
+
/** Pins by client id: root key → the version that client keeps (D16). */
|
|
60
|
+
const pinsByClient = new Map();
|
|
61
|
+
/** One promise per hash being downloaded and stored; resolves true when the file was stored. */
|
|
62
|
+
const storesInFlight = new Map();
|
|
63
|
+
/** Hashes known to be in the files cache, so a request can be passed through without waiting for a store read. */
|
|
64
|
+
const storedHashes = new Set();
|
|
65
|
+
/** The worker → pages broadcast channel. */
|
|
66
|
+
const contentChannel = new BroadcastChannel(BROADCAST_CHANNEL_NAME);
|
|
67
|
+
|
|
68
|
+
/** Resolves once roots, pins and stored hashes have been read from the store; null until the first read starts. */
|
|
69
|
+
let stateReady = null;
|
|
70
|
+
/** True once `stateReady` has resolved, so fetch events can decide synchronously whether to answer. */
|
|
71
|
+
let isStateLoaded = false;
|
|
72
|
+
/** Tail of the serial message queue: protocol messages run one after another in arrival order. */
|
|
73
|
+
let messageQueue = Promise.resolve();
|
|
74
|
+
|
|
75
|
+
self.addEventListener("install", handleInstall);
|
|
76
|
+
self.addEventListener("activate", handleActivate);
|
|
77
|
+
self.addEventListener("message", handleMessage);
|
|
78
|
+
self.addEventListener("fetch", handleFetch);
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
81
|
+
// Lifecycle (D17)
|
|
82
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Skips waiting on a first visit only, so the first page is controlled at once (FR-3); an updated worker waits until a
|
|
86
|
+
* page posts `skip-waiting`, so it never activates over open pages unasked. Nothing is downloaded here (D3).
|
|
87
|
+
* @param {ExtendableEvent} event The install event.
|
|
88
|
+
*/
|
|
89
|
+
function handleInstall(event) {
|
|
90
|
+
if (!self.registration.active) event.waitUntil(self.skipWaiting());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Takes control of every open page of the scope, so the first visit is served by this worker without a reload.
|
|
95
|
+
* @param {ExtendableEvent} event The activate event.
|
|
96
|
+
*/
|
|
97
|
+
function handleActivate(event) {
|
|
98
|
+
event.waitUntil(self.clients.claim());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
102
|
+
// Messages (page → worker)
|
|
103
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Runs one protocol message: `pin`, `roots-changed`, `collect-garbage` or `skip-waiting`. Store-changing messages run
|
|
107
|
+
* serially in arrival order, so a `collect-garbage` sent after `roots-changed` sees the new roots.
|
|
108
|
+
* @param {ExtendableMessageEvent} event The message event; `event.source.id` is the sending client.
|
|
109
|
+
*/
|
|
110
|
+
function handleMessage(event) {
|
|
111
|
+
const message = event.data;
|
|
112
|
+
if (message == null || typeof message.type !== "string") return;
|
|
113
|
+
if (message.type === "skip-waiting") {
|
|
114
|
+
event.waitUntil(self.skipWaiting());
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const clientId = event.source != null && typeof event.source.id === "string" ? event.source.id : "";
|
|
118
|
+
const task = messageQueue.then(() => runQueuedMessage(message, clientId));
|
|
119
|
+
messageQueue = task.catch((error) => console.warn("[bt-content-worker] The " + message.type + " message failed: " + describeError(error)));
|
|
120
|
+
event.waitUntil(messageQueue);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Applies one queued store-changing message.
|
|
125
|
+
* @param {{ type: string, rootKey?: string, version?: string }} message The protocol message.
|
|
126
|
+
* @param {string} clientId The sending client's id ("" when unknown).
|
|
127
|
+
* @returns {Promise<void>} Settles when the message has been applied.
|
|
128
|
+
*/
|
|
129
|
+
async function runQueuedMessage(message, clientId) {
|
|
130
|
+
if (message.type === "pin") {
|
|
131
|
+
if (clientId === "" || typeof message.rootKey !== "string" || typeof message.version !== "string") return;
|
|
132
|
+
await loadState();
|
|
133
|
+
await writePin(clientId, message.rootKey, message.version);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (message.type === "roots-changed") {
|
|
137
|
+
await loadState();
|
|
138
|
+
await loadRoots();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (message.type === "collect-garbage") await collectGarbage();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
145
|
+
// Fetch
|
|
146
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Routes a fetch: navigations to `handleNavigation`, listed GET requests to the store or the gateway download. A request
|
|
150
|
+
* the worker already knows it will not handle (unlisted, or ranged / unstored-by-policy and not stored) gets no
|
|
151
|
+
* `respondWith`, so the browser handles it untouched. `respondWith` must be called while the event is dispatched, so
|
|
152
|
+
* that decision is made from memory; while the state is still loading every request goes through the asynchronous path,
|
|
153
|
+
* which ends in `fetch(event.request)` for anything it does not serve.
|
|
154
|
+
* @param {FetchEvent} event The fetch event.
|
|
155
|
+
*/
|
|
156
|
+
function handleFetch(event) {
|
|
157
|
+
const request = event.request;
|
|
158
|
+
if (request.method !== "GET") return;
|
|
159
|
+
if (request.mode === "navigate") {
|
|
160
|
+
handleNavigation(event);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (isKnownPassThrough(request, event.clientId)) return;
|
|
164
|
+
const outcome = respondToContentRequest(event);
|
|
165
|
+
event.respondWith(outcome.then((result) => result.response));
|
|
166
|
+
event.waitUntil(outcome.then((result) => result.storeSettled));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Answers a request that may be listed: from the store, by joining a download already in flight for its hash, or by a
|
|
171
|
+
* gateway download. Any failure to read the store falls back to `fetch(event.request)`; it never produces an error page.
|
|
172
|
+
* A page request of a root the installer set to network-only (content-manifest-always-current P7: the server's files no
|
|
173
|
+
* longer match the manifest this page uses) is normally never answered (see `isKnownPassThrough`); one that arrives while
|
|
174
|
+
* the state is still loading is answered by `fetchNetworkOnly`, and nothing is stored. Installer downloads still go
|
|
175
|
+
* through the gateway, so an update can download.
|
|
176
|
+
* @param {FetchEvent} event The fetch event.
|
|
177
|
+
* @returns {Promise<{ response: Response | Promise<Response>, storeSettled: Promise<boolean> | null }>} The answer and the
|
|
178
|
+
* store work the event must outlive.
|
|
179
|
+
*/
|
|
180
|
+
async function respondToContentRequest(event) {
|
|
181
|
+
let resolved = null;
|
|
182
|
+
try {
|
|
183
|
+
resolved = await resolveRequest(event.request, event.clientId);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return { response: fetch(event.request), storeSettled: null };
|
|
186
|
+
}
|
|
187
|
+
if (resolved === null) return { response: fetch(event.request), storeSettled: null };
|
|
188
|
+
if (resolved.root.networkOnly != null && !resolved.isInstall) return { response: fetchNetworkOnly(event, resolved), storeSettled: null };
|
|
189
|
+
try {
|
|
190
|
+
const storedResponse = await serveStored(event.request, resolved.entry, resolved.entry.hash);
|
|
191
|
+
if (storedResponse !== null) {
|
|
192
|
+
// The installer waits for a stored message per file; repeat it when its download is answered from the store.
|
|
193
|
+
if (resolved.isInstall) broadcast(createStoredMessage(resolved));
|
|
194
|
+
return { response: storedResponse, storeSettled: null };
|
|
195
|
+
}
|
|
196
|
+
} catch (error) {
|
|
197
|
+
return { response: fallbackResponse(event, resolved), storeSettled: null };
|
|
198
|
+
}
|
|
199
|
+
return respondThroughGateway(event, resolved);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Answers a listed request whose file is not stored: joins the download in flight for its hash, passes a ranged or
|
|
204
|
+
* not-to-be-stored request through, or starts the gateway download.
|
|
205
|
+
* @param {FetchEvent} event The fetch event.
|
|
206
|
+
* @param {ResolvedRequest} resolved The resolved request.
|
|
207
|
+
* @returns {Promise<{ response: Response | Promise<Response>, storeSettled: Promise<boolean> | null }>} The answer.
|
|
208
|
+
*/
|
|
209
|
+
async function respondThroughGateway(event, resolved) {
|
|
210
|
+
const storeInFlight = storesInFlight.get(resolved.entry.hash);
|
|
211
|
+
if (storeInFlight !== undefined) return { response: serveAfterStore(event, resolved, storeInFlight), storeSettled: null };
|
|
212
|
+
if (isRangeRequest(event.request) || !shouldStore(resolved)) return { response: fetch(event.request), storeSettled: null };
|
|
213
|
+
return downloadAndStore(resolved, event);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Waits for another request's download of the same hash, then serves the stored file; when that download failed, the
|
|
218
|
+
* request goes to the network itself.
|
|
219
|
+
* @param {FetchEvent} event The fetch event.
|
|
220
|
+
* @param {ResolvedRequest} resolved The resolved request.
|
|
221
|
+
* @param {Promise<boolean>} storeInFlight The in-flight store of the same hash.
|
|
222
|
+
* @returns {Promise<Response>} The answer.
|
|
223
|
+
*/
|
|
224
|
+
async function serveAfterStore(event, resolved, storeInFlight) {
|
|
225
|
+
const wasStored = await storeInFlight;
|
|
226
|
+
if (!wasStored) return fallbackResponse(event, resolved);
|
|
227
|
+
try {
|
|
228
|
+
const storedResponse = await serveStored(event.request, resolved.entry, resolved.entry.hash);
|
|
229
|
+
if (storedResponse !== null) return storedResponse;
|
|
230
|
+
} catch (error) {
|
|
231
|
+
// A store read that throws falls through to the network, exactly like a miss.
|
|
232
|
+
}
|
|
233
|
+
return fallbackResponse(event, resolved);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The answer when the worker cannot serve a listed request itself. A scene request goes to the network unchanged. An
|
|
238
|
+
* installer download is not sent again (its marker parameters must never reach the server; the installer retries with a
|
|
239
|
+
* fresh attempt and learns the outcome from `file-failed`), so it gets an empty 204 marked `X-Bt-Content-Not-Stored`.
|
|
240
|
+
* Not `Response.error()`: Chrome logs every network-error answer as a console error ("Failed to load resource"), which
|
|
241
|
+
* would add one console error per file to the single warning the installer reports (Error policy: one `cors` warning).
|
|
242
|
+
* @param {FetchEvent} event The fetch event.
|
|
243
|
+
* @param {ResolvedRequest} resolved The resolved request.
|
|
244
|
+
* @returns {Response | Promise<Response>} The fallback answer.
|
|
245
|
+
*/
|
|
246
|
+
function fallbackResponse(event, resolved) {
|
|
247
|
+
if (!resolved.isInstall) return fetch(event.request);
|
|
248
|
+
return new Response(null, { status: STATUS_NO_CONTENT, headers: { [NOT_STORED_HEADER]: "true" } });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Answers a page request of a network-only root (P7) from the network without storing it. It fetches like the gateway
|
|
253
|
+
* download (`cors`, no credentials) and answers with a copy, so a cross-origin `no-cors` request never gets an opaque
|
|
254
|
+
* response, which a page with `Cross-Origin-Embedder-Policy` would reject. A host without CORS headers gets the page's
|
|
255
|
+
* own request replayed, as `fallbackResponse` does; so does a ranged request, whose Range header only that replay keeps.
|
|
256
|
+
* @param {FetchEvent} event The fetch event.
|
|
257
|
+
* @param {ResolvedRequest} resolved The resolved page request.
|
|
258
|
+
* @returns {Promise<Response>} The network answer.
|
|
259
|
+
*/
|
|
260
|
+
async function fetchNetworkOnly(event, resolved) {
|
|
261
|
+
if (isRangeRequest(event.request)) return fetch(event.request);
|
|
262
|
+
let networkResponse = null;
|
|
263
|
+
try {
|
|
264
|
+
networkResponse = await fetch(resolved.networkUrl, { mode: "cors", credentials: "omit" });
|
|
265
|
+
} catch (error) {
|
|
266
|
+
return fetch(event.request);
|
|
267
|
+
}
|
|
268
|
+
if (networkResponse.body === null) return networkResponse;
|
|
269
|
+
return buildPageResponse(networkResponse, networkResponse.body);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Whether a listed file may be stored for this request: installer downloads always are (D14); scene requests unless
|
|
274
|
+
* the root's install policy is `off`.
|
|
275
|
+
* @param {ResolvedRequest} resolved The resolved request.
|
|
276
|
+
* @returns {boolean} True when the downloaded bytes are verified and stored.
|
|
277
|
+
*/
|
|
278
|
+
function shouldStore(resolved) {
|
|
279
|
+
return resolved.isInstall || resolved.root.installPolicy !== INSTALL_POLICY_OFF;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Whether the request carries a Range header.
|
|
284
|
+
* @param {Request} request The request.
|
|
285
|
+
* @returns {boolean} True for a ranged request.
|
|
286
|
+
*/
|
|
287
|
+
function isRangeRequest(request) {
|
|
288
|
+
return request.headers.has("range");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Decides from memory, while the fetch event is dispatched, that the worker will not answer a request: it is outside
|
|
293
|
+
* every root, it is a page request of a network-only root (content-manifest-always-current P7), it is not listed in the
|
|
294
|
+
* version the client would get, or it is not stored and is either ranged or not to be stored. Returns false whenever
|
|
295
|
+
* memory cannot tell (state not loaded yet, manifest not in memory).
|
|
296
|
+
* A network-only page request gets no `respondWith`, so the browser fetches it itself under the page's own rules: a
|
|
297
|
+
* worker replay of a cross-origin `no-cors` request returns an opaque response that a page with
|
|
298
|
+
* `Cross-Origin-Embedder-Policy` rejects (`net::ERR_FAILED`).
|
|
299
|
+
* @param {Request} request The request.
|
|
300
|
+
* @param {string} clientId The requesting client ("" when none).
|
|
301
|
+
* @returns {boolean} True when the request must get no `respondWith`.
|
|
302
|
+
*/
|
|
303
|
+
function isKnownPassThrough(request, clientId) {
|
|
304
|
+
if (!isStateLoaded) return false;
|
|
305
|
+
const parsed = parseContentUrl(request.url);
|
|
306
|
+
const root = findRoot(parsed.lookupUrl);
|
|
307
|
+
if (root !== null && root.networkOnly != null && parsed.installVersion === null) return true;
|
|
308
|
+
const resolved = resolveFromMemory(parsed, clientId);
|
|
309
|
+
if (resolved === undefined) return false;
|
|
310
|
+
if (resolved === null) return true;
|
|
311
|
+
const hash = resolved.entry.hash;
|
|
312
|
+
if (storedHashes.has(hash) || storesInFlight.has(hash)) return false;
|
|
313
|
+
return isRangeRequest(request) || !shouldStore(resolved);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
317
|
+
// Request resolution (Algorithms › Worker request resolution)
|
|
318
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* @typedef {object} ParsedContentUrl
|
|
322
|
+
* @property {string} lookupUrl The URL without query and hash, matched against root base URLs.
|
|
323
|
+
* @property {string} networkUrl The URL with the two marker parameters removed (other parameters kept): the request the
|
|
324
|
+
* network sees, identical to the scene's own request.
|
|
325
|
+
* @property {string | null} installVersion The install marker's version, or null for a scene request.
|
|
326
|
+
* @property {number} attempt The installer attempt number (0 when absent).
|
|
327
|
+
*/
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* @typedef {object} ResolvedRequest
|
|
331
|
+
* @property {object} root The root state the URL belongs to.
|
|
332
|
+
* @property {string} version The manifest version the request is served from.
|
|
333
|
+
* @property {string} path The file's manifest path (relative to the root's base URL, decoded).
|
|
334
|
+
* @property {{ size: number, contentSize: number, hash: string, type: string, encoding?: string }} entry The manifest entry.
|
|
335
|
+
* @property {boolean} isInstall True for an installer download.
|
|
336
|
+
* @property {number} attempt The installer attempt number (0 for a scene request).
|
|
337
|
+
* @property {string} networkUrl The URL to download from.
|
|
338
|
+
*/
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Splits a request URL into the parts resolution needs.
|
|
342
|
+
* @param {string} requestUrl The request's absolute URL.
|
|
343
|
+
* @returns {ParsedContentUrl} The parsed URL.
|
|
344
|
+
*/
|
|
345
|
+
function parseContentUrl(requestUrl) {
|
|
346
|
+
const url = new URL(requestUrl);
|
|
347
|
+
const installVersion = url.searchParams.get(INSTALL_MARKER_PARAMETER);
|
|
348
|
+
const attemptText = url.searchParams.get(ATTEMPT_PARAMETER);
|
|
349
|
+
const attemptNumber = attemptText === null ? 0 : Number.parseInt(attemptText, 10);
|
|
350
|
+
url.searchParams.delete(INSTALL_MARKER_PARAMETER);
|
|
351
|
+
url.searchParams.delete(ATTEMPT_PARAMETER);
|
|
352
|
+
url.hash = "";
|
|
353
|
+
const networkUrl = url.href;
|
|
354
|
+
url.search = "";
|
|
355
|
+
return {
|
|
356
|
+
lookupUrl: url.href,
|
|
357
|
+
networkUrl: networkUrl,
|
|
358
|
+
installVersion: installVersion,
|
|
359
|
+
attempt: Number.isFinite(attemptNumber) && attemptNumber > 0 ? attemptNumber : 0
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Resolves a request against the stored roots and manifests, loading what is not in memory and writing the client's pin
|
|
365
|
+
* when it has none for the root (D16).
|
|
366
|
+
* @param {Request} request The request.
|
|
367
|
+
* @param {string} clientId The requesting client ("" when none).
|
|
368
|
+
* @returns {Promise<ResolvedRequest | null>} The resolution, or null when the URL is not a listed file.
|
|
369
|
+
* @throws {Error} When the store cannot be read.
|
|
370
|
+
*/
|
|
371
|
+
async function resolveRequest(request, clientId) {
|
|
372
|
+
await loadState();
|
|
373
|
+
const parsed = parseContentUrl(request.url);
|
|
374
|
+
const root = findRoot(parsed.lookupUrl);
|
|
375
|
+
if (root === null) return null;
|
|
376
|
+
const version = parsed.installVersion !== null ? parsed.installVersion : await versionForClient(clientId, root);
|
|
377
|
+
if (version === null) return null;
|
|
378
|
+
const manifest = await loadManifest(root.rootKey, version);
|
|
379
|
+
if (manifest === null) return null;
|
|
380
|
+
return buildResolution(parsed, root, version, manifest);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* The synchronous twin of `resolveRequest`: resolves from memory only and writes nothing.
|
|
385
|
+
* @param {ParsedContentUrl} parsed The parsed request URL.
|
|
386
|
+
* @param {string} clientId The requesting client ("" when none).
|
|
387
|
+
* @returns {ResolvedRequest | null | undefined} The resolution, null when not listed, undefined when memory cannot tell.
|
|
388
|
+
*/
|
|
389
|
+
function resolveFromMemory(parsed, clientId) {
|
|
390
|
+
const root = findRoot(parsed.lookupUrl);
|
|
391
|
+
if (root === null) return null;
|
|
392
|
+
const version = parsed.installVersion !== null ? parsed.installVersion : pinnedOrActiveVersion(clientId, root);
|
|
393
|
+
if (version === null) return null;
|
|
394
|
+
const manifest = manifestsByKey.get(manifestMapKey(root.rootKey, version));
|
|
395
|
+
if (manifest === undefined) return undefined;
|
|
396
|
+
return buildResolution(parsed, root, version, manifest);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Looks the URL's path up in a manifest.
|
|
401
|
+
* @param {ParsedContentUrl} parsed The parsed request URL.
|
|
402
|
+
* @param {object} root The root state whose base URL prefixes the URL.
|
|
403
|
+
* @param {string} version The manifest version.
|
|
404
|
+
* @param {{ files: Object<string, object> }} manifest The manifest.
|
|
405
|
+
* @returns {ResolvedRequest | null} The resolution, or null when the path is not listed.
|
|
406
|
+
*/
|
|
407
|
+
function buildResolution(parsed, root, version, manifest) {
|
|
408
|
+
const path = relativePathOf(parsed.lookupUrl, root.baseUrl);
|
|
409
|
+
if (path === null || manifest.files == null || !Object.hasOwn(manifest.files, path)) return null;
|
|
410
|
+
return {
|
|
411
|
+
root: root,
|
|
412
|
+
version: version,
|
|
413
|
+
path: path,
|
|
414
|
+
entry: manifest.files[path],
|
|
415
|
+
isInstall: parsed.installVersion !== null,
|
|
416
|
+
attempt: parsed.attempt,
|
|
417
|
+
networkUrl: parsed.networkUrl
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The root whose base URL is the longest prefix of the URL, so a content root inside the app-shell root wins.
|
|
423
|
+
* @param {string} lookupUrl The URL without query and hash.
|
|
424
|
+
* @returns {object | null} The root state, or null when no root contains the URL.
|
|
425
|
+
*/
|
|
426
|
+
function findRoot(lookupUrl) {
|
|
427
|
+
let bestRoot = null;
|
|
428
|
+
for (const root of rootsByKey.values()) {
|
|
429
|
+
if (typeof root.baseUrl !== "string" || !lookupUrl.startsWith(root.baseUrl)) continue;
|
|
430
|
+
if (bestRoot === null || root.baseUrl.length > bestRoot.baseUrl.length) bestRoot = root;
|
|
431
|
+
}
|
|
432
|
+
return bestRoot;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The decoded manifest path of a URL inside a root.
|
|
437
|
+
* @param {string} lookupUrl The URL without query and hash.
|
|
438
|
+
* @param {string} baseUrl The root's base URL (ends with "/").
|
|
439
|
+
* @returns {string | null} The decoded path, or null when the URL is malformed percent-encoding.
|
|
440
|
+
*/
|
|
441
|
+
function relativePathOf(lookupUrl, baseUrl) {
|
|
442
|
+
try {
|
|
443
|
+
return decodeURIComponent(lookupUrl.slice(baseUrl.length));
|
|
444
|
+
} catch (error) {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The version a client is served for a root: its pin, else the root's active version, which is then pinned (D16).
|
|
451
|
+
* @param {string} clientId The requesting client ("" when none: nothing is pinned).
|
|
452
|
+
* @param {object} root The root state.
|
|
453
|
+
* @returns {Promise<string | null>} The version, or null when the root has no active version.
|
|
454
|
+
*/
|
|
455
|
+
async function versionForClient(clientId, root) {
|
|
456
|
+
const pins = await readPin(clientId);
|
|
457
|
+
if (typeof pins[root.rootKey] === "string") return pins[root.rootKey];
|
|
458
|
+
const activeVersion = typeof root.activeVersion === "string" ? root.activeVersion : null;
|
|
459
|
+
if (clientId !== "" && activeVersion !== null) await writePin(clientId, root.rootKey, activeVersion);
|
|
460
|
+
return activeVersion;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The version a client would be served for a root, from memory, without pinning.
|
|
465
|
+
* @param {string} clientId The requesting client ("" when none).
|
|
466
|
+
* @param {object} root The root state.
|
|
467
|
+
* @returns {string | null} The pinned version, else the active version, else null.
|
|
468
|
+
*/
|
|
469
|
+
function pinnedOrActiveVersion(clientId, root) {
|
|
470
|
+
const pins = pinsByClient.get(clientId);
|
|
471
|
+
if (pins !== undefined && typeof pins[root.rootKey] === "string") return pins[root.rootKey];
|
|
472
|
+
return typeof root.activeVersion === "string" ? root.activeVersion : null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
476
|
+
// Serving from the store
|
|
477
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Serves a stored file, whole or ranged.
|
|
481
|
+
* @param {Request} request The request (its Range header decides the shape of the answer).
|
|
482
|
+
* @param {{ type: string }} entry The manifest entry (its `type` becomes `Content-Type`).
|
|
483
|
+
* @param {string} hash The file's hash.
|
|
484
|
+
* @returns {Promise<Response | null>} The answer, or null when the hash is not stored.
|
|
485
|
+
* @throws {Error} When the store cannot be read.
|
|
486
|
+
*/
|
|
487
|
+
async function serveStored(request, entry, hash) {
|
|
488
|
+
const filesCache = await caches.open(CONTENT_FILES_CACHE);
|
|
489
|
+
const storedResponse = await filesCache.match(fileKey(hash));
|
|
490
|
+
if (storedResponse === undefined) {
|
|
491
|
+
storedHashes.delete(hash);
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
const rangeHeader = request.headers.get("range");
|
|
495
|
+
if (rangeHeader === null) {
|
|
496
|
+
const headers = new Headers({ "Content-Type": entry.type, "Accept-Ranges": "bytes" });
|
|
497
|
+
const storedLength = storedResponse.headers.get("Content-Length");
|
|
498
|
+
if (storedLength !== null) headers.set("Content-Length", storedLength);
|
|
499
|
+
return new Response(storedResponse.body, { status: STATUS_OK, headers: headers });
|
|
500
|
+
}
|
|
501
|
+
return serveStoredRange(new Uint8Array(await storedResponse.arrayBuffer()), rangeHeader, entry.type);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Answers a Range request from stored bytes: 206 with the slice, or 416 when the header is not one satisfiable range.
|
|
506
|
+
* @param {Uint8Array} bytes The whole stored file.
|
|
507
|
+
* @param {string} rangeHeader The request's Range header.
|
|
508
|
+
* @param {string} contentType The manifest's content type.
|
|
509
|
+
* @returns {Response} The ranged answer.
|
|
510
|
+
*/
|
|
511
|
+
function serveStoredRange(bytes, rangeHeader, contentType) {
|
|
512
|
+
const total = bytes.byteLength;
|
|
513
|
+
const range = parseRange(rangeHeader, total);
|
|
514
|
+
if (range === null) {
|
|
515
|
+
return new Response(null, { status: STATUS_RANGE_NOT_SATISFIABLE, headers: { "Content-Range": "bytes */" + total } });
|
|
516
|
+
}
|
|
517
|
+
const slice = bytes.slice(range.start, range.end + 1);
|
|
518
|
+
const headers = {
|
|
519
|
+
"Content-Type": contentType,
|
|
520
|
+
"Content-Range": "bytes " + range.start + "-" + range.end + "/" + total,
|
|
521
|
+
"Content-Length": String(slice.byteLength),
|
|
522
|
+
"Accept-Ranges": "bytes"
|
|
523
|
+
};
|
|
524
|
+
return new Response(slice, { status: STATUS_PARTIAL_CONTENT, headers: headers });
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Parses a single byte range (`bytes=start-end`, `bytes=start-`, `bytes=-suffix`) against a file length. Several ranges,
|
|
529
|
+
* other units and ranges outside the file are unsatisfiable.
|
|
530
|
+
* @param {string} header The Range header.
|
|
531
|
+
* @param {number} total The file length in bytes.
|
|
532
|
+
* @returns {{ start: number, end: number } | null} The inclusive byte range clamped to the file, or null when unsatisfiable.
|
|
533
|
+
*/
|
|
534
|
+
function parseRange(header, total) {
|
|
535
|
+
const match = SINGLE_RANGE_PATTERN.exec(header.trim());
|
|
536
|
+
if (match === null || total === 0) return null;
|
|
537
|
+
const startText = match[1];
|
|
538
|
+
const endText = match[2];
|
|
539
|
+
if (startText === "" && endText === "") return null;
|
|
540
|
+
if (startText === "") {
|
|
541
|
+
const suffixLength = Number(endText);
|
|
542
|
+
if (suffixLength === 0) return null;
|
|
543
|
+
return { start: Math.max(0, total - suffixLength), end: total - 1 };
|
|
544
|
+
}
|
|
545
|
+
const start = Number(startText);
|
|
546
|
+
if (start >= total) return null;
|
|
547
|
+
const end = endText === "" ? total - 1 : Math.min(Number(endText), total - 1);
|
|
548
|
+
if (end < start) return null;
|
|
549
|
+
return { start: start, end: end };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
553
|
+
// Gateway download (D14)
|
|
554
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Downloads a listed file once for its hash, hands the page its bytes as they arrive and, in parallel, verifies and
|
|
558
|
+
* stores them. The in-flight entry is registered before the first await, so a concurrent request for the same hash joins
|
|
559
|
+
* this download instead of starting another. Failed, opaque and mismatched responses are never stored.
|
|
560
|
+
* @param {ResolvedRequest} resolved The resolved request.
|
|
561
|
+
* @param {FetchEvent} event The fetch event.
|
|
562
|
+
* @returns {Promise<{ response: Response | Promise<Response>, storeSettled: Promise<boolean> }>} The page's answer and the
|
|
563
|
+
* store outcome (true when stored).
|
|
564
|
+
*/
|
|
565
|
+
async function downloadAndStore(resolved, event) {
|
|
566
|
+
const hash = resolved.entry.hash;
|
|
567
|
+
let settleStore = null;
|
|
568
|
+
const storeSettled = new Promise((resolve) => { settleStore = resolve; });
|
|
569
|
+
storesInFlight.set(hash, storeSettled);
|
|
570
|
+
storeSettled.then(() => storesInFlight.delete(hash));
|
|
571
|
+
|
|
572
|
+
let networkResponse = null;
|
|
573
|
+
try {
|
|
574
|
+
networkResponse = await fetch(resolved.networkUrl, { mode: "cors", credentials: "omit", cache: resolved.attempt > 0 ? "reload" : "default" });
|
|
575
|
+
} catch (error) {
|
|
576
|
+
const reason = await diagnoseFetchFailure(resolved.networkUrl, error);
|
|
577
|
+
broadcast(createFailedMessage(resolved, reason, null));
|
|
578
|
+
settleStore(false);
|
|
579
|
+
return { response: fallbackResponse(event, resolved), storeSettled: storeSettled };
|
|
580
|
+
}
|
|
581
|
+
// `ok` is false for an opaque response too (status 0), so neither a failed nor an opaque response is stored.
|
|
582
|
+
if (!networkResponse.ok || networkResponse.body === null) {
|
|
583
|
+
broadcast(createFailedMessage(resolved, "status", networkResponse.status));
|
|
584
|
+
settleStore(false);
|
|
585
|
+
return { response: networkResponse, storeSettled: storeSettled };
|
|
586
|
+
}
|
|
587
|
+
const [pageBody, storeBody] = networkResponse.body.tee();
|
|
588
|
+
verifyAndStore(storeBody, resolved).then(settleStore);
|
|
589
|
+
return { response: buildPageResponse(networkResponse, pageBody), storeSettled: storeSettled };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Tells a CORS failure from a network failure after the CORS fetch threw: a cross-origin `no-cors` probe that succeeds
|
|
594
|
+
* means the host answers but sends no CORS headers. A same-origin failure is always a network failure.
|
|
595
|
+
* @param {string} networkUrl The URL that failed.
|
|
596
|
+
* @param {Error} error The error the CORS fetch threw.
|
|
597
|
+
* @returns {Promise<"cors" | "network">} The failure reason.
|
|
598
|
+
*/
|
|
599
|
+
async function diagnoseFetchFailure(networkUrl, error) {
|
|
600
|
+
if (error == null || error.name !== "TypeError") return "network";
|
|
601
|
+
if (new URL(networkUrl).origin === self.location.origin) return "network";
|
|
602
|
+
try {
|
|
603
|
+
await fetch(networkUrl, { mode: "no-cors", credentials: "omit" });
|
|
604
|
+
return "cors";
|
|
605
|
+
} catch (probeError) {
|
|
606
|
+
return "network";
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* The page's copy of a network response, streaming the page's branch of the tee. The browser has already decoded a
|
|
612
|
+
* gzip-encoded body, so `Content-Encoding` and the wire `Content-Length` are dropped from the copy.
|
|
613
|
+
* @param {Response} networkResponse The network response.
|
|
614
|
+
* @param {ReadableStream<Uint8Array>} pageBody The page's branch of the body.
|
|
615
|
+
* @returns {Response} The answer for the page.
|
|
616
|
+
*/
|
|
617
|
+
function buildPageResponse(networkResponse, pageBody) {
|
|
618
|
+
const headers = new Headers(networkResponse.headers);
|
|
619
|
+
if (headers.has("content-encoding")) {
|
|
620
|
+
headers.delete("content-encoding");
|
|
621
|
+
headers.delete("content-length");
|
|
622
|
+
}
|
|
623
|
+
return new Response(pageBody, { status: networkResponse.status, statusText: networkResponse.statusText, headers: headers });
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Reads the store's branch of a download, compares its SHA-256 with the manifest and stores it on a match. Broadcasts
|
|
628
|
+
* `file-stored`, or `file-failed` with reason `mismatch` or `network` (a storage error such as `QuotaExceededError` is a
|
|
629
|
+
* `network`-kind failure whose `message` names the error). Never rejects.
|
|
630
|
+
* @param {ReadableStream<Uint8Array>} storeBody The store's branch of the body.
|
|
631
|
+
* @param {ResolvedRequest} resolved The resolved request.
|
|
632
|
+
* @returns {Promise<boolean>} True when the file was stored.
|
|
633
|
+
*/
|
|
634
|
+
async function verifyAndStore(storeBody, resolved) {
|
|
635
|
+
try {
|
|
636
|
+
const bytes = await new Response(storeBody).arrayBuffer();
|
|
637
|
+
const actualHash = await sha256Hex(bytes);
|
|
638
|
+
if (actualHash !== resolved.entry.hash) {
|
|
639
|
+
broadcast(createFailedMessage(resolved, "mismatch", null));
|
|
640
|
+
return false;
|
|
641
|
+
}
|
|
642
|
+
await putFile(resolved.entry, bytes);
|
|
643
|
+
broadcast(createStoredMessage(resolved));
|
|
644
|
+
return true;
|
|
645
|
+
} catch (error) {
|
|
646
|
+
const failedMessage = createFailedMessage(resolved, "network", null);
|
|
647
|
+
failedMessage.message = describeError(error);
|
|
648
|
+
broadcast(failedMessage);
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Writes verified, decoded bytes to the files cache under their hash.
|
|
655
|
+
* @param {{ hash: string, type: string }} entry The manifest entry.
|
|
656
|
+
* @param {ArrayBuffer} bytes The verified bytes.
|
|
657
|
+
* @returns {Promise<void>} Settles when stored.
|
|
658
|
+
* @throws {Error} When the cache rejects the write (for example `QuotaExceededError`).
|
|
659
|
+
*/
|
|
660
|
+
async function putFile(entry, bytes) {
|
|
661
|
+
const filesCache = await caches.open(CONTENT_FILES_CACHE);
|
|
662
|
+
const headers = { "Content-Type": entry.type, "Content-Length": String(bytes.byteLength), "X-Bt-Content-Hash": entry.hash };
|
|
663
|
+
await filesCache.put(fileKey(entry.hash), new Response(bytes, { status: STATUS_OK, headers: headers }));
|
|
664
|
+
storedHashes.add(entry.hash);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Lowercase hex SHA-256 of a byte buffer.
|
|
669
|
+
* @param {ArrayBuffer} bytes The bytes.
|
|
670
|
+
* @returns {Promise<string>} 64 lowercase hex characters.
|
|
671
|
+
*/
|
|
672
|
+
async function sha256Hex(bytes) {
|
|
673
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
674
|
+
return Array.from(new Uint8Array(digest), (byteValue) => byteValue.toString(16).padStart(2, "0")).join("");
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
678
|
+
// Navigations and the app shell (D23)
|
|
679
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Answers a navigation network-first when an app-shell root exists, falling back offline to the shell's stored
|
|
683
|
+
* `navigationFallback` page. Without a shell root the navigation gets no `respondWith`.
|
|
684
|
+
* @param {FetchEvent} event The navigation's fetch event.
|
|
685
|
+
*/
|
|
686
|
+
function handleNavigation(event) {
|
|
687
|
+
if (isStateLoaded && findShellRoot() === null) return;
|
|
688
|
+
const outcome = respondToNavigation(event);
|
|
689
|
+
event.respondWith(outcome.then((result) => result.response));
|
|
690
|
+
event.waitUntil(outcome.then((result) => result.storeSettled));
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Network-first answer to a navigation. A successful answer for the shell's own page (the shell's base URL or its
|
|
695
|
+
* `navigationFallback` path) is also stored when that page is not stored yet, so an `as-you-play` tab can start offline
|
|
696
|
+
* next time; a failed network answer falls back to the stored page of the version the new page is pinned to.
|
|
697
|
+
* @param {FetchEvent} event The navigation's fetch event.
|
|
698
|
+
* @returns {Promise<{ response: Response, storeSettled: Promise<boolean> | null }>} The answer and any store work.
|
|
699
|
+
*/
|
|
700
|
+
async function respondToNavigation(event) {
|
|
701
|
+
const pageClientId = typeof event.resultingClientId === "string" ? event.resultingClientId : "";
|
|
702
|
+
let networkResponse = null;
|
|
703
|
+
try {
|
|
704
|
+
networkResponse = await fetch(event.request);
|
|
705
|
+
} catch (networkError) {
|
|
706
|
+
const fallbackPage = await readNavigationFallback(event.request, pageClientId);
|
|
707
|
+
return { response: fallbackPage !== null ? fallbackPage : Response.error(), storeSettled: null };
|
|
708
|
+
}
|
|
709
|
+
const shellPage = await resolveShellPageForStoring(event.request, networkResponse, pageClientId);
|
|
710
|
+
if (shellPage === null) return { response: networkResponse, storeSettled: null };
|
|
711
|
+
const [pageBody, storeBody] = networkResponse.body.tee();
|
|
712
|
+
return { response: buildPageResponse(networkResponse, pageBody), storeSettled: verifyAndStore(storeBody, shellPage) };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* The stored `navigationFallback` page of the shell root, for the version the navigating page is pinned to (else the
|
|
717
|
+
* active version, which is then pinned).
|
|
718
|
+
* @param {Request} request The navigation request.
|
|
719
|
+
* @param {string} pageClientId The id of the page the navigation creates ("" when unknown).
|
|
720
|
+
* @returns {Promise<Response | null>} The stored page, or null when there is none or the store cannot be read.
|
|
721
|
+
*/
|
|
722
|
+
async function readNavigationFallback(request, pageClientId) {
|
|
723
|
+
try {
|
|
724
|
+
const shellPage = await resolveShellFallback(pageClientId);
|
|
725
|
+
if (shellPage === null) return null;
|
|
726
|
+
return await serveStored(request, shellPage.entry, shellPage.entry.hash);
|
|
727
|
+
} catch (error) {
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* The shell root's `navigationFallback` entry for a page's version.
|
|
734
|
+
* @param {string} pageClientId The page's client id ("" when unknown).
|
|
735
|
+
* @returns {Promise<ResolvedRequest | null>} The fallback page as a resolved request (scene kind), or null.
|
|
736
|
+
* @throws {Error} When the store cannot be read.
|
|
737
|
+
*/
|
|
738
|
+
async function resolveShellFallback(pageClientId) {
|
|
739
|
+
await loadState();
|
|
740
|
+
const shellRoot = findShellRoot();
|
|
741
|
+
if (shellRoot === null) return null;
|
|
742
|
+
const version = await versionForClient(pageClientId, shellRoot);
|
|
743
|
+
if (version === null) return null;
|
|
744
|
+
const manifest = await loadManifest(shellRoot.rootKey, version);
|
|
745
|
+
if (manifest === null || typeof manifest.navigationFallback !== "string") return null;
|
|
746
|
+
const fallbackUrl = shellRoot.baseUrl + manifest.navigationFallback.split("/").map(encodeURIComponent).join("/");
|
|
747
|
+
return buildResolution(parseContentUrl(fallbackUrl), shellRoot, version, manifest);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* The shell page to store from a successful navigation answer: only when the navigation is for the shell's base URL or
|
|
752
|
+
* its fallback path, the answer is a plain OK same-origin response, storing is allowed and the page is not stored yet.
|
|
753
|
+
* @param {Request} request The navigation request.
|
|
754
|
+
* @param {Response} networkResponse The network's answer.
|
|
755
|
+
* @param {string} pageClientId The page's client id ("" when unknown).
|
|
756
|
+
* @returns {Promise<ResolvedRequest | null>} The page to verify and store, or null to store nothing.
|
|
757
|
+
*/
|
|
758
|
+
async function resolveShellPageForStoring(request, networkResponse, pageClientId) {
|
|
759
|
+
if (!networkResponse.ok || networkResponse.redirected || networkResponse.body === null) return null;
|
|
760
|
+
try {
|
|
761
|
+
const shellPage = await resolveShellFallback(pageClientId);
|
|
762
|
+
if (shellPage === null || !shouldStore(shellPage) || storedHashes.has(shellPage.entry.hash)) return null;
|
|
763
|
+
const lookupUrl = parseContentUrl(request.url).lookupUrl;
|
|
764
|
+
if (!lookupUrl.startsWith(shellPage.root.baseUrl)) return null;
|
|
765
|
+
const navigationPath = relativePathOf(lookupUrl, shellPage.root.baseUrl);
|
|
766
|
+
return navigationPath === "" || navigationPath === shellPage.path ? shellPage : null;
|
|
767
|
+
} catch (error) {
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* The first app-shell root.
|
|
774
|
+
* @returns {object | null} The shell root state, or null when none is configured.
|
|
775
|
+
*/
|
|
776
|
+
function findShellRoot() {
|
|
777
|
+
for (const root of rootsByKey.values()) {
|
|
778
|
+
if (root.isAppShell === true) return root;
|
|
779
|
+
}
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
784
|
+
// State: roots, manifests, pins (D15, D16)
|
|
785
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Reads roots, pins and stored hashes from the store once per worker start; later calls return the same promise. A read
|
|
789
|
+
* that fails is retried by the next call.
|
|
790
|
+
* @returns {Promise<void>} Resolves when memory mirrors the store.
|
|
791
|
+
*/
|
|
792
|
+
function loadState() {
|
|
793
|
+
if (stateReady === null) {
|
|
794
|
+
stateReady = Promise.all([loadRoots(), loadPins(), loadStoredHashes()]).then(
|
|
795
|
+
() => { isStateLoaded = true; },
|
|
796
|
+
(error) => { stateReady = null; throw error; }
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
return stateReady;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Reads the roots index and every root state, preloads the manifests of their active and pending versions, then
|
|
804
|
+
* replaces `rootsByKey` in one step (fetches keep using the previous roots until then).
|
|
805
|
+
* @returns {Promise<void>} Resolves when the roots are in memory.
|
|
806
|
+
* @throws {Error} When the store cannot be read.
|
|
807
|
+
*/
|
|
808
|
+
async function loadRoots() {
|
|
809
|
+
const stateCache = await caches.open(CONTENT_STATE_CACHE);
|
|
810
|
+
const indexedKeys = await readJson(stateCache, storeKey(ROOTS_INDEX_NAME));
|
|
811
|
+
const rootKeys = Array.isArray(indexedKeys) ? indexedKeys : [];
|
|
812
|
+
const loadedRoots = new Map();
|
|
813
|
+
for (const rootKey of rootKeys) {
|
|
814
|
+
const rootState = await readJson(stateCache, rootStateKey(rootKey));
|
|
815
|
+
if (rootState != null && typeof rootState.baseUrl === "string") loadedRoots.set(rootKey, rootState);
|
|
816
|
+
}
|
|
817
|
+
for (const rootState of loadedRoots.values()) {
|
|
818
|
+
for (const version of [rootState.activeVersion, rootState.pendingVersion]) {
|
|
819
|
+
if (typeof version === "string") await loadManifest(rootState.rootKey, version);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
rootsByKey.clear();
|
|
823
|
+
for (const [rootKey, rootState] of loadedRoots) rootsByKey.set(rootKey, rootState);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Reads every stored client pin into `pinsByClient`. Pins are written only by this worker, so memory stays exact after.
|
|
828
|
+
* @returns {Promise<void>} Resolves when the pins are in memory.
|
|
829
|
+
* @throws {Error} When the store cannot be read.
|
|
830
|
+
*/
|
|
831
|
+
async function loadPins() {
|
|
832
|
+
const stateCache = await caches.open(CONTENT_STATE_CACHE);
|
|
833
|
+
for (const keyRequest of await stateCache.keys()) {
|
|
834
|
+
const keyName = storeKeyName(keyRequest.url);
|
|
835
|
+
if (keyName === null || !keyName.startsWith(PINS_FOLDER)) continue;
|
|
836
|
+
const pins = await readJson(stateCache, keyRequest.url);
|
|
837
|
+
if (pins != null && typeof pins === "object") pinsByClient.set(decodeURIComponent(keyName.slice(PINS_FOLDER.length)), pins);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Reads the hashes present in the files cache into `storedHashes`.
|
|
843
|
+
* @returns {Promise<void>} Resolves when the hashes are in memory.
|
|
844
|
+
* @throws {Error} When the store cannot be read.
|
|
845
|
+
*/
|
|
846
|
+
async function loadStoredHashes() {
|
|
847
|
+
const filesCache = await caches.open(CONTENT_FILES_CACHE);
|
|
848
|
+
storedHashes.clear();
|
|
849
|
+
for (const keyRequest of await filesCache.keys()) {
|
|
850
|
+
const hash = hashOfFileKey(keyRequest.url);
|
|
851
|
+
if (hash !== null) storedHashes.add(hash);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* A manifest by root and version, from memory or the state cache.
|
|
857
|
+
* @param {string} rootKey The root key.
|
|
858
|
+
* @param {string} version The manifest version.
|
|
859
|
+
* @returns {Promise<object | null>} The parsed manifest, or null when it is not stored.
|
|
860
|
+
* @throws {Error} When the store cannot be read.
|
|
861
|
+
*/
|
|
862
|
+
async function loadManifest(rootKey, version) {
|
|
863
|
+
const mapKey = manifestMapKey(rootKey, version);
|
|
864
|
+
const known = manifestsByKey.get(mapKey);
|
|
865
|
+
if (known !== undefined) return known;
|
|
866
|
+
const stateCache = await caches.open(CONTENT_STATE_CACHE);
|
|
867
|
+
const manifest = await readJson(stateCache, manifestKey(rootKey, version));
|
|
868
|
+
if (manifest == null || typeof manifest.files !== "object") return null;
|
|
869
|
+
manifestsByKey.set(mapKey, manifest);
|
|
870
|
+
return manifest;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* A client's pins (root key → version).
|
|
875
|
+
* @param {string} clientId The client id ("" gives no pins).
|
|
876
|
+
* @returns {Promise<Object<string, string>>} The pins; an empty object when the client has none.
|
|
877
|
+
* @throws {Error} When the store cannot be read.
|
|
878
|
+
*/
|
|
879
|
+
async function readPin(clientId) {
|
|
880
|
+
if (clientId === "") return {};
|
|
881
|
+
await loadState();
|
|
882
|
+
const pins = pinsByClient.get(clientId);
|
|
883
|
+
return pins !== undefined ? pins : {};
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Pins a client to a version of a root, in memory and in the state cache, so the pin survives a worker restart.
|
|
888
|
+
* @param {string} clientId The client id.
|
|
889
|
+
* @param {string} rootKey The root key.
|
|
890
|
+
* @param {string} version The version the client keeps.
|
|
891
|
+
* @returns {Promise<void>} Settles when the pin is stored.
|
|
892
|
+
* @throws {Error} When the store cannot be written.
|
|
893
|
+
*/
|
|
894
|
+
async function writePin(clientId, rootKey, version) {
|
|
895
|
+
const pins = Object.assign({}, pinsByClient.get(clientId));
|
|
896
|
+
pins[rootKey] = version;
|
|
897
|
+
pinsByClient.set(clientId, pins);
|
|
898
|
+
const stateCache = await caches.open(CONTENT_STATE_CACHE);
|
|
899
|
+
await stateCache.put(pinKey(clientId), jsonResponse(pins));
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
903
|
+
// Garbage collection (D16)
|
|
904
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Deletes what no kept version needs. A version is kept when it is a root's active or pending version or a live window
|
|
908
|
+
* client's pin. In order: pins of closed clients, manifests of versions not kept, then every file whose hash no kept
|
|
909
|
+
* manifest lists. When a kept manifest cannot be read, no file is deleted (its files are unknown).
|
|
910
|
+
* @returns {Promise<void>} Settles when collection is done.
|
|
911
|
+
* @throws {Error} When the store cannot be read.
|
|
912
|
+
*/
|
|
913
|
+
async function collectGarbage() {
|
|
914
|
+
await loadState();
|
|
915
|
+
await loadRoots();
|
|
916
|
+
const stateCache = await caches.open(CONTENT_STATE_CACHE);
|
|
917
|
+
const liveClientIds = await listLiveClientIds();
|
|
918
|
+
await deleteClosedClientPins(stateCache, liveClientIds);
|
|
919
|
+
const keptVersions = collectKeptVersions();
|
|
920
|
+
const keptHashes = await deleteUnkeptManifests(stateCache, keptVersions);
|
|
921
|
+
if (keptHashes === null) return;
|
|
922
|
+
await deleteUnkeptFiles(keptHashes);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* The ids of every open window client of this origin, controlled or not.
|
|
927
|
+
* @returns {Promise<Set<string>>} The live client ids.
|
|
928
|
+
*/
|
|
929
|
+
async function listLiveClientIds() {
|
|
930
|
+
const windowClients = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
|
931
|
+
return new Set(windowClients.map((windowClient) => windowClient.id));
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Deletes the pins of clients that are no longer open, in memory and in the state cache.
|
|
936
|
+
* @param {Cache} stateCache The state cache.
|
|
937
|
+
* @param {Set<string>} liveClientIds The open clients.
|
|
938
|
+
* @returns {Promise<void>} Settles when deleted.
|
|
939
|
+
*/
|
|
940
|
+
async function deleteClosedClientPins(stateCache, liveClientIds) {
|
|
941
|
+
for (const clientId of Array.from(pinsByClient.keys())) {
|
|
942
|
+
if (liveClientIds.has(clientId)) continue;
|
|
943
|
+
pinsByClient.delete(clientId);
|
|
944
|
+
await stateCache.delete(pinKey(clientId));
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* The kept versions per root: active, pending and live pins (pins of closed clients are already gone).
|
|
950
|
+
* @returns {Map<string, Set<string>>} Root key → kept versions.
|
|
951
|
+
*/
|
|
952
|
+
function collectKeptVersions() {
|
|
953
|
+
const keptVersions = new Map();
|
|
954
|
+
const keepVersion = (rootKey, version) => {
|
|
955
|
+
if (typeof version !== "string") return;
|
|
956
|
+
if (!keptVersions.has(rootKey)) keptVersions.set(rootKey, new Set());
|
|
957
|
+
keptVersions.get(rootKey).add(version);
|
|
958
|
+
};
|
|
959
|
+
for (const rootState of rootsByKey.values()) {
|
|
960
|
+
keepVersion(rootState.rootKey, rootState.activeVersion);
|
|
961
|
+
keepVersion(rootState.rootKey, rootState.pendingVersion);
|
|
962
|
+
}
|
|
963
|
+
for (const pins of pinsByClient.values()) {
|
|
964
|
+
for (const [rootKey, version] of Object.entries(pins)) {
|
|
965
|
+
if (rootsByKey.has(rootKey)) keepVersion(rootKey, version);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return keptVersions;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Deletes every stored manifest whose version is not kept and gathers the hashes the kept ones list.
|
|
973
|
+
* @param {Cache} stateCache The state cache.
|
|
974
|
+
* @param {Map<string, Set<string>>} keptVersions Root key → kept versions.
|
|
975
|
+
* @returns {Promise<Set<string> | null>} The kept hashes, or null when a kept manifest could not be read.
|
|
976
|
+
*/
|
|
977
|
+
async function deleteUnkeptManifests(stateCache, keptVersions) {
|
|
978
|
+
const keptHashes = new Set();
|
|
979
|
+
let isEveryKeptManifestRead = true;
|
|
980
|
+
for (const keyRequest of await stateCache.keys()) {
|
|
981
|
+
const manifestId = parseManifestKey(keyRequest.url);
|
|
982
|
+
if (manifestId === null) continue;
|
|
983
|
+
const versions = keptVersions.get(manifestId.rootKey);
|
|
984
|
+
if (versions === undefined || !versions.has(manifestId.version)) {
|
|
985
|
+
manifestsByKey.delete(manifestMapKey(manifestId.rootKey, manifestId.version));
|
|
986
|
+
await stateCache.delete(keyRequest.url);
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
const manifest = await loadManifest(manifestId.rootKey, manifestId.version);
|
|
990
|
+
if (manifest === null) {
|
|
991
|
+
isEveryKeptManifestRead = false;
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
for (const entry of Object.values(manifest.files)) keptHashes.add(entry.hash);
|
|
995
|
+
}
|
|
996
|
+
return isEveryKeptManifestRead ? keptHashes : null;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Deletes every stored file whose hash no kept manifest lists.
|
|
1001
|
+
* @param {Set<string>} keptHashes The hashes kept versions need.
|
|
1002
|
+
* @returns {Promise<void>} Settles when deleted.
|
|
1003
|
+
*/
|
|
1004
|
+
async function deleteUnkeptFiles(keptHashes) {
|
|
1005
|
+
const filesCache = await caches.open(CONTENT_FILES_CACHE);
|
|
1006
|
+
for (const keyRequest of await filesCache.keys()) {
|
|
1007
|
+
const hash = hashOfFileKey(keyRequest.url);
|
|
1008
|
+
if (hash === null || keptHashes.has(hash)) continue;
|
|
1009
|
+
await filesCache.delete(keyRequest.url);
|
|
1010
|
+
storedHashes.delete(hash);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
1015
|
+
// Store keys and helpers
|
|
1016
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* The absolute synthetic store key of a name on this worker's origin.
|
|
1020
|
+
* @param {string} path The key name below `STORE_KEY_PREFIX`.
|
|
1021
|
+
* @returns {string} The key URL.
|
|
1022
|
+
*/
|
|
1023
|
+
function storeKey(path) {
|
|
1024
|
+
return new URL(STORE_KEY_PREFIX + path, self.location.origin).href;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* The files-cache key of a hash.
|
|
1029
|
+
* @param {string} hash The lowercase hex SHA-256.
|
|
1030
|
+
* @returns {string} The key URL.
|
|
1031
|
+
*/
|
|
1032
|
+
function fileKey(hash) {
|
|
1033
|
+
return storeKey(FILES_FOLDER + hash);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* The state-cache key of a root state.
|
|
1038
|
+
* @param {string} rootKey The root key (absolute manifest URL).
|
|
1039
|
+
* @returns {string} The key URL.
|
|
1040
|
+
*/
|
|
1041
|
+
function rootStateKey(rootKey) {
|
|
1042
|
+
return storeKey(ROOTS_FOLDER + encodeURIComponent(rootKey));
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* The state-cache key of a manifest version.
|
|
1047
|
+
* @param {string} rootKey The root key.
|
|
1048
|
+
* @param {string} version The manifest version.
|
|
1049
|
+
* @returns {string} The key URL.
|
|
1050
|
+
*/
|
|
1051
|
+
function manifestKey(rootKey, version) {
|
|
1052
|
+
return storeKey(MANIFESTS_FOLDER + encodeURIComponent(rootKey) + "/" + version);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* The state-cache key of a client's pins.
|
|
1057
|
+
* @param {string} clientId The client id.
|
|
1058
|
+
* @returns {string} The key URL.
|
|
1059
|
+
*/
|
|
1060
|
+
function pinKey(clientId) {
|
|
1061
|
+
return storeKey(PINS_FOLDER + encodeURIComponent(clientId));
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* The in-memory key of a manifest.
|
|
1066
|
+
* @param {string} rootKey The root key.
|
|
1067
|
+
* @param {string} version The manifest version.
|
|
1068
|
+
* @returns {string} `rootKey + "\n" + version`.
|
|
1069
|
+
*/
|
|
1070
|
+
function manifestMapKey(rootKey, version) {
|
|
1071
|
+
return rootKey + "\n" + version;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* The name part of a store key URL (below `STORE_KEY_PREFIX`).
|
|
1076
|
+
* @param {string} keyUrl A cache key URL.
|
|
1077
|
+
* @returns {string | null} The name, or null when the URL is not a store key of this origin.
|
|
1078
|
+
*/
|
|
1079
|
+
function storeKeyName(keyUrl) {
|
|
1080
|
+
const url = new URL(keyUrl);
|
|
1081
|
+
if (url.origin !== self.location.origin || !url.pathname.startsWith(STORE_KEY_PREFIX)) return null;
|
|
1082
|
+
return url.pathname.slice(STORE_KEY_PREFIX.length);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* The hash of a files-cache key.
|
|
1087
|
+
* @param {string} keyUrl A files-cache key URL.
|
|
1088
|
+
* @returns {string | null} The hash, or null when the URL is not a file key.
|
|
1089
|
+
*/
|
|
1090
|
+
function hashOfFileKey(keyUrl) {
|
|
1091
|
+
const keyName = storeKeyName(keyUrl);
|
|
1092
|
+
if (keyName === null || !keyName.startsWith(FILES_FOLDER)) return null;
|
|
1093
|
+
return keyName.slice(FILES_FOLDER.length);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* The root key and version of a manifest key.
|
|
1098
|
+
* @param {string} keyUrl A state-cache key URL.
|
|
1099
|
+
* @returns {{ rootKey: string, version: string } | null} The manifest's identity, or null for another kind of key.
|
|
1100
|
+
*/
|
|
1101
|
+
function parseManifestKey(keyUrl) {
|
|
1102
|
+
const keyName = storeKeyName(keyUrl);
|
|
1103
|
+
if (keyName === null || !keyName.startsWith(MANIFESTS_FOLDER)) return null;
|
|
1104
|
+
const parts = keyName.slice(MANIFESTS_FOLDER.length).split("/");
|
|
1105
|
+
if (parts.length !== 2) return null;
|
|
1106
|
+
return { rootKey: decodeURIComponent(parts[0]), version: parts[1] };
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Reads a JSON value stored in a cache.
|
|
1111
|
+
* @param {Cache} cache The cache.
|
|
1112
|
+
* @param {string} key The key URL.
|
|
1113
|
+
* @returns {Promise<*>} The parsed value, or null when the key is missing or not valid JSON.
|
|
1114
|
+
* @throws {Error} When the cache cannot be read.
|
|
1115
|
+
*/
|
|
1116
|
+
async function readJson(cache, key) {
|
|
1117
|
+
const storedResponse = await cache.match(key);
|
|
1118
|
+
if (storedResponse === undefined) return null;
|
|
1119
|
+
try {
|
|
1120
|
+
return JSON.parse(await storedResponse.text());
|
|
1121
|
+
} catch (error) {
|
|
1122
|
+
return null;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* A JSON response for the state cache.
|
|
1128
|
+
* @param {*} value The value to store.
|
|
1129
|
+
* @returns {Response} The response.
|
|
1130
|
+
*/
|
|
1131
|
+
function jsonResponse(value) {
|
|
1132
|
+
return new Response(JSON.stringify(value), { status: STATUS_OK, headers: { "Content-Type": "application/json" } });
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* The `file-stored` message of a resolved request.
|
|
1137
|
+
* @param {ResolvedRequest} resolved The resolved request.
|
|
1138
|
+
* @returns {{ type: string, rootKey: string, version: string, path: string, hash: string }} The message.
|
|
1139
|
+
*/
|
|
1140
|
+
function createStoredMessage(resolved) {
|
|
1141
|
+
return { type: "file-stored", rootKey: resolved.root.rootKey, version: resolved.version, path: resolved.path, hash: resolved.entry.hash };
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* The `file-failed` message of a resolved request.
|
|
1146
|
+
* @param {ResolvedRequest} resolved The resolved request.
|
|
1147
|
+
* @param {"mismatch" | "cors" | "status" | "network"} reason Why the file was not stored.
|
|
1148
|
+
* @param {number | null} status The HTTP status for `status`, else null.
|
|
1149
|
+
* @returns {{ type: string, rootKey: string, version: string, path: string, hash: string, reason: string, status: number | null, message?: string }} The message.
|
|
1150
|
+
*/
|
|
1151
|
+
function createFailedMessage(resolved, reason, status) {
|
|
1152
|
+
return {
|
|
1153
|
+
type: "file-failed",
|
|
1154
|
+
rootKey: resolved.root.rootKey,
|
|
1155
|
+
version: resolved.version,
|
|
1156
|
+
path: resolved.path,
|
|
1157
|
+
hash: resolved.entry.hash,
|
|
1158
|
+
reason: reason,
|
|
1159
|
+
status: status
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* Posts a message to every page on the broadcast channel.
|
|
1165
|
+
* @param {object} message The protocol message.
|
|
1166
|
+
*/
|
|
1167
|
+
function broadcast(message) {
|
|
1168
|
+
contentChannel.postMessage(message);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* A short description of an error for logs and failure messages.
|
|
1173
|
+
* @param {*} error The error.
|
|
1174
|
+
* @returns {string} Its name and message.
|
|
1175
|
+
*/
|
|
1176
|
+
function describeError(error) {
|
|
1177
|
+
if (error == null) return "unknown error";
|
|
1178
|
+
const errorName = typeof error.name === "string" ? error.name : "Error";
|
|
1179
|
+
return typeof error.message === "string" && error.message !== "" ? errorName + ": " + error.message : errorName;
|
|
1180
|
+
}
|