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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0
4
+
5
+ - Add the reusable `pwa-install.html` component and shared browser installation
6
+ lifecycle through `arcane-os/pwa`. Offer a compact themed Install action and
7
+ explicit Close, retain session dismissal, support inline placement, and surface
8
+ native prompt errors without claiming installation completion.
9
+ - Mount the component from generated PWA bootstraps independently of service
10
+ worker registration and application rendering. Use native installation and
11
+ display-mode events without polling, automatic native prompts, or focus capture.
12
+ - Return cached resources immediately while conditional background refreshes
13
+ are pending or in flight, so page requests do not wait on network revalidation.
14
+ - Document browser icon eligibility and the separate responsibilities of the
15
+ Web App Manifest, application file selection, and offline resource inventory.
16
+
17
+ ## 0.12.0
18
+
19
+ - Refresh the selected authored app descriptor's package projection before
20
+ development startup, so file and PWA configuration changes take effect through
21
+ the ordinary dev command without packaging.
22
+ - Use published `node-http-server` 9.1.1 for development and packaged-preview HTTPS
23
+ serving, preserving source mounts and generated resource transformations.
24
+ Supply modification dates for conditional GET and HEAD requests.
25
+ Enforce HTTPS for every Arcane app, including localhost and packaged browser
26
+ previews, using the configured workspace certificate pair or public TLS options.
27
+ Redirect the paired HTTP listener with status 308 while preserving the request
28
+ path and query; select its port with `httpPort` or CLI `--http-port`.
29
+ - Keep complete PWA resource responses across app and SDK version changes.
30
+ Check on page load after 120 seconds in development or 15 minutes otherwise,
31
+ with one DBOPFS timestamp updated only after the whole check succeeds. Reuse
32
+ cached responses on `304`, replace them after a successful current response, and retain offline
33
+ copies on network failures. No SDK cache expiration or polling is added.
34
+
3
35
  ## 0.11.3
4
36
 
5
37
  - Require Wllama's model-context load result to report success before publishing
package/NOTICE CHANGED
@@ -8,6 +8,7 @@ informational notice and is not itself a grant of commercial rights.
8
8
  Third-party material retains its own terms:
9
9
 
10
10
  - event-pubsub 6.1.0: MIT License; see its installed `licence` file.
11
+ - node-http-server 9.1.1: MIT License; see its installed `licence` file.
11
12
  - strong-type 2.0.1: MIT License; see its installed `licence` file.
12
13
  - vanilla-test 2.1.3: MIT License; see its installed `licence` file.
13
14
  - ansi-colors-es6 5.0.0: MIT License; see its installed `LICENSE` file.
package/README.md CHANGED
@@ -19,15 +19,20 @@ version-locked SDK runtime, while an integrated Arcane checkout uses its live
19
19
  `arcane/` runtime. Both profiles preserve the same app URLs, theme, packaging,
20
20
  event, cancellation, and browser run contracts.
21
21
 
22
- This checkout defines the `0.11.3` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.13.0` SDK contract. Applications pin one exact npm
23
23
  version and lockfile; registry state is deliberately not baked into application
24
24
  artifacts.
25
25
 
26
26
  Applications can enable [PWA installation and offline resources](docs/reference/pwa.md)
27
27
  through their app descriptor. The SDK generates manifests, an independent
28
28
  registration module and a service worker, with clean browser resource URLs,
29
- live-source revalidation and selected release caches. Apps retain ownership of
30
- branding, offline page selection and network-dependent product behavior.
29
+ page-load revalidation and persistent resource caches. The SDK records one
30
+ completed-check timestamp per app/cache in DBOPFS and checks after 120 seconds
31
+ in development or 15 minutes in packaged browser delivery. Apps retain ownership
32
+ of branding, offline page selection and network-dependent product behavior.
33
+ Enabled browser delivery also mounts the shared, themed installation suggestion.
34
+ It appears when the browser offers installation, includes Install and Close,
35
+ and remembers dismissal for the tab session without interrupting page startup.
31
36
 
32
37
  That registry query is a maintainer action, not an application behavior. Apps
33
38
  never poll npm for SDK updates or replace their own SDK or synchronized runtime.
@@ -41,9 +46,15 @@ Create one browser application, install its pinned SDK, and start its source
41
46
  server:
42
47
 
43
48
  ```bash
