arcane-os 0.8.1 → 0.10.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.
@@ -0,0 +1,227 @@
1
+ export function createPwaWorkerScript(manifest) {
2
+ return `(${installPwaWorker.toString()})(${JSON.stringify(manifest, null, 4)});\n`;
3
+ }
4
+
5
+ function installPwaWorker(manifest) {
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
+ );
18
+
19
+ function cacheUrl(value) {
20
+ const url = new URL(value, scope);
21
+ // Cache matching excludes fragments; preserve queries and the original network request.
22
+ url.hash = '';
23
+ return url.href;
24
+ }
25
+
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
37
+ );
38
+ }
39
+
40
+ function errorDetails(error) {
41
+ if (!(error instanceof Error)) {
42
+ return error;
43
+ }
44
+ const detail = {name: error.name, message: error.message, stack: error.stack};
45
+ if ('cause' in error) {
46
+ detail.cause = errorDetails(error.cause);
47
+ }
48
+ if ('errors' in error) {
49
+ detail.errors = Array.from(error.errors, errorDetails);
50
+ }
51
+ if ('url' in error) {
52
+ detail.url = error.url;
53
+ }
54
+ return detail;
55
+ }
56
+
57
+ async function reportFailure(error) {
58
+ const clients = await self.clients.matchAll(
59
+ {type: 'window', includeUncontrolled: true}
60
+ );
61
+ const message = {type: 'arcane.pwa.error', error: errorDetails(error)};
62
+ for (const client of clients) {
63
+ if (client.url.startsWith(scope)) {
64
+ client.postMessage(message);
65
+ }
66
+ }
67
+ }
68
+
69
+ async function populateCache() {
70
+ const cache = await caches.open(cacheName);
71
+ const failures = [];
72
+ let nextAsset = 0;
73
+
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);
95
+ }
96
+ }
97
+ }
98
+
99
+ const workers = [];
100
+ for (let index = 0; index < Math.min(4, assetUrls.length); index += 1) {
101
+ workers.push(
102
+ fetchAssets()
103
+ );
104
+ }
105
+ await Promise.all(workers);
106
+ if (failures.length > 0) {
107
+ throw new AggregateError(failures, 'The PWA resource generation could not be installed.');
108
+ }
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);
116
+ }
117
+ throw error;
118
+ }
119
+
120
+ function onInstall(event) {
121
+ event.waitUntil(
122
+ populateCache().catch(reportFailureAndReject)
123
+ );
124
+ }
125
+
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
+ );
134
+ }
135
+ }
136
+ await Promise.all(retirements);
137
+ }
138
+
139
+ function onActivate(event) {
140
+ event.waitUntil(
141
+ retireOldCaches().catch(reportFailureAndReject)
142
+ );
143
+ }
144
+
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
+ )
156
+ );
157
+ }
158
+
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) {
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
+ }
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
+ }
189
+ }
190
+
191
+ function onFetch(event) {
192
+ const request = event.request;
193
+ if (request.method !== 'GET' || request.headers.has('range')) {
194
+ return;
195
+ }
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)) {
207
+ return;
208
+ }
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
+ );
217
+ return;
218
+ }
219
+ event.respondWith(
220
+ releaseResource(request, url).catch(reportFailureAndReject)
221
+ );
222
+ }
223
+
224
+ self.addEventListener('install', onInstall);
225
+ self.addEventListener('activate', onActivate);
226
+ self.addEventListener('fetch', onFetch);
227
+ }
package/src/pwa.mjs ADDED
@@ -0,0 +1,311 @@
1
+ import Is from 'strong-type';
2
+ import {randomUUID} from 'node:crypto';
3
+ import {createPwaWorkerScript} from './pwa-worker.mjs';
4
+ import {versionAssetUrl} from './import-map.mjs';
5
+
6
+ const is = new Is(false);
7
+
8
+ export const PWA_MANIFEST_NAME = 'arcane.webmanifest';
9
+ export const PWA_OFFLINE_MANIFEST_NAME = 'arcane-offline.json';
10
+ export const PWA_WORKER_NAME = 'arcane-sw.js';
11
+ export const PWA_BOOTSTRAP_NAME = 'arcane-pwa.mjs';
12
+
13
+ function record(value, label) {
14
+ if (value === null || !is.object(value) || is.array(value)) {
15
+ throw new TypeError(`${label} must be an object.`);
16
+ }
17
+ return value;
18
+ }
19
+
20
+ function pathList(value, label) {
21
+ function invalidPath(item) {
22
+ return !is.string(item) || !item;
23
+ }
24
+ if (!is.array(value) || value.some(invalidPath)) {
25
+ throw new TypeError(`${label} must be an array of nonempty paths.`);
26
+ }
27
+ return [...value];
28
+ }
29
+
30
+ export function normalizePwaConfig(value) {
31
+ if (value === undefined) return undefined;
32
+ record(value, 'pwa');
33
+ for (const key of Object.keys(value)) {
34
+ if (!['enabled', 'manifest', 'offline'].includes(key)) {
35
+ throw new TypeError(`pwa contains an unsupported field: ${key}.`);
36
+ }
37
+ }
38
+ if (value.enabled !== undefined && !is.boolean(value.enabled)) {
39
+ throw new TypeError('pwa.enabled must be a boolean.');
40
+ }
41
+ const manifest = record(
42
+ value.manifest === undefined ? {} : value.manifest,
43
+ 'pwa.manifest'
44
+ );
45
+ const offline = record(
46
+ value.offline === undefined ? {} : value.offline,
47
+ 'pwa.offline'
48
+ );
49
+ for (const key of Object.keys(offline)) {
50
+ if (key !== 'include' && key !== 'exclude') {
51
+ throw new TypeError(`pwa.offline contains an unsupported field: ${key}.`);
52
+ }
53
+ }
54
+ return {
55
+ enabled: value.enabled === true,
56
+ manifest: {...manifest},
57
+ offline: {
58
+ include: pathList(
59
+ offline.include === undefined ? [] : offline.include,
60
+ 'pwa.offline.include'
61
+ ),
62
+ exclude: pathList(
63
+ offline.exclude === undefined ? [] : offline.exclude,
64
+ 'pwa.offline.exclude'
65
+ )
66
+ }
67
+ };
68
+ }
69
+
70
+ function selectedPath(file, selection) {
71
+ const prefix = selection.endsWith('/') ? selection : `${selection}/`;
72
+ return file === selection || file.startsWith(prefix);
73
+ }
74
+
75
+ export function selectPwaFiles(files, pwa) {
76
+ const config = normalizePwaConfig(pwa);
77
+ const include = config?.offline.include ?? [];
78
+ const exclude = config?.offline.exclude ?? [];
79
+ return files.filter(
80
+ function selectedOfflineFile(file) {
81
+ function includedPath(selection) {
82
+ return selectedPath(file, selection);
83
+ }
84
+ function excludedPath(selection) {
85
+ return selectedPath(file, selection);
86
+ }
87
+ return (include.length === 0 || include.some(includedPath))
88
+ && !exclude.some(excludedPath);
89
+ }
90
+ );
91
+ }
92
+
93
+ function resourceUrl(basePath, file) {
94
+ const encoded = file.split('/').map(
95
+ function encodePathSegment(segment) {
96
+ return encodeURIComponent(segment);
97
+ }
98
+ ).join('/');
99
+ return `${basePath.endsWith('/') ? basePath : `${basePath}/`}${encoded}`;
100
+ }
101
+
102
+ function json(value) {
103
+ return `${JSON.stringify(value, null, 2)}\n`;
104
+ }
105
+
106
+ function htmlAttribute(value) {
107
+ return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;');
108
+ }
109
+
110
+ function manifestUrl(value, appBase) {
111
+ if (!is.string(value) || !value || value.startsWith('/')
112
+ || /^[A-Za-z][A-Za-z0-9+.-]*:/u.test(value) || /^\s/u.test(value)) {
113
+ return value;
114
+ }
115
+ const base = new URL(appBase, 'https://arcane.invalid/');
116
+ const resolved = new URL(value, base);
117
+ const relative = `${resolved.pathname}${resolved.search}${resolved.hash}`;
118
+ return appBase.startsWith('/') ? relative : `.${relative}`;
119
+ }
120
+
121
+ function manifestImages(images, appBase) {
122
+ if (!is.array(images)) return images;
123
+ return images.map(
124
+ function mapManifestImage(image) {
125
+ if (image === null || !is.object(image) || is.array(image)) return image;
126
+ if (!Object.hasOwn(image, 'src')) return {...image};
127
+ const url = manifestUrl(image.src, appBase);
128
+ return {...image, src: versionAssetUrl(url, null)};
129
+ }
130
+ );
131
+ }
132
+
133
+ function applicationManifest(value, appBase) {
134
+ const manifest = {...value};
135
+ for (const field of ['id', 'start_url', 'scope']) {
136
+ if (Object.hasOwn(manifest, field)) {
137
+ manifest[field] = manifestUrl(manifest[field], appBase);
138
+ }
139
+ }
140
+ for (const field of ['icons', 'screenshots']) {
141
+ if (Object.hasOwn(manifest, field)) {
142
+ manifest[field] = manifestImages(manifest[field], appBase);
143
+ }
144
+ }
145
+ if (is.array(manifest.shortcuts)) {
146
+ manifest.shortcuts = manifest.shortcuts.map(
147
+ function mapManifestShortcut(shortcut) {
148
+ if (shortcut === null || !is.object(shortcut) || is.array(shortcut)) return shortcut;
149
+ const mapped = {...shortcut};
150
+ if (Object.hasOwn(mapped, 'url')) {
151
+ mapped.url = manifestUrl(mapped.url, appBase);
152
+ }
153
+ if (Object.hasOwn(mapped, 'icons')) {
154
+ mapped.icons = manifestImages(mapped.icons, appBase);
155
+ }
156
+ return mapped;
157
+ }
158
+ );
159
+ }
160
+ return manifest;
161
+ }
162
+
163
+ function manifestAssets(manifest) {
164
+ const urls = [manifest.start_url];
165
+ function appendImages(images) {
166
+ if (!is.array(images)) return;
167
+ for (const image of images) {
168
+ if (is.string(image?.src)) urls.push(image.src);
169
+ }
170
+ }
171
+ appendImages(manifest.icons);
172
+ appendImages(manifest.screenshots);
173
+ if (is.array(manifest.shortcuts)) {
174
+ for (const shortcut of manifest.shortcuts) {
175
+ if (is.string(shortcut?.url)) urls.push(shortcut.url);
176
+ appendImages(shortcut?.icons);
177
+ }
178
+ }
179
+ return urls.filter(
180
+ function manifestUrlString(url) {
181
+ return is.string(url);
182
+ }
183
+ );
184
+ }
185
+
186
+ function assetPath(url) {
187
+ const resolved = new URL(url, 'https://arcane.invalid/');
188
+ return `${resolved.origin}${resolved.pathname}`;
189
+ }
190
+
191
+ export function createPwaArtifacts(
192
+ {
193
+ app,
194
+ sdkVersion,
195
+ pwa,
196
+ files = [],
197
+ assets,
198
+ basePath = './',
199
+ mode = 'release',
200
+ runtimeBase = './arcane/sdk/',
201
+ appBase,
202
+ navigationAliases,
203
+ revision
204
+ } = {}
205
+ ) {
206
+ const config = normalizePwaConfig(pwa);
207
+ if (!config?.enabled) return null;
208
+ if (mode !== 'release' && mode !== 'development') {
209
+ throw new TypeError('PWA mode must be release or development.');
210
+ }
211
+ const entryUrl = new URL(app.entry, 'https://arcane.invalid/');
212
+ const applicationBase = appBase ?? (mode === 'development'
213
+ ? new URL('./', entryUrl).pathname : './');
214
+ const manifest = applicationManifest(
215
+ {
216
+ id: applicationBase,
217
+ name: app.displayName,
218
+ short_name: app.displayName,
219
+ start_url: app.entry,
220
+ scope: applicationBase,
221
+ display: 'standalone',
222
+ ...config.manifest
223
+ },
224
+ applicationBase
225
+ );
226
+ const generatedAssets = [
227
+ PWA_MANIFEST_NAME,
228
+ PWA_OFFLINE_MANIFEST_NAME,
229
+ PWA_BOOTSTRAP_NAME
230
+ ].map(
231
+ function generatedResource(file) {
232
+ return resourceUrl(basePath, file);
233
+ }
234
+ );
235
+ const selectedFiles = selectPwaFiles(files, config);
236
+ const selectedAssets = [
237
+ ...selectedFiles.map(
238
+ function packagedResource(file) {
239
+ return resourceUrl(basePath, file);
240
+ }
241
+ ),
242
+ ...(assets ?? []),
243
+ app.entry
244
+ ];
245
+ const selectedPaths = new Set(
246
+ selectedAssets.map(assetPath)
247
+ );
248
+ // Manifest URLs may retain functional queries. Cache those variants only
249
+ // when their resource already belongs to the selected application inventory.
250
+ const metadataAssets = manifestAssets(manifest).filter(
251
+ function selectedManifestAsset(url) {
252
+ return selectedPaths.has(
253
+ assetPath(url)
254
+ );
255
+ }
256
+ );
257
+ const workerUrl = resourceUrl(basePath, PWA_WORKER_NAME);
258
+ const workerPath = new URL(workerUrl, 'https://arcane.invalid/').pathname;
259
+ const allAssets = new Set(
260
+ [
261
+ ...selectedAssets,
262
+ ...metadataAssets,
263
+ ...generatedAssets
264
+ ]
265
+ );
266
+ const offlineManifest = {
267
+ schemaVersion: 1,
268
+ appId: app.id,
269
+ appVersion: app.version,
270
+ sdkVersion,
271
+ revision: revision ?? (mode === 'development' ? 'development' : randomUUID()),
272
+ mode,
273
+ assets: [...allAssets].filter(
274
+ function excludeWorker(url) {
275
+ return new URL(url, 'https://arcane.invalid/').pathname !== workerPath;
276
+ }
277
+ ),
278
+ navigationAliases: navigationAliases ?? {[basePath]: app.entry}
279
+ };
280
+ const bootstrap = `import {registerPwa} from ${JSON.stringify(`${runtimeBase}pwa.mjs`)};
281
+
282
+ const controller = registerPwa(
283
+ {
284
+ workerUrl: new URL('./${PWA_WORKER_NAME}', import.meta.url).href,
285
+ scope: new URL('./', import.meta.url).href
286
+ }
287
+ );
288
+
289
+ controller.ready.catch(
290
+ function reportPwaRegistrationFailure(error) {
291
+ console.error('Arcane PWA registration failed:', error);
292
+ }
293
+ );
294
+ `;
295
+ const entryAssets = {manifest: PWA_MANIFEST_NAME, bootstrap: PWA_BOOTSTRAP_NAME};
296
+ const manifestHref = resourceUrl(basePath, entryAssets.manifest);
297
+ const bootstrapHref = resourceUrl(basePath, entryAssets.bootstrap);
298
+ return {
299
+ manifest,
300
+ offlineManifest,
301
+ files: [
302
+ {path: PWA_MANIFEST_NAME, content: json(manifest)},
303
+ {path: PWA_OFFLINE_MANIFEST_NAME, content: json(offlineManifest)},
304
+ {path: PWA_WORKER_NAME, content: createPwaWorkerScript(offlineManifest)},
305
+ {path: PWA_BOOTSTRAP_NAME, content: bootstrap}
306
+ ],
307
+ entryAssets,
308
+ entryMarkup: `<link rel="manifest" href="${htmlAttribute(manifestHref)}">\n`
309
+ + `<script type="module" async data-arcane-pwa src="${htmlAttribute(bootstrapHref)}"></script>\n`
310
+ };
311
+ }
@@ -136,6 +136,7 @@ const browserAdapter={
136
136
  const release=await packageApp({
137
137
  workspaceRoot,
138
138
  appId,
139
+ browserPwa:true,
139
140
  dryRun,
140
141
  signal,
141
142
  onEvent,
package/src/toolchain.mjs CHANGED
@@ -863,6 +863,7 @@ async function packageNativeRelease(prepared,app,{
863
863
  const release=await packageApp({
864
864
  workspaceRoot:prepared.workspaceRoot,
865
865
  appId:app.appId,
866
+ browserPwa:false,
866
867
  workspaceOperationLease,
867
868
  signal,
868
869
  onEvent