@inkandswitch/patchwork-bootloader 0.3.0 → 0.3.1
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 +14 -0
- package/dist/automerge-worker.js +124 -8
- package/dist/externals.js +1 -2
- package/dist/module-loader-worker.d.ts +1 -0
- package/dist/module-loader-worker.js +55 -0
- package/dist/module-loader.d.ts +13 -0
- package/dist/module-loader.js +72 -0
- package/dist/service-worker.js +58 -6
- package/dist/setup.d.ts +2 -0
- package/dist/setup.js +134 -2
- package/dist/site.d.ts +1 -6
- package/dist/site.js +94 -42
- package/dist/types.d.ts +8 -0
- package/dist/types.js +6 -0
- package/dist/vite/service-worker-plugin.js +4 -0
- package/package.json +18 -14
- package/src/automerge-worker.ts +135 -9
- package/src/externals.ts +1 -2
- package/src/module-loader-worker.ts +68 -0
- package/src/module-loader.ts +85 -0
- package/src/service-worker.ts +78 -6
- package/src/setup.ts +141 -7
- package/src/site.ts +116 -62
- package/src/types.ts +9 -0
- package/src/vite/service-worker-plugin.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @inkandswitch/patchwork-bootloader
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 099e931: Discover a package's plugin descriptors in a dedicated module worker off the
|
|
8
|
+
main thread, then re-import the package (pinned to the same heads) on the main
|
|
9
|
+
thread to run each plugin's real loader. Adds
|
|
10
|
+
`importPluginFromFolderDocUrl(folderDocUrl, pluginType, pluginId)`, which selects
|
|
11
|
+
the plugin by both its `type` and `id` — a plugin `id` is only unique within a
|
|
12
|
+
plugin type, so a package may export e.g. a `patchwork:datatype` and a
|
|
13
|
+
`patchwork:tool` that share the same id.
|
|
14
|
+
- Updated dependencies [099e931]
|
|
15
|
+
- @inkandswitch/patchwork-filesystem@0.1.1
|
|
16
|
+
|
|
3
17
|
## 0.2.8
|
|
4
18
|
|
|
5
19
|
### Patch Changes
|
package/dist/automerge-worker.js
CHANGED
|
@@ -20,12 +20,99 @@ import { WebCryptoSigner } from "@automerge/automerge-subduction/slim";
|
|
|
20
20
|
import { Repo, isValidAutomergeUrl, parseAutomergeUrl, stringifyAutomergeUrl, } from "@automerge/automerge-repo/slim";
|
|
21
21
|
import { resolvePath } from "@inkandswitch/patchwork-filesystem";
|
|
22
22
|
// Small adapters — bundled directly into the worker
|
|
23
|
-
import {
|
|
23
|
+
import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
|
|
24
24
|
import { MessageChannelNetworkAdapter } from "@automerge/automerge-repo-network-messagechannel";
|
|
25
|
-
import {
|
|
25
|
+
import { WebSocketWorkerClientAdapter } from "@automerge/automerge-repo-network-websocket";
|
|
26
26
|
import { initializeAutomergeRepoKeyhiveRustWithRepo, initKeyhiveWasm, } from "@automerge/automerge-repo-keyhive";
|
|
27
27
|
import { HANDOFF_CHANNEL, } from "./types.js";
|
|
28
28
|
let debugging = false;
|
|
29
|
+
// Per-boot identity so a tab can detect a worker *restart*: a fresh instance
|
|
30
|
+
// means a new repo peerId + cold in-memory state, so the tab's docs must be
|
|
31
|
+
// re-subscribed. Sent in `hello` (on connect) and every `pong`.
|
|
32
|
+
const WORKER_INSTANCE_ID = Math.random().toString(36).slice(2);
|
|
33
|
+
const WORKER_BOOT_TIME = Date.now();
|
|
34
|
+
// ── Forward console output + uncaught errors to the main thread ─────────
|
|
35
|
+
// The SharedWorker has its own console that's a pain to find (chrome://inspect
|
|
36
|
+
// → shared workers). Patch console.* and the global error handlers to also
|
|
37
|
+
// post back over every connected tab's control port, tagged [automerge-worker].
|
|
38
|
+
const controlPorts = new Set();
|
|
39
|
+
// Logs emitted before any tab has connected (e.g. during wasm boot) would
|
|
40
|
+
// otherwise be lost — buffer a bounded number and flush on first connect.
|
|
41
|
+
const preConnectBuffer = [];
|
|
42
|
+
const MAX_BUFFER = 200;
|
|
43
|
+
function serializeArg(arg) {
|
|
44
|
+
if (typeof arg === "string")
|
|
45
|
+
return arg;
|
|
46
|
+
if (arg instanceof Error)
|
|
47
|
+
return arg.stack || `${arg.name}: ${arg.message}`;
|
|
48
|
+
try {
|
|
49
|
+
return JSON.stringify(arg);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return String(arg);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function forwardToMainThread(level, rawArgs) {
|
|
56
|
+
const args = rawArgs.map(serializeArg);
|
|
57
|
+
if (!controlPorts.size) {
|
|
58
|
+
if (preConnectBuffer.length < MAX_BUFFER) {
|
|
59
|
+
preConnectBuffer.push({ level, args });
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
for (const port of controlPorts) {
|
|
64
|
+
try {
|
|
65
|
+
port.postMessage({ type: "console", level, args });
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Port may be closing — ignore.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const level of ["log", "info", "warn", "error", "debug"]) {
|
|
73
|
+
const original = console[level].bind(console);
|
|
74
|
+
console[level] = (...args) => {
|
|
75
|
+
original(...args);
|
|
76
|
+
forwardToMainThread(level, args);
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
self.addEventListener("error", (event) => {
|
|
80
|
+
const e = event;
|
|
81
|
+
forwardToMainThread("error", [
|
|
82
|
+
`uncaught error: ${e.message}`,
|
|
83
|
+
e.error instanceof Error ? e.error.stack : undefined,
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
self.addEventListener("unhandledrejection", (event) => {
|
|
87
|
+
const reason = event.reason;
|
|
88
|
+
forwardToMainThread("error", [
|
|
89
|
+
"unhandled rejection:",
|
|
90
|
+
reason instanceof Error ? reason.stack || reason.message : reason,
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
// Boot marker, buffered until the first tab connects. A new instance id means
|
|
94
|
+
// the worker restarted (fresh peerId + cold state).
|
|
95
|
+
console.warn(`[lifecycle] ${new Date(WORKER_BOOT_TIME).toISOString()} automerge ` +
|
|
96
|
+
`SharedWorker started (instance ${WORKER_INSTANCE_ID})`);
|
|
97
|
+
// ── Suspension watchdog ─────────────────────────────────────────────────
|
|
98
|
+
// A SharedWorker gets no lifecycle events, so infer freeze/suspend from timer
|
|
99
|
+
// drift. A large gap means keepalive pongs stalled and the server may have
|
|
100
|
+
// reaped us.
|
|
101
|
+
const WATCHDOG_TICK_MS = 5_000;
|
|
102
|
+
const WATCHDOG_GAP_FACTOR = 2;
|
|
103
|
+
let watchdogLast = Date.now();
|
|
104
|
+
setInterval(() => {
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
const gap = now - watchdogLast;
|
|
107
|
+
watchdogLast = now;
|
|
108
|
+
if (gap > WATCHDOG_TICK_MS * WATCHDOG_GAP_FACTOR) {
|
|
109
|
+
console.warn(`[lifecycle] worker resumed after ~${Math.round(gap / 1000)}s gap ` +
|
|
110
|
+
`(timer expected every ${WATCHDOG_TICK_MS / 1000}s) — likely ` +
|
|
111
|
+
`suspended/frozen/throttled; WebSocket keepalive pongs were not sent ` +
|
|
112
|
+
`during this window, so the sync server may have reaped us. at ` +
|
|
113
|
+
`${new Date(now).toISOString()}`);
|
|
114
|
+
}
|
|
115
|
+
}, WATCHDOG_TICK_MS);
|
|
29
116
|
// Sync server selection. Sub is the default. Build with KEYHIVE_SYNC_SERVER=true
|
|
30
117
|
// to target keyhive.sync.automerge.org.
|
|
31
118
|
const useKeyhiveSyncServer = typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
|
|
@@ -61,7 +148,7 @@ async function connectClassicSyncNetwork(server) {
|
|
|
61
148
|
classicSyncConnectPromise = (async () => {
|
|
62
149
|
const { repo } = await getRepoHive();
|
|
63
150
|
if (!classicSyncAdapter) {
|
|
64
|
-
classicSyncAdapter = new
|
|
151
|
+
classicSyncAdapter = new WebSocketWorkerClientAdapter(url);
|
|
65
152
|
repo.networkSubsystem.addNetworkAdapter(classicSyncAdapter);
|
|
66
153
|
}
|
|
67
154
|
await classicSyncAdapter.whenReady();
|
|
@@ -99,7 +186,7 @@ function getRepoHive() {
|
|
|
99
186
|
if (!useKeyhive) {
|
|
100
187
|
const signer = await WebCryptoSigner.setup();
|
|
101
188
|
const repo = new Repo({
|
|
102
|
-
storage: new
|
|
189
|
+
storage: new IndexedDBWorkerStorageAdapter(),
|
|
103
190
|
signer,
|
|
104
191
|
peerId: ("automerge-worker-" +
|
|
105
192
|
Math.random()
|
|
@@ -122,7 +209,7 @@ function getRepoHive() {
|
|
|
122
209
|
// ARK variant for talking to the keyhive-enabled subduction sync server.
|
|
123
210
|
const { hive, repo } = await initializeAutomergeRepoKeyhiveRustWithRepo({
|
|
124
211
|
createRepo: (config) => new Repo(config),
|
|
125
|
-
storage: new
|
|
212
|
+
storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
|
|
126
213
|
peerIdSuffix: `${siteName}-worker` + Math.random().toString(36).slice(2),
|
|
127
214
|
automaticArchiveIngestion: true,
|
|
128
215
|
cachingMode: "periodic",
|
|
@@ -131,7 +218,7 @@ function getRepoHive() {
|
|
|
131
218
|
// defaults to "subduction".
|
|
132
219
|
...(useKeyhiveSyncServer ? { syncServer: "keyhive" } : {}),
|
|
133
220
|
repo: {
|
|
134
|
-
storage: new
|
|
221
|
+
storage: new IndexedDBWorkerStorageAdapter(),
|
|
135
222
|
subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
|
|
136
223
|
enableRemoteHeadsGossiping: true,
|
|
137
224
|
},
|
|
@@ -266,6 +353,14 @@ function handleControlMessage(event, controlPort, connection) {
|
|
|
266
353
|
replyPort?.close();
|
|
267
354
|
});
|
|
268
355
|
}
|
|
356
|
+
else if (data?.type === "ping") {
|
|
357
|
+
// Heartbeat: reply so the tab can detect our death or restart.
|
|
358
|
+
controlPort.postMessage({
|
|
359
|
+
type: "pong",
|
|
360
|
+
id: data.id,
|
|
361
|
+
instanceId: WORKER_INSTANCE_ID,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
269
364
|
}
|
|
270
365
|
self.addEventListener("connect", (event) => {
|
|
271
366
|
const controlPort = event.ports[0];
|
|
@@ -276,9 +371,30 @@ self.addEventListener("connect", (event) => {
|
|
|
276
371
|
// Fires when the owning page is destroyed. Browsers without the close
|
|
277
372
|
// event fall back to the adapters' lazy useWeakRef cleanup.
|
|
278
373
|
controlPort.addEventListener("close", () => {
|
|
374
|
+
controlPorts.delete(controlPort);
|
|
279
375
|
void dropConnection(connection);
|
|
280
376
|
});
|
|
281
377
|
controlPort.start();
|
|
378
|
+
// Greet the tab with our per-boot instance id so it can detect a restart
|
|
379
|
+
// (a different id than last seen) even if no port "close" fired.
|
|
380
|
+
controlPort.postMessage({
|
|
381
|
+
type: "hello",
|
|
382
|
+
instanceId: WORKER_INSTANCE_ID,
|
|
383
|
+
bootTime: WORKER_BOOT_TIME,
|
|
384
|
+
});
|
|
385
|
+
// Start forwarding console output to this tab, and flush anything buffered
|
|
386
|
+
// while no tab was connected (e.g. boot-time logs) to the first arrival.
|
|
387
|
+
controlPorts.add(controlPort);
|
|
388
|
+
if (preConnectBuffer.length) {
|
|
389
|
+
for (const { level, args } of preConnectBuffer.splice(0)) {
|
|
390
|
+
try {
|
|
391
|
+
controlPort.postMessage({ type: "console", level, args });
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
// Port may already be gone — ignore.
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
282
398
|
});
|
|
283
399
|
// ── Automerge URL resolution ───────────────────────────────────────────
|
|
284
400
|
/**
|
|
@@ -422,7 +538,7 @@ async function handleHandoffRequest(message) {
|
|
|
422
538
|
id,
|
|
423
539
|
type: "response",
|
|
424
540
|
response: {
|
|
425
|
-
status:
|
|
541
|
+
status: 557,
|
|
426
542
|
body,
|
|
427
543
|
headers: { "content-type": "text/plain" },
|
|
428
544
|
},
|
|
@@ -469,7 +585,7 @@ async function handleHandoffRequest(message) {
|
|
|
469
585
|
id,
|
|
470
586
|
type: "response",
|
|
471
587
|
response: {
|
|
472
|
-
status:
|
|
588
|
+
status: 558,
|
|
473
589
|
body: String(error),
|
|
474
590
|
headers: { "content-type": "text/plain" },
|
|
475
591
|
},
|
package/dist/externals.js
CHANGED
|
@@ -7,10 +7,9 @@ const externals = [
|
|
|
7
7
|
"@automerge/automerge-repo",
|
|
8
8
|
"@automerge/automerge-repo/slim",
|
|
9
9
|
"@automerge/automerge-repo-network-messagechannel",
|
|
10
|
+
"@automerge/automerge-repo-network-websocket",
|
|
10
11
|
"@automerge/automerge-repo-storage-indexeddb",
|
|
11
12
|
"@automerge/automerge-repo-keyhive",
|
|
12
|
-
"@automerge/automerge-repo-network-messagechannel",
|
|
13
|
-
"@automerge/automerge-repo-storage-indexeddb",
|
|
14
13
|
"@automerge/automerge-subduction",
|
|
15
14
|
"@automerge/automerge-subduction/slim",
|
|
16
15
|
"@keyhive/keyhive",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Dedicated module worker for plugin-descriptor discovery.
|
|
2
|
+
//
|
|
3
|
+
// A module-settings doc lists Automerge folder-doc packages. To register the
|
|
4
|
+
// plugins a package provides we only need their *descriptions* (id, type,
|
|
5
|
+
// name, icon…), not their implementations. This worker imports a package's
|
|
6
|
+
// entry point off the main thread purely to read its exported `plugins` array,
|
|
7
|
+
// strips the non-cloneable `load()` / `import` machinery, and posts the plain
|
|
8
|
+
// descriptors back. The main thread re-imports the package (at the same heads)
|
|
9
|
+
// only when a plugin is actually loaded — see `importPluginFromFolderDocUrl`.
|
|
10
|
+
//
|
|
11
|
+
// Created with type:"module"; its dynamic `import()` of `/<automergeUrl>/…`
|
|
12
|
+
// entry points is served by the service worker that controls this worker.
|
|
13
|
+
import { importModuleFromFolderDocUrl } from "@inkandswitch/patchwork-filesystem";
|
|
14
|
+
// Keep only the structured-cloneable description fields. `load` is a closure
|
|
15
|
+
// and `module` is the (possibly already-loaded) implementation — neither can
|
|
16
|
+
// cross the worker boundary. `import` is droppable too: the main thread
|
|
17
|
+
// rebuilds loading by re-importing the package and calling the live plugin.
|
|
18
|
+
function toDescriptor(plugin) {
|
|
19
|
+
if (!plugin || typeof plugin !== "object")
|
|
20
|
+
return {};
|
|
21
|
+
const { load, import: _import, module, ...description } = plugin;
|
|
22
|
+
return description;
|
|
23
|
+
}
|
|
24
|
+
function isDiscoverRequest(data) {
|
|
25
|
+
return (typeof data === "object" &&
|
|
26
|
+
data !== null &&
|
|
27
|
+
data.type === "discover" &&
|
|
28
|
+
typeof data.id === "number" &&
|
|
29
|
+
typeof data.url === "string");
|
|
30
|
+
}
|
|
31
|
+
self.addEventListener("message", (event) => {
|
|
32
|
+
const data = event.data;
|
|
33
|
+
if (!isDiscoverRequest(data))
|
|
34
|
+
return;
|
|
35
|
+
const { id, url } = data;
|
|
36
|
+
importModuleFromFolderDocUrl(url)
|
|
37
|
+
.then((mod) => {
|
|
38
|
+
const plugins = Array.isArray(mod?.plugins) ? mod.plugins : [];
|
|
39
|
+
const descriptors = plugins.map(toDescriptor);
|
|
40
|
+
self.postMessage({
|
|
41
|
+
type: "descriptors",
|
|
42
|
+
id,
|
|
43
|
+
descriptors,
|
|
44
|
+
});
|
|
45
|
+
})
|
|
46
|
+
.catch((error) => {
|
|
47
|
+
self.postMessage({
|
|
48
|
+
type: "error",
|
|
49
|
+
id,
|
|
50
|
+
error: error instanceof Error
|
|
51
|
+
? (error.stack ?? error.message)
|
|
52
|
+
: String(error),
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
type Descriptor = Record<string, unknown> & {
|
|
2
|
+
id?: string;
|
|
3
|
+
type?: string;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* ModuleWatcher `importAutomergeModule` hook: discover descriptors in the
|
|
7
|
+
* worker, then return the `{ plugins }` shape with a main-thread `load()` per
|
|
8
|
+
* plugin that imports the package at heads and calls its real loader.
|
|
9
|
+
*/
|
|
10
|
+
export declare function importAutomergeModuleViaWorker(urlAtHeads: string): Promise<{
|
|
11
|
+
plugins: Descriptor[];
|
|
12
|
+
}>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Main-thread client for the module-loader worker (see module-loader-worker.ts).
|
|
2
|
+
//
|
|
3
|
+
// `importAutomergeModuleViaWorker` is wired into the ModuleWatcher in place of
|
|
4
|
+
// its default (direct, main-thread) package import. It asks the worker to
|
|
5
|
+
// import the package entry point and report which plugins it exports, then
|
|
6
|
+
// returns the same `{ plugins }` shape the watcher already feeds to
|
|
7
|
+
// `registerPlugins` — except each plugin's `load()` re-imports the package
|
|
8
|
+
// (pinned to the same heads) on this thread and runs the real plugin loader.
|
|
9
|
+
import { importPluginFromFolderDocUrl } from "@inkandswitch/patchwork-filesystem";
|
|
10
|
+
const WORKER_PATH = "/module-loader-worker.js";
|
|
11
|
+
let worker;
|
|
12
|
+
let nextRequestId = 1;
|
|
13
|
+
const pending = new Map();
|
|
14
|
+
function getWorker() {
|
|
15
|
+
if (worker)
|
|
16
|
+
return worker;
|
|
17
|
+
worker = new Worker(WORKER_PATH, {
|
|
18
|
+
type: "module",
|
|
19
|
+
name: "patchwork-module-loader",
|
|
20
|
+
});
|
|
21
|
+
worker.addEventListener("message", (event) => {
|
|
22
|
+
const data = event.data;
|
|
23
|
+
if (!data || (data.type !== "descriptors" && data.type !== "error"))
|
|
24
|
+
return;
|
|
25
|
+
const entry = pending.get(data.id);
|
|
26
|
+
if (!entry)
|
|
27
|
+
return;
|
|
28
|
+
pending.delete(data.id);
|
|
29
|
+
if (data.type === "descriptors")
|
|
30
|
+
entry.resolve(data.descriptors);
|
|
31
|
+
else
|
|
32
|
+
entry.reject(new Error(data.error));
|
|
33
|
+
});
|
|
34
|
+
worker.addEventListener("error", (event) => {
|
|
35
|
+
// An uncaught worker error can't be tied to a single request — fail every
|
|
36
|
+
// outstanding one so callers don't hang.
|
|
37
|
+
const error = new Error(`module-loader worker error: ${event.message ?? "unknown"}`);
|
|
38
|
+
for (const [, entry] of pending)
|
|
39
|
+
entry.reject(error);
|
|
40
|
+
pending.clear();
|
|
41
|
+
});
|
|
42
|
+
return worker;
|
|
43
|
+
}
|
|
44
|
+
/** Ask the worker which plugins the package at `urlAtHeads` exports. */
|
|
45
|
+
function discoverDescriptors(urlAtHeads) {
|
|
46
|
+
const id = nextRequestId++;
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
pending.set(id, { resolve, reject });
|
|
49
|
+
getWorker().postMessage({ type: "discover", id, url: urlAtHeads });
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* ModuleWatcher `importAutomergeModule` hook: discover descriptors in the
|
|
54
|
+
* worker, then return the `{ plugins }` shape with a main-thread `load()` per
|
|
55
|
+
* plugin that imports the package at heads and calls its real loader.
|
|
56
|
+
*/
|
|
57
|
+
export async function importAutomergeModuleViaWorker(urlAtHeads) {
|
|
58
|
+
const url = urlAtHeads;
|
|
59
|
+
const descriptors = await discoverDescriptors(url);
|
|
60
|
+
const plugins = descriptors.map((descriptor) => {
|
|
61
|
+
const { id, type } = descriptor;
|
|
62
|
+
// A plugin id is only unique within a plugin type, so both are needed to
|
|
63
|
+
// re-select the right plugin when its load() re-imports the package.
|
|
64
|
+
if (typeof id !== "string" || typeof type !== "string")
|
|
65
|
+
return descriptor;
|
|
66
|
+
return {
|
|
67
|
+
...descriptor,
|
|
68
|
+
load: () => importPluginFromFolderDocUrl(url, type, id),
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
return { plugins };
|
|
72
|
+
}
|
package/dist/service-worker.js
CHANGED
|
@@ -17,7 +17,36 @@ function log(...args) {
|
|
|
17
17
|
return;
|
|
18
18
|
console.log.call(console, `%cpatchwork:serviceworker%c\n`, `color: #00ffcc; font-weight: bold`, "color: inherit", ...args);
|
|
19
19
|
}
|
|
20
|
+
// ── Lifecycle diagnostics ──────────────────────────────────────────────
|
|
21
|
+
// [lifecycle] markers for SW (re)boots, install/activate, crashes, and stranded
|
|
22
|
+
// handoffs. The SW can't read localStorage, so it always emits and forwards to
|
|
23
|
+
// the tab, which gates rendering on the live toggle. The SW holds no sync
|
|
24
|
+
// socket — observability only.
|
|
25
|
+
async function postToClients(message) {
|
|
26
|
+
const clients = await self.clients.matchAll({
|
|
27
|
+
type: "window",
|
|
28
|
+
includeUncontrolled: true,
|
|
29
|
+
});
|
|
30
|
+
for (const client of clients)
|
|
31
|
+
client.postMessage(message);
|
|
32
|
+
}
|
|
33
|
+
function lifecycle(level, text) {
|
|
34
|
+
const msg = `[lifecycle] ${new Date().toISOString()} ${text}`;
|
|
35
|
+
console[level](msg);
|
|
36
|
+
void postToClients({ type: "sw-lifecycle", level, msg });
|
|
37
|
+
}
|
|
38
|
+
lifecycle("info", `booted (scope ${self.registration?.scope ?? "?"})`);
|
|
39
|
+
self.addEventListener("error", (event) => {
|
|
40
|
+
const e = event;
|
|
41
|
+
lifecycle("warn", `uncaught error: ${e.message}` +
|
|
42
|
+
(e.filename ? ` @ ${e.filename}:${e.lineno}:${e.colno}` : ""));
|
|
43
|
+
});
|
|
44
|
+
self.addEventListener("unhandledrejection", (event) => {
|
|
45
|
+
const reason = event.reason;
|
|
46
|
+
lifecycle("warn", `unhandled rejection: ${reason instanceof Error ? reason.stack || reason.message : String(reason)}`);
|
|
47
|
+
});
|
|
20
48
|
self.addEventListener("install", (event) => {
|
|
49
|
+
lifecycle("info", "install (skipWaiting)");
|
|
21
50
|
// waitUntil keeps the worker alive until skipWaiting resolves, so a freshly
|
|
22
51
|
// installed SW reliably jumps the "waiting" queue instead of stalling until
|
|
23
52
|
// every old tab closes.
|
|
@@ -34,12 +63,28 @@ async function clearOldCaches() {
|
|
|
34
63
|
await Promise.all(deletePromises);
|
|
35
64
|
}
|
|
36
65
|
self.addEventListener("activate", (event) => {
|
|
37
|
-
|
|
38
|
-
// runs detached — the new worker can be killed before it takes control, so
|
|
39
|
-
// existing tabs keep talking to the old SW. Extend the event instead.
|
|
66
|
+
lifecycle("info", "activate (claiming clients)");
|
|
40
67
|
event.waitUntil((async () => {
|
|
41
68
|
await clearOldCaches();
|
|
42
69
|
await self.clients.claim();
|
|
70
|
+
// Pre-cache pages of already-open clients so they survive going offline
|
|
71
|
+
// before the next navigation.
|
|
72
|
+
const allClients = await self.clients.matchAll({ type: "window" });
|
|
73
|
+
const cache = await caches.open(cachename);
|
|
74
|
+
await Promise.all(allClients.map(async (client) => {
|
|
75
|
+
try {
|
|
76
|
+
const existing = await cache.match(client.url);
|
|
77
|
+
if (!existing) {
|
|
78
|
+
const response = await fetch(client.url);
|
|
79
|
+
if (cacheableStatuses.includes(response.status)) {
|
|
80
|
+
await cache.put(client.url, response);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// Network may be unavailable during activation
|
|
86
|
+
}
|
|
87
|
+
}));
|
|
43
88
|
})());
|
|
44
89
|
});
|
|
45
90
|
self.addEventListener("message", async (event) => {
|
|
@@ -72,7 +117,12 @@ handoffChannel.addEventListener("message", (event) => {
|
|
|
72
117
|
else if (data?.type === "online") {
|
|
73
118
|
// The automerge worker (re)started — re-broadcast anything still in
|
|
74
119
|
// flight so requests that raced its boot aren't stranded.
|
|
75
|
-
|
|
120
|
+
const stranded = [...pendingHandoffs.values()];
|
|
121
|
+
if (stranded.length > 0) {
|
|
122
|
+
lifecycle("info", `automerge worker (re)started; re-broadcasting ${stranded.length} ` +
|
|
123
|
+
`in-flight asset handoff(s)`);
|
|
124
|
+
}
|
|
125
|
+
for (const { message } of stranded) {
|
|
76
126
|
log(`re-broadcasting handoff ${message.id} to the fresh worker`);
|
|
77
127
|
handoffChannel.postMessage(message);
|
|
78
128
|
}
|
|
@@ -98,6 +148,8 @@ function handoff(request, handoffURL) {
|
|
|
98
148
|
log(`broadcasting handoff request for cache ${cachename}`, message);
|
|
99
149
|
handoffChannel.postMessage(message);
|
|
100
150
|
const timeout = setTimeout(() => {
|
|
151
|
+
lifecycle("warn", `asset handoff ${id} stranded: no reply from the automerge worker after ` +
|
|
152
|
+
`${HANDOFF_TIMEOUT_MS}ms (${handoffURL.href})`);
|
|
101
153
|
resolvers.reject(new Error(`no reply from the automerge worker after ${HANDOFF_TIMEOUT_MS}ms`));
|
|
102
154
|
}, HANDOFF_TIMEOUT_MS);
|
|
103
155
|
return resolvers.promise.finally(() => {
|
|
@@ -153,7 +205,7 @@ self.addEventListener("fetch", (fetchEvent) => {
|
|
|
153
205
|
// response in our cache
|
|
154
206
|
const cached = await cache.match(request);
|
|
155
207
|
if (!cached) {
|
|
156
|
-
return new Response(`the automerge worker reported ${handoffURL} cached, but it has no match in ${cachename}`, { status:
|
|
208
|
+
return new Response(`the automerge worker reported ${handoffURL} cached, but it has no match in ${cachename}`, { status: 555 });
|
|
157
209
|
}
|
|
158
210
|
log(`serving ${handoffURL} from cache ${cachename} after handoff`);
|
|
159
211
|
return withSpecialHeaders(cached);
|
|
@@ -185,7 +237,7 @@ self.addEventListener("fetch", (fetchEvent) => {
|
|
|
185
237
|
if (match)
|
|
186
238
|
return match;
|
|
187
239
|
return new Response(message, {
|
|
188
|
-
status:
|
|
240
|
+
status: 556,
|
|
189
241
|
headers: { "content-type": "text/plain" },
|
|
190
242
|
});
|
|
191
243
|
}
|
package/dist/setup.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { SetupServiceWorkerOptions, SetupServiceWorkerResult } from "./types.js";
|
|
2
|
+
export declare function lifecycleLoggingEnabled(): boolean;
|
|
2
3
|
export declare function bumpServiceWorkerCache(sw?: ServiceWorker | null): void;
|
|
4
|
+
export declare function getAutomergeWorker(): SharedWorker;
|
|
3
5
|
export declare function connectClassicSync(server?: string): Promise<void>;
|
|
4
6
|
export default function setupServiceWorker(options?: SetupServiceWorkerOptions): Promise<SetupServiceWorkerResult>;
|
package/dist/setup.js
CHANGED
|
@@ -2,6 +2,37 @@ import { readClassicSyncServer, DEFAULT_CLASSIC_SYNC_SERVER, } from "./sync-conf
|
|
|
2
2
|
import debug from "debug";
|
|
3
3
|
const serviceWorkerDebugging = debug.enabled("patchwork:serviceworker");
|
|
4
4
|
const workerDebugging = debug.enabled("patchwork:automergeworker");
|
|
5
|
+
// Diagnostic [lifecycle] logging, on by default. Disable via
|
|
6
|
+
// localStorage["patchwork:lifecycle-logs"] = "off". Read live at log time.
|
|
7
|
+
const LIFECYCLE_LOG_KEY = "patchwork:lifecycle-logs";
|
|
8
|
+
export function lifecycleLoggingEnabled() {
|
|
9
|
+
try {
|
|
10
|
+
const v = globalThis.localStorage?.getItem(LIFECYCLE_LOG_KEY);
|
|
11
|
+
return v !== "off" && v !== "false" && v !== "0" && v !== "no";
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
// The SW can't read localStorage, so it always emits [lifecycle] markers and
|
|
18
|
+
// forwards them as `sw-lifecycle`; gate rendering here on the live toggle.
|
|
19
|
+
let swLifecycleListenerInstalled = false;
|
|
20
|
+
function installServiceWorkerLogForwarding() {
|
|
21
|
+
if (swLifecycleListenerInstalled)
|
|
22
|
+
return;
|
|
23
|
+
if (typeof navigator === "undefined" || !navigator.serviceWorker)
|
|
24
|
+
return;
|
|
25
|
+
swLifecycleListenerInstalled = true;
|
|
26
|
+
navigator.serviceWorker.addEventListener("message", (event) => {
|
|
27
|
+
const data = event.data;
|
|
28
|
+
if (data?.type !== "sw-lifecycle")
|
|
29
|
+
return;
|
|
30
|
+
if (!lifecycleLoggingEnabled())
|
|
31
|
+
return;
|
|
32
|
+
const fn = console[data.level] ?? console.log;
|
|
33
|
+
fn(`[service-worker] ${data.msg}`);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
5
36
|
const key = "patchworkServiceWorkerCacheVersion";
|
|
6
37
|
let nextRepoChannelId = 0;
|
|
7
38
|
function bumpServiceWorkerCacheVersion() {
|
|
@@ -48,7 +79,7 @@ function configureServiceWorker(sw) {
|
|
|
48
79
|
// port; it talks to the service worker over a BroadcastChannel.
|
|
49
80
|
let automergeWorkerPath = "/automerge-worker.js";
|
|
50
81
|
let automergeWorker;
|
|
51
|
-
function getAutomergeWorker() {
|
|
82
|
+
export function getAutomergeWorker() {
|
|
52
83
|
if (!automergeWorker) {
|
|
53
84
|
automergeWorker = new SharedWorker(automergeWorkerPath, {
|
|
54
85
|
name: "patchwork-automerge",
|
|
@@ -57,10 +88,99 @@ function getAutomergeWorker() {
|
|
|
57
88
|
// Control replies (port-ready &c) come back on this port, so it needs
|
|
58
89
|
// start() — we listen with addEventListener, not onmessage.
|
|
59
90
|
automergeWorker.port.start();
|
|
91
|
+
// Surface the SharedWorker's console output and uncaught errors in this
|
|
92
|
+
// tab's console (it has its own console that's awkward to find otherwise).
|
|
93
|
+
automergeWorker.port.addEventListener("message", (event) => {
|
|
94
|
+
if (event.data?.type !== "console")
|
|
95
|
+
return;
|
|
96
|
+
const { level, args } = event.data;
|
|
97
|
+
// Gate forwarded [lifecycle] logs on the toggle too.
|
|
98
|
+
if (!lifecycleLoggingEnabled() &&
|
|
99
|
+
typeof args?.[0] === "string" &&
|
|
100
|
+
args[0].includes("[lifecycle]")) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const fn = console[level] ?? console.log;
|
|
104
|
+
// The worker's logs (debug library, the worker's own log()) carry %c
|
|
105
|
+
// format directives in args[0] with CSS in the following args. Prefix
|
|
106
|
+
// the tag into the format string rather than as a separate positional,
|
|
107
|
+
// or the %c would no longer be in arg 0 and the CSS would print raw.
|
|
108
|
+
if (typeof args[0] === "string") {
|
|
109
|
+
fn(`[automerge-worker] ${args[0]}`, ...args.slice(1));
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
fn("[automerge-worker]", ...args);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
60
115
|
automergeWorker.port.postMessage({ type: "debug", debug: workerDebugging });
|
|
116
|
+
installWorkerDeathDetection(automergeWorker);
|
|
61
117
|
}
|
|
62
118
|
return automergeWorker;
|
|
63
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Detect when the automerge SharedWorker dies or restarts: control-port close,
|
|
122
|
+
* worker error, changed instance id, or an unanswered heartbeat while the tab
|
|
123
|
+
* is visible (a miss while hidden is more likely suspension). [lifecycle]-tagged.
|
|
124
|
+
*/
|
|
125
|
+
function installWorkerDeathDetection(worker) {
|
|
126
|
+
const stamp = () => new Date().toISOString();
|
|
127
|
+
const warn = (msg) => {
|
|
128
|
+
if (lifecycleLoggingEnabled())
|
|
129
|
+
console.warn(`[lifecycle] ${stamp()} ${msg}`);
|
|
130
|
+
};
|
|
131
|
+
const info = (msg) => {
|
|
132
|
+
if (lifecycleLoggingEnabled())
|
|
133
|
+
console.info(`[lifecycle] ${stamp()} ${msg}`);
|
|
134
|
+
};
|
|
135
|
+
let instanceId;
|
|
136
|
+
let lastPongAt = Date.now();
|
|
137
|
+
let warnedUnresponsive = false;
|
|
138
|
+
worker.port.addEventListener("message", (event) => {
|
|
139
|
+
const data = event.data;
|
|
140
|
+
if (data?.type !== "hello" && data?.type !== "pong")
|
|
141
|
+
return;
|
|
142
|
+
if (data.type === "pong") {
|
|
143
|
+
lastPongAt = Date.now();
|
|
144
|
+
warnedUnresponsive = false;
|
|
145
|
+
}
|
|
146
|
+
if (instanceId === undefined) {
|
|
147
|
+
instanceId = data.instanceId;
|
|
148
|
+
info(`automerge SharedWorker instance ${data.instanceId} (via ${data.type})`);
|
|
149
|
+
}
|
|
150
|
+
else if (data.instanceId && data.instanceId !== instanceId) {
|
|
151
|
+
warn(`automerge SharedWorker RESTARTED (instance ${data.instanceId}, ` +
|
|
152
|
+
`was ${instanceId}) — fresh peerId + cold state; docs need re-subscribe`);
|
|
153
|
+
instanceId = data.instanceId;
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
// Fires when the SharedWorker is destroyed (where supported).
|
|
157
|
+
worker.port.addEventListener("close", () => {
|
|
158
|
+
warn("automerge SharedWorker control port CLOSED — worker terminated");
|
|
159
|
+
});
|
|
160
|
+
worker.addEventListener("error", event => {
|
|
161
|
+
warn(`automerge SharedWorker error: ${event.message || event}`);
|
|
162
|
+
});
|
|
163
|
+
// A missed pong while the tab is visible means the worker likely died (an
|
|
164
|
+
// active tab keeps it alive); a miss while hidden is more likely suspension.
|
|
165
|
+
const HEARTBEAT_MS = 10_000;
|
|
166
|
+
const HEARTBEAT_TIMEOUT_MS = 25_000;
|
|
167
|
+
let seq = 0;
|
|
168
|
+
setInterval(() => {
|
|
169
|
+
try {
|
|
170
|
+
worker.port.postMessage({ type: "ping", id: ++seq });
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// Port already torn down — the "close" handler covers that case.
|
|
174
|
+
}
|
|
175
|
+
const silentMs = Date.now() - lastPongAt;
|
|
176
|
+
const visible = typeof document === "undefined" || document.visibilityState === "visible";
|
|
177
|
+
if (silentMs > HEARTBEAT_TIMEOUT_MS && visible && !warnedUnresponsive) {
|
|
178
|
+
warnedUnresponsive = true;
|
|
179
|
+
warn(`automerge SharedWorker UNRESPONSIVE ~${Math.round(silentMs / 1000)}s ` +
|
|
180
|
+
`while tab visible — likely died/crashed`);
|
|
181
|
+
}
|
|
182
|
+
}, HEARTBEAT_MS);
|
|
183
|
+
}
|
|
64
184
|
export function connectClassicSync(server = readClassicSyncServer()) {
|
|
65
185
|
const url = server.trim() || DEFAULT_CLASSIC_SYNC_SERVER;
|
|
66
186
|
if (!/^wss?:\/\//.test(url)) {
|
|
@@ -155,11 +275,15 @@ function getRepoChannel() {
|
|
|
155
275
|
return port1;
|
|
156
276
|
}
|
|
157
277
|
export default async function setupServiceWorker(options) {
|
|
278
|
+
// Attach the SW→tab [lifecycle] log bridge as early as possible so boot /
|
|
279
|
+
// install / activate markers from the controlling worker are rendered here.
|
|
280
|
+
installServiceWorkerLogForwarding();
|
|
158
281
|
if (options?.workerPath)
|
|
159
282
|
automergeWorkerPath = options.workerPath;
|
|
160
283
|
// Start the automerge worker right away so it boots (wasm, repo) while the
|
|
161
284
|
// service worker installs.
|
|
162
|
-
getAutomergeWorker();
|
|
285
|
+
const shared = getAutomergeWorker();
|
|
286
|
+
// todo delete
|
|
163
287
|
const path = options?.path ?? "/service-worker.js";
|
|
164
288
|
// No controller at this point means the page loaded without a service
|
|
165
289
|
// worker — i.e. this is a first-time install (or a hard reload). Wait for
|
|
@@ -183,7 +307,15 @@ export default async function setupServiceWorker(options) {
|
|
|
183
307
|
configureServiceWorker(navigator.serviceWorker.controller);
|
|
184
308
|
});
|
|
185
309
|
console.log("service worker alive, loading %c patchwork system ", "background: #fcf2f0; color: #333; border: 2px solid; border-radius: 4px");
|
|
310
|
+
// todon't
|
|
311
|
+
window.killsw = () => {
|
|
312
|
+
if (automergeWorker) {
|
|
313
|
+
automergeWorker.port.close();
|
|
314
|
+
automergeWorker = undefined;
|
|
315
|
+
}
|
|
316
|
+
};
|
|
186
317
|
return {
|
|
318
|
+
shared,
|
|
187
319
|
connectClassicSync,
|
|
188
320
|
getRepoChannel,
|
|
189
321
|
async subscribeToRepoChannel(listener) {
|