44
- npx arcane-os@0.11.3 new hello-speech --path ./hello-speech --target browser
49
+ npx arcane-os@0.12.0 new hello-speech --path ./hello-speech --target browser
45
50
  cd hello-speech
46
51
  npm install
52
+ ```
53
+
54
+ Configure the workspace's HTTPS certificate pair as described in
55
+ [development HTTPS setup](docs/reference/cli.md#development-https-setup), then start:
56
+
57
+ ```bash
47
58
  npm run dev
48
59
  ```
49
60
 
@@ -62,9 +73,12 @@ each device. Keep these local files ignored by Git. The same certificate pair
62
73
  works for any selected app in that workspace. The command reports missing TLS
63
74
  files instead of starting an HTTP listener.
64
75
 
65
- Plain `npm run dev` keeps its HTTP localhost default. An explicit `--host`
66
- overrides the public bind address, and `--port` selects the port. Use `--https`
67
- for HTTPS on localhost, or `--cert <file> --key <file>` for an explicit PEM pair;
76
+ Every Arcane app uses HTTPS for development and packaged browser previews.
77
+ Plain `npm run dev` binds to localhost with the same workspace certificate pair.
78
+ An explicit `--host` overrides the bind address, and `--port` selects the HTTPS port.
79
+ The paired HTTP listener returns 308 redirects and uses an OS-assigned port
80
+ unless selected with `--http-port`. The command prints its redirect URL.
81
+ Use `--cert <file> --key <file>` for an explicit PEM pair;
68
82
  relative paths resolve from the workspace. The server prints HTTPS network URLs
69
83
  for public mode. Network reachability depends on the machine's firewall and
70
84
  network. See the [development HTTPS setup](docs/reference/cli.md#development-https-setup)
@@ -392,7 +406,7 @@ uses the same controller for automatic memory extraction.
392
406
  Create a new repository-shaped Arcane application with the exact stable SDK:
393
407
 
394
408
  ```bash
395
- npx arcane-os@0.11.3 new my-app --path ./my-app --target portable --git
409
+ npx arcane-os@0.12.0 new my-app --path ./my-app --target portable --git
396
410
  cd my-app
397
411
  npm install
398
412
  npm run dev
@@ -402,7 +416,7 @@ To enroll an existing repository, install the exact SDK and initialize only
402
416
  missing Arcane files:
403
417
 
404
418
  ```bash
405
- npm install --save-dev --save-exact arcane-os@0.11.3
419
+ npm install --save-dev --save-exact arcane-os@0.12.0
406
420
  npm exec -- arcane init my-app --target portable
407
421
  ```
408
422
 
@@ -418,7 +432,7 @@ npm exec -- arcane-os targets
418
432
  No global SDK install or standalone Arcane CLI is required. The application
419
433
  repository's exact npm dependency and lockfile own the CLI and toolchain version.
420
434
 
421
- Use `npx arcane-os@0.11.3` for the initial bootstrap because it names this npm
435
+ Use `npx arcane-os@0.12.0` for the initial bootstrap because it names this npm
422
436
  package explicitly; bare `npx arcane` outside an installed project could resolve
423
437
  a different package. Both installed commands invoke the same headless toolchain.
424
438
  Project-local npm scripts use the SDK pinned by that app's `package-lock.json`,
@@ -438,7 +452,7 @@ node ./bin/arcane.mjs new local-app --path ../local-app --target portable --git
438
452
 
439
453
  # From the generated app repository
440
454
  cd ../local-app
441
- npm install --save-dev --save-exact ../arcane-os-sdk/arcane-os-0.11.3.tgz
455
+ npm install --save-dev --save-exact ../arcane-os-sdk/arcane-os-0.12.0.tgz
442
456
  npm ci
