arcane-os 0.11.2 → 0.12.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.
@@ -1,40 +1,60 @@
1
- export function createPwaWorkerScript(manifest) {
2
- return `(${installPwaWorker.toString()})(${JSON.stringify(manifest, null, 4)});\n`;
1
+ export function createPwaWorkerScript(manifest, clientUrl = 'arcane/sdk/pwa.mjs') {
2
+ return `(${installPwaWorker.toString()})(${JSON.stringify(manifest, null, 4)}, ${JSON.stringify(clientUrl)});\n`;
3
3
  }
4
4
 
5
- function installPwaWorker(manifest) {
5
+ function installPwaWorker(manifest, clientUrl) {
6
6
  const scope = self.registration.scope;
7
- const cachePrefix = `arcane-pwa|${JSON.stringify(
8
- [manifest.appId, scope]
9
- )}|`;
10
- const cacheName = cachePrefix + JSON.stringify(
11
- [
12
- manifest.appVersion,
13
- manifest.sdkVersion,
14
- manifest.mode,
15
- manifest.revision
16
- ]
17
- );
7
+ const scopeOrigin = new URL(scope).origin;
8
+ const cachePrefix = `arcane-pwa|${JSON.stringify([manifest.appId, scope])}|`;
9
+ const cacheName = `${cachePrefix}resources`;
10
+ const manifestUrl = cacheUrl('arcane-offline.json');
11
+ const protocolUrls = new Set([cacheUrl('arcane-pwa.mjs'), cacheUrl(clientUrl)]);
12
+ const installationAssets = [...new Set(manifest.assets.map(cacheUrl))];
13
+ const resourceJobs = new Map();
14
+ const pendingChecks = new Set();
15
+ const refreshJobs = new Map();
16
+ const ownedUrls = new Set();
17
+ const navigationAliases = new Map();
18
+ let currentManifest = manifest;
19
+ let refreshTask = null;
20
+ let previousCaches = null;
21
+ let manifestRestored = false;
22
+ let lastChecked = null;
18
23
 
19
24
  function cacheUrl(value) {
20
25
  const url = new URL(value, scope);
21
- // Cache matching excludes fragments; preserve queries and the original network request.
26
+ // Fragments identify document positions; query variants remain distinct resources.
22
27
  url.hash = '';
23
28
  return url.href;
24
29
  }
25
30
 
26
- const assetUrls = [
27
- ...new Set(
28
- manifest.assets.map(cacheUrl)
29
- )
30
- ];
31
- const ownedUrls = new Set(assetUrls);
32
- const navigationAliases = new Map();
33
- for (const [alias, asset] of Object.entries(manifest.navigationAliases)) {
34
- navigationAliases.set(
35
- cacheUrl(alias),
36
- new URL(asset, scope).href
31
+ function manifestResources(value) {
32
+ if (!Array.isArray(value.assets) || !value.navigationAliases || typeof value.navigationAliases !== 'object') {
33
+ throw new TypeError('The PWA offline inventory requires assets and navigationAliases.');
34
+ }
35
+ const assets = value.assets.map(cacheUrl);
36
+ const aliases = Object.entries(value.navigationAliases).map(
37
+ function navigationAlias([alias, asset]) {
38
+ return [cacheUrl(alias), new URL(asset, scope).href];
39
+ }
37
40
  );
41
+ return {value, assets, aliases};
42
+ }
43
+
44
+ function useManifest({value, assets, aliases}) {
45
+ currentManifest = value;
46
+ ownedUrls.clear();
47
+ for (const asset of assets) {
48
+ ownedUrls.add(asset);
49
+ }
50
+ navigationAliases.clear();
51
+ for (const [alias, asset] of aliases) {
52
+ navigationAliases.set(alias, asset);
53
+ }
54
+ }
55
+
56
+ async function readManifest(response) {
57
+ return manifestResources(await response.json());
38
58
  }
39
59
 
40
60
  function errorDetails(error) {
@@ -55,9 +75,7 @@ function installPwaWorker(manifest) {
55
75
  }
56
76
 
57
77
  async function reportFailure(error) {
58
- const clients = await self.clients.matchAll(
59
- {type: 'window', includeUncontrolled: true}
60
- );
78
+ const clients = await self.clients.matchAll({type: 'window', includeUncontrolled: true});
61
79
  const message = {type: 'arcane.pwa.error', error: errorDetails(error)};
62
80
  for (const client of clients) {
63
81
  if (client.url.startsWith(scope)) {
@@ -66,126 +84,334 @@ function installPwaWorker(manifest) {
66
84
  }
67
85
  }
68
86
 
69
- async function populateCache() {
87
+ async function reportFailureAndReject(error) {
88
+ try {
89
+ await reportFailure(error);
90
+ } catch (reportError) {
91
+ console.error('PWA failure could not be sent to application clients.', reportError, error);
92
+ }
93
+ throw error;
94
+ }
95
+
96
+ function resourceError(url, cause) {
97
+ const error = new Error(`PWA resource could not be cached: ${url}`, {cause});
98
+ error.url = url;
99
+ return error;
100
+ }
101
+
102
+ async function findCachedResource(url) {
70
103
  const cache = await caches.open(cacheName);
71
- const failures = [];
72
- let nextAsset = 0;
104
+ const response = await cache.match(url);
105
+ if (response) {
106
+ return {cache, response, current: true};
107
+ }
108
+ // Carry existing app resources forward without deleting older generations.
109
+ previousCaches ??= caches.keys().then(
110
+ function openPriorCaches(names) {
111
+ return Promise.all(
112
+ names.reverse().filter(
113
+ function priorAppCache(name) {
114
+ return name.startsWith(cachePrefix) && name !== cacheName;
115
+ }
116
+ ).map(
117
+ function openPriorCache(name) {
118
+ return caches.open(name);
119
+ }
120
+ )
121
+ );
122
+ }
123
+ );
124
+ for (const previous of await previousCaches) {
125
+ const retained = await previous.match(url);
126
+ if (retained) {
127
+ return {cache, response: retained, current: false};
128
+ }
129
+ }
130
+ return {cache, response: null, current: true};
131
+ }
73
132
 
74
- async function fetchAssets() {
75
- while (nextAsset < assetUrls.length) {
76
- const url = assetUrls[nextAsset++];
133
+ async function restoreManifest() {
134
+ const cached = await findCachedResource(manifestUrl);
135
+ if (cached.response) {
136
+ const inventory = await readManifest(cached.response);
137
+ if (['appVersion', 'sdkVersion', 'mode', 'revision'].every(
138
+ function sameWorkerDeclaration(field) {
139
+ return inventory.value[field] === manifest[field];
140
+ }
141
+ )) {
142
+ useManifest(inventory);
143
+ }
144
+ }
145
+ }
146
+
147
+ useManifest(manifestResources(manifest));
148
+ const restored = restoreManifest().catch(
149
+ async function reportManifestRestoreFailure(error) {
150
+ try {
151
+ await reportFailure(error);
152
+ } catch (reportError) {
153
+ console.error('PWA manifest restore failure could not be reported.', reportError, error);
154
+ }
155
+ }
156
+ ).finally(
157
+ function manifestRestoreComplete() {
158
+ manifestRestored = true;
159
+ }
160
+ );
161
+
162
+ async function fetchResource(request, url, validate) {
163
+ const cached = await findCachedResource(url);
164
+ // Earlier clients cannot initiate this protocol; upgrade only its owners on migration.
165
+ const protocolMigration = cached.response && !cached.current && protocolUrls.has(url);
166
+ if (cached.response && !validate && !protocolMigration) {
167
+ return {
168
+ response: cached.response,
169
+ saved: cached.current ? Promise.resolve() : cached.cache.put(url, cached.response.clone()),
170
+ checked: false,
171
+ error: null
172
+ };
173
+ }
174
+ try {
175
+ const headers = new Headers(request.headers);
176
+ const modified = protocolMigration ? null : cached.response?.headers.get('last-modified');
177
+ headers.delete('if-none-match');
178
+ if (modified) {
179
+ headers.set('if-modified-since', modified);
180
+ } else {
181
+ headers.delete('if-modified-since');
182
+ }
183
+ // This conditional request owns validation; CacheStorage owns the durable body.
184
+ const response = await fetch(new Request(request, {headers, cache: 'no-store'}));
185
+ if (response.status === 304 && cached.response) {
186
+ return {
187
+ response: cached.response,
188
+ saved: cached.current ? Promise.resolve() : cached.cache.put(url, cached.response.clone()),
189
+ checked: true,
190
+ error: null
191
+ };
192
+ }
193
+ if (!response.ok) {
194
+ throw new Error(`PWA resource returned HTTP ${response.status} ${response.statusText}: ${url}`);
195
+ }
196
+ if (url === manifestUrl) {
197
+ // Parse the control document before replacing the last usable inventory.
198
+ await readManifest(response.clone());
199
+ }
200
+ return {
201
+ response,
202
+ saved: cached.cache.put(url, response.clone()),
203
+ checked: true,
204
+ error: null
205
+ };
206
+ } catch (cause) {
207
+ const error = resourceError(url, cause);
208
+ if (!cached.response) {
209
+ throw error;
210
+ }
211
+ return {response: cached.response, saved: Promise.resolve(), checked: false, error};
212
+ }
213
+ }
214
+
215
+ function resourceJob(request, url, validate = false) {
216
+ if (resourceJobs.has(url)) {
217
+ return resourceJobs.get(url);
218
+ }
219
+ const job = {result: fetchResource(request, url, validate), done: null, checked: false};
220
+ resourceJobs.set(url, job);
221
+ if (pendingChecks.has(url)) {
222
+ refreshJobs.set(url, job);
223
+ }
224
+ job.done = job.result.then(
225
+ async function finishResource(result) {
226
+ job.checked = result.checked;
77
227
  try {
78
- const response = await fetch(
79
- new Request(
80
- url,
81
- {cache: manifest.mode === 'development' ? 'no-cache' : 'reload'}
82
- )
83
- );
84
- if (!response.ok) {
85
- throw new Error(`PWA resource returned HTTP ${response.status} ${response.statusText}: ${url}`);
86
- }
87
- await cache.put(url, response);
228
+ await result.saved;
229
+ return result.error;
88
230
  } catch (cause) {
89
- const error = new Error(
90
- `PWA resource could not be cached: ${url}`,
91
- {cause}
92
- );
93
- error.url = url;
231
+ return resourceError(url, cause);
232
+ }
233
+ },
234
+ function missingResource(error) {
235
+ return error;
236
+ }
237
+ ).then(
238
+ function releaseResource(error) {
239
+ resourceJobs.delete(url);
240
+ pendingChecks.delete(url);
241
+ if (refreshJobs.get(url) === job) {
242
+ // Retain cycle outcomes without retaining every completed response stream.
243
+ refreshJobs.set(url, {
244
+ result: Promise.resolve({checked: job.checked}),
245
+ done: Promise.resolve(error)
246
+ });
247
+ }
248
+ return error;
249
+ }
250
+ );
251
+ return job;
252
+ }
253
+
254
+ function resourceResponse(result) {
255
+ return result.response.clone();
256
+ }
257
+
258
+ async function populateResources(urls, validate = false) {
259
+ let nextAsset = 0;
260
+ const failures = [];
261
+ let allChecked = true;
262
+ async function fetchAssets() {
263
+ while (nextAsset < urls.length) {
264
+ const url = urls[nextAsset++];
265
+ let job = (validate ? refreshJobs.get(url) : null) ?? resourceJob(new Request(url), url, validate);
266
+ let error = await job.done;
267
+ if (validate && !error && !(await job.result).checked) {
268
+ // A concurrent cache carry-forward is complete, but has not checked this file.
269
+ job = resourceJob(new Request(url), url, true);
270
+ error = await job.done;
271
+ }
272
+ if (error) {
94
273
  failures.push(error);
95
274
  }
275
+ if (error || !(await job.result).checked) {
276
+ allChecked = false;
277
+ }
96
278
  }
97
279
  }
98
-
99
280
  const workers = [];
100
- for (let index = 0; index < Math.min(4, assetUrls.length); index += 1) {
101
- workers.push(
102
- fetchAssets()
103
- );
281
+ for (let index = 0; index < Math.min(4, urls.length); index += 1) {
282
+ workers.push(fetchAssets());
104
283
  }
105
284
  await Promise.all(workers);
285
+ return {failures, allChecked};
286
+ }
287
+
288
+ async function populateCache() {
289
+ await restored;
290
+ const {failures, allChecked} = await populateResources(installationAssets);
106
291
  if (failures.length > 0) {
107
292
  throw new AggregateError(failures, 'The PWA resource generation could not be installed.');
108
293
  }
109
- }
110
-
111
- async function reportFailureAndReject(error) {
112
- try {
113
- await reportFailure(error);
114
- } catch (reportError) {
115
- console.error('PWA failure could not be sent to application clients.', reportError, error);
294
+ if (allChecked) {
295
+ lastChecked = Date.now();
116
296
  }
117
- throw error;
118
297
  }
119
298
 
120
299
  function onInstall(event) {
121
- event.waitUntil(
122
- populateCache().catch(reportFailureAndReject)
123
- );
300
+ event.waitUntil(populateCache().catch(reportFailureAndReject));
124
301
  }
125
302
 
126
- async function retireOldCaches() {
127
- const names = await caches.keys();
128
- const retirements = [];
129
- for (const name of names) {
130
- if (name.startsWith(cachePrefix) && name !== cacheName) {
131
- retirements.push(
132
- caches.delete(name)
133
- );
303
+ function checkDue() {
304
+ const interval = currentManifest.mode === 'development' ? 120000 : 900000;
305
+ return lastChecked === null || Date.now() - lastChecked > interval;
306
+ }
307
+
308
+ async function refreshResources() {
309
+ await restored;
310
+ if (!checkDue()) {
311
+ return {lastChecked, error: null};
312
+ }
313
+ const failures = [];
314
+ let inventory = resourceJob(new Request(manifestUrl), manifestUrl, true);
315
+ let error = await inventory.done;
316
+ if (!error && !(await inventory.result).checked) {
317
+ inventory = resourceJob(new Request(manifestUrl), manifestUrl, true);
318
+ error = await inventory.done;
319
+ }
320
+ if (error) {
321
+ failures.push(error);
322
+ } else {
323
+ try {
324
+ const result = await inventory.result;
325
+ useManifest(await readManifest(result.response.clone()));
326
+ } catch (cause) {
327
+ failures.push(resourceError(manifestUrl, cause));
134
328
  }
135
329
  }
136
- await Promise.all(retirements);
330
+ const urls = [...ownedUrls].filter(
331
+ function selectedResource(url) {
332
+ return url !== manifestUrl;
333
+ }
334
+ );
335
+ for (const url of urls) {
336
+ pendingChecks.add(url);
337
+ }
338
+ const checked = await populateResources(urls, true);
339
+ failures.push(...checked.failures);
340
+ if (failures.length === 0) {
341
+ lastChecked = Date.now();
342
+ }
343
+ return {
344
+ lastChecked,
345
+ error: failures.length > 0
346
+ ? errorDetails(new AggregateError(failures, 'PWA resources could not all be updated.'))
347
+ : null
348
+ };
137
349
  }
138
350
 
139
- function onActivate(event) {
140
- event.waitUntil(
141
- retireOldCaches().catch(reportFailureAndReject)
142
- );
351
+ function refresh(checked) {
352
+ if (Number.isFinite(checked)) {
353
+ lastChecked = Math.max(lastChecked ?? 0, checked);
354
+ }
355
+ if (!refreshTask) {
356
+ refreshTask = refreshResources().finally(
357
+ function releaseRefresh() {
358
+ refreshTask = null;
359
+ refreshJobs.clear();
360
+ }
361
+ );
362
+ }
363
+ return refreshTask;
143
364
  }
144
365
 
145
- async function releaseResource(request, url) {
146
- const cache = await caches.open(cacheName);
147
- const cached = await cache.match(url);
148
- if (cached) {
149
- return cached;
150
- }
151
- return fetch(
152
- new Request(
153
- request,
154
- {cache: 'no-cache'}
155
- )
366
+ function onMessage(event) {
367
+ if (event.data?.type === 'arcane.pwa.capabilities') {
368
+ try {
369
+ event.source?.postMessage({type: 'arcane.pwa.capabilities', refresh: true, cacheName});
370
+ } catch (error) {
371
+ event.waitUntil(reportFailureAndReject(error));
372
+ }
373
+ return;
374
+ }
375
+ if (event.data?.type !== 'arcane.pwa.refresh') {
376
+ return;
377
+ }
378
+ const port = event.ports?.[0];
379
+ event.waitUntil(
380
+ refresh(event.data.lastChecked).then(
381
+ async function completeRefresh(result) {
382
+ port?.postMessage({type: 'arcane.pwa.refreshed', ...result});
383
+ port?.close();
384
+ if (result.error) {
385
+ await reportFailure(result.error);
386
+ }
387
+ },
388
+ async function failRefresh(error) {
389
+ port?.postMessage({type: 'arcane.pwa.refreshed', lastChecked, error: errorDetails(error)});
390
+ port?.close();
391
+ await reportFailure(error);
392
+ }
393
+ ).catch(reportFailureAndReject)
156
394
  );
157
395
  }
158
396
 
159
- async function developmentResource(request, url) {
160
- let response;
161
- try {
162
- response = await fetch(
163
- new Request(
164
- request,
165
- {cache: 'no-cache'}
166
- )
167
- );
168
- } catch (error) {
397
+ async function requestedResource(request, url) {
398
+ await restored;
399
+ const redirect = request.mode === 'navigate' ? navigationAliases.get(url) : null;
400
+ if (redirect && cacheUrl(redirect) !== url && ownedUrls.has(cacheUrl(redirect))) {
401
+ return {response: Response.redirect(redirect, 302), done: Promise.resolve(null)};
402
+ }
403
+ if (!ownedUrls.has(url)) {
404
+ return {response: await fetch(request), done: Promise.resolve(null)};
405
+ }
406
+ if (!pendingChecks.has(url) && !resourceJobs.has(url)) {
169
407
  const cache = await caches.open(cacheName);
170
408
  const cached = await cache.match(url);
171
409
  if (cached) {
172
- return {response: cached, fromNetwork: false, url};
410
+ return {response: cached, done: Promise.resolve(null)};
173
411
  }
174
- throw error;
175
- }
176
- return {response, fromNetwork: true, url};
177
- }
178
-
179
- function resourceResponse(resource) {
180
- return resource.response;
181
- }
182
-
183
- async function saveDevelopmentResource(resource) {
184
- if (resource.fromNetwork && resource.response.ok) {
185
- const response = resource.response.clone();
186
- const cache = await caches.open(cacheName);
187
- await cache.put(resource.url, response);
188
412
  }
413
+ const job = resourceJob(request, url, pendingChecks.has(url));
414
+ return {response: resourceResponse(await job.result), done: job.done};
189
415
  }
190
416
 
191
417
  function onFetch(event) {
@@ -193,35 +419,35 @@ function installPwaWorker(manifest) {
193
419
  if (request.method !== 'GET' || request.headers.has('range')) {
194
420
  return;
195
421
  }
196
- const requestUrl = cacheUrl(request.url);
197
- const redirect = request.mode === 'navigate' ? navigationAliases.get(requestUrl) : null;
198
- if (redirect && cacheUrl(redirect) !== requestUrl && ownedUrls.has(cacheUrl(redirect))) {
199
- // Navigation retains its requested document URL unless it follows a redirect.
200
- event.respondWith(
201
- Response.redirect(redirect, 302)
202
- );
203
- return;
204
- }
205
- const url = requestUrl;
206
- if (!ownedUrls.has(url)) {
422
+ const url = cacheUrl(request.url);
423
+ if (new URL(url).origin !== scopeOrigin && !ownedUrls.has(url)) {
207
424
  return;
208
425
  }
209
- if (manifest.mode === 'development') {
210
- const resource = developmentResource(request, url);
211
- event.waitUntil(
212
- resource.then(saveDevelopmentResource).catch(reportFailureAndReject)
213
- );
214
- event.respondWith(
215
- resource.then(resourceResponse)
216
- );
426
+ if (manifestRestored && !ownedUrls.has(url)
427
+ && !(request.mode === 'navigate' && navigationAliases.has(url))) {
217
428
  return;
218
429
  }
430
+ const resource = requestedResource(request, url);
219
431
  event.respondWith(
220
- releaseResource(request, url).catch(reportFailureAndReject)
432
+ resource.then(
433
+ function requestedResponse(result) {
434
+ return result.response;
435
+ }
436
+ )
437
+ );
438
+ event.waitUntil(
439
+ resource.then(
440
+ async function completeRequestedResource(result) {
441
+ const error = await result.done;
442
+ if (error) {
443
+ throw error;
444
+ }
445
+ }
446
+ ).catch(reportFailureAndReject)
221
447
  );
222
448
  }
223
449
 
224
450
  self.addEventListener('install', onInstall);
225
- self.addEventListener('activate', onActivate);
226
451
  self.addEventListener('fetch', onFetch);
452
+ self.addEventListener('message', onMessage);
227
453
  }
package/src/pwa.mjs CHANGED
@@ -301,7 +301,10 @@ controller.ready.catch(
301
301
  files: [
302
302
  {path: PWA_MANIFEST_NAME, content: json(manifest)},
303
303
  {path: PWA_OFFLINE_MANIFEST_NAME, content: json(offlineManifest)},
304
- {path: PWA_WORKER_NAME, content: createPwaWorkerScript(offlineManifest)},
304
+ {
305
+ path: PWA_WORKER_NAME,
306
+ content: createPwaWorkerScript(offlineManifest, `${runtimeBase}pwa.mjs`)
307
+ },
305
308
  {path: PWA_BOOTSTRAP_NAME, content: bootstrap}
306
309
  ],
307
310
  entryAssets,
@@ -148,7 +148,7 @@ const browserAdapter={
148
148
  throwIfAborted(signal);
149
149
  return {target:'browser',release:await verifyApp({workspaceRoot,appId,signal,onEvent})};
150
150
  },
151
- async run({workspaceRoot,appId,host='127.0.0.1',port=0,signal,onEvent}={}){
151
+ async run({workspaceRoot,appId,host='127.0.0.1',port=0,httpPort=0,tls,certPath,keyPath,signal,onEvent}={}){
152
152
  throwIfAborted(signal);
153
153
  const releaseRoot=path.join(workspaceRoot,'dist',appId);
154
154
  const server=await startDevServer({
@@ -158,6 +158,10 @@ const browserAdapter={
158
158
  releaseRoot,
159
159
  host,
160
160
  port,
161
+ httpPort,
162
+ tls,
163
+ certPath,
164
+ keyPath,
161
165
  signal,
162
166
  onEvent
163
167
  });