@inkandswitch/patchwork-bootloader 0.4.2 → 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 +60 -0
- package/dist/automerge-worker.js +560 -758
- package/dist/module-loader.d.ts +0 -5
- package/dist/module-loader.js +0 -6
- package/dist/service-worker.js +183 -192
- package/dist/setup.d.ts +2 -1
- package/dist/setup.js +350 -271
- package/dist/site.d.ts +17 -32
- package/dist/site.js +309 -340
- package/dist/types.d.ts +18 -1
- package/dist/vite/importmap-plugin.js +32 -6
- package/package.json +30 -11
- package/src/automerge-worker.ts +654 -820
- package/src/module-loader.ts +0 -6
- package/src/service-worker.ts +237 -225
- package/src/setup.ts +384 -281
- package/src/site.ts +404 -404
- package/src/types.ts +22 -1
- package/src/vite/importmap-plugin.ts +39 -6
package/src/setup.ts
CHANGED
|
@@ -17,270 +17,363 @@ 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, so there's no keepalive ping and no restart detection: if we're
|
|
94
|
-
// alive, it's alive. Repo sync ports are passed to it over its connect
|
|
95
|
-
// 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.
|
|
96
71
|
|
|
97
72
|
let automergeWorkerPath = "/automerge-worker.js";
|
|
98
73
|
let automergeWorker: SharedWorker | undefined;
|
|
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.
|
|
76
|
+
let workerGeneration = 0;
|
|
77
|
+
let disposeWorkerDeathDetection: (() => void) | undefined;
|
|
78
|
+
const repoChannelListeners = new Set<ServiceWorkerRepoChannelListener>();
|
|
79
|
+
let recoveringWorker = false;
|
|
80
|
+
let lastWorkerRecoveryAt = 0;
|
|
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.
|
|
83
|
+
const RECOVERY_MIN_INTERVAL_MS = 15_000;
|
|
84
|
+
let nextRepoChannelId = 0;
|
|
99
85
|
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
// port — outlives the donor tab. Emitted at /packages/... via externals.ts.
|
|
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.
|
|
105
90
|
const SUBDUCTION_IO_WORKER_URL =
|
|
106
91
|
"/packages/@automerge/automerge-repo/subduction-websocket-worker-shared.js";
|
|
107
92
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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;
|
|
130
121
|
}
|
|
131
122
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const workerUrl = `${automergeWorkerPath}${workerBenchParams()}`;
|
|
135
|
-
automergeWorker = new SharedWorker(workerUrl, {
|
|
136
|
-
name: "patchwork-automerge",
|
|
137
|
-
type: "module",
|
|
138
|
-
});
|
|
139
|
-
// Control replies (port-ready &c) come back on this port, so it needs
|
|
140
|
-
// start() — we listen with addEventListener, not onmessage.
|
|
141
|
-
automergeWorker.port.start();
|
|
142
|
-
// Surface the SharedWorker's console output and uncaught errors in this
|
|
143
|
-
// tab's console (it has its own console that's awkward to find otherwise).
|
|
144
|
-
automergeWorker.port.addEventListener("message", (event: MessageEvent) => {
|
|
145
|
-
if (event.data?.type === "sync-state") {
|
|
146
|
-
dispatchSyncState(event.data as SyncStateDocMessage);
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
if (isWorkerErrorMessage(event.data)) {
|
|
150
|
-
// Crash/skew reports relayed from the subduction io proxy (e.g.
|
|
151
|
-
// protocol-mismatch from a stale SW-cached worker chunk). Surface
|
|
152
|
-
// loudly — these otherwise only exist in chrome://inspect.
|
|
153
|
-
console.error("[subduction-io]", event.data);
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
if (event.data?.type === "drift-samples") {
|
|
157
|
-
// Keepalive-drift samples from the worker's bench probe. Kept on a
|
|
158
|
-
// bounded window global for the Playwright bench to harvest.
|
|
159
|
-
const sink = ((window as any).__driftSamples ??= []) as number[];
|
|
160
|
-
sink.push(...event.data.samples);
|
|
161
|
-
if (sink.length > 10_000) sink.splice(0, sink.length - 10_000);
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
if (event.data?.type !== "console") return;
|
|
165
|
-
const { level, args } = event.data;
|
|
166
|
-
// Gate forwarded [lifecycle] logs on the toggle too.
|
|
167
|
-
if (
|
|
168
|
-
!lifecycleLoggingEnabled() &&
|
|
169
|
-
typeof args?.[0] === "string" &&
|
|
170
|
-
args[0].includes("[lifecycle]")
|
|
171
|
-
) {
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
const fn = (console as any)[level] ?? console.log;
|
|
175
|
-
// The worker's logs (debug library, the worker's own log()) carry %c
|
|
176
|
-
// format directives in args[0] with CSS in the following args. Prefix
|
|
177
|
-
// the tag into the format string rather than as a separate positional,
|
|
178
|
-
// or the %c would no longer be in arg 0 and the CSS would print raw.
|
|
179
|
-
if (typeof args[0] === "string") {
|
|
180
|
-
fn(`[automerge-worker] ${args[0]}`, ...args.slice(1));
|
|
181
|
-
} else {
|
|
182
|
-
fn("[automerge-worker]", ...args);
|
|
183
|
-
}
|
|
184
|
-
});
|
|
185
|
-
automergeWorker.port.postMessage({ type: "debug", debug: workerDebugging });
|
|
186
|
-
|
|
187
|
-
// Offer the subduction io proxy's port; the worker's port provider pulls
|
|
188
|
-
// it when (re)constructing its WorkerWebSocketEndpoint.
|
|
189
|
-
donatePort(automergeWorker.port, () => {
|
|
190
|
-
const io = new SharedWorker(SUBDUCTION_IO_WORKER_URL, {
|
|
191
|
-
type: "module",
|
|
192
|
-
name: "subduction-websocket",
|
|
193
|
-
});
|
|
194
|
-
return io.port;
|
|
195
|
-
});
|
|
123
|
+
function handleWorkerMessage(event: MessageEvent): void {
|
|
124
|
+
const data = event.data;
|
|
196
125
|
|
|
197
|
-
|
|
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);
|
|
198
155
|
}
|
|
199
|
-
|
|
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;
|
|
200
175
|
}
|
|
201
176
|
|
|
202
177
|
/**
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
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
|
|
180
|
+
* getAutomergeWorker), the per-doc sync-state subscriptions, and every
|
|
181
|
+
* subscriber's repo port. The new instance boots with cold state.
|
|
206
182
|
*/
|
|
207
|
-
function
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
183
|
+
async function recoverAutomergeWorker(
|
|
184
|
+
reason: string,
|
|
185
|
+
deadWorker: SharedWorker
|
|
186
|
+
): Promise<void> {
|
|
187
|
+
if (deadWorker !== automergeWorker) return;
|
|
188
|
+
if (recoveringWorker) return;
|
|
189
|
+
const now = Date.now();
|
|
190
|
+
if (now - lastWorkerRecoveryAt < RECOVERY_MIN_INTERVAL_MS) return;
|
|
191
|
+
recoveringWorker = true;
|
|
192
|
+
lastWorkerRecoveryAt = now;
|
|
193
|
+
lifecycleLog("recreating the automerge SharedWorker (%s)", reason);
|
|
215
194
|
|
|
195
|
+
try {
|
|
196
|
+
disposeWorkerDeathDetection?.();
|
|
197
|
+
disposeWorkerDeathDetection = undefined;
|
|
198
|
+
automergeWorker = undefined;
|
|
199
|
+
try {
|
|
200
|
+
deadWorker.port.close();
|
|
201
|
+
} catch {}
|
|
202
|
+
|
|
203
|
+
const fresh = getAutomergeWorker();
|
|
204
|
+
for (const documentId of syncStateListeners.keys()) {
|
|
205
|
+
fresh.port.postMessage({ type: "sync-sub", documentId });
|
|
206
|
+
}
|
|
207
|
+
for (const listener of repoChannelListeners) {
|
|
208
|
+
try {
|
|
209
|
+
const generation = workerGeneration;
|
|
210
|
+
const port = await openRepoChannel();
|
|
211
|
+
// Replaced again while we waited — the newer recovery re-delivers.
|
|
212
|
+
if (generation !== workerGeneration) break;
|
|
213
|
+
await listener(port);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
console.error(
|
|
216
|
+
"failed to re-wire a repo channel after worker recovery",
|
|
217
|
+
err
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} finally {
|
|
222
|
+
recoveringWorker = false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
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;
|
|
242
|
+
|
|
243
|
+
function installWorkerDeathDetection(worker: SharedWorker): () => void {
|
|
216
244
|
let instanceId: string | undefined;
|
|
217
|
-
let
|
|
245
|
+
let lastHeardAt = Date.now();
|
|
218
246
|
let warnedUnresponsive = false;
|
|
247
|
+
let warnedSendFailed = false;
|
|
248
|
+
let disposed = false;
|
|
249
|
+
let probe: SharedWorker | undefined;
|
|
250
|
+
let seq = 0;
|
|
251
|
+
|
|
252
|
+
const closeProbe = () => {
|
|
253
|
+
if (!probe) return;
|
|
254
|
+
try {
|
|
255
|
+
probe.port.close();
|
|
256
|
+
} catch {}
|
|
257
|
+
probe = undefined;
|
|
258
|
+
};
|
|
219
259
|
|
|
220
260
|
worker.port.addEventListener("message", (event: MessageEvent) => {
|
|
221
261
|
const data = event.data;
|
|
222
262
|
if (data?.type !== "hello" && data?.type !== "pong") return;
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
263
|
+
lastHeardAt = Date.now();
|
|
264
|
+
warnedUnresponsive = false;
|
|
265
|
+
closeProbe();
|
|
227
266
|
if (instanceId === undefined) {
|
|
228
267
|
instanceId = data.instanceId;
|
|
229
|
-
|
|
268
|
+
lifecycleLog(
|
|
269
|
+
"automerge SharedWorker instance %s (via %s)",
|
|
270
|
+
data.instanceId,
|
|
271
|
+
data.type
|
|
272
|
+
);
|
|
230
273
|
} else if (data.instanceId && data.instanceId !== instanceId) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
274
|
+
lifecycleLog(
|
|
275
|
+
"automerge SharedWorker instance changed (instance %s, was %s)",
|
|
276
|
+
data.instanceId,
|
|
277
|
+
instanceId
|
|
234
278
|
);
|
|
235
279
|
instanceId = data.instanceId;
|
|
236
280
|
}
|
|
237
281
|
});
|
|
238
282
|
|
|
239
|
-
// Fires when the SharedWorker is destroyed (where supported).
|
|
240
283
|
worker.port.addEventListener("close", () => {
|
|
241
|
-
|
|
284
|
+
if (disposed) return;
|
|
285
|
+
lifecycleLog("automerge SharedWorker control port closed");
|
|
286
|
+
void recoverAutomergeWorker("control port closed", worker);
|
|
242
287
|
});
|
|
243
288
|
|
|
244
|
-
worker
|
|
245
|
-
|
|
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));
|
|
246
293
|
});
|
|
247
294
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
295
|
+
const startProbe = (reason: string) => {
|
|
296
|
+
if (probe || disposed) return;
|
|
297
|
+
lifecycleLog(
|
|
298
|
+
"automerge SharedWorker %s; probing with a second connection",
|
|
299
|
+
reason
|
|
300
|
+
);
|
|
301
|
+
const startedAt = Date.now();
|
|
302
|
+
const p = new SharedWorker(automergeWorkerPath, {
|
|
303
|
+
name: "patchwork-automerge",
|
|
304
|
+
type: "module",
|
|
305
|
+
});
|
|
306
|
+
probe = p;
|
|
307
|
+
p.port.start();
|
|
308
|
+
p.port.addEventListener("message", (event: MessageEvent) => {
|
|
309
|
+
if (event.data?.type !== "hello") return;
|
|
310
|
+
setTimeout(() => {
|
|
311
|
+
if (disposed || probe !== p) return;
|
|
312
|
+
closeProbe();
|
|
313
|
+
// The suspect spoke while the probe ran: it was merely busy, and
|
|
314
|
+
// everything queued on it has been delivered.
|
|
315
|
+
if (lastHeardAt >= startedAt) return;
|
|
316
|
+
void recoverAutomergeWorker(
|
|
317
|
+
`port unresponsive on a live worker (${reason}; probe confirmed)`,
|
|
318
|
+
worker
|
|
319
|
+
);
|
|
320
|
+
}, PROBE_GRACE_MS);
|
|
321
|
+
});
|
|
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.
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const heartbeat = setInterval(() => {
|
|
254
327
|
try {
|
|
255
328
|
worker.port.postMessage({ type: "ping", id: ++seq });
|
|
256
|
-
} catch {
|
|
257
|
-
//
|
|
329
|
+
} catch (error) {
|
|
330
|
+
// Without this a failed send is indistinguishable from a dead worker.
|
|
331
|
+
if (!warnedSendFailed) {
|
|
332
|
+
warnedSendFailed = true;
|
|
333
|
+
console.error("automerge SharedWorker ping send threw", error);
|
|
334
|
+
}
|
|
258
335
|
}
|
|
259
|
-
|
|
336
|
+
|
|
337
|
+
const neverHeard = instanceId === undefined;
|
|
338
|
+
const silentMs = Date.now() - lastHeardAt;
|
|
339
|
+
const timeoutMs = neverHeard
|
|
340
|
+
? FIRST_CONTACT_TIMEOUT_MS
|
|
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.
|
|
260
348
|
const visible =
|
|
261
349
|
typeof document === "undefined" || document.visibilityState === "visible";
|
|
262
|
-
if (
|
|
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) {
|
|
263
357
|
warnedUnresponsive = true;
|
|
264
|
-
|
|
265
|
-
`automerge SharedWorker UNRESPONSIVE ~${Math.round(silentMs / 1000)}s ` +
|
|
266
|
-
`while tab visible — likely died/crashed`
|
|
267
|
-
);
|
|
358
|
+
lifecycleLog("automerge SharedWorker %s (tab visible)", reason);
|
|
268
359
|
}
|
|
360
|
+
startProbe(reason);
|
|
269
361
|
}, HEARTBEAT_MS);
|
|
362
|
+
|
|
363
|
+
return () => {
|
|
364
|
+
disposed = true;
|
|
365
|
+
clearInterval(heartbeat);
|
|
366
|
+
closeProbe();
|
|
367
|
+
};
|
|
270
368
|
}
|
|
271
369
|
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
// them (see SyncStateDocMessage). We ref-count locally so several callers in
|
|
275
|
-
// this tab can watch the same doc with a single worker subscription, and tear
|
|
276
|
-
// 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.
|
|
277
372
|
type SyncStateListener = (update: SyncStateDocMessage) => void;
|
|
278
373
|
const syncStateListeners = new Map<string, Set<SyncStateListener>>();
|
|
279
374
|
|
|
280
375
|
function dispatchSyncState(update: SyncStateDocMessage): void {
|
|
281
|
-
const
|
|
282
|
-
if (!listeners) return;
|
|
283
|
-
for (const listener of listeners) {
|
|
376
|
+
for (const listener of syncStateListeners.get(update.documentId) ?? []) {
|
|
284
377
|
try {
|
|
285
378
|
listener(update);
|
|
286
379
|
} catch (err) {
|
|
@@ -297,22 +390,22 @@ export function subscribeSyncState(
|
|
|
297
390
|
let listeners = syncStateListeners.get(documentId);
|
|
298
391
|
if (!listeners) {
|
|
299
392
|
syncStateListeners.set(documentId, (listeners = new Set()));
|
|
300
|
-
// First local watcher for this doc — ask the worker to start pushing it.
|
|
301
393
|
worker.port.postMessage({ type: "sync-sub", documentId });
|
|
302
394
|
}
|
|
303
395
|
listeners.add(listener);
|
|
304
396
|
|
|
305
397
|
let active = true;
|
|
306
398
|
return () => {
|
|
307
|
-
if (!active) return;
|
|
399
|
+
if (!active) return;
|
|
308
400
|
active = false;
|
|
309
401
|
const set = syncStateListeners.get(documentId);
|
|
310
402
|
if (!set) return;
|
|
311
403
|
set.delete(listener);
|
|
312
|
-
if (set.size
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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 });
|
|
316
409
|
};
|
|
317
410
|
}
|
|
318
411
|
|
|
@@ -336,11 +429,9 @@ export function connectClassicSync(
|
|
|
336
429
|
port1.onmessage = (event) => {
|
|
337
430
|
clearTimeout(timeout);
|
|
338
431
|
port1.close();
|
|
339
|
-
if (event.data?.type === "connect-classic-sync-ready")
|
|
340
|
-
|
|
341
|
-
} else {
|
|
432
|
+
if (event.data?.type === "connect-classic-sync-ready") resolve();
|
|
433
|
+
else
|
|
342
434
|
reject(new Error(event.data?.error ?? "connect-classic-sync failed"));
|
|
343
|
-
}
|
|
344
435
|
};
|
|
345
436
|
worker.port.postMessage({ type: "connect-classic-sync", server: url }, [
|
|
346
437
|
port2,
|
|
@@ -348,104 +439,113 @@ export function connectClassicSync(
|
|
|
348
439
|
});
|
|
349
440
|
}
|
|
350
441
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if (!worker)
|
|
356
|
-
return Promise.reject(new Error("no service worker in registration"));
|
|
357
|
-
return new Promise((resolve) => {
|
|
358
|
-
worker.addEventListener("statechange", () => {
|
|
359
|
-
if (worker.state === "activated") resolve(worker);
|
|
360
|
-
});
|
|
361
|
-
});
|
|
442
|
+
function sendRepoPort(id: number): MessagePort {
|
|
443
|
+
const { port1, port2 } = new MessageChannel();
|
|
444
|
+
getAutomergeWorker().port.postMessage({ type: "port", id }, [port2]);
|
|
445
|
+
return port1;
|
|
362
446
|
}
|
|
363
447
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
const id = ++nextRepoChannelId;
|
|
373
|
-
const { port1, port2 } = new MessageChannel();
|
|
374
|
-
const workerReady = new Promise<void>((resolve, reject) => {
|
|
375
|
-
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) => {
|
|
376
456
|
const cleanup = () => {
|
|
377
457
|
clearTimeout(timeout);
|
|
378
|
-
|
|
458
|
+
control.removeEventListener("message", listener);
|
|
379
459
|
};
|
|
380
460
|
const listener = (event: MessageEvent) => {
|
|
381
461
|
if (event.data?.id !== id) return;
|
|
382
|
-
if (event.data
|
|
462
|
+
if (event.data.type === "port-ready") {
|
|
383
463
|
cleanup();
|
|
384
464
|
resolve();
|
|
385
|
-
} else if (event.data
|
|
465
|
+
} else if (event.data.type === "port-failed") {
|
|
386
466
|
cleanup();
|
|
387
467
|
reject(new Error(`automerge worker init failed: ${event.data.error}`));
|
|
388
468
|
}
|
|
389
469
|
};
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
// the issue and let the rest of the site come up rather than hanging on a
|
|
393
|
-
// blank page.
|
|
394
|
-
timeout = setTimeout(() => {
|
|
470
|
+
control.addEventListener("message", listener);
|
|
471
|
+
const timeout = setTimeout(() => {
|
|
395
472
|
cleanup();
|
|
396
473
|
reject(new Error("automerge worker port-ready timeout"));
|
|
397
474
|
}, 30_000);
|
|
398
475
|
});
|
|
399
|
-
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function openRepoChannel(): Promise<MessagePort> {
|
|
479
|
+
const id = ++nextRepoChannelId;
|
|
480
|
+
const ready = awaitPortReady(getAutomergeWorker().port, id);
|
|
481
|
+
const port = sendRepoPort(id);
|
|
400
482
|
try {
|
|
401
|
-
await
|
|
483
|
+
await ready;
|
|
402
484
|
} catch (err) {
|
|
485
|
+
// Surface the problem and let the rest of the site come up rather than
|
|
486
|
+
// hanging on a blank page.
|
|
403
487
|
console.warn(
|
|
404
488
|
"proceeding without worker ready ack:",
|
|
405
489
|
err instanceof Error ? err.message : err
|
|
406
490
|
);
|
|
407
491
|
}
|
|
408
|
-
return
|
|
492
|
+
return port;
|
|
409
493
|
}
|
|
410
494
|
|
|
411
495
|
/** Open a fresh repo sync port to the automerge worker (dev console). */
|
|
412
496
|
function getRepoChannel(): MessagePort {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
+
});
|
|
417
515
|
}
|
|
418
516
|
|
|
419
517
|
export default async function setupServiceWorker(
|
|
420
518
|
options?: SetupServiceWorkerOptions
|
|
421
519
|
): Promise<SetupServiceWorkerResult> {
|
|
422
|
-
// Attach the
|
|
423
|
-
//
|
|
520
|
+
// Attach the log bridge first so the controlling worker's boot/install/
|
|
521
|
+
// activate markers are rendered here.
|
|
424
522
|
installServiceWorkerLogForwarding();
|
|
425
|
-
localStorage.removeItem(
|
|
523
|
+
localStorage.removeItem(CACHE_VERSION_KEY);
|
|
524
|
+
|
|
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.
|
|
529
|
+
void navigator.storage?.persist?.().catch(() => {});
|
|
426
530
|
|
|
427
531
|
if (options?.workerPath) automergeWorkerPath = options.workerPath;
|
|
428
532
|
|
|
429
|
-
// Start the automerge worker
|
|
533
|
+
// Start the automerge worker now so it boots wasm and its repo while the
|
|
430
534
|
// service worker installs.
|
|
431
535
|
const shared = getAutomergeWorker();
|
|
432
|
-
// todo delete
|
|
433
|
-
|
|
434
|
-
const path = options?.path ?? "/service-worker.js";
|
|
435
|
-
// No controller at this point means the page loaded without a service
|
|
436
|
-
// worker — i.e. this is a first-time install (or a hard reload). Wait for
|
|
437
|
-
// activation so the app boots with the SW in control of generated fetches.
|
|
438
|
-
const reg = await navigator.serviceWorker.register(path, { type: "module" });
|
|
439
|
-
|
|
440
|
-
// If there's an update waiting or installing, wait for it to activate
|
|
441
|
-
let active = reg.active;
|
|
442
|
-
if (reg.installing || reg.waiting) {
|
|
443
|
-
active = await waitForActive(reg);
|
|
444
|
-
}
|
|
445
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;
|
|
446
544
|
configureServiceWorker(active);
|
|
447
545
|
|
|
448
|
-
//
|
|
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.
|
|
449
549
|
if (!navigator.serviceWorker.controller) {
|
|
450
550
|
await new Promise<void>((resolve) => {
|
|
451
551
|
navigator.serviceWorker.addEventListener(
|
|
@@ -456,8 +556,8 @@ export default async function setupServiceWorker(
|
|
|
456
556
|
});
|
|
457
557
|
}
|
|
458
558
|
|
|
459
|
-
// A replacement
|
|
460
|
-
//
|
|
559
|
+
// A replacement worker boots with the default cache name, so reconfigure
|
|
560
|
+
// whenever a new one takes control.
|
|
461
561
|
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
|
462
562
|
configureServiceWorker(navigator.serviceWorker.controller);
|
|
463
563
|
});
|
|
@@ -467,24 +567,27 @@ export default async function setupServiceWorker(
|
|
|
467
567
|
"background: #fcf2f0; color: #333; border: 2px solid; border-radius: 4px"
|
|
468
568
|
);
|
|
469
569
|
|
|
470
|
-
// todon't
|
|
471
|
-
(window as any).killsw = () => {
|
|
472
|
-
if (automergeWorker) {
|
|
473
|
-
automergeWorker.port.close();
|
|
474
|
-
automergeWorker = undefined;
|
|
475
|
-
}
|
|
476
|
-
};
|
|
477
|
-
|
|
478
570
|
return {
|
|
479
571
|
shared,
|
|
480
572
|
connectClassicSync,
|
|
481
573
|
getRepoChannel,
|
|
482
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".
|
|
483
578
|
async subscribeToRepoChannel(listener: ServiceWorkerRepoChannelListener) {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
579
|
+
repoChannelListeners.add(listener);
|
|
580
|
+
const generation = workerGeneration;
|
|
581
|
+
const port = await openRepoChannel();
|
|
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
|
|
584
|
+
// rather than wiring the repo to a dead channel.
|
|
585
|
+
if (generation === workerGeneration) await listener(port);
|
|
586
|
+
return () => {
|
|
587
|
+
repoChannelListeners.delete(listener);
|
|
588
|
+
};
|
|
488
589
|
},
|
|
489
590
|
};
|
|
490
591
|
}
|
|
592
|
+
|
|
593
|
+
(window as any).bumpServiceWorkerCache = bumpServiceWorkerCache;
|