@inkandswitch/patchwork-bootloader 0.4.1 → 0.4.3
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 +70 -0
- package/dist/automerge-worker.js +43 -13
- package/dist/module-loader-worker.js +2 -2
- package/dist/module-loader.d.ts +2 -2
- package/dist/module-loader.js +3 -3
- package/dist/service-worker.js +35 -13
- package/dist/setup.js +238 -35
- package/dist/site.d.ts +1 -1
- package/dist/site.js +75 -18
- package/dist/types.d.ts +18 -1
- package/dist/vite/importmap-plugin.js +33 -3
- package/package.json +30 -11
- package/src/automerge-worker.ts +48 -16
- package/src/module-loader-worker.ts +2 -2
- package/src/module-loader.ts +3 -3
- package/src/service-worker.ts +44 -13
- package/src/setup.ts +256 -39
- package/src/site.ts +90 -24
- package/src/types.ts +22 -1
- package/src/vite/importmap-plugin.ts +40 -3
package/dist/setup.js
CHANGED
|
@@ -70,12 +70,82 @@ function configureServiceWorker(sw) {
|
|
|
70
70
|
}
|
|
71
71
|
// ── The automerge worker ───────────────────────────────────────────────
|
|
72
72
|
// The automerge repo lives in a SharedWorker (not the service worker). One
|
|
73
|
-
// instance is shared by every tab and lives
|
|
74
|
-
// does
|
|
75
|
-
//
|
|
76
|
-
//
|
|
73
|
+
// instance is shared by every tab and normally lives as long as any tab
|
|
74
|
+
// does — but browsers do reap SharedWorkers under memory pressure, so we
|
|
75
|
+
// heartbeat it and rebuild everything if it dies (see
|
|
76
|
+
// recoverAutomergeWorker). Repo sync ports are passed to it over its
|
|
77
|
+
// connect port; it talks to the service worker over a BroadcastChannel.
|
|
77
78
|
let automergeWorkerPath = "/automerge-worker.js";
|
|
78
79
|
let automergeWorker;
|
|
80
|
+
// Bumped whenever a new SharedWorker is constructed. A repo port opened
|
|
81
|
+
// against instance N is stale once instance N+1 exists (its channel ends in
|
|
82
|
+
// a dead worker), so deliveries are guarded on the generation they started in.
|
|
83
|
+
let workerGeneration = 0;
|
|
84
|
+
// Tears down the current worker's heartbeat when it's replaced.
|
|
85
|
+
let disposeWorkerDeathDetection;
|
|
86
|
+
// Every subscribeToRepoChannel listener, kept so a recovered worker can hand
|
|
87
|
+
// each subscriber a fresh repo port.
|
|
88
|
+
const repoChannelListeners = new Set();
|
|
89
|
+
let recoveringWorker = false;
|
|
90
|
+
let lastWorkerRecoveryAt = 0;
|
|
91
|
+
// Below this spacing, skip: if the fresh worker is dead too, its own
|
|
92
|
+
// heartbeat re-triggers recovery later rather than spinning in a tight loop.
|
|
93
|
+
const RECOVERY_MIN_INTERVAL_MS = 15_000;
|
|
94
|
+
/**
|
|
95
|
+
* The automerge SharedWorker died (browser reaped it, or it crashed): build a
|
|
96
|
+
* replacement and re-wire everything a live tab holds against it — console
|
|
97
|
+
* forwarding and the io-proxy port donation (both re-done by
|
|
98
|
+
* getAutomergeWorker), the per-doc sync-state subscriptions, and every
|
|
99
|
+
* subscriber's repo sync port. The new instance boots with cold state; its
|
|
100
|
+
* repo is reconstructed on the first port we send.
|
|
101
|
+
*/
|
|
102
|
+
async function recoverAutomergeWorker(reason, deadWorker) {
|
|
103
|
+
if (deadWorker !== automergeWorker)
|
|
104
|
+
return; // already replaced
|
|
105
|
+
if (recoveringWorker)
|
|
106
|
+
return;
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
if (now - lastWorkerRecoveryAt < RECOVERY_MIN_INTERVAL_MS)
|
|
109
|
+
return;
|
|
110
|
+
recoveringWorker = true;
|
|
111
|
+
lastWorkerRecoveryAt = now;
|
|
112
|
+
console.warn(`[lifecycle] ${new Date().toISOString()} recreating the automerge ` +
|
|
113
|
+
`SharedWorker (${reason})`);
|
|
114
|
+
try {
|
|
115
|
+
disposeWorkerDeathDetection?.();
|
|
116
|
+
disposeWorkerDeathDetection = undefined;
|
|
117
|
+
automergeWorker = undefined;
|
|
118
|
+
try {
|
|
119
|
+
deadWorker.port.close();
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Port already dead.
|
|
123
|
+
}
|
|
124
|
+
const fresh = getAutomergeWorker();
|
|
125
|
+
// The fresh instance knows nothing — replay every doc subscription.
|
|
126
|
+
for (const documentId of syncStateListeners.keys()) {
|
|
127
|
+
fresh.port.postMessage({ type: "sync-sub", documentId });
|
|
128
|
+
}
|
|
129
|
+
// Hand every repo-channel subscriber a fresh port so their repos sync
|
|
130
|
+
// again (the old adapters sit on dead MessagePorts).
|
|
131
|
+
for (const listener of repoChannelListeners) {
|
|
132
|
+
try {
|
|
133
|
+
const generation = workerGeneration;
|
|
134
|
+
const port = await openRepoChannel();
|
|
135
|
+
// Replaced again while we waited — the newer recovery re-delivers.
|
|
136
|
+
if (generation !== workerGeneration)
|
|
137
|
+
break;
|
|
138
|
+
await listener(port);
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
console.error("failed to re-wire a repo channel after worker recovery", err);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
recoveringWorker = false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
79
149
|
// SharedWorker proxy entry that owns the subduction WebSocket. Chrome can't
|
|
80
150
|
// spawn workers from inside a SharedWorker, so each tab offers this proxy's
|
|
81
151
|
// port to the automerge worker (which requests one via its port provider).
|
|
@@ -107,13 +177,23 @@ function workerBenchParams() {
|
|
|
107
177
|
const qs = params.toString();
|
|
108
178
|
return qs ? `?${qs}` : "";
|
|
109
179
|
}
|
|
180
|
+
function automergeWorkerUrl() {
|
|
181
|
+
return `${automergeWorkerPath}${workerBenchParams()}`;
|
|
182
|
+
}
|
|
110
183
|
export function getAutomergeWorker() {
|
|
111
184
|
if (!automergeWorker) {
|
|
112
|
-
|
|
113
|
-
automergeWorker = new SharedWorker(
|
|
185
|
+
workerGeneration++;
|
|
186
|
+
automergeWorker = new SharedWorker(automergeWorkerUrl(), {
|
|
114
187
|
name: "patchwork-automerge",
|
|
115
188
|
type: "module",
|
|
116
189
|
});
|
|
190
|
+
// Fired when a message arrives that can't be structured-deserialized —
|
|
191
|
+
// e.g. a transfer list that named something unclonable. Silent otherwise:
|
|
192
|
+
// the message is simply dropped, which looks identical to a worker that
|
|
193
|
+
// never replied. Always loud, not gated on the lifecycle toggle.
|
|
194
|
+
automergeWorker.port.addEventListener("messageerror", (event) => {
|
|
195
|
+
console.error("[automerge-worker] undeserializable message from worker:", event);
|
|
196
|
+
});
|
|
117
197
|
// Control replies (port-ready &c) come back on this port, so it needs
|
|
118
198
|
// start() — we listen with addEventListener, not onmessage.
|
|
119
199
|
automergeWorker.port.start();
|
|
@@ -169,16 +249,37 @@ export function getAutomergeWorker() {
|
|
|
169
249
|
type: "module",
|
|
170
250
|
name: "subduction-websocket",
|
|
171
251
|
});
|
|
252
|
+
// This worker carries the websocket to the sync server, so if it fails
|
|
253
|
+
// to load, sync silently stops with no other symptom.
|
|
254
|
+
io.addEventListener("error", (event) => {
|
|
255
|
+
const error = event;
|
|
256
|
+
console.error(`[subduction-io] failed to load/run ${SUBDUCTION_IO_WORKER_URL}:`, error.message || event, error.filename ? `(${error.filename}:${error.lineno})` : "");
|
|
257
|
+
});
|
|
258
|
+
io.port.addEventListener("messageerror", (event) => {
|
|
259
|
+
console.error("[subduction-io] undeserializable message:", event);
|
|
260
|
+
});
|
|
172
261
|
return io.port;
|
|
173
262
|
});
|
|
174
|
-
installWorkerDeathDetection(automergeWorker);
|
|
263
|
+
disposeWorkerDeathDetection = installWorkerDeathDetection(automergeWorker);
|
|
175
264
|
}
|
|
176
265
|
return automergeWorker;
|
|
177
266
|
}
|
|
178
267
|
/**
|
|
179
|
-
* Detect when the automerge SharedWorker dies or
|
|
180
|
-
*
|
|
181
|
-
* is
|
|
268
|
+
* Detect when the automerge SharedWorker dies or its control port goes deaf.
|
|
269
|
+
*
|
|
270
|
+
* Silence alone is NOT proof of death: the worker may still be evaluating its
|
|
271
|
+
* (large) module graph on a cold boot, or its single thread may be busy with
|
|
272
|
+
* wasm/sync work — in both cases every queued message (including the repo
|
|
273
|
+
* ports the network adapters ride on) is delivered fine once it catches up,
|
|
274
|
+
* and tearing the port down would *lose* them. So silence only starts a
|
|
275
|
+
* non-destructive PROBE: a second SharedWorker connection to the same
|
|
276
|
+
* instance. Only when the probe gets a `hello` while this port stays silent do
|
|
277
|
+
* we know the instance is alive-and-responsive but our port is stranded (a
|
|
278
|
+
* failure mode observed in the wild) — or was replaced — and recovery is
|
|
279
|
+
* warranted. A `close` event (where supported) is a definitive death signal
|
|
280
|
+
* and recovers immediately. [lifecycle]-tagged. Returns a dispose that stops
|
|
281
|
+
* the heartbeat and any outstanding probe (called when this worker is
|
|
282
|
+
* replaced).
|
|
182
283
|
*/
|
|
183
284
|
function installWorkerDeathDetection(worker) {
|
|
184
285
|
const stamp = () => new Date().toISOString();
|
|
@@ -191,53 +292,135 @@ function installWorkerDeathDetection(worker) {
|
|
|
191
292
|
console.info(`[lifecycle] ${stamp()} ${msg}`);
|
|
192
293
|
};
|
|
193
294
|
let instanceId;
|
|
194
|
-
let
|
|
295
|
+
let lastHeardAt = Date.now();
|
|
195
296
|
let warnedUnresponsive = false;
|
|
297
|
+
let disposed = false;
|
|
298
|
+
let probe;
|
|
299
|
+
const closeProbe = () => {
|
|
300
|
+
if (!probe)
|
|
301
|
+
return;
|
|
302
|
+
try {
|
|
303
|
+
probe.port.close();
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
// Already closed.
|
|
307
|
+
}
|
|
308
|
+
probe = undefined;
|
|
309
|
+
};
|
|
310
|
+
let warnedSendFailed = false;
|
|
311
|
+
let pingsSent = 0;
|
|
196
312
|
worker.port.addEventListener("message", (event) => {
|
|
197
313
|
const data = event.data;
|
|
198
314
|
if (data?.type !== "hello" && data?.type !== "pong")
|
|
199
315
|
return;
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
316
|
+
lastHeardAt = Date.now();
|
|
317
|
+
warnedUnresponsive = false;
|
|
318
|
+
// The port spoke — any outstanding probe is moot.
|
|
319
|
+
closeProbe();
|
|
204
320
|
if (instanceId === undefined) {
|
|
205
321
|
instanceId = data.instanceId;
|
|
206
322
|
info(`automerge SharedWorker instance ${data.instanceId} (via ${data.type})`);
|
|
207
323
|
}
|
|
208
324
|
else if (data.instanceId && data.instanceId !== instanceId) {
|
|
209
|
-
warn(`automerge SharedWorker
|
|
210
|
-
`was ${instanceId})
|
|
325
|
+
warn(`automerge SharedWorker instance changed (instance ${data.instanceId}, ` +
|
|
326
|
+
`was ${instanceId})`);
|
|
211
327
|
instanceId = data.instanceId;
|
|
212
328
|
}
|
|
213
329
|
});
|
|
214
330
|
// Fires when the SharedWorker is destroyed (where supported).
|
|
215
331
|
worker.port.addEventListener("close", () => {
|
|
216
|
-
|
|
332
|
+
if (disposed)
|
|
333
|
+
return;
|
|
334
|
+
warn("automerge SharedWorker control port closed");
|
|
335
|
+
void recoverAutomergeWorker("control port closed", worker);
|
|
217
336
|
});
|
|
337
|
+
// Not gated on the lifecycle toggle: a worker that fails to load never
|
|
338
|
+
// replies to anything, and this is the only signal that says so.
|
|
218
339
|
worker.addEventListener("error", event => {
|
|
219
|
-
|
|
340
|
+
const error = event;
|
|
341
|
+
console.error(`[lifecycle] ${stamp()} automerge SharedWorker error:`, error.message || event, error.filename ? `(${error.filename}:${error.lineno})` : "");
|
|
220
342
|
});
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
343
|
+
// On probe hello, give the suspect port this long to also speak before
|
|
344
|
+
// concluding it's stranded: after a slow worker boot both connections hello
|
|
345
|
+
// at roughly the same moment and cross-port delivery order isn't guaranteed.
|
|
346
|
+
const PROBE_GRACE_MS = 500;
|
|
347
|
+
const startProbe = (reason) => {
|
|
348
|
+
if (probe || disposed)
|
|
349
|
+
return;
|
|
350
|
+
warn(`automerge SharedWorker ${reason}; probing with a second connection`);
|
|
351
|
+
const startedAt = Date.now();
|
|
352
|
+
const p = new SharedWorker(automergeWorkerUrl(), {
|
|
353
|
+
name: "patchwork-automerge",
|
|
354
|
+
type: "module",
|
|
355
|
+
});
|
|
356
|
+
probe = p;
|
|
357
|
+
p.port.start();
|
|
358
|
+
p.port.addEventListener("message", (event) => {
|
|
359
|
+
if (event.data?.type !== "hello")
|
|
360
|
+
return;
|
|
361
|
+
setTimeout(() => {
|
|
362
|
+
if (disposed || probe !== p)
|
|
363
|
+
return; // superseded or torn down
|
|
364
|
+
closeProbe();
|
|
365
|
+
// The suspect spoke while (or just after) the probe ran: it was
|
|
366
|
+
// merely slow/busy, and everything queued on it has been delivered.
|
|
367
|
+
if (lastHeardAt >= startedAt)
|
|
368
|
+
return;
|
|
369
|
+
void recoverAutomergeWorker(`port unresponsive on a live worker (${reason}; probe confirmed)`, worker);
|
|
370
|
+
}, PROBE_GRACE_MS);
|
|
371
|
+
});
|
|
372
|
+
// No hello on the probe means the instance is loading or busy (the probe
|
|
373
|
+
// waits indefinitely — its hello triggers the check above whenever it
|
|
374
|
+
// lands) — never tear anything down on a timer.
|
|
375
|
+
};
|
|
376
|
+
// A silent-too-long port while the tab is visible starts a probe (a miss
|
|
377
|
+
// while hidden is more likely suspension). Before first contact the budget
|
|
378
|
+
// is tighter: an idle worker hellos within milliseconds of connecting, so
|
|
379
|
+
// probing early costs nothing and rescues genuinely stranded boots fast.
|
|
380
|
+
const HEARTBEAT_MS = 5_000;
|
|
224
381
|
const HEARTBEAT_TIMEOUT_MS = 25_000;
|
|
382
|
+
const FIRST_CONTACT_TIMEOUT_MS = 4_000;
|
|
225
383
|
let seq = 0;
|
|
226
|
-
setInterval(() => {
|
|
384
|
+
const heartbeat = setInterval(() => {
|
|
227
385
|
try {
|
|
228
386
|
worker.port.postMessage({ type: "ping", id: ++seq });
|
|
387
|
+
pingsSent++;
|
|
229
388
|
}
|
|
230
|
-
catch {
|
|
231
|
-
//
|
|
389
|
+
catch (error) {
|
|
390
|
+
// Swallowing this makes a failed send indistinguishable from a dead
|
|
391
|
+
// worker in the "no pong" warning below. Once, not every heartbeat.
|
|
392
|
+
if (!warnedSendFailed) {
|
|
393
|
+
warnedSendFailed = true;
|
|
394
|
+
console.error(`[lifecycle] ${stamp()} automerge SharedWorker ping send threw ` +
|
|
395
|
+
`after ${pingsSent} sent:`, error);
|
|
396
|
+
}
|
|
232
397
|
}
|
|
233
|
-
const
|
|
398
|
+
const neverHeard = instanceId === undefined;
|
|
399
|
+
const silentMs = Date.now() - lastHeardAt;
|
|
400
|
+
const timeoutMs = neverHeard
|
|
401
|
+
? FIRST_CONTACT_TIMEOUT_MS
|
|
402
|
+
: HEARTBEAT_TIMEOUT_MS;
|
|
234
403
|
const visible = typeof document === "undefined" || document.visibilityState === "visible";
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
404
|
+
// First contact probes regardless of visibility: SharedWorkers don't
|
|
405
|
+
// suspend with tab visibility, boots in background tabs must still get
|
|
406
|
+
// rescued, and the probe destroys nothing. Post-contact silence defers to
|
|
407
|
+
// visibility, since a hidden page's own throttling can fake it.
|
|
408
|
+
if (silentMs > timeoutMs && (neverHeard || visible)) {
|
|
409
|
+
const reason = neverHeard
|
|
410
|
+
? `no hello ~${Math.round(silentMs / 1000)}s after connecting`
|
|
411
|
+
: `no pong for ~${Math.round(silentMs / 1000)}s`;
|
|
412
|
+
if (!warnedUnresponsive) {
|
|
413
|
+
warnedUnresponsive = true;
|
|
414
|
+
warn(`automerge SharedWorker ${reason} (tab visible)`);
|
|
415
|
+
}
|
|
416
|
+
startProbe(reason);
|
|
239
417
|
}
|
|
240
418
|
}, HEARTBEAT_MS);
|
|
419
|
+
return () => {
|
|
420
|
+
disposed = true;
|
|
421
|
+
clearInterval(heartbeat);
|
|
422
|
+
closeProbe();
|
|
423
|
+
};
|
|
241
424
|
}
|
|
242
425
|
const syncStateListeners = new Map();
|
|
243
426
|
function dispatchSyncState(update) {
|
|
@@ -273,7 +456,10 @@ export function subscribeSyncState(documentId, listener) {
|
|
|
273
456
|
set.delete(listener);
|
|
274
457
|
if (set.size === 0) {
|
|
275
458
|
syncStateListeners.delete(documentId);
|
|
276
|
-
worker
|
|
459
|
+
// The worker may have been replaced since we subscribed (recovery
|
|
460
|
+
// replays subscriptions onto the new instance) — unsubscribe from
|
|
461
|
+
// whichever instance is current, not the one captured above.
|
|
462
|
+
automergeWorker?.port.postMessage({ type: "sync-unsub", documentId });
|
|
277
463
|
}
|
|
278
464
|
};
|
|
279
465
|
}
|
|
@@ -375,6 +561,11 @@ export default async function setupServiceWorker(options) {
|
|
|
375
561
|
// install / activate markers from the controlling worker are rendered here.
|
|
376
562
|
installServiceWorkerLogForwarding();
|
|
377
563
|
localStorage.removeItem(key);
|
|
564
|
+
// Ask for persistent storage so cache growth can't trip origin-wide
|
|
565
|
+
// eviction, which would take the Automerge IndexedDB — the user's documents
|
|
566
|
+
// — with it. Chrome/Safari decide silently from site engagement; Firefox may
|
|
567
|
+
// show a one-time prompt. Best-effort: denial just means default eviction.
|
|
568
|
+
void navigator.storage?.persist?.().catch(() => { });
|
|
378
569
|
if (options?.workerPath)
|
|
379
570
|
automergeWorkerPath = options.workerPath;
|
|
380
571
|
// Start the automerge worker right away so it boots (wasm, repo) while the
|
|
@@ -417,10 +608,22 @@ export default async function setupServiceWorker(options) {
|
|
|
417
608
|
getRepoChannel,
|
|
418
609
|
subscribeSyncState,
|
|
419
610
|
async subscribeToRepoChannel(listener) {
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
|
|
423
|
-
|
|
611
|
+
// Called once with the boot port. If the automerge worker later dies
|
|
612
|
+
// and is recreated (recoverAutomergeWorker), the listener is called
|
|
613
|
+
// again with a fresh port — treat every call as "(re)wire your repo's
|
|
614
|
+
// sync onto this port".
|
|
615
|
+
repoChannelListeners.add(listener);
|
|
616
|
+
const generation = workerGeneration;
|
|
617
|
+
const port = await openRepoChannel();
|
|
618
|
+
// If the worker was replaced while this channel was opening (e.g. the
|
|
619
|
+
// port-ready wait timed out against a stranded connection and recovery
|
|
620
|
+
// already delivered a good port to this listener), drop the stale one
|
|
621
|
+
// rather than wiring the repo to a dead channel.
|
|
622
|
+
if (generation === workerGeneration)
|
|
623
|
+
await listener(port);
|
|
624
|
+
return () => {
|
|
625
|
+
repoChannelListeners.delete(listener);
|
|
626
|
+
};
|
|
424
627
|
},
|
|
425
628
|
};
|
|
426
629
|
}
|
package/dist/site.d.ts
CHANGED
|
@@ -61,7 +61,7 @@ export interface SiteConfig {
|
|
|
61
61
|
* folder docs or plain HTTP(S) bundles, so deployment targets can be freely
|
|
62
62
|
* mixed.
|
|
63
63
|
*
|
|
64
|
-
* Can be overridden at runtime by setting `localStorage.
|
|
64
|
+
* Can be overridden at runtime by setting `localStorage.systemPackageListURL` to
|
|
65
65
|
* another `automerge:` URL or manifest URL — useful for local development
|
|
66
66
|
* against an unpublished tool set.
|
|
67
67
|
*/
|
package/dist/site.js
CHANGED
|
@@ -20,15 +20,15 @@ import { initKeyhiveWasm, initializeAutomergeRepoKeyhiveWithRepo, } from "@autom
|
|
|
20
20
|
// @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
|
|
21
21
|
import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
|
|
22
22
|
import { MemorySigner } from "@automerge/automerge-subduction/slim";
|
|
23
|
-
const siteName = typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "
|
|
23
|
+
const siteName = typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "patchwork.inkandswitch.com";
|
|
24
24
|
const useKeyhiveSyncServer = typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
|
|
25
25
|
import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
|
|
26
|
-
import {
|
|
26
|
+
import { importAutomergePackageViaWorker } from "./module-loader.js";
|
|
27
27
|
import { openDocument, registerPatchworkViewElement, } from "@inkandswitch/patchwork-elements";
|
|
28
28
|
import { registerRepoProviderElement } from "@inkandswitch/patchwork-providers";
|
|
29
29
|
import { getRegistry, registerPlugins, resolveAccountHandle, unregisterPlugins, } from "@inkandswitch/patchwork-plugins";
|
|
30
30
|
import * as plugins from "@inkandswitch/patchwork-plugins";
|
|
31
|
-
import setupServiceWorker, { lifecycleLoggingEnabled
|
|
31
|
+
import setupServiceWorker, { lifecycleLoggingEnabled } from "./setup.js";
|
|
32
32
|
import debug from "debug";
|
|
33
33
|
const log = debug("patchwork:bootloader:site");
|
|
34
34
|
// Legacy big-patchwork hash shape: `<slug>--<documentId>[?…]`. The slug can
|
|
@@ -37,7 +37,7 @@ const log = debug("patchwork:bootloader:site");
|
|
|
37
37
|
// characters ahead of it rather than a strict slug charset.
|
|
38
38
|
const BIG_PATCHWORK_HASH_REGEX = /^(?<title>[^=&?/#]*)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)/;
|
|
39
39
|
const [automergeWasm, subductionWasm] = await Promise.all([
|
|
40
|
-
fetch("/automerge.wasm
|
|
40
|
+
fetch("/automerge.wasm").then((r) => r.bytes()),
|
|
41
41
|
fetch("/subduction.wasm").then((r) => r.bytes()),
|
|
42
42
|
]);
|
|
43
43
|
/**
|
|
@@ -64,6 +64,9 @@ export async function bootPatchworkSite(config) {
|
|
|
64
64
|
let hive;
|
|
65
65
|
let repo;
|
|
66
66
|
let tabSignerIdentity;
|
|
67
|
+
// Called with a fresh port when the automerge worker dies and is recreated
|
|
68
|
+
// (see recoverAutomergeWorker in setup.ts); assigned once the repo exists.
|
|
69
|
+
let onWorkerPortRenewed;
|
|
67
70
|
// If a Repo is already on `window` — an embedding context provided one before
|
|
68
71
|
// this entry ran — reuse it and its keyhive instead of standing up a fresh
|
|
69
72
|
// realm-local Repo, so we share the same documents and sync/keyhive context.
|
|
@@ -76,14 +79,34 @@ export async function bootPatchworkSite(config) {
|
|
|
76
79
|
else {
|
|
77
80
|
// Get the initial automerge-worker port via subscribeToRepoChannel,
|
|
78
81
|
// then pass it to keyhive init which wraps it in its own network adapter.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
// The listener is called again with a fresh port if the worker is ever
|
|
83
|
+
// recreated after dying.
|
|
84
|
+
let resolveFirstPort;
|
|
85
|
+
const firstPortPromise = new Promise((r) => {
|
|
86
|
+
resolveFirstPort = r;
|
|
82
87
|
});
|
|
88
|
+
let seenFirstPort = false;
|
|
83
89
|
log("subscribing to repo channel");
|
|
84
|
-
|
|
90
|
+
// Deliberately not awaited: subscribeToRepoChannel resolves only after
|
|
91
|
+
// the boot channel's port-ready handshake, which can take its full 30s
|
|
92
|
+
// timeout against a stranded worker connection. Boot should block on the
|
|
93
|
+
// first *delivered* port instead — if the boot channel stalls, worker
|
|
94
|
+
// recovery hands the listener a good port long before that timeout.
|
|
95
|
+
void sw.subscribeToRepoChannel((port) => {
|
|
96
|
+
if (!seenFirstPort) {
|
|
97
|
+
seenFirstPort = true;
|
|
98
|
+
resolveFirstPort(port);
|
|
99
|
+
}
|
|
100
|
+
else if (onWorkerPortRenewed) {
|
|
101
|
+
onWorkerPortRenewed(port);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
console.warn("automerge worker port renewed before the repo existed; dropping it");
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
const workerPort = await firstPortPromise;
|
|
85
108
|
log("repo channel subscribed");
|
|
86
|
-
|
|
109
|
+
let workerAdapter = new MessageChannelNetworkAdapter(workerPort);
|
|
87
110
|
if (config.keyhive) {
|
|
88
111
|
log("setting up keyhive");
|
|
89
112
|
initKeyhiveWasm();
|
|
@@ -91,7 +114,7 @@ export async function bootPatchworkSite(config) {
|
|
|
91
114
|
createRepo: (config) => new Repo(config),
|
|
92
115
|
storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
|
|
93
116
|
peerIdSuffix: siteName + Math.random().toString(36).slice(2),
|
|
94
|
-
networkAdapter:
|
|
117
|
+
networkAdapter: workerAdapter,
|
|
95
118
|
automaticArchiveIngestion: true,
|
|
96
119
|
cachingMode: "periodic",
|
|
97
120
|
onlyShareWithHardcodedServerPeerId: false,
|
|
@@ -113,7 +136,7 @@ export async function bootPatchworkSite(config) {
|
|
|
113
136
|
// id never goes on the wire.
|
|
114
137
|
const tabSigner = new MemorySigner();
|
|
115
138
|
repo = new Repo({
|
|
116
|
-
network: [
|
|
139
|
+
network: [workerAdapter],
|
|
117
140
|
storage: new IndexedDBWorkerStorageAdapter(),
|
|
118
141
|
signer: tabSigner,
|
|
119
142
|
async sharePolicy(peerId) {
|
|
@@ -129,6 +152,37 @@ export async function bootPatchworkSite(config) {
|
|
|
129
152
|
console.log("[patchwork] tab subduction identity:", tabSignerIdentity);
|
|
130
153
|
log("repo created");
|
|
131
154
|
}
|
|
155
|
+
// The worker was recreated with cold state: wire the repo onto the fresh
|
|
156
|
+
// port and drop the adapter stranded on the dead one. `repo`/`hive` are
|
|
157
|
+
// settled by the time this can fire (recovery needs a missed heartbeat).
|
|
158
|
+
const bootHive = hive;
|
|
159
|
+
onWorkerPortRenewed = (port) => {
|
|
160
|
+
const fresh = new MessageChannelNetworkAdapter(port);
|
|
161
|
+
// Mirror the boot wiring: a keyhive repo talks to the worker through a
|
|
162
|
+
// keyhive adapter wrapped around the message channel (same parameters
|
|
163
|
+
// the worker uses for its side of the pair).
|
|
164
|
+
const registered = bootHive
|
|
165
|
+
? bootHive.createKeyhiveNetworkAdapter(fresh, false, false, 2000)
|
|
166
|
+
: fresh;
|
|
167
|
+
repo.networkSubsystem.addNetworkAdapter(registered);
|
|
168
|
+
for (const adapter of [...repo.networkSubsystem.adapters]) {
|
|
169
|
+
if (adapter === registered)
|
|
170
|
+
continue;
|
|
171
|
+
// The keyhive wrapper keeps the wrapped adapter on `.networkAdapter`.
|
|
172
|
+
const base = adapter.networkAdapter ?? adapter;
|
|
173
|
+
if (base !== workerAdapter)
|
|
174
|
+
continue;
|
|
175
|
+
try {
|
|
176
|
+
repo.networkSubsystem.removeNetworkAdapter(adapter);
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
console.error("failed to remove stale worker network adapter", err);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
workerAdapter = fresh;
|
|
183
|
+
console.warn(`[lifecycle] ${new Date().toISOString()} repo re-wired to the ` +
|
|
184
|
+
`recreated automerge worker`);
|
|
185
|
+
};
|
|
132
186
|
}
|
|
133
187
|
log("popping repo on window");
|
|
134
188
|
window.repo = repo;
|
|
@@ -157,7 +211,7 @@ export async function bootPatchworkSite(config) {
|
|
|
157
211
|
const moduleWatcher = new ModuleWatcher(repo, buildSystemSources(defaultModuleSources), onModuleLoaded, unregisterPlugins,
|
|
158
212
|
// Discover an Automerge package's plugin descriptors off the main thread;
|
|
159
213
|
// each plugin's load() re-imports the package (at heads) on this thread.
|
|
160
|
-
|
|
214
|
+
importAutomergePackageViaWorker);
|
|
161
215
|
const accountDocHandle = (await resolveAccountHandle(repo, {
|
|
162
216
|
storageKey: config.accountStorageKey,
|
|
163
217
|
hive,
|
|
@@ -204,21 +258,24 @@ function isValidModuleSource(source) {
|
|
|
204
258
|
}
|
|
205
259
|
/**
|
|
206
260
|
* Resolve the site's default module-list sources, honouring the
|
|
207
|
-
* `localStorage.
|
|
261
|
+
* `localStorage.systemPackageListURL` dev override (which replaces the entire
|
|
208
262
|
* built-in default bundle).
|
|
209
263
|
*/
|
|
210
264
|
function resolveDefaultModules(config) {
|
|
211
265
|
const builtin = config.defaultModules ?? config.defaultModulesUrl ?? [];
|
|
212
266
|
const builtinList = (Array.isArray(builtin) ? builtin : [builtin]).filter(Boolean);
|
|
213
|
-
const
|
|
267
|
+
const storage = globalThis.localStorage;
|
|
268
|
+
// `defaultToolsUrl` is the pre-rename key, still honoured for existing browsers.
|
|
269
|
+
const override = storage?.getItem("systemPackageListURL") ??
|
|
270
|
+
storage?.getItem("defaultToolsUrl");
|
|
214
271
|
if (override) {
|
|
215
272
|
if (isValidModuleSource(override)) {
|
|
216
273
|
if (!builtinList.includes(override)) {
|
|
217
|
-
console.info(`using
|
|
274
|
+
console.info(`using systemPackageListURL override from localStorage: ${override}`);
|
|
218
275
|
}
|
|
219
276
|
return [override];
|
|
220
277
|
}
|
|
221
|
-
console.warn(`ignoring invalid
|
|
278
|
+
console.warn(`ignoring invalid systemPackageListURL in localStorage: ${override}; using built-in default`);
|
|
222
279
|
}
|
|
223
280
|
if (builtinList.length === 0) {
|
|
224
281
|
throw new Error("bootPatchworkSite: no default module sources configured (set `defaultModules`)");
|
|
@@ -269,8 +326,8 @@ function installLifecycleLogging() {
|
|
|
269
326
|
document.addEventListener("visibilitychange", () => note(`visibilitychange → ${document.visibilityState}`), opts);
|
|
270
327
|
document.addEventListener("freeze", () => note("freeze (tab suspended)"), opts);
|
|
271
328
|
document.addEventListener("resume", () => note("resume (tab unsuspended)"), opts);
|
|
272
|
-
window.addEventListener("pageshow", e => note("pageshow", { persisted: e.persisted }), opts);
|
|
273
|
-
window.addEventListener("pagehide", e => note("pagehide", { persisted: e.persisted }), opts);
|
|
329
|
+
window.addEventListener("pageshow", (e) => note("pageshow", { persisted: e.persisted }), opts);
|
|
330
|
+
window.addEventListener("pagehide", (e) => note("pagehide", { persisted: e.persisted }), opts);
|
|
274
331
|
window.addEventListener("online", () => note("online"), opts);
|
|
275
332
|
window.addEventListener("offline", () => note("offline"), opts);
|
|
276
333
|
note(`lifecycle logging installed (visibilityState=${document.visibilityState}, hasFocus=${document.hasFocus()})`);
|
package/dist/types.d.ts
CHANGED
|
@@ -133,7 +133,24 @@ export interface HandoffResponseMessage {
|
|
|
133
133
|
type: "response";
|
|
134
134
|
response: HandoffResponse;
|
|
135
135
|
}
|
|
136
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Automerge worker → service worker: fail the request as a network error
|
|
138
|
+
* rather than serving any response at all.
|
|
139
|
+
*
|
|
140
|
+
* "Heads haven't arrived yet" is not a 404 — the document may well exist, so
|
|
141
|
+
* a status implying it doesn't is a lie any HTTP cache is entitled to store.
|
|
142
|
+
* A network error is the honest answer and isn't storable as a response.
|
|
143
|
+
*
|
|
144
|
+
* This does *not* help with `import()`: the ES module map memoizes failed
|
|
145
|
+
* fetches, network errors included, so a retry needs a distinct URL either
|
|
146
|
+
* way (see `importModule` in patchwork-filesystem).
|
|
147
|
+
*/
|
|
148
|
+
export interface HandoffAbortMessage {
|
|
149
|
+
id: string;
|
|
150
|
+
type: "abort";
|
|
151
|
+
reason: string;
|
|
152
|
+
}
|
|
153
|
+
export type HandoffReplyMessage = HandoffCachedMessage | HandoffResponseMessage | HandoffAbortMessage;
|
|
137
154
|
/**
|
|
138
155
|
* Automerge worker → world: broadcast once on startup so the service worker
|
|
139
156
|
* can re-send any handoff requests that raced the worker's boot.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
3
4
|
const require = createRequire(import.meta.url);
|
|
4
5
|
/**
|
|
5
6
|
* these dependencies will be built into the outdir,
|
|
@@ -7,6 +8,29 @@ const require = createRequire(import.meta.url);
|
|
|
7
8
|
*/
|
|
8
9
|
import externals from "../externals.js";
|
|
9
10
|
export const builtins = externals.reduce((builtins, name) => ((builtins[name] = `/packages/${name}.js`), builtins), {});
|
|
11
|
+
/**
|
|
12
|
+
* pretend the import came from inside this package, so node_modules resolution
|
|
13
|
+
* walks up from *our* directory and finds our copy of each external. that's why
|
|
14
|
+
* a consuming site never has to install them or agree with us about versions.
|
|
15
|
+
*/
|
|
16
|
+
const self = fileURLToPath(import.meta.url);
|
|
17
|
+
/**
|
|
18
|
+
* resolve an external from our node_modules rather than the site's.
|
|
19
|
+
*
|
|
20
|
+
* this goes through rollup's resolver rather than import.meta.resolve or
|
|
21
|
+
* require.resolve because those apply node's conditions: subduction (and
|
|
22
|
+
* others) would hand us `dist/esm/node.js`, which imports node:path and blows
|
|
23
|
+
* up at bundle time. rollup applies the browser conditions vite configured.
|
|
24
|
+
*/
|
|
25
|
+
async function resolveExternal(name) {
|
|
26
|
+
const resolved = await this.resolve(name, self, { skipSelf: true });
|
|
27
|
+
if (!resolved) {
|
|
28
|
+
throw new Error(`@patchwork/vite: couldn't resolve the external "${name}" from ` +
|
|
29
|
+
`@inkandswitch/patchwork-bootloader. it should be one of the ` +
|
|
30
|
+
`bootloader's own dependencies.`);
|
|
31
|
+
}
|
|
32
|
+
return resolved.id;
|
|
33
|
+
}
|
|
10
34
|
/**
|
|
11
35
|
* merge the importmap option with our builtins
|
|
12
36
|
*/
|
|
@@ -26,7 +50,7 @@ export function importmap(options) {
|
|
|
26
50
|
this.emitFile({
|
|
27
51
|
type: "chunk",
|
|
28
52
|
fileName: fileName.slice(1),
|
|
29
|
-
id,
|
|
53
|
+
id: await resolveExternal.call(this, id),
|
|
30
54
|
preserveSignature: "strict",
|
|
31
55
|
});
|
|
32
56
|
}
|
|
@@ -51,8 +75,14 @@ export function importmap(options) {
|
|
|
51
75
|
source: readFileSync(subdWasmPath),
|
|
52
76
|
});
|
|
53
77
|
},
|
|
54
|
-
resolveId(id) {
|
|
55
|
-
if (id in
|
|
78
|
+
async resolveId(id) {
|
|
79
|
+
if (id in builtins) {
|
|
80
|
+
// point the site's own imports at the same copy we emit as a chunk,
|
|
81
|
+
// otherwise rollup bundles a second one out of the site's node_modules
|
|
82
|
+
// and you end up with two automerges racing to init the same wasm
|
|
83
|
+
return resolveExternal.call(this, id);
|
|
84
|
+
}
|
|
85
|
+
if (id in importmap.imports) {
|
|
56
86
|
return { id: importmap.imports[id], external: true };
|
|
57
87
|
}
|
|
58
88
|
},
|