arcane-os 0.11.3 → 0.13.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,332 @@ 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++];
77
- 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);
88
- } catch (cause) {
89
- const error = new Error(
90
- `PWA resource could not be cached: ${url}`,
91
- {cause}
92
- );
93
- error.url = url;
94
- failures.push(error);
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];
95
140
  }
141
+ )) {
142
+ useManifest(inventory);
96
143
  }
97
144
  }
145
+ }
98
146
 
99
- const workers = [];
100
- for (let index = 0; index < Math.min(4, assetUrls.length); index += 1) {
101
- workers.push(
102
- fetchAssets()
103
- );
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
+ }
104
155
  }
105
- await Promise.all(workers);
106
- if (failures.length > 0) {
107
- throw new AggregateError(failures, 'The PWA resource generation could not be installed.');
156
+ ).finally(
157
+ function manifestRestoreComplete() {
158
+ manifestRestored = true;
108
159
  }
109
- }
160
+ );
110
161
 
111
- async function reportFailureAndReject(error) {
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
+ }
112
174
  try {
113
- await reportFailure(error);
114
- } catch (reportError) {
115
- console.error('PWA failure could not be sent to application clients.', reportError, error);
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};
116
212
  }
117
- throw error;
118
213
  }
119
214
 
120
- function onInstall(event) {
121
- event.waitUntil(
122
- populateCache().catch(reportFailureAndReject)
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;
227
+ try {
228
+ await result.saved;
229
+ return result.error;
230
+ } catch (cause) {
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
+ }
123
250
  );
251
+ return job;
124
252
  }
125
253
 
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
- );
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) {
273
+ failures.push(error);
274
+ }
275
+ if (error || !(await job.result).checked) {
276
+ allChecked = false;
277
+ }
134
278
  }
135
279
  }
136
- await Promise.all(retirements);
280
+ const workers = [];
281
+ for (let index = 0; index < Math.min(4, urls.length); index += 1) {
282
+ workers.push(fetchAssets());
283
+ }
284
+ await Promise.all(workers);
285
+ return {failures, allChecked};
137
286
  }
138
287
 
139
- function onActivate(event) {
140
- event.waitUntil(
141
- retireOldCaches().catch(reportFailureAndReject)
142
- );
288
+ async function populateCache() {
289
+ await restored;
290
+ const {failures, allChecked} = await populateResources(installationAssets);
291
+ if (failures.length > 0) {
292
+ throw new AggregateError(failures, 'The PWA resource generation could not be installed.');
293
+ }
294
+ if (allChecked) {
295
+ lastChecked = Date.now();
296
+ }
143
297
  }
144
298
 
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;
299
+ function onInstall(event) {
300
+ event.waitUntil(populateCache().catch(reportFailureAndReject));
301
+ }
302
+
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};
150
312
  }
151
- return fetch(
152
- new Request(
153
- request,
154
- {cache: 'no-cache'}
155
- )
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));
328
+ }
329
+ }
330
+ const urls = [...ownedUrls].filter(
331
+ function selectedResource(url) {
332
+ return url !== manifestUrl;
333
+ }
156
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
+ };
157
349
  }
158
350
 
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
- )
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
+ }
167
361
  );
168
- } catch (error) {
169
- const cache = await caches.open(cacheName);
170
- const cached = await cache.match(url);
171
- if (cached) {
172
- return {response: cached, fromNetwork: false, url};
173
- }
174
- throw error;
175
362
  }
176
- return {response, fromNetwork: true, url};
363
+ return refreshTask;
177
364
  }
178
365
 
179
- function resourceResponse(resource) {
180
- return resource.response;
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)
394
+ );
181
395
  }
182
396
 
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);
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)};
188
405
  }
406
+ const cache = await caches.open(cacheName);
407
+ const cached = await cache.match(url);
408
+ if (cached) {
409
+ return {response: cached, done: Promise.resolve(null)};
410
+ }
411
+ const job = resourceJob(request, url, pendingChecks.has(url));
412
+ return {response: resourceResponse(await job.result), done: job.done};
189
413
  }
190
414
 
191
415
  function onFetch(event) {
@@ -193,35 +417,35 @@ function installPwaWorker(manifest) {
193
417
  if (request.method !== 'GET' || request.headers.has('range')) {
194
418
  return;
195
419
  }
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)) {
420
+ const url = cacheUrl(request.url);
421
+ if (new URL(url).origin !== scopeOrigin && !ownedUrls.has(url)) {
207
422
  return;
208
423
  }
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
- );
424
+ if (manifestRestored && !ownedUrls.has(url)
425
+ && !(request.mode === 'navigate' && navigationAliases.has(url))) {
217
426
  return;
218
427
  }
428
+ const resource = requestedResource(request, url);
219
429
  event.respondWith(
220
- releaseResource(request, url).catch(reportFailureAndReject)
430
+ resource.then(
431
+ function requestedResponse(result) {
432
+ return result.response;
433
+ }
434
+ )
435
+ );
436
+ event.waitUntil(
437
+ resource.then(
438
+ async function completeRequestedResource(result) {
439
+ const error = await result.done;
440
+ if (error) {
441
+ throw error;
442
+ }
443
+ }
444
+ ).catch(reportFailureAndReject)
221
445
  );
222
446
  }
223
447
 
224
448
  self.addEventListener('install', onInstall);
225
- self.addEventListener('activate', onActivate);
226
449
  self.addEventListener('fetch', onFetch);
450
+ self.addEventListener('message', onMessage);
227
451
  }
package/src/pwa.mjs CHANGED
@@ -277,7 +277,13 @@ export function createPwaArtifacts(
277
277
  ),
278
278
  navigationAliases: navigationAliases ?? {[basePath]: app.entry}
279
279
  };
280
- const bootstrap = `import {registerPwa} from ${JSON.stringify(`${runtimeBase}pwa.mjs`)};
280
+ const bootstrap = `import {registerPwa, mountPwaInstallPrompt} from ${JSON.stringify(`${runtimeBase}pwa.mjs`)};
281
+
282
+ mountPwaInstallPrompt({appName: ${JSON.stringify(manifest.name)}}).catch(
283
+ function reportPwaInstallComponentFailure(error) {
284
+ console.error('Arcane PWA install component failed:', error);
285
+ }
286
+ );
281
287
 
282
288
  const controller = registerPwa(
283
289
  {
@@ -301,7 +307,10 @@ controller.ready.catch(
301
307
  files: [
302
308
  {path: PWA_MANIFEST_NAME, content: json(manifest)},
303
309
  {path: PWA_OFFLINE_MANIFEST_NAME, content: json(offlineManifest)},
304
- {path: PWA_WORKER_NAME, content: createPwaWorkerScript(offlineManifest)},
310
+ {
311
+ path: PWA_WORKER_NAME,
312
+ content: createPwaWorkerScript(offlineManifest, `${runtimeBase}pwa.mjs`)
313
+ },
305
314
  {path: PWA_BOOTSTRAP_NAME, content: bootstrap}
306
315
  ],
307
316
  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
  });