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 +32 -0
- package/NOTICE +1 -0
- package/README.md +27 -13
- package/browser-runtime/pwa-install.mjs +242 -0
- package/browser-runtime/pwa.mjs +131 -0
- package/docs/architecture.md +28 -12
- package/docs/reference/cli.md +42 -16
- package/docs/reference/inventory/package-api.json +44 -2
- package/docs/reference/inventory/runtime-components.json +29 -1
- package/docs/reference/pwa.md +252 -24
- package/docs/reference/runtime-components.md +64 -0
- package/docs/reference/sdk-api.md +158 -28
- package/package.json +3 -2
- package/runtime/arcane/components/pwa-install.html +216 -0
- package/src/app-descriptor.mjs +43 -1
- package/src/cli/main.mjs +29 -13
- package/src/dev-server.mjs +387 -136
- package/src/pwa-worker.mjs +361 -137
- package/src/pwa.mjs +11 -2
- package/src/targets/index.mjs +5 -1
- package/src/toolchain.mjs +62 -12
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.
|
|
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
|
-
|
|
30
|
-
|
|
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.
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/browser-runtime/pwa.mjs
CHANGED
|
@@ -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();
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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.
|
|
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
|