443
457
  ```
444
458
 
@@ -447,7 +461,7 @@ same location. The lockfile retains the selected package dependency while
447
461
  Arcane uses the installed package name and version. Local directory `file:` dependencies are not
448
462
  accepted because npm may install them as links; use a packed `.tgz`. A GitHub
449
463
  runner also needs that tarball at the locked path. After publication, replace
450
- the local declaration with the exact `arcane-os@0.11.3` registry package and
464
+ the local declaration with the exact `arcane-os@0.12.0` registry package and
451
465
  commit the regenerated lock.
452
466
 
453
467
  Generated repositories use `npm ci --ignore-scripts` in CI. Run dependency
@@ -586,7 +600,7 @@ package installation, or assertions.
586
600
 
587
601
  ## Current target support
588
602
 
589
- Version `0.11.3` exposes one browser target and five explicitly paired
603
+ Version `0.12.0` exposes one browser target and five explicitly paired
590
604
  native development targets: a non-runnable portable directory, a
591
605
  Windows x64 unsigned-local-test EXE bundle, Linux x64 and Linux ARM64
592
606
  unsigned-local-test DEBs, and an Android development-signed APK. The
@@ -0,0 +1,242 @@
1
+ import Is from './dependencies/strong-type/index.js';
2
+ import {createArcaneEventSource} from './event-manager.mjs';
3
+
4
+ const is = new Is(false);
5
+ export const PWA_INSTALL_STATE_EVENT = 'arcane.pwa.install.state';
6
+ let sharedOwner = null;
7
+ let mountedPrompt = null;
8
+
9
+ /** Capture native installation availability once per page, before loading UI. */
10
+ export function getPwaInstall() {
11
+ if (sharedOwner && sharedOwner.state.status !== 'disposed') {
12
+ return sharedOwner;
13
+ }
14
+ const owner = {get state() { return snapshot(); }, subscribe, prompt, dismiss, dispose};
15
+ const source = createArcaneEventSource(owner, {
16
+ source: 'arcane.pwa.install', eventTypes: [PWA_INSTALL_STATE_EVENT]
17
+ });
18
+ const listeners = [];
19
+ const displayMode = globalThis.matchMedia?.(
20
+ '(display-mode: standalone), (display-mode: minimal-ui), '
21
+ + '(display-mode: fullscreen), (display-mode: window-controls-overlay)'
22
+ );
23
+ const manifestUrl = globalThis.document?.querySelector('link[rel~="manifest"]')?.href;
24
+ const dismissalKey = `arcane.pwa.install.dismissed:${manifestUrl ?? globalThis.location?.href ?? ''}`;
25
+ let deferredPrompt = null;
26
+ let disposed = false;
27
+ let dismissed = false;
28
+ let status = isRunningAsApp() ? 'running' : 'waiting';
29
+ let outcome = null;
30
+ let error = null;
31
+ try {
32
+ dismissed = globalThis.sessionStorage?.getItem(dismissalKey) === 'true';
33
+ } catch (storageError) {
34
+ console.warn('Arcane PWA install dismissal could not be read:', storageError);
35
+ }
36
+
37
+ function isRunningAsApp() {
38
+ return displayMode?.matches === true || globalThis.navigator?.standalone === true;
39
+ }
40
+
41
+ function snapshot() {
42
+ return {status, available: deferredPrompt !== null && !disposed,
43
+ dismissed, outcome, error};
44
+ }
45
+
46
+ function publish(nextStatus, nextError = null) {
47
+ if (disposed) return;
48
+ status = nextStatus;
49
+ error = nextError;
50
+ source.dispatch(PWA_INSTALL_STATE_EVENT, snapshot());
51
+ }
52
+
53
+ function observe(target, type, listener) {
54
+ if (!is.function(target?.addEventListener)) return;
55
+ target.addEventListener(type, listener);
56
+ listeners.push(function removeInstallListener() {
57
+ target.removeEventListener(type, listener);
58
+ });
59
+ }
60
+
61
+ function rememberDismissal() {
62
+ dismissed = true;
63
+ try {
64
+ globalThis.sessionStorage?.setItem(dismissalKey, 'true');
65
+ } catch (storageError) {
66
+ console.warn('Arcane PWA install dismissal could not be saved:', storageError);
67
+ }
68
+ }
69
+
70
+ function onBeforeInstallPrompt(event) {
71
+ if (disposed || isRunningAsApp() || status === 'installed' || status === 'accepted') return;
72
+ event.preventDefault();
73
+ deferredPrompt = event;
74
+ outcome = null;
75
+ publish('available');
76
+ }
77
+
78
+ function onInstalled() {
79
+ deferredPrompt = null;
80
+ // This event may precede Android's completion of WebAPK creation.
81
+ publish('installed');
82
+ }
83
+
84
+ function onDisplayModeChange() {
85
+ if (isRunningAsApp()) {
86
+ deferredPrompt = null;
87
+ publish('running');
88
+ } else if (status === 'running') {
89
+ publish('waiting');
90
+ }
91
+ }
92
+
93
+ function onPageHide(event) {
94
+ if (!event.persisted) dispose();
95
+ }
96
+
97
+ function subscribe(listener, {emitCurrent = true, signal} = {}) {
98
+ function forwardInstallState(event) { listener(event.detail); }
99
+ const unsubscribe = source.on(PWA_INSTALL_STATE_EVENT, forwardInstallState,
100
+ signal ? {signal} : undefined);
101
+ try {
102
+ if (emitCurrent && !signal?.aborted) listener(snapshot());
103
+ } catch (listenerError) {
104
+ unsubscribe();
105
+ throw listenerError;
106
+ }
107
+ return unsubscribe;
108
+ }
109
+
110
+ function prompt() {
111
+ if (disposed || !deferredPrompt) return Promise.resolve(null);
112
+ const event = deferredPrompt;
113
+ deferredPrompt = null;
114
+ publish('prompting');
115
+ let result;
116
+ try {
117
+ // Native user activation must reach prompt() in the same click stack.
118
+ result = event.prompt();
119
+ } catch (promptError) {
120
+ return rejectPrompt(promptError);
121
+ }
122
+ return Promise.resolve(result).then(async function receiveInstallChoice(value) {
123
+ const choice = value ?? await event.userChoice;
124
+ if (!choice || !['accepted', 'dismissed'].includes(choice.outcome)) {
125
+ throw new Error('The browser did not return an installation choice.');
126
+ }
127
+ if (!disposed) {
128
+ outcome = choice.outcome;
129
+ if (status === 'installed' || status === 'running') {
130
+ publish(status);
131
+ } else {
132
+ if (outcome === 'dismissed') rememberDismissal();
133
+ publish(outcome);
134
+ }
135
+ }
136
+ return choice;
137
+ }).catch(rejectPrompt);
138
+ }
139
+
140
+ function rejectPrompt(promptError) {
141
+ if (status !== 'installed' && status !== 'running') publish('error', promptError);
142
+ return Promise.reject(promptError);
143
+ }
144
+
145
+ function dismiss() {
146
+ if (disposed) return snapshot();
147
+ rememberDismissal();
148
+ publish(status, error);
149
+ return snapshot();
150
+ }
151
+
152
+ function dispose() {
153
+ if (disposed) return;
154
+ deferredPrompt = null;
155
+ publish('disposed');
156
+ disposed = true;
157
+ for (const removeListener of listeners) removeListener();
158
+ listeners.length = 0;
159
+ source.dispose();
160
+ }
161
+
162
+ observe(globalThis, 'beforeinstallprompt', onBeforeInstallPrompt);
163
+ observe(globalThis, 'appinstalled', onInstalled);
164
+ observe(displayMode, 'change', onDisplayModeChange);
165
+ observe(globalThis, 'pagehide', onPageHide);
166
+ sharedOwner = owner;
167
+ return owner;
168
+ }
169
+
170
+ /** Mount one shared, initially hidden install component without delaying the app. */
171
+ export function mountPwaInstallPrompt({appName = ''} = {}) {
172
+ const owner = getPwaInstall();
173
+ if (mountedPrompt) return mountedPrompt;
174
+ mountedPrompt = mountComponent().catch(function releaseFailedMount(error) {
175
+ mountedPrompt = null;
176
+ throw error;
177
+ });
178
+ return mountedPrompt;
179
+
180
+ async function mountComponent() {
181
+ if (!globalThis.document) return null;
182
+ // Both modules may start independently; saved theme loading is not a barrier.
183
+ await Promise.all([
184
+ import(new URL('../modules/HTMLImport.js', import.meta.url).href),
185
+ import(new URL('../modules/ThemeBootstrap.js', import.meta.url).href)
186
+ ]);
187
+ if (owner.state.status === 'disposed') return null;
188
+ if (!document.body) {
189
+ await new Promise(function waitForComponentParent(resolve) {
190
+ const unsubscribe = owner.subscribe(function observeParentWaitDisposal(state) {
191
+ if (state.status === 'disposed') completeParentWait();
192
+ }, {emitCurrent: false});
193
+ function completeParentWait() {
194
+ document.removeEventListener('DOMContentLoaded', completeParentWait);
195
+ unsubscribe();
196
+ resolve();
197
+ }
198
+ document.addEventListener('DOMContentLoaded', completeParentWait, {once: true});
199
+ });
200
+ }
201
+ if (owner.state.status === 'disposed') return null;
202
+ const host = document.createElement('html-import');
203
+ host.hidden = true;
204
+ host.dataset.appName = String(appName);
205
+ host.dataset.arcanePwaInstall = '';
206
+ host.setAttribute('href', new URL('../components/pwa-install.html', import.meta.url).href);
207
+ return new Promise(function waitForInstallComponent(resolve, reject) {
208
+ const observer = new MutationObserver(function observeRemovedInstallComponent() {
209
+ if (!host.isConnected) cancelMount();
210
+ });
211
+ const unsubscribe = owner.subscribe(function observeDisposedInstallOwner(state) {
212
+ if (state.status === 'disposed') cancelMount();
213
+ }, {emitCurrent: false});
214
+ function cleanup() {
215
+ observer.disconnect();
216
+ unsubscribe();
217
+ host.removeEventListener('html-import-ready', onReady);
218
+ host.removeEventListener('html-import-error', onError);
219
+ }
220
+ function cancelMount() {
221
+ cleanup();
222
+ host.remove();
223
+ const error = new Error('The PWA install component was removed before it became ready.');
224
+ error.name = 'AbortError';
225
+ reject(error);
226
+ }
227
+ function onReady() {
228
+ cleanup();
229
+ resolve(host);
230
+ }
231
+ function onError(event) {
232
+ cleanup();
233
+ host.remove();
234
+ reject(event.detail?.error ?? new Error('The PWA install component could not load.'));
235
+ }
236
+ host.addEventListener('html-import-ready', onReady);
237
+ host.addEventListener('html-import-error', onError);
238
+ document.body.append(host);
239
+ observer.observe(document.documentElement, {childList: true, subtree: true});
240
+ });
241
+ }
242
+ }
@@ -1,5 +1,7 @@
1
1
  import {createArcaneEventSource} from './event-manager.mjs';
2
2
 
3
+ export {PWA_INSTALL_STATE_EVENT, getPwaInstall, mountPwaInstallPrompt} from './pwa-install.mjs';
4
+
3
5
  export const PWA_STATE_EVENT = 'arcane.pwa.state';
4
6
 
5
7
  export function registerPwa({workerUrl = './arcane-sw.js', scope} = {}) {
@@ -24,6 +26,11 @@ export function registerPwa({workerUrl = './arcane-sw.js', scope} = {}) {
24
26
  const container = globalThis.navigator?.serviceWorker;
25
27
  const listeners = [];
26
28
  const workers = new Map();
29
+ const queriedWorkers = new WeakSet();
30
+ const refreshedWorkers = new WeakSet();
31
+ const pendingRefreshes = new Set();
32
+ const refreshTasks = new Set();
33
+ let storageTask = null;
27
34
  let registration = null;
28
35
  let disposed = false;
29
36
  let current = {
@@ -83,6 +90,13 @@ export function registerPwa({workerUrl = './arcane-sw.js', scope} = {}) {
83
90
  function onWorkerStateChange(event) {
84
91
  const previousState = workers.get(event.target);
85
92
  workers.set(event.target, event.target.state);
93
+ if (event.target.state === 'redundant') {
94
+ for (const operation of pendingRefreshes) {
95
+ if (operation.worker === event.target) {
96
+ operation.cancel();
97
+ }
98
+ }
99
+ }
86
100
  // A newer installation normally supersedes an already waiting worker.
87
101
  if (event.target.state === 'redundant'
88
102
  && previousState !== 'activated' && previousState !== 'installed') {
@@ -107,7 +121,118 @@ export function registerPwa({workerUrl = './arcane-sw.js', scope} = {}) {
107
121
  watchWorker(registration.installing);
108
122
  watchWorker(registration.waiting);
109
123
  watchWorker(registration.active);
124
+ watchWorker(container.controller);
110
125
  publish(currentStatus());
126
+ const worker = registration.active ?? container.controller;
127
+ if (worker?.state === 'activated' && !queriedWorkers.has(worker)) {
128
+ queriedWorkers.add(worker);
129
+ try {
130
+ worker.postMessage({type: 'arcane.pwa.capabilities'});
131
+ } catch (error) {
132
+ publish('error', error);
133
+ }
134
+ }
135
+ }
136
+
137
+ function startResourceRefresh(worker, cacheName) {
138
+ if (disposed || worker.state !== 'activated' || refreshedWorkers.has(worker)) {
139
+ return;
140
+ }
141
+ refreshedWorkers.add(worker);
142
+ const task = refreshCachedResources(worker, cacheName);
143
+ refreshTasks.add(task);
144
+ task.then(
145
+ function resourceRefreshComplete() {
146
+ refreshTasks.delete(task);
147
+ },
148
+ function resourceRefreshFailed(error) {
149
+ refreshTasks.delete(task);
150
+ if (error?.code !== 'ARCANE_PWA_REFRESH_CANCELLED') {
151
+ publish('error', error);
152
+ }
153
+ }
154
+ );
155
+ }
156
+
157
+ async function loadCheckStorage() {
158
+ if (!globalThis.dbopfs) {
159
+ await import('arcane/DBOPFS');
160
+ }
161
+ const storage = globalThis.dbopfs;
162
+ if (!storage) {
163
+ throw new Error('PWA check history could not open DBOPFS.');
164
+ }
165
+ await storage.readyPromise;
166
+ return storage;
167
+ }
168
+
169
+ function requestResourceRefresh(worker, lastChecked) {
170
+ const channel = new MessageChannel();
171
+ return new Promise(
172
+ function resourceRefreshReply(resolve, reject) {
173
+ function cleanup() {
174
+ pendingRefreshes.delete(operation);
175
+ channel.port1.onmessage = null;
176
+ channel.port1.onmessageerror = null;
177
+ channel.port1.close();
178
+ channel.port2.close();
179
+ }
180
+ function cancel() {
181
+ cleanup();
182
+ const error = new Error('The PWA resource refresh owner is no longer active.');
183
+ error.code = 'ARCANE_PWA_REFRESH_CANCELLED';
184
+ reject(error);
185
+ }
186
+ const operation = {worker, cancel};
187
+ pendingRefreshes.add(operation);
188
+ channel.port1.onmessage = function receiveResourceRefresh(event) {
189
+ if (event.data?.type === 'arcane.pwa.refreshed') {
190
+ cleanup();
191
+ resolve(event.data);
192
+ }
193
+ };
194
+ channel.port1.onmessageerror = function unreadableResourceRefresh() {
195
+ cleanup();
196
+ reject(new Error('The PWA resource refresh reply could not be read.'));
197
+ };
198
+ channel.port1.start();
199
+ try {
200
+ worker.postMessage({type: 'arcane.pwa.refresh', lastChecked}, [channel.port2]);
201
+ } catch (error) {
202
+ cleanup();
203
+ reject(error);
204
+ }
205
+ }
206
+ );
207
+ }
208
+
209
+ async function refreshCachedResources(worker, cacheName) {
210
+ storageTask ??= loadCheckStorage();
211
+ const storage = await storageTask;
212
+ const key = `${encodeURIComponent(cacheName)}.json`;
213
+ async function refreshStoredChecks() {
214
+ if (disposed || worker.state !== 'activated') {
215
+ return;
216
+ }
217
+ // A fresh read under the shared lock preserves checks made by another tab.
218
+ const record = await storage.get('pwa', key, true);
219
+ if (disposed || worker.state !== 'activated') {
220
+ return;
221
+ }
222
+ const result = await requestResourceRefresh(worker, record?.lastChecked ?? null);
223
+ if (!result.error && Number.isFinite(result.lastChecked) && result.lastChecked !== record?.lastChecked) {
224
+ await storage.set('pwa', key, {lastChecked: result.lastChecked});
225
+ }
226
+ if (result.error) {
227
+ publish('error', result.error);
228
+ }
229
+ }
230
+ const locks = globalThis.navigator?.locks;
231
+ if (locks?.request) {
232
+ await locks.request(`arcane-pwa-checks|${cacheName}`, refreshStoredChecks);
233
+ } else {
234
+ await refreshStoredChecks();
235
+ }
111
236
  }
112
237
 
113
238
  function onControllerChange() {
@@ -115,6 +240,9 @@ export function registerPwa({workerUrl = './arcane-sw.js', scope} = {}) {
115
240
  }
116
241
 
117
242
  function onWorkerMessage(event) {
243
+ if (event.data?.type === 'arcane.pwa.capabilities' && event.data.refresh === true && workers.has(event.source)) {
244
+ startResourceRefresh(event.source, event.data.cacheName);
245
+ }
118
246
  if (event.data?.type === 'arcane.pwa.error' && workers.has(event.source)) {
119
247
  publish('error', event.data.error);
120
248
  }
@@ -205,6 +333,9 @@ export function registerPwa({workerUrl = './arcane-sw.js', scope} = {}) {
205
333
  for (const removeListener of listeners) {
206
334
  removeListener();
207
335
  }
336
+ for (const operation of pendingRefreshes) {
337
+ operation.cancel();
338
+ }
208
339
  listeners.length = 0;
209
340
  workers.clear();
210
341
  source.dispose();
@@ -110,21 +110,37 @@ behavior or claiming that the capability exists.
110
110
 
111
111
  Rapid development uses `arcane dev`. The development server maps the selected
112
112
  application's canonical source tree and the live installed SDK/runtime routes.
113
- Each request reads and returns the complete current saved source, so a browser
114
- refresh shows source changes without packaging, copying
115
- files into `dist`, or restarting the server. Restarting is not a content
116
- synchronization step; when a refresh is stale, first verify the command, URL,
117
- workspace, selected app, and resolved source route.
118
-
119
- The shared dev server owns HTTP and HTTPS transport for the same selected
120
- routes. `arcane dev --public` selects HTTPS on the IPv4 wildcard address;
121
- explicit `--host` controls the bind address, while `--https` selects HTTPS
122
- without changing it. Public/HTTPS CLI startup reads one workspace-local PEM
123
- pair before binding. The certificate covers the device-facing address, and
113
+ At startup, the SDK refreshes only the selected authored app descriptor's
114
+ schema-1 package projection and managed import maps under the existing
115
+ development-refresh lock, then releases the lock before binding the listener.
116
+ The authored descriptor remains unchanged, and package-only apps retain their
117
+ existing path. An enabled PWA receives generated manifests directly from this
118
+ source server without creating `dist` output.
119
+ Changed resource requests return the complete current saved source without
120
+ packaging, copying files into `dist`, or restarting the server. Conditional
121
+ requests for unchanged resources return `304`. Enabled PWAs check on page load
122
+ when their single DBOPFS `lastChecked` value is older than 120 seconds in
123
+ development or 15 minutes in packaged browser delivery. Cached resource bodies have no SDK
124
+ expiration. The timestamp advances only after the whole resource check succeeds.
125
+ Restarting is not a content synchronization step; inspect the
126
+ selected source route and the last successful check when evaluating freshness.
127
+
128
+ The shared dev server uses RIAEvangelist's `node-http-server` public interface
129
+ for HTTPS and conditional responses on those selected routes. The SDK
130
+ owns source selection and generated representations. Every Arcane development
131
+ server and packaged browser preview serves content on HTTPS and redirects its
132
+ paired HTTP listener with status 308 through the public request hook.
133
+ `arcane dev --public` selects the IPv4 wildcard address;
134
+ explicit `--host` controls the bind address. CLI startup reads one workspace-local
135
+ PEM pair before binding, including ordinary localhost startup.
136
+ The certificate covers the device-facing address, and
124
137
  each client trusts its issuing CA through that platform's certificate setup.
125
138
  This supplies the secure origin required by OPFS/DBOPFS on LAN devices. The
126
139
  server does not install trust, generate certificates, or expose private TLS
127
- material through CLI events. Ordinary localhost development remains HTTP.
140
+ material through CLI events. `port` selects HTTPS; `httpPort` selects the HTTP
141
+ redirect listener, defaulting to an OS-assigned port. Existing raw `tls` inputs
142
+ retain their native HTTPS transport under the SDK, as described by the module's
143
+ advanced TLS extension guidance; all content uses its public serving methods.
128
144
 
129
145
  Development is an intentionally fast feedback loop. Keep each increment small
130
146
  and independently understandable so its effect has one clear cause and a