@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/src/setup.ts
CHANGED
|
@@ -89,13 +89,85 @@ function configureServiceWorker(sw: ServiceWorker | null) {
|
|
|
89
89
|
|
|
90
90
|
// ── The automerge worker ───────────────────────────────────────────────
|
|
91
91
|
// The automerge repo lives in a SharedWorker (not the service worker). One
|
|
92
|
-
// instance is shared by every tab and lives
|
|
93
|
-
// does
|
|
94
|
-
//
|
|
95
|
-
//
|
|
92
|
+
// instance is shared by every tab and normally lives as long as any tab
|
|
93
|
+
// does — but browsers do reap SharedWorkers under memory pressure, so we
|
|
94
|
+
// heartbeat it and rebuild everything if it dies (see
|
|
95
|
+
// recoverAutomergeWorker). Repo sync ports are passed to it over its
|
|
96
|
+
// connect port; it talks to the service worker over a BroadcastChannel.
|
|
96
97
|
|
|
97
98
|
let automergeWorkerPath = "/automerge-worker.js";
|
|
98
99
|
let automergeWorker: SharedWorker | undefined;
|
|
100
|
+
// Bumped whenever a new SharedWorker is constructed. A repo port opened
|
|
101
|
+
// against instance N is stale once instance N+1 exists (its channel ends in
|
|
102
|
+
// a dead worker), so deliveries are guarded on the generation they started in.
|
|
103
|
+
let workerGeneration = 0;
|
|
104
|
+
// Tears down the current worker's heartbeat when it's replaced.
|
|
105
|
+
let disposeWorkerDeathDetection: (() => void) | undefined;
|
|
106
|
+
// Every subscribeToRepoChannel listener, kept so a recovered worker can hand
|
|
107
|
+
// each subscriber a fresh repo port.
|
|
108
|
+
const repoChannelListeners = new Set<ServiceWorkerRepoChannelListener>();
|
|
109
|
+
let recoveringWorker = false;
|
|
110
|
+
let lastWorkerRecoveryAt = 0;
|
|
111
|
+
// Below this spacing, skip: if the fresh worker is dead too, its own
|
|
112
|
+
// heartbeat re-triggers recovery later rather than spinning in a tight loop.
|
|
113
|
+
const RECOVERY_MIN_INTERVAL_MS = 15_000;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The automerge SharedWorker died (browser reaped it, or it crashed): build a
|
|
117
|
+
* replacement and re-wire everything a live tab holds against it — console
|
|
118
|
+
* forwarding and the io-proxy port donation (both re-done by
|
|
119
|
+
* getAutomergeWorker), the per-doc sync-state subscriptions, and every
|
|
120
|
+
* subscriber's repo sync port. The new instance boots with cold state; its
|
|
121
|
+
* repo is reconstructed on the first port we send.
|
|
122
|
+
*/
|
|
123
|
+
async function recoverAutomergeWorker(
|
|
124
|
+
reason: string,
|
|
125
|
+
deadWorker: SharedWorker
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
if (deadWorker !== automergeWorker) return; // already replaced
|
|
128
|
+
if (recoveringWorker) return;
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
if (now - lastWorkerRecoveryAt < RECOVERY_MIN_INTERVAL_MS) return;
|
|
131
|
+
recoveringWorker = true;
|
|
132
|
+
lastWorkerRecoveryAt = now;
|
|
133
|
+
console.warn(
|
|
134
|
+
`[lifecycle] ${new Date().toISOString()} recreating the automerge ` +
|
|
135
|
+
`SharedWorker (${reason})`
|
|
136
|
+
);
|
|
137
|
+
try {
|
|
138
|
+
disposeWorkerDeathDetection?.();
|
|
139
|
+
disposeWorkerDeathDetection = undefined;
|
|
140
|
+
automergeWorker = undefined;
|
|
141
|
+
try {
|
|
142
|
+
deadWorker.port.close();
|
|
143
|
+
} catch {
|
|
144
|
+
// Port already dead.
|
|
145
|
+
}
|
|
146
|
+
const fresh = getAutomergeWorker();
|
|
147
|
+
// The fresh instance knows nothing — replay every doc subscription.
|
|
148
|
+
for (const documentId of syncStateListeners.keys()) {
|
|
149
|
+
fresh.port.postMessage({ type: "sync-sub", documentId });
|
|
150
|
+
}
|
|
151
|
+
// Hand every repo-channel subscriber a fresh port so their repos sync
|
|
152
|
+
// again (the old adapters sit on dead MessagePorts).
|
|
153
|
+
for (const listener of repoChannelListeners) {
|
|
154
|
+
try {
|
|
155
|
+
const generation = workerGeneration;
|
|
156
|
+
const port = await openRepoChannel();
|
|
157
|
+
// Replaced again while we waited — the newer recovery re-delivers.
|
|
158
|
+
if (generation !== workerGeneration) break;
|
|
159
|
+
await listener(port);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
console.error(
|
|
162
|
+
"failed to re-wire a repo channel after worker recovery",
|
|
163
|
+
err
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} finally {
|
|
168
|
+
recoveringWorker = false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
99
171
|
|
|
100
172
|
// SharedWorker proxy entry that owns the subduction WebSocket. Chrome can't
|
|
101
173
|
// spawn workers from inside a SharedWorker, so each tab offers this proxy's
|
|
@@ -129,13 +201,27 @@ function workerBenchParams(): string {
|
|
|
129
201
|
return qs ? `?${qs}` : "";
|
|
130
202
|
}
|
|
131
203
|
|
|
204
|
+
function automergeWorkerUrl(): string {
|
|
205
|
+
return `${automergeWorkerPath}${workerBenchParams()}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
132
208
|
export function getAutomergeWorker(): SharedWorker {
|
|
133
209
|
if (!automergeWorker) {
|
|
134
|
-
|
|
135
|
-
automergeWorker = new SharedWorker(
|
|
210
|
+
workerGeneration++;
|
|
211
|
+
automergeWorker = new SharedWorker(automergeWorkerUrl(), {
|
|
136
212
|
name: "patchwork-automerge",
|
|
137
213
|
type: "module",
|
|
138
214
|
});
|
|
215
|
+
// Fired when a message arrives that can't be structured-deserialized —
|
|
216
|
+
// e.g. a transfer list that named something unclonable. Silent otherwise:
|
|
217
|
+
// the message is simply dropped, which looks identical to a worker that
|
|
218
|
+
// never replied. Always loud, not gated on the lifecycle toggle.
|
|
219
|
+
automergeWorker.port.addEventListener("messageerror", (event) => {
|
|
220
|
+
console.error(
|
|
221
|
+
"[automerge-worker] undeserializable message from worker:",
|
|
222
|
+
event
|
|
223
|
+
);
|
|
224
|
+
});
|
|
139
225
|
// Control replies (port-ready &c) come back on this port, so it needs
|
|
140
226
|
// start() — we listen with addEventListener, not onmessage.
|
|
141
227
|
automergeWorker.port.start();
|
|
@@ -191,20 +277,45 @@ export function getAutomergeWorker(): SharedWorker {
|
|
|
191
277
|
type: "module",
|
|
192
278
|
name: "subduction-websocket",
|
|
193
279
|
});
|
|
280
|
+
// This worker carries the websocket to the sync server, so if it fails
|
|
281
|
+
// to load, sync silently stops with no other symptom.
|
|
282
|
+
io.addEventListener("error", (event) => {
|
|
283
|
+
const error = event as ErrorEvent;
|
|
284
|
+
console.error(
|
|
285
|
+
`[subduction-io] failed to load/run ${SUBDUCTION_IO_WORKER_URL}:`,
|
|
286
|
+
error.message || event,
|
|
287
|
+
error.filename ? `(${error.filename}:${error.lineno})` : ""
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
io.port.addEventListener("messageerror", (event) => {
|
|
291
|
+
console.error("[subduction-io] undeserializable message:", event);
|
|
292
|
+
});
|
|
194
293
|
return io.port;
|
|
195
294
|
});
|
|
196
295
|
|
|
197
|
-
installWorkerDeathDetection(automergeWorker);
|
|
296
|
+
disposeWorkerDeathDetection = installWorkerDeathDetection(automergeWorker);
|
|
198
297
|
}
|
|
199
298
|
return automergeWorker;
|
|
200
299
|
}
|
|
201
300
|
|
|
202
301
|
/**
|
|
203
|
-
* Detect when the automerge SharedWorker dies or
|
|
204
|
-
*
|
|
205
|
-
* is
|
|
302
|
+
* Detect when the automerge SharedWorker dies or its control port goes deaf.
|
|
303
|
+
*
|
|
304
|
+
* Silence alone is NOT proof of death: the worker may still be evaluating its
|
|
305
|
+
* (large) module graph on a cold boot, or its single thread may be busy with
|
|
306
|
+
* wasm/sync work — in both cases every queued message (including the repo
|
|
307
|
+
* ports the network adapters ride on) is delivered fine once it catches up,
|
|
308
|
+
* and tearing the port down would *lose* them. So silence only starts a
|
|
309
|
+
* non-destructive PROBE: a second SharedWorker connection to the same
|
|
310
|
+
* instance. Only when the probe gets a `hello` while this port stays silent do
|
|
311
|
+
* we know the instance is alive-and-responsive but our port is stranded (a
|
|
312
|
+
* failure mode observed in the wild) — or was replaced — and recovery is
|
|
313
|
+
* warranted. A `close` event (where supported) is a definitive death signal
|
|
314
|
+
* and recovers immediately. [lifecycle]-tagged. Returns a dispose that stops
|
|
315
|
+
* the heartbeat and any outstanding probe (called when this worker is
|
|
316
|
+
* replaced).
|
|
206
317
|
*/
|
|
207
|
-
function installWorkerDeathDetection(worker: SharedWorker): void {
|
|
318
|
+
function installWorkerDeathDetection(worker: SharedWorker): () => void {
|
|
208
319
|
const stamp = () => new Date().toISOString();
|
|
209
320
|
const warn = (msg: string) => {
|
|
210
321
|
if (lifecycleLoggingEnabled()) console.warn(`[lifecycle] ${stamp()} ${msg}`);
|
|
@@ -214,23 +325,37 @@ function installWorkerDeathDetection(worker: SharedWorker): void {
|
|
|
214
325
|
};
|
|
215
326
|
|
|
216
327
|
let instanceId: string | undefined;
|
|
217
|
-
let
|
|
328
|
+
let lastHeardAt = Date.now();
|
|
218
329
|
let warnedUnresponsive = false;
|
|
330
|
+
let disposed = false;
|
|
331
|
+
let probe: SharedWorker | undefined;
|
|
332
|
+
|
|
333
|
+
const closeProbe = () => {
|
|
334
|
+
if (!probe) return;
|
|
335
|
+
try {
|
|
336
|
+
probe.port.close();
|
|
337
|
+
} catch {
|
|
338
|
+
// Already closed.
|
|
339
|
+
}
|
|
340
|
+
probe = undefined;
|
|
341
|
+
};
|
|
342
|
+
let warnedSendFailed = false;
|
|
343
|
+
let pingsSent = 0;
|
|
219
344
|
|
|
220
345
|
worker.port.addEventListener("message", (event: MessageEvent) => {
|
|
221
346
|
const data = event.data;
|
|
222
347
|
if (data?.type !== "hello" && data?.type !== "pong") return;
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
348
|
+
lastHeardAt = Date.now();
|
|
349
|
+
warnedUnresponsive = false;
|
|
350
|
+
// The port spoke — any outstanding probe is moot.
|
|
351
|
+
closeProbe();
|
|
227
352
|
if (instanceId === undefined) {
|
|
228
353
|
instanceId = data.instanceId;
|
|
229
354
|
info(`automerge SharedWorker instance ${data.instanceId} (via ${data.type})`);
|
|
230
355
|
} else if (data.instanceId && data.instanceId !== instanceId) {
|
|
231
356
|
warn(
|
|
232
|
-
`automerge SharedWorker
|
|
233
|
-
`was ${instanceId})
|
|
357
|
+
`automerge SharedWorker instance changed (instance ${data.instanceId}, ` +
|
|
358
|
+
`was ${instanceId})`
|
|
234
359
|
);
|
|
235
360
|
instanceId = data.instanceId;
|
|
236
361
|
}
|
|
@@ -238,35 +363,107 @@ function installWorkerDeathDetection(worker: SharedWorker): void {
|
|
|
238
363
|
|
|
239
364
|
// Fires when the SharedWorker is destroyed (where supported).
|
|
240
365
|
worker.port.addEventListener("close", () => {
|
|
241
|
-
|
|
366
|
+
if (disposed) return;
|
|
367
|
+
warn("automerge SharedWorker control port closed");
|
|
368
|
+
void recoverAutomergeWorker("control port closed", worker);
|
|
242
369
|
});
|
|
243
370
|
|
|
371
|
+
// Not gated on the lifecycle toggle: a worker that fails to load never
|
|
372
|
+
// replies to anything, and this is the only signal that says so.
|
|
244
373
|
worker.addEventListener("error", event => {
|
|
245
|
-
|
|
374
|
+
const error = event as ErrorEvent;
|
|
375
|
+
console.error(
|
|
376
|
+
`[lifecycle] ${stamp()} automerge SharedWorker error:`,
|
|
377
|
+
error.message || event,
|
|
378
|
+
error.filename ? `(${error.filename}:${error.lineno})` : ""
|
|
379
|
+
);
|
|
246
380
|
});
|
|
247
381
|
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
|
|
382
|
+
// On probe hello, give the suspect port this long to also speak before
|
|
383
|
+
// concluding it's stranded: after a slow worker boot both connections hello
|
|
384
|
+
// at roughly the same moment and cross-port delivery order isn't guaranteed.
|
|
385
|
+
const PROBE_GRACE_MS = 500;
|
|
386
|
+
const startProbe = (reason: string) => {
|
|
387
|
+
if (probe || disposed) return;
|
|
388
|
+
warn(`automerge SharedWorker ${reason}; probing with a second connection`);
|
|
389
|
+
const startedAt = Date.now();
|
|
390
|
+
const p = new SharedWorker(automergeWorkerUrl(), {
|
|
391
|
+
name: "patchwork-automerge",
|
|
392
|
+
type: "module",
|
|
393
|
+
});
|
|
394
|
+
probe = p;
|
|
395
|
+
p.port.start();
|
|
396
|
+
p.port.addEventListener("message", (event: MessageEvent) => {
|
|
397
|
+
if (event.data?.type !== "hello") return;
|
|
398
|
+
setTimeout(() => {
|
|
399
|
+
if (disposed || probe !== p) return; // superseded or torn down
|
|
400
|
+
closeProbe();
|
|
401
|
+
// The suspect spoke while (or just after) the probe ran: it was
|
|
402
|
+
// merely slow/busy, and everything queued on it has been delivered.
|
|
403
|
+
if (lastHeardAt >= startedAt) return;
|
|
404
|
+
void recoverAutomergeWorker(
|
|
405
|
+
`port unresponsive on a live worker (${reason}; probe confirmed)`,
|
|
406
|
+
worker
|
|
407
|
+
);
|
|
408
|
+
}, PROBE_GRACE_MS);
|
|
409
|
+
});
|
|
410
|
+
// No hello on the probe means the instance is loading or busy (the probe
|
|
411
|
+
// waits indefinitely — its hello triggers the check above whenever it
|
|
412
|
+
// lands) — never tear anything down on a timer.
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
// A silent-too-long port while the tab is visible starts a probe (a miss
|
|
416
|
+
// while hidden is more likely suspension). Before first contact the budget
|
|
417
|
+
// is tighter: an idle worker hellos within milliseconds of connecting, so
|
|
418
|
+
// probing early costs nothing and rescues genuinely stranded boots fast.
|
|
419
|
+
const HEARTBEAT_MS = 5_000;
|
|
251
420
|
const HEARTBEAT_TIMEOUT_MS = 25_000;
|
|
421
|
+
const FIRST_CONTACT_TIMEOUT_MS = 4_000;
|
|
252
422
|
let seq = 0;
|
|
253
|
-
setInterval(() => {
|
|
423
|
+
const heartbeat = setInterval(() => {
|
|
254
424
|
try {
|
|
255
425
|
worker.port.postMessage({ type: "ping", id: ++seq });
|
|
256
|
-
|
|
257
|
-
|
|
426
|
+
pingsSent++;
|
|
427
|
+
} catch (error) {
|
|
428
|
+
// Swallowing this makes a failed send indistinguishable from a dead
|
|
429
|
+
// worker in the "no pong" warning below. Once, not every heartbeat.
|
|
430
|
+
if (!warnedSendFailed) {
|
|
431
|
+
warnedSendFailed = true;
|
|
432
|
+
console.error(
|
|
433
|
+
`[lifecycle] ${stamp()} automerge SharedWorker ping send threw ` +
|
|
434
|
+
`after ${pingsSent} sent:`,
|
|
435
|
+
error
|
|
436
|
+
);
|
|
437
|
+
}
|
|
258
438
|
}
|
|
259
|
-
const
|
|
439
|
+
const neverHeard = instanceId === undefined;
|
|
440
|
+
const silentMs = Date.now() - lastHeardAt;
|
|
441
|
+
const timeoutMs = neverHeard
|
|
442
|
+
? FIRST_CONTACT_TIMEOUT_MS
|
|
443
|
+
: HEARTBEAT_TIMEOUT_MS;
|
|
260
444
|
const visible =
|
|
261
445
|
typeof document === "undefined" || document.visibilityState === "visible";
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
446
|
+
// First contact probes regardless of visibility: SharedWorkers don't
|
|
447
|
+
// suspend with tab visibility, boots in background tabs must still get
|
|
448
|
+
// rescued, and the probe destroys nothing. Post-contact silence defers to
|
|
449
|
+
// visibility, since a hidden page's own throttling can fake it.
|
|
450
|
+
if (silentMs > timeoutMs && (neverHeard || visible)) {
|
|
451
|
+
const reason = neverHeard
|
|
452
|
+
? `no hello ~${Math.round(silentMs / 1000)}s after connecting`
|
|
453
|
+
: `no pong for ~${Math.round(silentMs / 1000)}s`;
|
|
454
|
+
if (!warnedUnresponsive) {
|
|
455
|
+
warnedUnresponsive = true;
|
|
456
|
+
warn(`automerge SharedWorker ${reason} (tab visible)`);
|
|
457
|
+
}
|
|
458
|
+
startProbe(reason);
|
|
459
|
+
}
|
|
269
460
|
}, HEARTBEAT_MS);
|
|
461
|
+
|
|
462
|
+
return () => {
|
|
463
|
+
disposed = true;
|
|
464
|
+
clearInterval(heartbeat);
|
|
465
|
+
closeProbe();
|
|
466
|
+
};
|
|
270
467
|
}
|
|
271
468
|
|
|
272
469
|
// ── Sync-state subscriptions ────────────────────────────────────────────
|
|
@@ -311,7 +508,10 @@ export function subscribeSyncState(
|
|
|
311
508
|
set.delete(listener);
|
|
312
509
|
if (set.size === 0) {
|
|
313
510
|
syncStateListeners.delete(documentId);
|
|
314
|
-
worker
|
|
511
|
+
// The worker may have been replaced since we subscribed (recovery
|
|
512
|
+
// replays subscriptions onto the new instance) — unsubscribe from
|
|
513
|
+
// whichever instance is current, not the one captured above.
|
|
514
|
+
automergeWorker?.port.postMessage({ type: "sync-unsub", documentId });
|
|
315
515
|
}
|
|
316
516
|
};
|
|
317
517
|
}
|
|
@@ -424,6 +624,12 @@ export default async function setupServiceWorker(
|
|
|
424
624
|
installServiceWorkerLogForwarding();
|
|
425
625
|
localStorage.removeItem(key);
|
|
426
626
|
|
|
627
|
+
// Ask for persistent storage so cache growth can't trip origin-wide
|
|
628
|
+
// eviction, which would take the Automerge IndexedDB — the user's documents
|
|
629
|
+
// — with it. Chrome/Safari decide silently from site engagement; Firefox may
|
|
630
|
+
// show a one-time prompt. Best-effort: denial just means default eviction.
|
|
631
|
+
void navigator.storage?.persist?.().catch(() => {});
|
|
632
|
+
|
|
427
633
|
if (options?.workerPath) automergeWorkerPath = options.workerPath;
|
|
428
634
|
|
|
429
635
|
// Start the automerge worker right away so it boots (wasm, repo) while the
|
|
@@ -481,10 +687,21 @@ export default async function setupServiceWorker(
|
|
|
481
687
|
getRepoChannel,
|
|
482
688
|
subscribeSyncState,
|
|
483
689
|
async subscribeToRepoChannel(listener: ServiceWorkerRepoChannelListener) {
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
|
|
487
|
-
|
|
690
|
+
// Called once with the boot port. If the automerge worker later dies
|
|
691
|
+
// and is recreated (recoverAutomergeWorker), the listener is called
|
|
692
|
+
// again with a fresh port — treat every call as "(re)wire your repo's
|
|
693
|
+
// sync onto this port".
|
|
694
|
+
repoChannelListeners.add(listener);
|
|
695
|
+
const generation = workerGeneration;
|
|
696
|
+
const port = await openRepoChannel();
|
|
697
|
+
// If the worker was replaced while this channel was opening (e.g. the
|
|
698
|
+
// port-ready wait timed out against a stranded connection and recovery
|
|
699
|
+
// already delivered a good port to this listener), drop the stale one
|
|
700
|
+
// rather than wiring the repo to a dead channel.
|
|
701
|
+
if (generation === workerGeneration) await listener(port);
|
|
702
|
+
return () => {
|
|
703
|
+
repoChannelListeners.delete(listener);
|
|
704
|
+
};
|
|
488
705
|
},
|
|
489
706
|
};
|
|
490
707
|
}
|
package/src/site.ts
CHANGED
|
@@ -37,7 +37,7 @@ import { MemorySigner } from "@automerge/automerge-subduction/slim";
|
|
|
37
37
|
|
|
38
38
|
declare const __SITE_NAME__: string;
|
|
39
39
|
const siteName =
|
|
40
|
-
typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "
|
|
40
|
+
typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "patchwork.inkandswitch.com";
|
|
41
41
|
|
|
42
42
|
// Sync-server selection for keyhive. Defaults to "subduction". Build with
|
|
43
43
|
// KEYHIVE_SYNC_SERVER=true to target keyhive.sync.automerge.org. This must match
|
|
@@ -48,7 +48,7 @@ const useKeyhiveSyncServer =
|
|
|
48
48
|
typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
|
|
49
49
|
|
|
50
50
|
import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
|
|
51
|
-
import {
|
|
51
|
+
import { importAutomergePackageViaWorker } from "./module-loader.js";
|
|
52
52
|
import {
|
|
53
53
|
openDocument,
|
|
54
54
|
registerPatchworkViewElement,
|
|
@@ -65,10 +65,7 @@ import {
|
|
|
65
65
|
} from "@inkandswitch/patchwork-plugins";
|
|
66
66
|
import * as plugins from "@inkandswitch/patchwork-plugins";
|
|
67
67
|
|
|
68
|
-
import setupServiceWorker, {
|
|
69
|
-
getAutomergeWorker,
|
|
70
|
-
lifecycleLoggingEnabled,
|
|
71
|
-
} from "./setup.js";
|
|
68
|
+
import setupServiceWorker, { lifecycleLoggingEnabled } from "./setup.js";
|
|
72
69
|
import type {
|
|
73
70
|
ServiceWorkerRepoChannelListener,
|
|
74
71
|
SyncStateDocMessage,
|
|
@@ -126,7 +123,7 @@ export interface SiteConfig {
|
|
|
126
123
|
* folder docs or plain HTTP(S) bundles, so deployment targets can be freely
|
|
127
124
|
* mixed.
|
|
128
125
|
*
|
|
129
|
-
* Can be overridden at runtime by setting `localStorage.
|
|
126
|
+
* Can be overridden at runtime by setting `localStorage.systemPackageListURL` to
|
|
130
127
|
* another `automerge:` URL or manifest URL — useful for local development
|
|
131
128
|
* against an unpublished tool set.
|
|
132
129
|
*/
|
|
@@ -179,7 +176,7 @@ const BIG_PATCHWORK_HASH_REGEX =
|
|
|
179
176
|
/^(?<title>[^=&?/#]*)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)/;
|
|
180
177
|
|
|
181
178
|
const [automergeWasm, subductionWasm] = await Promise.all([
|
|
182
|
-
fetch("/automerge.wasm
|
|
179
|
+
fetch("/automerge.wasm").then((r) => r.bytes()),
|
|
183
180
|
fetch("/subduction.wasm").then((r) => r.bytes()),
|
|
184
181
|
]);
|
|
185
182
|
|
|
@@ -211,6 +208,10 @@ export async function bootPatchworkSite(
|
|
|
211
208
|
let repo: Repo;
|
|
212
209
|
let tabSignerIdentity: { peerId: string; verifyingKey: string } | undefined;
|
|
213
210
|
|
|
211
|
+
// Called with a fresh port when the automerge worker dies and is recreated
|
|
212
|
+
// (see recoverAutomergeWorker in setup.ts); assigned once the repo exists.
|
|
213
|
+
let onWorkerPortRenewed: ((port: MessagePort) => void) | undefined;
|
|
214
|
+
|
|
214
215
|
// If a Repo is already on `window` — an embedding context provided one before
|
|
215
216
|
// this entry ran — reuse it and its keyhive instead of standing up a fresh
|
|
216
217
|
// realm-local Repo, so we share the same documents and sync/keyhive context.
|
|
@@ -222,14 +223,34 @@ export async function bootPatchworkSite(
|
|
|
222
223
|
} else {
|
|
223
224
|
// Get the initial automerge-worker port via subscribeToRepoChannel,
|
|
224
225
|
// then pass it to keyhive init which wraps it in its own network adapter.
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
226
|
+
// The listener is called again with a fresh port if the worker is ever
|
|
227
|
+
// recreated after dying.
|
|
228
|
+
let resolveFirstPort!: (port: MessagePort) => void;
|
|
229
|
+
const firstPortPromise = new Promise<MessagePort>((r) => {
|
|
230
|
+
resolveFirstPort = r;
|
|
228
231
|
});
|
|
232
|
+
let seenFirstPort = false;
|
|
229
233
|
log("subscribing to repo channel");
|
|
230
|
-
|
|
234
|
+
// Deliberately not awaited: subscribeToRepoChannel resolves only after
|
|
235
|
+
// the boot channel's port-ready handshake, which can take its full 30s
|
|
236
|
+
// timeout against a stranded worker connection. Boot should block on the
|
|
237
|
+
// first *delivered* port instead — if the boot channel stalls, worker
|
|
238
|
+
// recovery hands the listener a good port long before that timeout.
|
|
239
|
+
void sw.subscribeToRepoChannel((port) => {
|
|
240
|
+
if (!seenFirstPort) {
|
|
241
|
+
seenFirstPort = true;
|
|
242
|
+
resolveFirstPort(port);
|
|
243
|
+
} else if (onWorkerPortRenewed) {
|
|
244
|
+
onWorkerPortRenewed(port);
|
|
245
|
+
} else {
|
|
246
|
+
console.warn(
|
|
247
|
+
"automerge worker port renewed before the repo existed; dropping it"
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
const workerPort = await firstPortPromise;
|
|
231
252
|
log("repo channel subscribed");
|
|
232
|
-
|
|
253
|
+
let workerAdapter = new MessageChannelNetworkAdapter(workerPort);
|
|
233
254
|
|
|
234
255
|
if (config.keyhive) {
|
|
235
256
|
log("setting up keyhive");
|
|
@@ -239,7 +260,7 @@ export async function bootPatchworkSite(
|
|
|
239
260
|
createRepo: (config) => new Repo(config),
|
|
240
261
|
storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
|
|
241
262
|
peerIdSuffix: siteName + Math.random().toString(36).slice(2),
|
|
242
|
-
networkAdapter:
|
|
263
|
+
networkAdapter: workerAdapter,
|
|
243
264
|
automaticArchiveIngestion: true,
|
|
244
265
|
cachingMode: "periodic",
|
|
245
266
|
onlyShareWithHardcodedServerPeerId: false,
|
|
@@ -260,7 +281,7 @@ export async function bootPatchworkSite(
|
|
|
260
281
|
// id never goes on the wire.
|
|
261
282
|
const tabSigner = new MemorySigner();
|
|
262
283
|
repo = new Repo({
|
|
263
|
-
network: [
|
|
284
|
+
network: [workerAdapter],
|
|
264
285
|
storage: new IndexedDBWorkerStorageAdapter(),
|
|
265
286
|
signer: tabSigner,
|
|
266
287
|
async sharePolicy(peerId) {
|
|
@@ -281,6 +302,37 @@ export async function bootPatchworkSite(
|
|
|
281
302
|
console.log("[patchwork] tab subduction identity:", tabSignerIdentity);
|
|
282
303
|
log("repo created");
|
|
283
304
|
}
|
|
305
|
+
|
|
306
|
+
// The worker was recreated with cold state: wire the repo onto the fresh
|
|
307
|
+
// port and drop the adapter stranded on the dead one. `repo`/`hive` are
|
|
308
|
+
// settled by the time this can fire (recovery needs a missed heartbeat).
|
|
309
|
+
const bootHive = hive;
|
|
310
|
+
onWorkerPortRenewed = (port) => {
|
|
311
|
+
const fresh = new MessageChannelNetworkAdapter(port);
|
|
312
|
+
// Mirror the boot wiring: a keyhive repo talks to the worker through a
|
|
313
|
+
// keyhive adapter wrapped around the message channel (same parameters
|
|
314
|
+
// the worker uses for its side of the pair).
|
|
315
|
+
const registered = bootHive
|
|
316
|
+
? bootHive.createKeyhiveNetworkAdapter(fresh, false, false, 2000)
|
|
317
|
+
: fresh;
|
|
318
|
+
repo.networkSubsystem.addNetworkAdapter(registered as any);
|
|
319
|
+
for (const adapter of [...repo.networkSubsystem.adapters]) {
|
|
320
|
+
if (adapter === (registered as any)) continue;
|
|
321
|
+
// The keyhive wrapper keeps the wrapped adapter on `.networkAdapter`.
|
|
322
|
+
const base = (adapter as any).networkAdapter ?? adapter;
|
|
323
|
+
if (base !== workerAdapter) continue;
|
|
324
|
+
try {
|
|
325
|
+
repo.networkSubsystem.removeNetworkAdapter(adapter as any);
|
|
326
|
+
} catch (err) {
|
|
327
|
+
console.error("failed to remove stale worker network adapter", err);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
workerAdapter = fresh;
|
|
331
|
+
console.warn(
|
|
332
|
+
`[lifecycle] ${new Date().toISOString()} repo re-wired to the ` +
|
|
333
|
+
`recreated automerge worker`
|
|
334
|
+
);
|
|
335
|
+
};
|
|
284
336
|
}
|
|
285
337
|
log("popping repo on window");
|
|
286
338
|
window.repo = repo;
|
|
@@ -322,7 +374,7 @@ export async function bootPatchworkSite(
|
|
|
322
374
|
unregisterPlugins,
|
|
323
375
|
// Discover an Automerge package's plugin descriptors off the main thread;
|
|
324
376
|
// each plugin's load() re-imports the package (at heads) on this thread.
|
|
325
|
-
|
|
377
|
+
importAutomergePackageViaWorker
|
|
326
378
|
);
|
|
327
379
|
|
|
328
380
|
const accountDocHandle = (await resolveAccountHandle(repo, {
|
|
@@ -381,7 +433,7 @@ function isValidModuleSource(source: string): boolean {
|
|
|
381
433
|
|
|
382
434
|
/**
|
|
383
435
|
* Resolve the site's default module-list sources, honouring the
|
|
384
|
-
* `localStorage.
|
|
436
|
+
* `localStorage.systemPackageListURL` dev override (which replaces the entire
|
|
385
437
|
* built-in default bundle).
|
|
386
438
|
*/
|
|
387
439
|
function resolveDefaultModules(config: SiteConfig): string[] {
|
|
@@ -390,18 +442,22 @@ function resolveDefaultModules(config: SiteConfig): string[] {
|
|
|
390
442
|
Boolean
|
|
391
443
|
);
|
|
392
444
|
|
|
393
|
-
const
|
|
445
|
+
const storage = globalThis.localStorage;
|
|
446
|
+
// `defaultToolsUrl` is the pre-rename key, still honoured for existing browsers.
|
|
447
|
+
const override =
|
|
448
|
+
storage?.getItem("systemPackageListURL") ??
|
|
449
|
+
storage?.getItem("defaultToolsUrl");
|
|
394
450
|
if (override) {
|
|
395
451
|
if (isValidModuleSource(override)) {
|
|
396
452
|
if (!builtinList.includes(override)) {
|
|
397
453
|
console.info(
|
|
398
|
-
`using
|
|
454
|
+
`using systemPackageListURL override from localStorage: ${override}`
|
|
399
455
|
);
|
|
400
456
|
}
|
|
401
457
|
return [override];
|
|
402
458
|
}
|
|
403
459
|
console.warn(
|
|
404
|
-
`ignoring invalid
|
|
460
|
+
`ignoring invalid systemPackageListURL in localStorage: ${override}; using built-in default`
|
|
405
461
|
);
|
|
406
462
|
}
|
|
407
463
|
|
|
@@ -462,16 +518,26 @@ function installLifecycleLogging(): void {
|
|
|
462
518
|
() => note(`visibilitychange → ${document.visibilityState}`),
|
|
463
519
|
opts
|
|
464
520
|
);
|
|
465
|
-
document.addEventListener(
|
|
466
|
-
|
|
521
|
+
document.addEventListener(
|
|
522
|
+
"freeze",
|
|
523
|
+
() => note("freeze (tab suspended)"),
|
|
524
|
+
opts
|
|
525
|
+
);
|
|
526
|
+
document.addEventListener(
|
|
527
|
+
"resume",
|
|
528
|
+
() => note("resume (tab unsuspended)"),
|
|
529
|
+
opts
|
|
530
|
+
);
|
|
467
531
|
window.addEventListener(
|
|
468
532
|
"pageshow",
|
|
469
|
-
|
|
533
|
+
(e) =>
|
|
534
|
+
note("pageshow", { persisted: (e as PageTransitionEvent).persisted }),
|
|
470
535
|
opts
|
|
471
536
|
);
|
|
472
537
|
window.addEventListener(
|
|
473
538
|
"pagehide",
|
|
474
|
-
|
|
539
|
+
(e) =>
|
|
540
|
+
note("pagehide", { persisted: (e as PageTransitionEvent).persisted }),
|
|
475
541
|
opts
|
|
476
542
|
);
|
|
477
543
|
window.addEventListener("online", () => note("online"), opts);
|
package/src/types.ts
CHANGED
|
@@ -161,7 +161,28 @@ export interface HandoffResponseMessage {
|
|
|
161
161
|
response: HandoffResponse;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
|
|
164
|
+
/**
|
|
165
|
+
* Automerge worker → service worker: fail the request as a network error
|
|
166
|
+
* rather than serving any response at all.
|
|
167
|
+
*
|
|
168
|
+
* "Heads haven't arrived yet" is not a 404 — the document may well exist, so
|
|
169
|
+
* a status implying it doesn't is a lie any HTTP cache is entitled to store.
|
|
170
|
+
* A network error is the honest answer and isn't storable as a response.
|
|
171
|
+
*
|
|
172
|
+
* This does *not* help with `import()`: the ES module map memoizes failed
|
|
173
|
+
* fetches, network errors included, so a retry needs a distinct URL either
|
|
174
|
+
* way (see `importModule` in patchwork-filesystem).
|
|
175
|
+
*/
|
|
176
|
+
export interface HandoffAbortMessage {
|
|
177
|
+
id: string;
|
|
178
|
+
type: "abort";
|
|
179
|
+
reason: string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export type HandoffReplyMessage =
|
|
183
|
+
| HandoffCachedMessage
|
|
184
|
+
| HandoffResponseMessage
|
|
185
|
+
| HandoffAbortMessage;
|
|
165
186
|
|
|
166
187
|
/**
|
|
167
188
|
* Automerge worker → world: broadcast once on startup so the service worker
|