@inkandswitch/patchwork-bootloader 0.4.3 → 0.4.4
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 +11 -0
- package/dist/automerge-worker.js +543 -771
- package/dist/module-loader.d.ts +0 -5
- package/dist/module-loader.js +0 -6
- package/dist/service-worker.js +174 -205
- package/dist/setup.d.ts +2 -1
- package/dist/setup.js +251 -375
- package/dist/site.d.ts +17 -32
- package/dist/site.js +291 -376
- package/dist/vite/importmap-plugin.js +1 -5
- package/package.json +4 -4
- package/src/automerge-worker.ts +635 -833
- package/src/module-loader.ts +0 -6
- package/src/service-worker.ts +219 -238
- package/src/setup.ts +287 -401
- package/src/site.ts +377 -439
- package/src/vite/importmap-plugin.ts +1 -5
package/src/setup.ts
CHANGED
|
@@ -17,139 +17,193 @@ import {
|
|
|
17
17
|
const serviceWorkerDebugging = debug.enabled("patchwork:serviceworker");
|
|
18
18
|
const workerDebugging = debug.enabled("patchwork:automergeworker");
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return true;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// The SW can't read localStorage, so it always emits [lifecycle] markers and
|
|
33
|
-
// forwards them as `sw-lifecycle`; gate rendering here on the live toggle.
|
|
34
|
-
let swLifecycleListenerInstalled = false;
|
|
35
|
-
function installServiceWorkerLogForwarding(): void {
|
|
36
|
-
if (swLifecycleListenerInstalled) return;
|
|
37
|
-
if (typeof navigator === "undefined" || !navigator.serviceWorker) return;
|
|
38
|
-
swLifecycleListenerInstalled = true;
|
|
39
|
-
navigator.serviceWorker.addEventListener("message", (event: MessageEvent) => {
|
|
40
|
-
const data = event.data;
|
|
41
|
-
if (data?.type !== "sw-lifecycle") return;
|
|
42
|
-
if (!lifecycleLoggingEnabled()) return;
|
|
43
|
-
const fn = (console as any)[data.level] ?? console.log;
|
|
44
|
-
fn(`[service-worker] ${data.msg}`);
|
|
45
|
-
});
|
|
20
|
+
export const lifecycleLog = debug("patchwork:lifecycle");
|
|
21
|
+
|
|
22
|
+
function describeErrorEvent(event: Event): string {
|
|
23
|
+
const error = event as ErrorEvent;
|
|
24
|
+
const where = error.filename
|
|
25
|
+
? ` (${error.filename}:${error.lineno}:${error.colno})`
|
|
26
|
+
: "";
|
|
27
|
+
return `${error.message || String(event)}${where}`;
|
|
46
28
|
}
|
|
47
29
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
localStorage.setItem(key, version);
|
|
55
|
-
return getServiceWorkerCacheVersion();
|
|
56
|
-
}
|
|
30
|
+
// The version is cleared on every boot, so the steady state is
|
|
31
|
+
// DEFAULT_CACHE_NAME. bumpServiceWorkerCache is a dev escape hatch: it moves
|
|
32
|
+
// the worker to a throwaway cache now, and the next boot both reverts the name
|
|
33
|
+
// and (via the worker's activate handler) deletes the throwaway.
|
|
34
|
+
const CACHE_VERSION_KEY = "patchworkServiceWorkerCacheVersion";
|
|
35
|
+
const DEFAULT_CACHE_NAME = "patchwork";
|
|
57
36
|
|
|
58
|
-
function
|
|
59
|
-
return localStorage.getItem(
|
|
37
|
+
function currentCacheName(): string {
|
|
38
|
+
return localStorage.getItem(CACHE_VERSION_KEY) ?? DEFAULT_CACHE_NAME;
|
|
60
39
|
}
|
|
61
40
|
|
|
62
|
-
function
|
|
63
|
-
if (!sw)
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
sw.postMessage({
|
|
67
|
-
type: "cachename",
|
|
68
|
-
cachename: getServiceWorkerCacheVersion() ?? defaultServiceWorkerCacheName,
|
|
69
|
-
});
|
|
41
|
+
function configureServiceWorker(sw: ServiceWorker | null) {
|
|
42
|
+
if (!sw) return;
|
|
43
|
+
sw.postMessage({ type: "debug", debug: serviceWorkerDebugging });
|
|
44
|
+
sw.postMessage({ type: "cachename", cachename: currentCacheName() });
|
|
70
45
|
}
|
|
71
46
|
|
|
72
47
|
export function bumpServiceWorkerCache(
|
|
73
|
-
sw = navigator.serviceWorker.controller
|
|
48
|
+
sw: ServiceWorker | null = navigator.serviceWorker.controller
|
|
74
49
|
) {
|
|
75
|
-
|
|
76
|
-
|
|
50
|
+
if (!sw) throw new Error("no service worker!");
|
|
51
|
+
localStorage.setItem(CACHE_VERSION_KEY, Date.now().toString(36));
|
|
52
|
+
sw.postMessage({ type: "cachename", cachename: currentCacheName() });
|
|
77
53
|
}
|
|
78
54
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
55
|
+
// The service worker has no localStorage, so it can't read the debug config —
|
|
56
|
+
// it always emits lifecycle markers and forwards them here to be filtered.
|
|
57
|
+
let logForwardingInstalled = false;
|
|
58
|
+
function installServiceWorkerLogForwarding(): void {
|
|
59
|
+
if (logForwardingInstalled) return;
|
|
60
|
+
if (typeof navigator === "undefined" || !navigator.serviceWorker) return;
|
|
61
|
+
logForwardingInstalled = true;
|
|
62
|
+
navigator.serviceWorker.addEventListener("message", (event: MessageEvent) => {
|
|
63
|
+
if (event.data?.type !== "sw-lifecycle") return;
|
|
64
|
+
lifecycleLog("[service-worker] %s", event.data.msg);
|
|
87
65
|
});
|
|
88
66
|
}
|
|
89
67
|
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
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.
|
|
68
|
+
// The automerge repo lives in a SharedWorker. one instance serves every
|
|
69
|
+
// tab. Browsers might kill a SharedWorker under memory pressure, so we
|
|
70
|
+
// heartbeat it and rebuild everything if it dies.
|
|
97
71
|
|
|
98
72
|
let automergeWorkerPath = "/automerge-worker.js";
|
|
99
73
|
let automergeWorker: SharedWorker | undefined;
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
// a dead worker), so deliveries are guarded on the generation they started in.
|
|
74
|
+
// A repo port opened against instance N is stale once instance N+1 exists — its
|
|
75
|
+
// channel ends in a dead worker — so deliveries are guarded on generation.
|
|
103
76
|
let workerGeneration = 0;
|
|
104
|
-
// Tears down the current worker's heartbeat when it's replaced.
|
|
105
77
|
let disposeWorkerDeathDetection: (() => void) | undefined;
|
|
106
|
-
// Every subscribeToRepoChannel listener, kept so a recovered worker can hand
|
|
107
|
-
// each subscriber a fresh repo port.
|
|
108
78
|
const repoChannelListeners = new Set<ServiceWorkerRepoChannelListener>();
|
|
109
79
|
let recoveringWorker = false;
|
|
110
80
|
let lastWorkerRecoveryAt = 0;
|
|
111
|
-
// Below this spacing, skip: if the fresh worker is dead too, its own
|
|
112
|
-
//
|
|
81
|
+
// Below this spacing, skip: if the fresh worker is dead too, its own heartbeat
|
|
82
|
+
// re-triggers recovery later rather than spinning in a tight loop.
|
|
113
83
|
const RECOVERY_MIN_INTERVAL_MS = 15_000;
|
|
84
|
+
let nextRepoChannelId = 0;
|
|
85
|
+
|
|
86
|
+
// Chrome can't spawn workers inside a SharedWorker, so each tab offers this
|
|
87
|
+
// proxy's port to the automerge worker, which requests one via its port
|
|
88
|
+
// provider. Being a SharedWorker itself, the proxy — and the donated
|
|
89
|
+
// worker↔worker port — outlives the donor tab.
|
|
90
|
+
const SUBDUCTION_IO_WORKER_URL =
|
|
91
|
+
"/packages/@automerge/automerge-repo/subduction-websocket-worker-shared.js";
|
|
92
|
+
|
|
93
|
+
export function getAutomergeWorker(): SharedWorker {
|
|
94
|
+
if (automergeWorker) return automergeWorker;
|
|
95
|
+
|
|
96
|
+
workerGeneration++;
|
|
97
|
+
const worker = new SharedWorker(automergeWorkerPath, {
|
|
98
|
+
name: "patchwork-automerge",
|
|
99
|
+
type: "module",
|
|
100
|
+
});
|
|
101
|
+
automergeWorker = worker;
|
|
102
|
+
|
|
103
|
+
// Fires when a message can't be structured-deserialized. Silent otherwise:
|
|
104
|
+
// the message is dropped, which looks identical to a worker that never
|
|
105
|
+
// replied.
|
|
106
|
+
worker.port.addEventListener("messageerror", (event) => {
|
|
107
|
+
console.error(
|
|
108
|
+
"[automerge-worker] undeserializable message from worker:",
|
|
109
|
+
event
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
// Control replies come back on this port, and we listen with
|
|
113
|
+
// addEventListener rather than onmessage, so it needs start().
|
|
114
|
+
worker.port.start();
|
|
115
|
+
worker.port.addEventListener("message", handleWorkerMessage);
|
|
116
|
+
worker.port.postMessage({ type: "debug", debug: workerDebugging });
|
|
117
|
+
|
|
118
|
+
donatePort(worker.port, createSubductionIoPort);
|
|
119
|
+
disposeWorkerDeathDetection = installWorkerDeathDetection(worker);
|
|
120
|
+
return worker;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function handleWorkerMessage(event: MessageEvent): void {
|
|
124
|
+
const data = event.data;
|
|
125
|
+
|
|
126
|
+
if (data?.type === "sync-state") {
|
|
127
|
+
dispatchSyncState(data as SyncStateDocMessage);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Crash/skew reports relayed from the subduction io proxy (e.g. a protocol
|
|
132
|
+
// mismatch from a stale SW-cached worker chunk). These otherwise only exist
|
|
133
|
+
// in chrome://inspect.
|
|
134
|
+
if (isWorkerErrorMessage(data)) {
|
|
135
|
+
console.error("[subduction-io]", data);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (data?.type !== "console") return;
|
|
140
|
+
const { level, args } = data;
|
|
141
|
+
if (
|
|
142
|
+
!lifecycleLog.enabled &&
|
|
143
|
+
typeof args?.[0] === "string" &&
|
|
144
|
+
args[0].includes("[lifecycle]")
|
|
145
|
+
) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const write = (console as any)[level] ?? console.log;
|
|
149
|
+
// The worker's logs carry %c directives in args[0] with CSS in the following
|
|
150
|
+
// args, so the tag has to go inside the format string or the CSS prints raw.
|
|
151
|
+
if (typeof args[0] === "string") {
|
|
152
|
+
write(`[automerge-worker] ${args[0]}`, ...args.slice(1));
|
|
153
|
+
} else {
|
|
154
|
+
write("[automerge-worker]", ...args);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function createSubductionIoPort(): MessagePort {
|
|
159
|
+
const io = new SharedWorker(SUBDUCTION_IO_WORKER_URL, {
|
|
160
|
+
type: "module",
|
|
161
|
+
name: "subduction-websocket",
|
|
162
|
+
});
|
|
163
|
+
// This worker carries the websocket to the sync server, so a load failure
|
|
164
|
+
// stops sync with no other symptom.
|
|
165
|
+
io.addEventListener("error", (event) => {
|
|
166
|
+
console.error(
|
|
167
|
+
`[subduction-io] failed to load/run ${SUBDUCTION_IO_WORKER_URL}:`,
|
|
168
|
+
describeErrorEvent(event)
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
io.port.addEventListener("messageerror", (event) => {
|
|
172
|
+
console.error("[subduction-io] undeserializable message:", event);
|
|
173
|
+
});
|
|
174
|
+
return io.port;
|
|
175
|
+
}
|
|
114
176
|
|
|
115
177
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* forwarding and the io-proxy port donation (both re-done by
|
|
178
|
+
* Build a replacement worker and re-wire everything a live tab holds against
|
|
179
|
+
* it: console forwarding and port donation (both re-done by
|
|
119
180
|
* getAutomergeWorker), the per-doc sync-state subscriptions, and every
|
|
120
|
-
* subscriber's repo
|
|
121
|
-
* repo is reconstructed on the first port we send.
|
|
181
|
+
* subscriber's repo port. The new instance boots with cold state.
|
|
122
182
|
*/
|
|
123
183
|
async function recoverAutomergeWorker(
|
|
124
184
|
reason: string,
|
|
125
185
|
deadWorker: SharedWorker
|
|
126
186
|
): Promise<void> {
|
|
127
|
-
if (deadWorker !== automergeWorker) return;
|
|
187
|
+
if (deadWorker !== automergeWorker) return;
|
|
128
188
|
if (recoveringWorker) return;
|
|
129
189
|
const now = Date.now();
|
|
130
190
|
if (now - lastWorkerRecoveryAt < RECOVERY_MIN_INTERVAL_MS) return;
|
|
131
191
|
recoveringWorker = true;
|
|
132
192
|
lastWorkerRecoveryAt = now;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
`SharedWorker (${reason})`
|
|
136
|
-
);
|
|
193
|
+
lifecycleLog("recreating the automerge SharedWorker (%s)", reason);
|
|
194
|
+
|
|
137
195
|
try {
|
|
138
196
|
disposeWorkerDeathDetection?.();
|
|
139
197
|
disposeWorkerDeathDetection = undefined;
|
|
140
198
|
automergeWorker = undefined;
|
|
141
199
|
try {
|
|
142
200
|
deadWorker.port.close();
|
|
143
|
-
} catch {
|
|
144
|
-
|
|
145
|
-
}
|
|
201
|
+
} catch {}
|
|
202
|
+
|
|
146
203
|
const fresh = getAutomergeWorker();
|
|
147
|
-
// The fresh instance knows nothing — replay every doc subscription.
|
|
148
204
|
for (const documentId of syncStateListeners.keys()) {
|
|
149
205
|
fresh.port.postMessage({ type: "sync-sub", documentId });
|
|
150
206
|
}
|
|
151
|
-
// Hand every repo-channel subscriber a fresh port so their repos sync
|
|
152
|
-
// again (the old adapters sit on dead MessagePorts).
|
|
153
207
|
for (const listener of repoChannelListeners) {
|
|
154
208
|
try {
|
|
155
209
|
const generation = workerGeneration;
|
|
@@ -169,225 +223,83 @@ async function recoverAutomergeWorker(
|
|
|
169
223
|
}
|
|
170
224
|
}
|
|
171
225
|
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
const params = new URLSearchParams();
|
|
189
|
-
try {
|
|
190
|
-
for (const [key, param] of [
|
|
191
|
-
["patchwork:ws-mode", "ws-mode"],
|
|
192
|
-
["patchwork:ws-window", "ws-window"],
|
|
193
|
-
] as const) {
|
|
194
|
-
const value = globalThis.localStorage?.getItem(key);
|
|
195
|
-
if (value) params.set(param, value);
|
|
196
|
-
}
|
|
197
|
-
} catch {
|
|
198
|
-
// No localStorage (shouldn't happen in a tab) — use defaults.
|
|
199
|
-
}
|
|
200
|
-
const qs = params.toString();
|
|
201
|
-
return qs ? `?${qs}` : "";
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function automergeWorkerUrl(): string {
|
|
205
|
-
return `${automergeWorkerPath}${workerBenchParams()}`;
|
|
206
|
-
}
|
|
226
|
+
// A silent port is not proof of death: the worker may still be evaluating its
|
|
227
|
+
// module graph, or be busy with wasm/sync work. In both cases every queued
|
|
228
|
+
// message — including the repo ports the network adapters ride on — is
|
|
229
|
+
// delivered once it catches up, and tearing the port down would lose them. So
|
|
230
|
+
// silence only starts a non-destructive probe: a second connection to the same
|
|
231
|
+
// instance. Only if the probe gets a `hello` while this port stays silent do we
|
|
232
|
+
// know the instance is alive but our port is stranded, and recover.
|
|
233
|
+
const HEARTBEAT_MS = 5_000;
|
|
234
|
+
const HEARTBEAT_TIMEOUT_MS = 25_000;
|
|
235
|
+
// An idle worker hellos within milliseconds of connecting, so before first
|
|
236
|
+
// contact the budget is tighter — probing early rescues stranded boots fast.
|
|
237
|
+
const FIRST_CONTACT_TIMEOUT_MS = 4_000;
|
|
238
|
+
// After a slow boot both connections hello at roughly the same moment and
|
|
239
|
+
// cross-port delivery order isn't guaranteed, so give the suspect this long to
|
|
240
|
+
// also speak before concluding it's stranded.
|
|
241
|
+
const PROBE_GRACE_MS = 500;
|
|
207
242
|
|
|
208
|
-
export function getAutomergeWorker(): SharedWorker {
|
|
209
|
-
if (!automergeWorker) {
|
|
210
|
-
workerGeneration++;
|
|
211
|
-
automergeWorker = new SharedWorker(automergeWorkerUrl(), {
|
|
212
|
-
name: "patchwork-automerge",
|
|
213
|
-
type: "module",
|
|
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
|
-
});
|
|
225
|
-
// Control replies (port-ready &c) come back on this port, so it needs
|
|
226
|
-
// start() — we listen with addEventListener, not onmessage.
|
|
227
|
-
automergeWorker.port.start();
|
|
228
|
-
// Surface the SharedWorker's console output and uncaught errors in this
|
|
229
|
-
// tab's console (it has its own console that's awkward to find otherwise).
|
|
230
|
-
automergeWorker.port.addEventListener("message", (event: MessageEvent) => {
|
|
231
|
-
if (event.data?.type === "sync-state") {
|
|
232
|
-
dispatchSyncState(event.data as SyncStateDocMessage);
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
if (isWorkerErrorMessage(event.data)) {
|
|
236
|
-
// Crash/skew reports relayed from the subduction io proxy (e.g.
|
|
237
|
-
// protocol-mismatch from a stale SW-cached worker chunk). Surface
|
|
238
|
-
// loudly — these otherwise only exist in chrome://inspect.
|
|
239
|
-
console.error("[subduction-io]", event.data);
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
if (event.data?.type === "drift-samples") {
|
|
243
|
-
// Keepalive-drift samples from the worker's bench probe. Kept on a
|
|
244
|
-
// bounded window global for the Playwright bench to harvest.
|
|
245
|
-
const sink = ((window as any).__driftSamples ??= []) as number[];
|
|
246
|
-
sink.push(...event.data.samples);
|
|
247
|
-
if (sink.length > 10_000) sink.splice(0, sink.length - 10_000);
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
if (event.data?.type !== "console") return;
|
|
251
|
-
const { level, args } = event.data;
|
|
252
|
-
// Gate forwarded [lifecycle] logs on the toggle too.
|
|
253
|
-
if (
|
|
254
|
-
!lifecycleLoggingEnabled() &&
|
|
255
|
-
typeof args?.[0] === "string" &&
|
|
256
|
-
args[0].includes("[lifecycle]")
|
|
257
|
-
) {
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
const fn = (console as any)[level] ?? console.log;
|
|
261
|
-
// The worker's logs (debug library, the worker's own log()) carry %c
|
|
262
|
-
// format directives in args[0] with CSS in the following args. Prefix
|
|
263
|
-
// the tag into the format string rather than as a separate positional,
|
|
264
|
-
// or the %c would no longer be in arg 0 and the CSS would print raw.
|
|
265
|
-
if (typeof args[0] === "string") {
|
|
266
|
-
fn(`[automerge-worker] ${args[0]}`, ...args.slice(1));
|
|
267
|
-
} else {
|
|
268
|
-
fn("[automerge-worker]", ...args);
|
|
269
|
-
}
|
|
270
|
-
});
|
|
271
|
-
automergeWorker.port.postMessage({ type: "debug", debug: workerDebugging });
|
|
272
|
-
|
|
273
|
-
// Offer the subduction io proxy's port; the worker's port provider pulls
|
|
274
|
-
// it when (re)constructing its WorkerWebSocketEndpoint.
|
|
275
|
-
donatePort(automergeWorker.port, () => {
|
|
276
|
-
const io = new SharedWorker(SUBDUCTION_IO_WORKER_URL, {
|
|
277
|
-
type: "module",
|
|
278
|
-
name: "subduction-websocket",
|
|
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
|
-
});
|
|
293
|
-
return io.port;
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
disposeWorkerDeathDetection = installWorkerDeathDetection(automergeWorker);
|
|
297
|
-
}
|
|
298
|
-
return automergeWorker;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/**
|
|
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).
|
|
317
|
-
*/
|
|
318
243
|
function installWorkerDeathDetection(worker: SharedWorker): () => void {
|
|
319
|
-
const stamp = () => new Date().toISOString();
|
|
320
|
-
const warn = (msg: string) => {
|
|
321
|
-
if (lifecycleLoggingEnabled()) console.warn(`[lifecycle] ${stamp()} ${msg}`);
|
|
322
|
-
};
|
|
323
|
-
const info = (msg: string) => {
|
|
324
|
-
if (lifecycleLoggingEnabled()) console.info(`[lifecycle] ${stamp()} ${msg}`);
|
|
325
|
-
};
|
|
326
|
-
|
|
327
244
|
let instanceId: string | undefined;
|
|
328
245
|
let lastHeardAt = Date.now();
|
|
329
246
|
let warnedUnresponsive = false;
|
|
247
|
+
let warnedSendFailed = false;
|
|
330
248
|
let disposed = false;
|
|
331
249
|
let probe: SharedWorker | undefined;
|
|
250
|
+
let seq = 0;
|
|
332
251
|
|
|
333
252
|
const closeProbe = () => {
|
|
334
253
|
if (!probe) return;
|
|
335
254
|
try {
|
|
336
255
|
probe.port.close();
|
|
337
|
-
} catch {
|
|
338
|
-
// Already closed.
|
|
339
|
-
}
|
|
256
|
+
} catch {}
|
|
340
257
|
probe = undefined;
|
|
341
258
|
};
|
|
342
|
-
let warnedSendFailed = false;
|
|
343
|
-
let pingsSent = 0;
|
|
344
259
|
|
|
345
260
|
worker.port.addEventListener("message", (event: MessageEvent) => {
|
|
346
261
|
const data = event.data;
|
|
347
262
|
if (data?.type !== "hello" && data?.type !== "pong") return;
|
|
348
263
|
lastHeardAt = Date.now();
|
|
349
264
|
warnedUnresponsive = false;
|
|
350
|
-
// The port spoke — any outstanding probe is moot.
|
|
351
265
|
closeProbe();
|
|
352
266
|
if (instanceId === undefined) {
|
|
353
267
|
instanceId = data.instanceId;
|
|
354
|
-
|
|
268
|
+
lifecycleLog(
|
|
269
|
+
"automerge SharedWorker instance %s (via %s)",
|
|
270
|
+
data.instanceId,
|
|
271
|
+
data.type
|
|
272
|
+
);
|
|
355
273
|
} else if (data.instanceId && data.instanceId !== instanceId) {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
274
|
+
lifecycleLog(
|
|
275
|
+
"automerge SharedWorker instance changed (instance %s, was %s)",
|
|
276
|
+
data.instanceId,
|
|
277
|
+
instanceId
|
|
359
278
|
);
|
|
360
279
|
instanceId = data.instanceId;
|
|
361
280
|
}
|
|
362
281
|
});
|
|
363
282
|
|
|
364
|
-
// Fires when the SharedWorker is destroyed (where supported).
|
|
365
283
|
worker.port.addEventListener("close", () => {
|
|
366
284
|
if (disposed) return;
|
|
367
|
-
|
|
285
|
+
lifecycleLog("automerge SharedWorker control port closed");
|
|
368
286
|
void recoverAutomergeWorker("control port closed", worker);
|
|
369
287
|
});
|
|
370
288
|
|
|
371
|
-
// Not gated on the
|
|
372
|
-
//
|
|
373
|
-
worker.addEventListener("error", event => {
|
|
374
|
-
|
|
375
|
-
console.error(
|
|
376
|
-
`[lifecycle] ${stamp()} automerge SharedWorker error:`,
|
|
377
|
-
error.message || event,
|
|
378
|
-
error.filename ? `(${error.filename}:${error.lineno})` : ""
|
|
379
|
-
);
|
|
289
|
+
// Not gated on the debug namespace: a worker that fails to load never replies
|
|
290
|
+
// to anything, and this is the only signal that says so.
|
|
291
|
+
worker.addEventListener("error", (event) => {
|
|
292
|
+
console.error("automerge SharedWorker error:", describeErrorEvent(event));
|
|
380
293
|
});
|
|
381
294
|
|
|
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
295
|
const startProbe = (reason: string) => {
|
|
387
296
|
if (probe || disposed) return;
|
|
388
|
-
|
|
297
|
+
lifecycleLog(
|
|
298
|
+
"automerge SharedWorker %s; probing with a second connection",
|
|
299
|
+
reason
|
|
300
|
+
);
|
|
389
301
|
const startedAt = Date.now();
|
|
390
|
-
const p = new SharedWorker(
|
|
302
|
+
const p = new SharedWorker(automergeWorkerPath, {
|
|
391
303
|
name: "patchwork-automerge",
|
|
392
304
|
type: "module",
|
|
393
305
|
});
|
|
@@ -396,10 +308,10 @@ function installWorkerDeathDetection(worker: SharedWorker): () => void {
|
|
|
396
308
|
p.port.addEventListener("message", (event: MessageEvent) => {
|
|
397
309
|
if (event.data?.type !== "hello") return;
|
|
398
310
|
setTimeout(() => {
|
|
399
|
-
if (disposed || probe !== p) return;
|
|
311
|
+
if (disposed || probe !== p) return;
|
|
400
312
|
closeProbe();
|
|
401
|
-
// The suspect spoke while
|
|
402
|
-
//
|
|
313
|
+
// The suspect spoke while the probe ran: it was merely busy, and
|
|
314
|
+
// everything queued on it has been delivered.
|
|
403
315
|
if (lastHeardAt >= startedAt) return;
|
|
404
316
|
void recoverAutomergeWorker(
|
|
405
317
|
`port unresponsive on a live worker (${reason}; probe confirmed)`,
|
|
@@ -407,56 +319,45 @@ function installWorkerDeathDetection(worker: SharedWorker): () => void {
|
|
|
407
319
|
);
|
|
408
320
|
}, PROBE_GRACE_MS);
|
|
409
321
|
});
|
|
410
|
-
// No hello on the probe means the instance is loading or busy
|
|
411
|
-
// waits indefinitely
|
|
412
|
-
// lands) — never tear anything down on a timer.
|
|
322
|
+
// No hello on the probe means the instance is loading or busy. The probe
|
|
323
|
+
// waits indefinitely rather than tearing anything down on a timer.
|
|
413
324
|
};
|
|
414
325
|
|
|
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;
|
|
420
|
-
const HEARTBEAT_TIMEOUT_MS = 25_000;
|
|
421
|
-
const FIRST_CONTACT_TIMEOUT_MS = 4_000;
|
|
422
|
-
let seq = 0;
|
|
423
326
|
const heartbeat = setInterval(() => {
|
|
424
327
|
try {
|
|
425
328
|
worker.port.postMessage({ type: "ping", id: ++seq });
|
|
426
|
-
pingsSent++;
|
|
427
329
|
} catch (error) {
|
|
428
|
-
//
|
|
429
|
-
// worker in the "no pong" warning below. Once, not every heartbeat.
|
|
330
|
+
// Without this a failed send is indistinguishable from a dead worker.
|
|
430
331
|
if (!warnedSendFailed) {
|
|
431
332
|
warnedSendFailed = true;
|
|
432
|
-
console.error(
|
|
433
|
-
`[lifecycle] ${stamp()} automerge SharedWorker ping send threw ` +
|
|
434
|
-
`after ${pingsSent} sent:`,
|
|
435
|
-
error
|
|
436
|
-
);
|
|
333
|
+
console.error("automerge SharedWorker ping send threw", error);
|
|
437
334
|
}
|
|
438
335
|
}
|
|
336
|
+
|
|
439
337
|
const neverHeard = instanceId === undefined;
|
|
440
338
|
const silentMs = Date.now() - lastHeardAt;
|
|
441
339
|
const timeoutMs = neverHeard
|
|
442
340
|
? FIRST_CONTACT_TIMEOUT_MS
|
|
443
341
|
: HEARTBEAT_TIMEOUT_MS;
|
|
342
|
+
if (silentMs <= timeoutMs) return;
|
|
343
|
+
|
|
344
|
+
// First contact probes regardless of visibility: SharedWorkers don't
|
|
345
|
+
// suspend with the tab, and the probe destroys nothing. Post-contact
|
|
346
|
+
// silence defers to visibility, since a hidden page's throttling can fake
|
|
347
|
+
// it.
|
|
444
348
|
const visible =
|
|
445
349
|
typeof document === "undefined" || document.visibilityState === "visible";
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
}
|
|
458
|
-
startProbe(reason);
|
|
459
|
-
}
|
|
350
|
+
if (!neverHeard && !visible) return;
|
|
351
|
+
|
|
352
|
+
const seconds = Math.round(silentMs / 1000);
|
|
353
|
+
const reason = neverHeard
|
|
354
|
+
? `no hello ~${seconds}s after connecting`
|
|
355
|
+
: `no pong for ~${seconds}s`;
|
|
356
|
+
if (!warnedUnresponsive) {
|
|
357
|
+
warnedUnresponsive = true;
|
|
358
|
+
lifecycleLog("automerge SharedWorker %s (tab visible)", reason);
|
|
359
|
+
}
|
|
360
|
+
startProbe(reason);
|
|
460
361
|
}, HEARTBEAT_MS);
|
|
461
362
|
|
|
462
363
|
return () => {
|
|
@@ -466,18 +367,13 @@ function installWorkerDeathDetection(worker: SharedWorker): () => void {
|
|
|
466
367
|
};
|
|
467
368
|
}
|
|
468
369
|
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
// them (see SyncStateDocMessage). We ref-count locally so several callers in
|
|
472
|
-
// this tab can watch the same doc with a single worker subscription, and tear
|
|
473
|
-
// the worker subscription down when the last local watcher drops.
|
|
370
|
+
// Ref-counted locally so several callers in this tab can watch the same doc
|
|
371
|
+
// with a single worker subscription.
|
|
474
372
|
type SyncStateListener = (update: SyncStateDocMessage) => void;
|
|
475
373
|
const syncStateListeners = new Map<string, Set<SyncStateListener>>();
|
|
476
374
|
|
|
477
375
|
function dispatchSyncState(update: SyncStateDocMessage): void {
|
|
478
|
-
const
|
|
479
|
-
if (!listeners) return;
|
|
480
|
-
for (const listener of listeners) {
|
|
376
|
+
for (const listener of syncStateListeners.get(update.documentId) ?? []) {
|
|
481
377
|
try {
|
|
482
378
|
listener(update);
|
|
483
379
|
} catch (err) {
|
|
@@ -494,25 +390,22 @@ export function subscribeSyncState(
|
|
|
494
390
|
let listeners = syncStateListeners.get(documentId);
|
|
495
391
|
if (!listeners) {
|
|
496
392
|
syncStateListeners.set(documentId, (listeners = new Set()));
|
|
497
|
-
// First local watcher for this doc — ask the worker to start pushing it.
|
|
498
393
|
worker.port.postMessage({ type: "sync-sub", documentId });
|
|
499
394
|
}
|
|
500
395
|
listeners.add(listener);
|
|
501
396
|
|
|
502
397
|
let active = true;
|
|
503
398
|
return () => {
|
|
504
|
-
if (!active) return;
|
|
399
|
+
if (!active) return;
|
|
505
400
|
active = false;
|
|
506
401
|
const set = syncStateListeners.get(documentId);
|
|
507
402
|
if (!set) return;
|
|
508
403
|
set.delete(listener);
|
|
509
|
-
if (set.size
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
automergeWorker?.port.postMessage({ type: "sync-unsub", documentId });
|
|
515
|
-
}
|
|
404
|
+
if (set.size > 0) return;
|
|
405
|
+
syncStateListeners.delete(documentId);
|
|
406
|
+
// Unsubscribe from whichever instance is current: recovery replays
|
|
407
|
+
// subscriptions onto a new worker, so it may not be the one captured above.
|
|
408
|
+
automergeWorker?.port.postMessage({ type: "sync-unsub", documentId });
|
|
516
409
|
};
|
|
517
410
|
}
|
|
518
411
|
|
|
@@ -536,11 +429,9 @@ export function connectClassicSync(
|
|
|
536
429
|
port1.onmessage = (event) => {
|
|
537
430
|
clearTimeout(timeout);
|
|
538
431
|
port1.close();
|
|
539
|
-
if (event.data?.type === "connect-classic-sync-ready")
|
|
540
|
-
|
|
541
|
-
} else {
|
|
432
|
+
if (event.data?.type === "connect-classic-sync-ready") resolve();
|
|
433
|
+
else
|
|
542
434
|
reject(new Error(event.data?.error ?? "connect-classic-sync failed"));
|
|
543
|
-
}
|
|
544
435
|
};
|
|
545
436
|
worker.port.postMessage({ type: "connect-classic-sync", server: url }, [
|
|
546
437
|
port2,
|
|
@@ -548,110 +439,113 @@ export function connectClassicSync(
|
|
|
548
439
|
});
|
|
549
440
|
}
|
|
550
441
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
if (!worker)
|
|
556
|
-
return Promise.reject(new Error("no service worker in registration"));
|
|
557
|
-
return new Promise((resolve) => {
|
|
558
|
-
worker.addEventListener("statechange", () => {
|
|
559
|
-
if (worker.state === "activated") resolve(worker);
|
|
560
|
-
});
|
|
561
|
-
});
|
|
442
|
+
function sendRepoPort(id: number): MessagePort {
|
|
443
|
+
const { port1, port2 } = new MessageChannel();
|
|
444
|
+
getAutomergeWorker().port.postMessage({ type: "port", id }, [port2]);
|
|
445
|
+
return port1;
|
|
562
446
|
}
|
|
563
447
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
const id = ++nextRepoChannelId;
|
|
573
|
-
const { port1, port2 } = new MessageChannel();
|
|
574
|
-
const workerReady = new Promise<void>((resolve, reject) => {
|
|
575
|
-
let timeout: ReturnType<typeof setTimeout>;
|
|
448
|
+
/**
|
|
449
|
+
* Wait for the worker to confirm its repo is constructed. The MessageChannel
|
|
450
|
+
* adapter's whenReady() force-resolves after 100ms regardless of the other
|
|
451
|
+
* end's state, so it can't serve as a readiness signal on first boot, when the
|
|
452
|
+
* worker still has to fetch wasm and build its repo.
|
|
453
|
+
*/
|
|
454
|
+
function awaitPortReady(control: MessagePort, id: number): Promise<void> {
|
|
455
|
+
return new Promise((resolve, reject) => {
|
|
576
456
|
const cleanup = () => {
|
|
577
457
|
clearTimeout(timeout);
|
|
578
|
-
|
|
458
|
+
control.removeEventListener("message", listener);
|
|
579
459
|
};
|
|
580
460
|
const listener = (event: MessageEvent) => {
|
|
581
461
|
if (event.data?.id !== id) return;
|
|
582
|
-
if (event.data
|
|
462
|
+
if (event.data.type === "port-ready") {
|
|
583
463
|
cleanup();
|
|
584
464
|
resolve();
|
|
585
|
-
} else if (event.data
|
|
465
|
+
} else if (event.data.type === "port-failed") {
|
|
586
466
|
cleanup();
|
|
587
467
|
reject(new Error(`automerge worker init failed: ${event.data.error}`));
|
|
588
468
|
}
|
|
589
469
|
};
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
// the issue and let the rest of the site come up rather than hanging on a
|
|
593
|
-
// blank page.
|
|
594
|
-
timeout = setTimeout(() => {
|
|
470
|
+
control.addEventListener("message", listener);
|
|
471
|
+
const timeout = setTimeout(() => {
|
|
595
472
|
cleanup();
|
|
596
473
|
reject(new Error("automerge worker port-ready timeout"));
|
|
597
474
|
}, 30_000);
|
|
598
475
|
});
|
|
599
|
-
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function openRepoChannel(): Promise<MessagePort> {
|
|
479
|
+
const id = ++nextRepoChannelId;
|
|
480
|
+
const ready = awaitPortReady(getAutomergeWorker().port, id);
|
|
481
|
+
const port = sendRepoPort(id);
|
|
600
482
|
try {
|
|
601
|
-
await
|
|
483
|
+
await ready;
|
|
602
484
|
} catch (err) {
|
|
485
|
+
// Surface the problem and let the rest of the site come up rather than
|
|
486
|
+
// hanging on a blank page.
|
|
603
487
|
console.warn(
|
|
604
488
|
"proceeding without worker ready ack:",
|
|
605
489
|
err instanceof Error ? err.message : err
|
|
606
490
|
);
|
|
607
491
|
}
|
|
608
|
-
return
|
|
492
|
+
return port;
|
|
609
493
|
}
|
|
610
494
|
|
|
611
495
|
/** Open a fresh repo sync port to the automerge worker (dev console). */
|
|
612
496
|
function getRepoChannel(): MessagePort {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
497
|
+
return sendRepoPort(++nextRepoChannelId);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function waitForActive(reg: ServiceWorkerRegistration): Promise<ServiceWorker> {
|
|
501
|
+
if (reg.active) return Promise.resolve(reg.active);
|
|
502
|
+
const worker = reg.installing || reg.waiting;
|
|
503
|
+
if (!worker) {
|
|
504
|
+
return Promise.reject(new Error("no service worker in registration"));
|
|
505
|
+
}
|
|
506
|
+
return new Promise((resolve, reject) => {
|
|
507
|
+
worker.addEventListener("statechange", () => {
|
|
508
|
+
if (worker.state === "activated") resolve(worker);
|
|
509
|
+
// Without this the promise never settles when an install fails.
|
|
510
|
+
else if (worker.state === "redundant") {
|
|
511
|
+
reject(new Error("service worker became redundant before activating"));
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
});
|
|
617
515
|
}
|
|
618
516
|
|
|
619
517
|
export default async function setupServiceWorker(
|
|
620
518
|
options?: SetupServiceWorkerOptions
|
|
621
519
|
): Promise<SetupServiceWorkerResult> {
|
|
622
|
-
// Attach the
|
|
623
|
-
//
|
|
520
|
+
// Attach the log bridge first so the controlling worker's boot/install/
|
|
521
|
+
// activate markers are rendered here.
|
|
624
522
|
installServiceWorkerLogForwarding();
|
|
625
|
-
localStorage.removeItem(
|
|
523
|
+
localStorage.removeItem(CACHE_VERSION_KEY);
|
|
626
524
|
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
//
|
|
525
|
+
// Cache growth can otherwise trip origin-wide eviction, which would take the
|
|
526
|
+
// Automerge IndexedDB — the user's documents — with it. Chrome/Safari decide
|
|
527
|
+
// silently from site engagement; Firefox may prompt. Denial just means
|
|
528
|
+
// default eviction.
|
|
631
529
|
void navigator.storage?.persist?.().catch(() => {});
|
|
632
530
|
|
|
633
531
|
if (options?.workerPath) automergeWorkerPath = options.workerPath;
|
|
634
532
|
|
|
635
|
-
// Start the automerge worker
|
|
533
|
+
// Start the automerge worker now so it boots wasm and its repo while the
|
|
636
534
|
// service worker installs.
|
|
637
535
|
const shared = getAutomergeWorker();
|
|
638
|
-
// todo delete
|
|
639
|
-
|
|
640
|
-
const path = options?.path ?? "/service-worker.js";
|
|
641
|
-
// No controller at this point means the page loaded without a service
|
|
642
|
-
// worker — i.e. this is a first-time install (or a hard reload). Wait for
|
|
643
|
-
// activation so the app boots with the SW in control of generated fetches.
|
|
644
|
-
const reg = await navigator.serviceWorker.register(path, { type: "module" });
|
|
645
|
-
|
|
646
|
-
// If there's an update waiting or installing, wait for it to activate
|
|
647
|
-
let active = reg.active;
|
|
648
|
-
if (reg.installing || reg.waiting) {
|
|
649
|
-
active = await waitForActive(reg);
|
|
650
|
-
}
|
|
651
536
|
|
|
537
|
+
const reg = await navigator.serviceWorker.register(
|
|
538
|
+
options?.path ?? "/service-worker.js",
|
|
539
|
+
{ type: "module" }
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
const active =
|
|
543
|
+
reg.installing || reg.waiting ? await waitForActive(reg) : reg.active;
|
|
652
544
|
configureServiceWorker(active);
|
|
653
545
|
|
|
654
|
-
//
|
|
546
|
+
// No controller means the page loaded without a service worker — a first-time
|
|
547
|
+
// install or a hard reload. Wait for it so the app boots with the worker in
|
|
548
|
+
// control of generated fetches.
|
|
655
549
|
if (!navigator.serviceWorker.controller) {
|
|
656
550
|
await new Promise<void>((resolve) => {
|
|
657
551
|
navigator.serviceWorker.addEventListener(
|
|
@@ -662,8 +556,8 @@ export default async function setupServiceWorker(
|
|
|
662
556
|
});
|
|
663
557
|
}
|
|
664
558
|
|
|
665
|
-
// A replacement
|
|
666
|
-
//
|
|
559
|
+
// A replacement worker boots with the default cache name, so reconfigure
|
|
560
|
+
// whenever a new one takes control.
|
|
667
561
|
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
|
668
562
|
configureServiceWorker(navigator.serviceWorker.controller);
|
|
669
563
|
});
|
|
@@ -673,30 +567,20 @@ export default async function setupServiceWorker(
|
|
|
673
567
|
"background: #fcf2f0; color: #333; border: 2px solid; border-radius: 4px"
|
|
674
568
|
);
|
|
675
569
|
|
|
676
|
-
// todon't
|
|
677
|
-
(window as any).killsw = () => {
|
|
678
|
-
if (automergeWorker) {
|
|
679
|
-
automergeWorker.port.close();
|
|
680
|
-
automergeWorker = undefined;
|
|
681
|
-
}
|
|
682
|
-
};
|
|
683
|
-
|
|
684
570
|
return {
|
|
685
571
|
shared,
|
|
686
572
|
connectClassicSync,
|
|
687
573
|
getRepoChannel,
|
|
688
574
|
subscribeSyncState,
|
|
575
|
+
// Called once with the boot port. If the automerge worker later dies and is
|
|
576
|
+
// recreated, the listener is called again with a fresh port — treat every
|
|
577
|
+
// call as "(re)wire your repo's sync onto this port".
|
|
689
578
|
async subscribeToRepoChannel(listener: ServiceWorkerRepoChannelListener) {
|
|
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
579
|
repoChannelListeners.add(listener);
|
|
695
580
|
const generation = workerGeneration;
|
|
696
581
|
const port = await openRepoChannel();
|
|
697
|
-
// If the worker was replaced while this channel was opening
|
|
698
|
-
// port
|
|
699
|
-
// already delivered a good port to this listener), drop the stale one
|
|
582
|
+
// If the worker was replaced while this channel was opening, recovery has
|
|
583
|
+
// already delivered a good port to this listener — drop the stale one
|
|
700
584
|
// rather than wiring the repo to a dead channel.
|
|
701
585
|
if (generation === workerGeneration) await listener(port);
|
|
702
586
|
return () => {
|
|
@@ -705,3 +589,5 @@ export default async function setupServiceWorker(
|
|
|
705
589
|
},
|
|
706
590
|
};
|
|
707
591
|
}
|
|
592
|
+
|
|
593
|
+
(window as any).bumpServiceWorkerCache = bumpServiceWorkerCache;
|