@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/dist/setup.js
CHANGED
|
@@ -3,248 +3,320 @@ import debug from "debug";
|
|
|
3
3
|
import { donatePort, isWorkerErrorMessage, } from "@automerge/automerge-repo/worker-port";
|
|
4
4
|
const serviceWorkerDebugging = debug.enabled("patchwork:serviceworker");
|
|
5
5
|
const workerDebugging = debug.enabled("patchwork:automergeworker");
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
6
|
+
export const lifecycleLog = debug("patchwork:lifecycle");
|
|
7
|
+
function describeErrorEvent(event) {
|
|
8
|
+
const error = event;
|
|
9
|
+
const where = error.filename
|
|
10
|
+
? ` (${error.filename}:${error.lineno}:${error.colno})`
|
|
11
|
+
: "";
|
|
12
|
+
return `${error.message || String(event)}${where}`;
|
|
13
|
+
}
|
|
14
|
+
// The version is cleared on every boot, so the steady state is
|
|
15
|
+
// DEFAULT_CACHE_NAME. bumpServiceWorkerCache is a dev escape hatch: it moves
|
|
16
|
+
// the worker to a throwaway cache now, and the next boot both reverts the name
|
|
17
|
+
// and (via the worker's activate handler) deletes the throwaway.
|
|
18
|
+
const CACHE_VERSION_KEY = "patchworkServiceWorkerCacheVersion";
|
|
19
|
+
const DEFAULT_CACHE_NAME = "patchwork";
|
|
20
|
+
function currentCacheName() {
|
|
21
|
+
return localStorage.getItem(CACHE_VERSION_KEY) ?? DEFAULT_CACHE_NAME;
|
|
22
|
+
}
|
|
23
|
+
function configureServiceWorker(sw) {
|
|
24
|
+
if (!sw)
|
|
25
|
+
return;
|
|
26
|
+
sw.postMessage({ type: "debug", debug: serviceWorkerDebugging });
|
|
27
|
+
sw.postMessage({ type: "cachename", cachename: currentCacheName() });
|
|
17
28
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
29
|
+
export function bumpServiceWorkerCache(sw = navigator.serviceWorker.controller) {
|
|
30
|
+
if (!sw)
|
|
31
|
+
throw new Error("no service worker!");
|
|
32
|
+
localStorage.setItem(CACHE_VERSION_KEY, Date.now().toString(36));
|
|
33
|
+
sw.postMessage({ type: "cachename", cachename: currentCacheName() });
|
|
34
|
+
}
|
|
35
|
+
// The service worker has no localStorage, so it can't read the debug config —
|
|
36
|
+
// it always emits lifecycle markers and forwards them here to be filtered.
|
|
37
|
+
let logForwardingInstalled = false;
|
|
21
38
|
function installServiceWorkerLogForwarding() {
|
|
22
|
-
if (
|
|
39
|
+
if (logForwardingInstalled)
|
|
23
40
|
return;
|
|
24
41
|
if (typeof navigator === "undefined" || !navigator.serviceWorker)
|
|
25
42
|
return;
|
|
26
|
-
|
|
43
|
+
logForwardingInstalled = true;
|
|
27
44
|
navigator.serviceWorker.addEventListener("message", (event) => {
|
|
28
|
-
|
|
29
|
-
if (data?.type !== "sw-lifecycle")
|
|
30
|
-
return;
|
|
31
|
-
if (!lifecycleLoggingEnabled())
|
|
45
|
+
if (event.data?.type !== "sw-lifecycle")
|
|
32
46
|
return;
|
|
33
|
-
|
|
34
|
-
fn(`[service-worker] ${data.msg}`);
|
|
47
|
+
lifecycleLog("[service-worker] %s", event.data.msg);
|
|
35
48
|
});
|
|
36
49
|
}
|
|
37
|
-
|
|
38
|
-
|
|
50
|
+
// The automerge repo lives in a SharedWorker. one instance serves every
|
|
51
|
+
// tab. Browsers might kill a SharedWorker under memory pressure, so we
|
|
52
|
+
// heartbeat it and rebuild everything if it dies.
|
|
53
|
+
let automergeWorkerPath = "/automerge-worker.js";
|
|
54
|
+
let automergeWorker;
|
|
55
|
+
// A repo port opened against instance N is stale once instance N+1 exists — its
|
|
56
|
+
// channel ends in a dead worker — so deliveries are guarded on generation.
|
|
57
|
+
let workerGeneration = 0;
|
|
58
|
+
let disposeWorkerDeathDetection;
|
|
59
|
+
const repoChannelListeners = new Set();
|
|
60
|
+
let recoveringWorker = false;
|
|
61
|
+
let lastWorkerRecoveryAt = 0;
|
|
62
|
+
// Below this spacing, skip: if the fresh worker is dead too, its own heartbeat
|
|
63
|
+
// re-triggers recovery later rather than spinning in a tight loop.
|
|
64
|
+
const RECOVERY_MIN_INTERVAL_MS = 15_000;
|
|
39
65
|
let nextRepoChannelId = 0;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
function
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
sw.postMessage({
|
|
53
|
-
type: "cachename",
|
|
54
|
-
cachename: getServiceWorkerCacheVersion() ?? defaultServiceWorkerCacheName,
|
|
66
|
+
// Chrome can't spawn workers inside a SharedWorker, so each tab offers this
|
|
67
|
+
// proxy's port to the automerge worker, which requests one via its port
|
|
68
|
+
// provider. Being a SharedWorker itself, the proxy — and the donated
|
|
69
|
+
// worker↔worker port — outlives the donor tab.
|
|
70
|
+
const SUBDUCTION_IO_WORKER_URL = "/packages/@automerge/automerge-repo/subduction-websocket-worker-shared.js";
|
|
71
|
+
export function getAutomergeWorker() {
|
|
72
|
+
if (automergeWorker)
|
|
73
|
+
return automergeWorker;
|
|
74
|
+
workerGeneration++;
|
|
75
|
+
const worker = new SharedWorker(automergeWorkerPath, {
|
|
76
|
+
name: "patchwork-automerge",
|
|
77
|
+
type: "module",
|
|
55
78
|
});
|
|
79
|
+
automergeWorker = worker;
|
|
80
|
+
// Fires when a message can't be structured-deserialized. Silent otherwise:
|
|
81
|
+
// the message is dropped, which looks identical to a worker that never
|
|
82
|
+
// replied.
|
|
83
|
+
worker.port.addEventListener("messageerror", (event) => {
|
|
84
|
+
console.error("[automerge-worker] undeserializable message from worker:", event);
|
|
85
|
+
});
|
|
86
|
+
// Control replies come back on this port, and we listen with
|
|
87
|
+
// addEventListener rather than onmessage, so it needs start().
|
|
88
|
+
worker.port.start();
|
|
89
|
+
worker.port.addEventListener("message", handleWorkerMessage);
|
|
90
|
+
worker.port.postMessage({ type: "debug", debug: workerDebugging });
|
|
91
|
+
donatePort(worker.port, createSubductionIoPort);
|
|
92
|
+
disposeWorkerDeathDetection = installWorkerDeathDetection(worker);
|
|
93
|
+
return worker;
|
|
56
94
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
window.bumpServiceWorkerCache = bumpServiceWorkerCache;
|
|
62
|
-
function configureServiceWorker(sw) {
|
|
63
|
-
if (!sw)
|
|
95
|
+
function handleWorkerMessage(event) {
|
|
96
|
+
const data = event.data;
|
|
97
|
+
if (data?.type === "sync-state") {
|
|
98
|
+
dispatchSyncState(data);
|
|
64
99
|
return;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
100
|
+
}
|
|
101
|
+
// Crash/skew reports relayed from the subduction io proxy (e.g. a protocol
|
|
102
|
+
// mismatch from a stale SW-cached worker chunk). These otherwise only exist
|
|
103
|
+
// in chrome://inspect.
|
|
104
|
+
if (isWorkerErrorMessage(data)) {
|
|
105
|
+
console.error("[subduction-io]", data);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (data?.type !== "console")
|
|
109
|
+
return;
|
|
110
|
+
const { level, args } = data;
|
|
111
|
+
if (!lifecycleLog.enabled &&
|
|
112
|
+
typeof args?.[0] === "string" &&
|
|
113
|
+
args[0].includes("[lifecycle]")) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const write = console[level] ?? console.log;
|
|
117
|
+
// The worker's logs carry %c directives in args[0] with CSS in the following
|
|
118
|
+
// args, so the tag has to go inside the format string or the CSS prints raw.
|
|
119
|
+
if (typeof args[0] === "string") {
|
|
120
|
+
write(`[automerge-worker] ${args[0]}`, ...args.slice(1));
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
write("[automerge-worker]", ...args);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function createSubductionIoPort() {
|
|
127
|
+
const io = new SharedWorker(SUBDUCTION_IO_WORKER_URL, {
|
|
128
|
+
type: "module",
|
|
129
|
+
name: "subduction-websocket",
|
|
130
|
+
});
|
|
131
|
+
// This worker carries the websocket to the sync server, so a load failure
|
|
132
|
+
// stops sync with no other symptom.
|
|
133
|
+
io.addEventListener("error", (event) => {
|
|
134
|
+
console.error(`[subduction-io] failed to load/run ${SUBDUCTION_IO_WORKER_URL}:`, describeErrorEvent(event));
|
|
69
135
|
});
|
|
136
|
+
io.port.addEventListener("messageerror", (event) => {
|
|
137
|
+
console.error("[subduction-io] undeserializable message:", event);
|
|
138
|
+
});
|
|
139
|
+
return io.port;
|
|
70
140
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
// instance, so bench arms can't share state.
|
|
89
|
-
// localStorage["patchwork:ws-mode"] = "inline" → socket on worker thread
|
|
90
|
-
// localStorage["patchwork:ws-window"] = "16" → WorkerWebSocketEndpoint
|
|
91
|
-
// windowFrames override
|
|
92
|
-
function workerBenchParams() {
|
|
93
|
-
const params = new URLSearchParams();
|
|
141
|
+
/**
|
|
142
|
+
* Build a replacement worker and re-wire everything a live tab holds against
|
|
143
|
+
* it: console forwarding and port donation (both re-done by
|
|
144
|
+
* getAutomergeWorker), the per-doc sync-state subscriptions, and every
|
|
145
|
+
* subscriber's repo port. The new instance boots with cold state.
|
|
146
|
+
*/
|
|
147
|
+
async function recoverAutomergeWorker(reason, deadWorker) {
|
|
148
|
+
if (deadWorker !== automergeWorker)
|
|
149
|
+
return;
|
|
150
|
+
if (recoveringWorker)
|
|
151
|
+
return;
|
|
152
|
+
const now = Date.now();
|
|
153
|
+
if (now - lastWorkerRecoveryAt < RECOVERY_MIN_INTERVAL_MS)
|
|
154
|
+
return;
|
|
155
|
+
recoveringWorker = true;
|
|
156
|
+
lastWorkerRecoveryAt = now;
|
|
157
|
+
lifecycleLog("recreating the automerge SharedWorker (%s)", reason);
|
|
94
158
|
try {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if (value)
|
|
101
|
-
params.set(param, value);
|
|
159
|
+
disposeWorkerDeathDetection?.();
|
|
160
|
+
disposeWorkerDeathDetection = undefined;
|
|
161
|
+
automergeWorker = undefined;
|
|
162
|
+
try {
|
|
163
|
+
deadWorker.port.close();
|
|
102
164
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
});
|
|
117
|
-
// Control replies (port-ready &c) come back on this port, so it needs
|
|
118
|
-
// start() — we listen with addEventListener, not onmessage.
|
|
119
|
-
automergeWorker.port.start();
|
|
120
|
-
// Surface the SharedWorker's console output and uncaught errors in this
|
|
121
|
-
// tab's console (it has its own console that's awkward to find otherwise).
|
|
122
|
-
automergeWorker.port.addEventListener("message", (event) => {
|
|
123
|
-
if (event.data?.type === "sync-state") {
|
|
124
|
-
dispatchSyncState(event.data);
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
if (isWorkerErrorMessage(event.data)) {
|
|
128
|
-
// Crash/skew reports relayed from the subduction io proxy (e.g.
|
|
129
|
-
// protocol-mismatch from a stale SW-cached worker chunk). Surface
|
|
130
|
-
// loudly — these otherwise only exist in chrome://inspect.
|
|
131
|
-
console.error("[subduction-io]", event.data);
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
if (event.data?.type === "drift-samples") {
|
|
135
|
-
// Keepalive-drift samples from the worker's bench probe. Kept on a
|
|
136
|
-
// bounded window global for the Playwright bench to harvest.
|
|
137
|
-
const sink = (window.__driftSamples ??= []);
|
|
138
|
-
sink.push(...event.data.samples);
|
|
139
|
-
if (sink.length > 10_000)
|
|
140
|
-
sink.splice(0, sink.length - 10_000);
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
if (event.data?.type !== "console")
|
|
144
|
-
return;
|
|
145
|
-
const { level, args } = event.data;
|
|
146
|
-
// Gate forwarded [lifecycle] logs on the toggle too.
|
|
147
|
-
if (!lifecycleLoggingEnabled() &&
|
|
148
|
-
typeof args?.[0] === "string" &&
|
|
149
|
-
args[0].includes("[lifecycle]")) {
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
const fn = console[level] ?? console.log;
|
|
153
|
-
// The worker's logs (debug library, the worker's own log()) carry %c
|
|
154
|
-
// format directives in args[0] with CSS in the following args. Prefix
|
|
155
|
-
// the tag into the format string rather than as a separate positional,
|
|
156
|
-
// or the %c would no longer be in arg 0 and the CSS would print raw.
|
|
157
|
-
if (typeof args[0] === "string") {
|
|
158
|
-
fn(`[automerge-worker] ${args[0]}`, ...args.slice(1));
|
|
165
|
+
catch { }
|
|
166
|
+
const fresh = getAutomergeWorker();
|
|
167
|
+
for (const documentId of syncStateListeners.keys()) {
|
|
168
|
+
fresh.port.postMessage({ type: "sync-sub", documentId });
|
|
169
|
+
}
|
|
170
|
+
for (const listener of repoChannelListeners) {
|
|
171
|
+
try {
|
|
172
|
+
const generation = workerGeneration;
|
|
173
|
+
const port = await openRepoChannel();
|
|
174
|
+
// Replaced again while we waited — the newer recovery re-delivers.
|
|
175
|
+
if (generation !== workerGeneration)
|
|
176
|
+
break;
|
|
177
|
+
await listener(port);
|
|
159
178
|
}
|
|
160
|
-
|
|
161
|
-
|
|
179
|
+
catch (err) {
|
|
180
|
+
console.error("failed to re-wire a repo channel after worker recovery", err);
|
|
162
181
|
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
donatePort(automergeWorker.port, () => {
|
|
168
|
-
const io = new SharedWorker(SUBDUCTION_IO_WORKER_URL, {
|
|
169
|
-
type: "module",
|
|
170
|
-
name: "subduction-websocket",
|
|
171
|
-
});
|
|
172
|
-
return io.port;
|
|
173
|
-
});
|
|
174
|
-
installWorkerDeathDetection(automergeWorker);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
recoveringWorker = false;
|
|
175
186
|
}
|
|
176
|
-
return automergeWorker;
|
|
177
187
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
188
|
+
// A silent port is not proof of death: the worker may still be evaluating its
|
|
189
|
+
// module graph, or be busy with wasm/sync work. In both cases every queued
|
|
190
|
+
// message — including the repo ports the network adapters ride on — is
|
|
191
|
+
// delivered once it catches up, and tearing the port down would lose them. So
|
|
192
|
+
// silence only starts a non-destructive probe: a second connection to the same
|
|
193
|
+
// instance. Only if the probe gets a `hello` while this port stays silent do we
|
|
194
|
+
// know the instance is alive but our port is stranded, and recover.
|
|
195
|
+
const HEARTBEAT_MS = 5_000;
|
|
196
|
+
const HEARTBEAT_TIMEOUT_MS = 25_000;
|
|
197
|
+
// An idle worker hellos within milliseconds of connecting, so before first
|
|
198
|
+
// contact the budget is tighter — probing early rescues stranded boots fast.
|
|
199
|
+
const FIRST_CONTACT_TIMEOUT_MS = 4_000;
|
|
200
|
+
// After a slow boot both connections hello at roughly the same moment and
|
|
201
|
+
// cross-port delivery order isn't guaranteed, so give the suspect this long to
|
|
202
|
+
// also speak before concluding it's stranded.
|
|
203
|
+
const PROBE_GRACE_MS = 500;
|
|
183
204
|
function installWorkerDeathDetection(worker) {
|
|
184
|
-
const stamp = () => new Date().toISOString();
|
|
185
|
-
const warn = (msg) => {
|
|
186
|
-
if (lifecycleLoggingEnabled())
|
|
187
|
-
console.warn(`[lifecycle] ${stamp()} ${msg}`);
|
|
188
|
-
};
|
|
189
|
-
const info = (msg) => {
|
|
190
|
-
if (lifecycleLoggingEnabled())
|
|
191
|
-
console.info(`[lifecycle] ${stamp()} ${msg}`);
|
|
192
|
-
};
|
|
193
205
|
let instanceId;
|
|
194
|
-
let
|
|
206
|
+
let lastHeardAt = Date.now();
|
|
195
207
|
let warnedUnresponsive = false;
|
|
208
|
+
let warnedSendFailed = false;
|
|
209
|
+
let disposed = false;
|
|
210
|
+
let probe;
|
|
211
|
+
let seq = 0;
|
|
212
|
+
const closeProbe = () => {
|
|
213
|
+
if (!probe)
|
|
214
|
+
return;
|
|
215
|
+
try {
|
|
216
|
+
probe.port.close();
|
|
217
|
+
}
|
|
218
|
+
catch { }
|
|
219
|
+
probe = undefined;
|
|
220
|
+
};
|
|
196
221
|
worker.port.addEventListener("message", (event) => {
|
|
197
222
|
const data = event.data;
|
|
198
223
|
if (data?.type !== "hello" && data?.type !== "pong")
|
|
199
224
|
return;
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
225
|
+
lastHeardAt = Date.now();
|
|
226
|
+
warnedUnresponsive = false;
|
|
227
|
+
closeProbe();
|
|
204
228
|
if (instanceId === undefined) {
|
|
205
229
|
instanceId = data.instanceId;
|
|
206
|
-
|
|
230
|
+
lifecycleLog("automerge SharedWorker instance %s (via %s)", data.instanceId, data.type);
|
|
207
231
|
}
|
|
208
232
|
else if (data.instanceId && data.instanceId !== instanceId) {
|
|
209
|
-
|
|
210
|
-
`was ${instanceId}) — fresh peerId + cold state; docs need re-subscribe`);
|
|
233
|
+
lifecycleLog("automerge SharedWorker instance changed (instance %s, was %s)", data.instanceId, instanceId);
|
|
211
234
|
instanceId = data.instanceId;
|
|
212
235
|
}
|
|
213
236
|
});
|
|
214
|
-
// Fires when the SharedWorker is destroyed (where supported).
|
|
215
237
|
worker.port.addEventListener("close", () => {
|
|
216
|
-
|
|
238
|
+
if (disposed)
|
|
239
|
+
return;
|
|
240
|
+
lifecycleLog("automerge SharedWorker control port closed");
|
|
241
|
+
void recoverAutomergeWorker("control port closed", worker);
|
|
217
242
|
});
|
|
218
|
-
worker
|
|
219
|
-
|
|
243
|
+
// Not gated on the debug namespace: a worker that fails to load never replies
|
|
244
|
+
// to anything, and this is the only signal that says so.
|
|
245
|
+
worker.addEventListener("error", (event) => {
|
|
246
|
+
console.error("automerge SharedWorker error:", describeErrorEvent(event));
|
|
220
247
|
});
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
248
|
+
const startProbe = (reason) => {
|
|
249
|
+
if (probe || disposed)
|
|
250
|
+
return;
|
|
251
|
+
lifecycleLog("automerge SharedWorker %s; probing with a second connection", reason);
|
|
252
|
+
const startedAt = Date.now();
|
|
253
|
+
const p = new SharedWorker(automergeWorkerPath, {
|
|
254
|
+
name: "patchwork-automerge",
|
|
255
|
+
type: "module",
|
|
256
|
+
});
|
|
257
|
+
probe = p;
|
|
258
|
+
p.port.start();
|
|
259
|
+
p.port.addEventListener("message", (event) => {
|
|
260
|
+
if (event.data?.type !== "hello")
|
|
261
|
+
return;
|
|
262
|
+
setTimeout(() => {
|
|
263
|
+
if (disposed || probe !== p)
|
|
264
|
+
return;
|
|
265
|
+
closeProbe();
|
|
266
|
+
// The suspect spoke while the probe ran: it was merely busy, and
|
|
267
|
+
// everything queued on it has been delivered.
|
|
268
|
+
if (lastHeardAt >= startedAt)
|
|
269
|
+
return;
|
|
270
|
+
void recoverAutomergeWorker(`port unresponsive on a live worker (${reason}; probe confirmed)`, worker);
|
|
271
|
+
}, PROBE_GRACE_MS);
|
|
272
|
+
});
|
|
273
|
+
// No hello on the probe means the instance is loading or busy. The probe
|
|
274
|
+
// waits indefinitely rather than tearing anything down on a timer.
|
|
275
|
+
};
|
|
276
|
+
const heartbeat = setInterval(() => {
|
|
227
277
|
try {
|
|
228
278
|
worker.port.postMessage({ type: "ping", id: ++seq });
|
|
229
279
|
}
|
|
230
|
-
catch {
|
|
231
|
-
//
|
|
280
|
+
catch (error) {
|
|
281
|
+
// Without this a failed send is indistinguishable from a dead worker.
|
|
282
|
+
if (!warnedSendFailed) {
|
|
283
|
+
warnedSendFailed = true;
|
|
284
|
+
console.error("automerge SharedWorker ping send threw", error);
|
|
285
|
+
}
|
|
232
286
|
}
|
|
233
|
-
const
|
|
287
|
+
const neverHeard = instanceId === undefined;
|
|
288
|
+
const silentMs = Date.now() - lastHeardAt;
|
|
289
|
+
const timeoutMs = neverHeard
|
|
290
|
+
? FIRST_CONTACT_TIMEOUT_MS
|
|
291
|
+
: HEARTBEAT_TIMEOUT_MS;
|
|
292
|
+
if (silentMs <= timeoutMs)
|
|
293
|
+
return;
|
|
294
|
+
// First contact probes regardless of visibility: SharedWorkers don't
|
|
295
|
+
// suspend with the tab, and the probe destroys nothing. Post-contact
|
|
296
|
+
// silence defers to visibility, since a hidden page's throttling can fake
|
|
297
|
+
// it.
|
|
234
298
|
const visible = typeof document === "undefined" || document.visibilityState === "visible";
|
|
235
|
-
if (
|
|
299
|
+
if (!neverHeard && !visible)
|
|
300
|
+
return;
|
|
301
|
+
const seconds = Math.round(silentMs / 1000);
|
|
302
|
+
const reason = neverHeard
|
|
303
|
+
? `no hello ~${seconds}s after connecting`
|
|
304
|
+
: `no pong for ~${seconds}s`;
|
|
305
|
+
if (!warnedUnresponsive) {
|
|
236
306
|
warnedUnresponsive = true;
|
|
237
|
-
|
|
238
|
-
`while tab visible — likely died/crashed`);
|
|
307
|
+
lifecycleLog("automerge SharedWorker %s (tab visible)", reason);
|
|
239
308
|
}
|
|
309
|
+
startProbe(reason);
|
|
240
310
|
}, HEARTBEAT_MS);
|
|
311
|
+
return () => {
|
|
312
|
+
disposed = true;
|
|
313
|
+
clearInterval(heartbeat);
|
|
314
|
+
closeProbe();
|
|
315
|
+
};
|
|
241
316
|
}
|
|
242
317
|
const syncStateListeners = new Map();
|
|
243
318
|
function dispatchSyncState(update) {
|
|
244
|
-
const
|
|
245
|
-
if (!listeners)
|
|
246
|
-
return;
|
|
247
|
-
for (const listener of listeners) {
|
|
319
|
+
for (const listener of syncStateListeners.get(update.documentId) ?? []) {
|
|
248
320
|
try {
|
|
249
321
|
listener(update);
|
|
250
322
|
}
|
|
@@ -258,23 +330,24 @@ export function subscribeSyncState(documentId, listener) {
|
|
|
258
330
|
let listeners = syncStateListeners.get(documentId);
|
|
259
331
|
if (!listeners) {
|
|
260
332
|
syncStateListeners.set(documentId, (listeners = new Set()));
|
|
261
|
-
// First local watcher for this doc — ask the worker to start pushing it.
|
|
262
333
|
worker.port.postMessage({ type: "sync-sub", documentId });
|
|
263
334
|
}
|
|
264
335
|
listeners.add(listener);
|
|
265
336
|
let active = true;
|
|
266
337
|
return () => {
|
|
267
338
|
if (!active)
|
|
268
|
-
return;
|
|
339
|
+
return;
|
|
269
340
|
active = false;
|
|
270
341
|
const set = syncStateListeners.get(documentId);
|
|
271
342
|
if (!set)
|
|
272
343
|
return;
|
|
273
344
|
set.delete(listener);
|
|
274
|
-
if (set.size
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
345
|
+
if (set.size > 0)
|
|
346
|
+
return;
|
|
347
|
+
syncStateListeners.delete(documentId);
|
|
348
|
+
// Unsubscribe from whichever instance is current: recovery replays
|
|
349
|
+
// subscriptions onto a new worker, so it may not be the one captured above.
|
|
350
|
+
automergeWorker?.port.postMessage({ type: "sync-unsub", documentId });
|
|
278
351
|
};
|
|
279
352
|
}
|
|
280
353
|
export function connectClassicSync(server = readClassicSyncServer()) {
|
|
@@ -292,135 +365,141 @@ export function connectClassicSync(server = readClassicSyncServer()) {
|
|
|
292
365
|
port1.onmessage = (event) => {
|
|
293
366
|
clearTimeout(timeout);
|
|
294
367
|
port1.close();
|
|
295
|
-
if (event.data?.type === "connect-classic-sync-ready")
|
|
368
|
+
if (event.data?.type === "connect-classic-sync-ready")
|
|
296
369
|
resolve();
|
|
297
|
-
|
|
298
|
-
else {
|
|
370
|
+
else
|
|
299
371
|
reject(new Error(event.data?.error ?? "connect-classic-sync failed"));
|
|
300
|
-
}
|
|
301
372
|
};
|
|
302
373
|
worker.port.postMessage({ type: "connect-classic-sync", server: url }, [
|
|
303
374
|
port2,
|
|
304
375
|
]);
|
|
305
376
|
});
|
|
306
377
|
}
|
|
307
|
-
|
|
308
|
-
function waitForActive(reg) {
|
|
309
|
-
if (reg.active)
|
|
310
|
-
return Promise.resolve(reg.active);
|
|
311
|
-
const worker = reg.installing || reg.waiting;
|
|
312
|
-
if (!worker)
|
|
313
|
-
return Promise.reject(new Error("no service worker in registration"));
|
|
314
|
-
return new Promise((resolve) => {
|
|
315
|
-
worker.addEventListener("statechange", () => {
|
|
316
|
-
if (worker.state === "activated")
|
|
317
|
-
resolve(worker);
|
|
318
|
-
});
|
|
319
|
-
});
|
|
320
|
-
}
|
|
321
|
-
async function openRepoChannel() {
|
|
322
|
-
const worker = getAutomergeWorker();
|
|
323
|
-
// Send a MessagePort so the worker's repo can sync with this tab, and wait
|
|
324
|
-
// for the worker to confirm its repo is constructed before returning. The
|
|
325
|
-
// MessageChannel adapter's whenReady() force-resolves after 100ms regardless
|
|
326
|
-
// of the other end's state, so it can't be used as a real readiness signal
|
|
327
|
-
// on first boot (when the worker still has to fetch wasm and build its repo).
|
|
328
|
-
const id = ++nextRepoChannelId;
|
|
378
|
+
function sendRepoPort(id) {
|
|
329
379
|
const { port1, port2 } = new MessageChannel();
|
|
330
|
-
|
|
331
|
-
|
|
380
|
+
getAutomergeWorker().port.postMessage({ type: "port", id }, [port2]);
|
|
381
|
+
return port1;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Wait for the worker to confirm its repo is constructed. The MessageChannel
|
|
385
|
+
* adapter's whenReady() force-resolves after 100ms regardless of the other
|
|
386
|
+
* end's state, so it can't serve as a readiness signal on first boot, when the
|
|
387
|
+
* worker still has to fetch wasm and build its repo.
|
|
388
|
+
*/
|
|
389
|
+
function awaitPortReady(control, id) {
|
|
390
|
+
return new Promise((resolve, reject) => {
|
|
332
391
|
const cleanup = () => {
|
|
333
392
|
clearTimeout(timeout);
|
|
334
|
-
|
|
393
|
+
control.removeEventListener("message", listener);
|
|
335
394
|
};
|
|
336
395
|
const listener = (event) => {
|
|
337
396
|
if (event.data?.id !== id)
|
|
338
397
|
return;
|
|
339
|
-
if (event.data
|
|
398
|
+
if (event.data.type === "port-ready") {
|
|
340
399
|
cleanup();
|
|
341
400
|
resolve();
|
|
342
401
|
}
|
|
343
|
-
else if (event.data
|
|
402
|
+
else if (event.data.type === "port-failed") {
|
|
344
403
|
cleanup();
|
|
345
404
|
reject(new Error(`automerge worker init failed: ${event.data.error}`));
|
|
346
405
|
}
|
|
347
406
|
};
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
// the issue and let the rest of the site come up rather than hanging on a
|
|
351
|
-
// blank page.
|
|
352
|
-
timeout = setTimeout(() => {
|
|
407
|
+
control.addEventListener("message", listener);
|
|
408
|
+
const timeout = setTimeout(() => {
|
|
353
409
|
cleanup();
|
|
354
410
|
reject(new Error("automerge worker port-ready timeout"));
|
|
355
411
|
}, 30_000);
|
|
356
412
|
});
|
|
357
|
-
|
|
413
|
+
}
|
|
414
|
+
async function openRepoChannel() {
|
|
415
|
+
const id = ++nextRepoChannelId;
|
|
416
|
+
const ready = awaitPortReady(getAutomergeWorker().port, id);
|
|
417
|
+
const port = sendRepoPort(id);
|
|
358
418
|
try {
|
|
359
|
-
await
|
|
419
|
+
await ready;
|
|
360
420
|
}
|
|
361
421
|
catch (err) {
|
|
422
|
+
// Surface the problem and let the rest of the site come up rather than
|
|
423
|
+
// hanging on a blank page.
|
|
362
424
|
console.warn("proceeding without worker ready ack:", err instanceof Error ? err.message : err);
|
|
363
425
|
}
|
|
364
|
-
return
|
|
426
|
+
return port;
|
|
365
427
|
}
|
|
366
428
|
/** Open a fresh repo sync port to the automerge worker (dev console). */
|
|
367
429
|
function getRepoChannel() {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
430
|
+
return sendRepoPort(++nextRepoChannelId);
|
|
431
|
+
}
|
|
432
|
+
function waitForActive(reg) {
|
|
433
|
+
if (reg.active)
|
|
434
|
+
return Promise.resolve(reg.active);
|
|
435
|
+
const worker = reg.installing || reg.waiting;
|
|
436
|
+
if (!worker) {
|
|
437
|
+
return Promise.reject(new Error("no service worker in registration"));
|
|
438
|
+
}
|
|
439
|
+
return new Promise((resolve, reject) => {
|
|
440
|
+
worker.addEventListener("statechange", () => {
|
|
441
|
+
if (worker.state === "activated")
|
|
442
|
+
resolve(worker);
|
|
443
|
+
// Without this the promise never settles when an install fails.
|
|
444
|
+
else if (worker.state === "redundant") {
|
|
445
|
+
reject(new Error("service worker became redundant before activating"));
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
});
|
|
372
449
|
}
|
|
373
450
|
export default async function setupServiceWorker(options) {
|
|
374
|
-
// Attach the
|
|
375
|
-
//
|
|
451
|
+
// Attach the log bridge first so the controlling worker's boot/install/
|
|
452
|
+
// activate markers are rendered here.
|
|
376
453
|
installServiceWorkerLogForwarding();
|
|
377
|
-
localStorage.removeItem(
|
|
454
|
+
localStorage.removeItem(CACHE_VERSION_KEY);
|
|
455
|
+
// Cache growth can otherwise trip origin-wide eviction, which would take the
|
|
456
|
+
// Automerge IndexedDB — the user's documents — with it. Chrome/Safari decide
|
|
457
|
+
// silently from site engagement; Firefox may prompt. Denial just means
|
|
458
|
+
// default eviction.
|
|
459
|
+
void navigator.storage?.persist?.().catch(() => { });
|
|
378
460
|
if (options?.workerPath)
|
|
379
461
|
automergeWorkerPath = options.workerPath;
|
|
380
|
-
// Start the automerge worker
|
|
462
|
+
// Start the automerge worker now so it boots wasm and its repo while the
|
|
381
463
|
// service worker installs.
|
|
382
464
|
const shared = getAutomergeWorker();
|
|
383
|
-
|
|
384
|
-
const
|
|
385
|
-
// No controller at this point means the page loaded without a service
|
|
386
|
-
// worker — i.e. this is a first-time install (or a hard reload). Wait for
|
|
387
|
-
// activation so the app boots with the SW in control of generated fetches.
|
|
388
|
-
const reg = await navigator.serviceWorker.register(path, { type: "module" });
|
|
389
|
-
// If there's an update waiting or installing, wait for it to activate
|
|
390
|
-
let active = reg.active;
|
|
391
|
-
if (reg.installing || reg.waiting) {
|
|
392
|
-
active = await waitForActive(reg);
|
|
393
|
-
}
|
|
465
|
+
const reg = await navigator.serviceWorker.register(options?.path ?? "/service-worker.js", { type: "module" });
|
|
466
|
+
const active = reg.installing || reg.waiting ? await waitForActive(reg) : reg.active;
|
|
394
467
|
configureServiceWorker(active);
|
|
395
|
-
//
|
|
468
|
+
// No controller means the page loaded without a service worker — a first-time
|
|
469
|
+
// install or a hard reload. Wait for it so the app boots with the worker in
|
|
470
|
+
// control of generated fetches.
|
|
396
471
|
if (!navigator.serviceWorker.controller) {
|
|
397
472
|
await new Promise((resolve) => {
|
|
398
473
|
navigator.serviceWorker.addEventListener("controllerchange", () => resolve(), { once: true });
|
|
399
474
|
});
|
|
400
475
|
}
|
|
401
|
-
// A replacement
|
|
402
|
-
//
|
|
476
|
+
// A replacement worker boots with the default cache name, so reconfigure
|
|
477
|
+
// whenever a new one takes control.
|
|
403
478
|
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
|
404
479
|
configureServiceWorker(navigator.serviceWorker.controller);
|
|
405
480
|
});
|
|
406
481
|
console.log("service worker alive, loading %c patchwork system ", "background: #fcf2f0; color: #333; border: 2px solid; border-radius: 4px");
|
|
407
|
-
// todon't
|
|
408
|
-
window.killsw = () => {
|
|
409
|
-
if (automergeWorker) {
|
|
410
|
-
automergeWorker.port.close();
|
|
411
|
-
automergeWorker = undefined;
|
|
412
|
-
}
|
|
413
|
-
};
|
|
414
482
|
return {
|
|
415
483
|
shared,
|
|
416
484
|
connectClassicSync,
|
|
417
485
|
getRepoChannel,
|
|
418
486
|
subscribeSyncState,
|
|
487
|
+
// Called once with the boot port. If the automerge worker later dies and is
|
|
488
|
+
// recreated, the listener is called again with a fresh port — treat every
|
|
489
|
+
// call as "(re)wire your repo's sync onto this port".
|
|
419
490
|
async subscribeToRepoChannel(listener) {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
491
|
+
repoChannelListeners.add(listener);
|
|
492
|
+
const generation = workerGeneration;
|
|
493
|
+
const port = await openRepoChannel();
|
|
494
|
+
// If the worker was replaced while this channel was opening, recovery has
|
|
495
|
+
// already delivered a good port to this listener — drop the stale one
|
|
496
|
+
// rather than wiring the repo to a dead channel.
|
|
497
|
+
if (generation === workerGeneration)
|
|
498
|
+
await listener(port);
|
|
499
|
+
return () => {
|
|
500
|
+
repoChannelListeners.delete(listener);
|
|
501
|
+
};
|
|
424
502
|
},
|
|
425
503
|
};
|
|
426
504
|
}
|
|
505
|
+
window.bumpServiceWorkerCache = bumpServiceWorkerCache;
|