@inkandswitch/patchwork-bootloader 0.4.3 → 0.5.0
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 +29 -0
- package/dist/automerge-worker.js +533 -771
- package/dist/externals-list.d.ts +5 -0
- package/dist/externals-list.js +41 -0
- package/dist/externals.d.ts +14 -3
- package/dist/externals.js +50 -38
- package/dist/global.css +30 -0
- package/dist/module-loader.d.ts +0 -5
- package/dist/module-loader.js +0 -6
- package/dist/service-worker.js +178 -205
- package/dist/setup.d.ts +2 -1
- package/dist/setup.js +251 -375
- package/dist/sync-config.d.ts +1 -1
- package/dist/sync-config.js +3 -1
- package/package.json +15 -14
- package/src/automerge-worker.ts +634 -839
- package/src/externals-list.ts +43 -0
- package/src/externals.ts +64 -38
- package/src/global.css +30 -0
- package/src/module-loader.ts +0 -6
- package/src/service-worker.ts +224 -238
- package/src/setup.ts +287 -401
- package/src/sync-config.ts +6 -1
- package/dist/site.d.ts +0 -111
- package/dist/site.js +0 -602
- package/dist/vite/importmap-plugin.d.ts +0 -4
- package/dist/vite/importmap-plugin.js +0 -105
- package/dist/vite/patchwork-plugin.d.ts +0 -14
- package/dist/vite/patchwork-plugin.js +0 -8
- package/dist/vite/service-worker-plugin.d.ts +0 -2
- package/dist/vite/service-worker-plugin.js +0 -42
- package/src/site.ts +0 -859
- package/src/vite/importmap-plugin.ts +0 -133
- package/src/vite/patchwork-plugin.ts +0 -20
- package/src/vite/service-worker-plugin.ts +0 -46
package/dist/setup.js
CHANGED
|
@@ -3,105 +3,150 @@ 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
|
-
|
|
39
|
-
|
|
40
|
-
function bumpServiceWorkerCacheVersion() {
|
|
41
|
-
const version = new Date().valueOf().toString(36);
|
|
42
|
-
localStorage.setItem(key, version);
|
|
43
|
-
return getServiceWorkerCacheVersion();
|
|
44
|
-
}
|
|
45
|
-
function getServiceWorkerCacheVersion() {
|
|
46
|
-
return localStorage.getItem(key);
|
|
47
|
-
}
|
|
48
|
-
function setServiceWorkerCacheName(sw) {
|
|
49
|
-
if (!sw) {
|
|
50
|
-
throw new Error("no service worker!");
|
|
51
|
-
}
|
|
52
|
-
sw.postMessage({
|
|
53
|
-
type: "cachename",
|
|
54
|
-
cachename: getServiceWorkerCacheVersion() ?? defaultServiceWorkerCacheName,
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
export function bumpServiceWorkerCache(sw = navigator.serviceWorker.controller) {
|
|
58
|
-
bumpServiceWorkerCacheVersion();
|
|
59
|
-
setServiceWorkerCacheName(sw);
|
|
60
|
-
}
|
|
61
|
-
window.bumpServiceWorkerCache = bumpServiceWorkerCache;
|
|
62
|
-
function configureServiceWorker(sw) {
|
|
63
|
-
if (!sw)
|
|
64
|
-
return;
|
|
65
|
-
sw.postMessage({ type: "debug", debug: serviceWorkerDebugging });
|
|
66
|
-
sw.postMessage({
|
|
67
|
-
type: "cachename",
|
|
68
|
-
cachename: getServiceWorkerCacheVersion() ?? defaultServiceWorkerCacheName,
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
// ── The automerge worker ───────────────────────────────────────────────
|
|
72
|
-
// The automerge repo lives in a SharedWorker (not the service worker). One
|
|
73
|
-
// instance is shared by every tab and normally lives as long as any tab
|
|
74
|
-
// does — but browsers do reap SharedWorkers under memory pressure, so we
|
|
75
|
-
// heartbeat it and rebuild everything if it dies (see
|
|
76
|
-
// recoverAutomergeWorker). Repo sync ports are passed to it over its
|
|
77
|
-
// connect port; it talks to the service worker over a BroadcastChannel.
|
|
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.
|
|
78
53
|
let automergeWorkerPath = "/automerge-worker.js";
|
|
79
54
|
let automergeWorker;
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
// a dead worker), so deliveries are guarded on the generation they started in.
|
|
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.
|
|
83
57
|
let workerGeneration = 0;
|
|
84
|
-
// Tears down the current worker's heartbeat when it's replaced.
|
|
85
58
|
let disposeWorkerDeathDetection;
|
|
86
|
-
// Every subscribeToRepoChannel listener, kept so a recovered worker can hand
|
|
87
|
-
// each subscriber a fresh repo port.
|
|
88
59
|
const repoChannelListeners = new Set();
|
|
89
60
|
let recoveringWorker = false;
|
|
90
61
|
let lastWorkerRecoveryAt = 0;
|
|
91
|
-
// Below this spacing, skip: if the fresh worker is dead too, its own
|
|
92
|
-
//
|
|
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.
|
|
93
64
|
const RECOVERY_MIN_INTERVAL_MS = 15_000;
|
|
65
|
+
let nextRepoChannelId = 0;
|
|
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",
|
|
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;
|
|
94
|
+
}
|
|
95
|
+
function handleWorkerMessage(event) {
|
|
96
|
+
const data = event.data;
|
|
97
|
+
if (data?.type === "sync-state") {
|
|
98
|
+
dispatchSyncState(data);
|
|
99
|
+
return;
|
|
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));
|
|
135
|
+
});
|
|
136
|
+
io.port.addEventListener("messageerror", (event) => {
|
|
137
|
+
console.error("[subduction-io] undeserializable message:", event);
|
|
138
|
+
});
|
|
139
|
+
return io.port;
|
|
140
|
+
}
|
|
94
141
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* forwarding and the io-proxy port donation (both re-done by
|
|
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
|
|
98
144
|
* getAutomergeWorker), the per-doc sync-state subscriptions, and every
|
|
99
|
-
* subscriber's repo
|
|
100
|
-
* repo is reconstructed on the first port we send.
|
|
145
|
+
* subscriber's repo port. The new instance boots with cold state.
|
|
101
146
|
*/
|
|
102
147
|
async function recoverAutomergeWorker(reason, deadWorker) {
|
|
103
148
|
if (deadWorker !== automergeWorker)
|
|
104
|
-
return;
|
|
149
|
+
return;
|
|
105
150
|
if (recoveringWorker)
|
|
106
151
|
return;
|
|
107
152
|
const now = Date.now();
|
|
@@ -109,8 +154,7 @@ async function recoverAutomergeWorker(reason, deadWorker) {
|
|
|
109
154
|
return;
|
|
110
155
|
recoveringWorker = true;
|
|
111
156
|
lastWorkerRecoveryAt = now;
|
|
112
|
-
|
|
113
|
-
`SharedWorker (${reason})`);
|
|
157
|
+
lifecycleLog("recreating the automerge SharedWorker (%s)", reason);
|
|
114
158
|
try {
|
|
115
159
|
disposeWorkerDeathDetection?.();
|
|
116
160
|
disposeWorkerDeathDetection = undefined;
|
|
@@ -118,16 +162,11 @@ async function recoverAutomergeWorker(reason, deadWorker) {
|
|
|
118
162
|
try {
|
|
119
163
|
deadWorker.port.close();
|
|
120
164
|
}
|
|
121
|
-
catch {
|
|
122
|
-
// Port already dead.
|
|
123
|
-
}
|
|
165
|
+
catch { }
|
|
124
166
|
const fresh = getAutomergeWorker();
|
|
125
|
-
// The fresh instance knows nothing — replay every doc subscription.
|
|
126
167
|
for (const documentId of syncStateListeners.keys()) {
|
|
127
168
|
fresh.port.postMessage({ type: "sync-sub", documentId });
|
|
128
169
|
}
|
|
129
|
-
// Hand every repo-channel subscriber a fresh port so their repos sync
|
|
130
|
-
// again (the old adapters sit on dead MessagePorts).
|
|
131
170
|
for (const listener of repoChannelListeners) {
|
|
132
171
|
try {
|
|
133
172
|
const generation = workerGeneration;
|
|
@@ -146,210 +185,72 @@ async function recoverAutomergeWorker(reason, deadWorker) {
|
|
|
146
185
|
recoveringWorker = false;
|
|
147
186
|
}
|
|
148
187
|
}
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
for (const [key, param] of [
|
|
166
|
-
["patchwork:ws-mode", "ws-mode"],
|
|
167
|
-
["patchwork:ws-window", "ws-window"],
|
|
168
|
-
]) {
|
|
169
|
-
const value = globalThis.localStorage?.getItem(key);
|
|
170
|
-
if (value)
|
|
171
|
-
params.set(param, value);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
catch {
|
|
175
|
-
// No localStorage (shouldn't happen in a tab) — use defaults.
|
|
176
|
-
}
|
|
177
|
-
const qs = params.toString();
|
|
178
|
-
return qs ? `?${qs}` : "";
|
|
179
|
-
}
|
|
180
|
-
function automergeWorkerUrl() {
|
|
181
|
-
return `${automergeWorkerPath}${workerBenchParams()}`;
|
|
182
|
-
}
|
|
183
|
-
export function getAutomergeWorker() {
|
|
184
|
-
if (!automergeWorker) {
|
|
185
|
-
workerGeneration++;
|
|
186
|
-
automergeWorker = new SharedWorker(automergeWorkerUrl(), {
|
|
187
|
-
name: "patchwork-automerge",
|
|
188
|
-
type: "module",
|
|
189
|
-
});
|
|
190
|
-
// Fired when a message arrives that can't be structured-deserialized —
|
|
191
|
-
// e.g. a transfer list that named something unclonable. Silent otherwise:
|
|
192
|
-
// the message is simply dropped, which looks identical to a worker that
|
|
193
|
-
// never replied. Always loud, not gated on the lifecycle toggle.
|
|
194
|
-
automergeWorker.port.addEventListener("messageerror", (event) => {
|
|
195
|
-
console.error("[automerge-worker] undeserializable message from worker:", event);
|
|
196
|
-
});
|
|
197
|
-
// Control replies (port-ready &c) come back on this port, so it needs
|
|
198
|
-
// start() — we listen with addEventListener, not onmessage.
|
|
199
|
-
automergeWorker.port.start();
|
|
200
|
-
// Surface the SharedWorker's console output and uncaught errors in this
|
|
201
|
-
// tab's console (it has its own console that's awkward to find otherwise).
|
|
202
|
-
automergeWorker.port.addEventListener("message", (event) => {
|
|
203
|
-
if (event.data?.type === "sync-state") {
|
|
204
|
-
dispatchSyncState(event.data);
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
if (isWorkerErrorMessage(event.data)) {
|
|
208
|
-
// Crash/skew reports relayed from the subduction io proxy (e.g.
|
|
209
|
-
// protocol-mismatch from a stale SW-cached worker chunk). Surface
|
|
210
|
-
// loudly — these otherwise only exist in chrome://inspect.
|
|
211
|
-
console.error("[subduction-io]", event.data);
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
if (event.data?.type === "drift-samples") {
|
|
215
|
-
// Keepalive-drift samples from the worker's bench probe. Kept on a
|
|
216
|
-
// bounded window global for the Playwright bench to harvest.
|
|
217
|
-
const sink = (window.__driftSamples ??= []);
|
|
218
|
-
sink.push(...event.data.samples);
|
|
219
|
-
if (sink.length > 10_000)
|
|
220
|
-
sink.splice(0, sink.length - 10_000);
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
if (event.data?.type !== "console")
|
|
224
|
-
return;
|
|
225
|
-
const { level, args } = event.data;
|
|
226
|
-
// Gate forwarded [lifecycle] logs on the toggle too.
|
|
227
|
-
if (!lifecycleLoggingEnabled() &&
|
|
228
|
-
typeof args?.[0] === "string" &&
|
|
229
|
-
args[0].includes("[lifecycle]")) {
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
const fn = console[level] ?? console.log;
|
|
233
|
-
// The worker's logs (debug library, the worker's own log()) carry %c
|
|
234
|
-
// format directives in args[0] with CSS in the following args. Prefix
|
|
235
|
-
// the tag into the format string rather than as a separate positional,
|
|
236
|
-
// or the %c would no longer be in arg 0 and the CSS would print raw.
|
|
237
|
-
if (typeof args[0] === "string") {
|
|
238
|
-
fn(`[automerge-worker] ${args[0]}`, ...args.slice(1));
|
|
239
|
-
}
|
|
240
|
-
else {
|
|
241
|
-
fn("[automerge-worker]", ...args);
|
|
242
|
-
}
|
|
243
|
-
});
|
|
244
|
-
automergeWorker.port.postMessage({ type: "debug", debug: workerDebugging });
|
|
245
|
-
// Offer the subduction io proxy's port; the worker's port provider pulls
|
|
246
|
-
// it when (re)constructing its WorkerWebSocketEndpoint.
|
|
247
|
-
donatePort(automergeWorker.port, () => {
|
|
248
|
-
const io = new SharedWorker(SUBDUCTION_IO_WORKER_URL, {
|
|
249
|
-
type: "module",
|
|
250
|
-
name: "subduction-websocket",
|
|
251
|
-
});
|
|
252
|
-
// This worker carries the websocket to the sync server, so if it fails
|
|
253
|
-
// to load, sync silently stops with no other symptom.
|
|
254
|
-
io.addEventListener("error", (event) => {
|
|
255
|
-
const error = event;
|
|
256
|
-
console.error(`[subduction-io] failed to load/run ${SUBDUCTION_IO_WORKER_URL}:`, error.message || event, error.filename ? `(${error.filename}:${error.lineno})` : "");
|
|
257
|
-
});
|
|
258
|
-
io.port.addEventListener("messageerror", (event) => {
|
|
259
|
-
console.error("[subduction-io] undeserializable message:", event);
|
|
260
|
-
});
|
|
261
|
-
return io.port;
|
|
262
|
-
});
|
|
263
|
-
disposeWorkerDeathDetection = installWorkerDeathDetection(automergeWorker);
|
|
264
|
-
}
|
|
265
|
-
return automergeWorker;
|
|
266
|
-
}
|
|
267
|
-
/**
|
|
268
|
-
* Detect when the automerge SharedWorker dies or its control port goes deaf.
|
|
269
|
-
*
|
|
270
|
-
* Silence alone is NOT proof of death: the worker may still be evaluating its
|
|
271
|
-
* (large) module graph on a cold boot, or its single thread may be busy with
|
|
272
|
-
* wasm/sync work — in both cases every queued message (including the repo
|
|
273
|
-
* ports the network adapters ride on) is delivered fine once it catches up,
|
|
274
|
-
* and tearing the port down would *lose* them. So silence only starts a
|
|
275
|
-
* non-destructive PROBE: a second SharedWorker connection to the same
|
|
276
|
-
* instance. Only when the probe gets a `hello` while this port stays silent do
|
|
277
|
-
* we know the instance is alive-and-responsive but our port is stranded (a
|
|
278
|
-
* failure mode observed in the wild) — or was replaced — and recovery is
|
|
279
|
-
* warranted. A `close` event (where supported) is a definitive death signal
|
|
280
|
-
* and recovers immediately. [lifecycle]-tagged. Returns a dispose that stops
|
|
281
|
-
* the heartbeat and any outstanding probe (called when this worker is
|
|
282
|
-
* replaced).
|
|
283
|
-
*/
|
|
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;
|
|
284
204
|
function installWorkerDeathDetection(worker) {
|
|
285
|
-
const stamp = () => new Date().toISOString();
|
|
286
|
-
const warn = (msg) => {
|
|
287
|
-
if (lifecycleLoggingEnabled())
|
|
288
|
-
console.warn(`[lifecycle] ${stamp()} ${msg}`);
|
|
289
|
-
};
|
|
290
|
-
const info = (msg) => {
|
|
291
|
-
if (lifecycleLoggingEnabled())
|
|
292
|
-
console.info(`[lifecycle] ${stamp()} ${msg}`);
|
|
293
|
-
};
|
|
294
205
|
let instanceId;
|
|
295
206
|
let lastHeardAt = Date.now();
|
|
296
207
|
let warnedUnresponsive = false;
|
|
208
|
+
let warnedSendFailed = false;
|
|
297
209
|
let disposed = false;
|
|
298
210
|
let probe;
|
|
211
|
+
let seq = 0;
|
|
299
212
|
const closeProbe = () => {
|
|
300
213
|
if (!probe)
|
|
301
214
|
return;
|
|
302
215
|
try {
|
|
303
216
|
probe.port.close();
|
|
304
217
|
}
|
|
305
|
-
catch {
|
|
306
|
-
// Already closed.
|
|
307
|
-
}
|
|
218
|
+
catch { }
|
|
308
219
|
probe = undefined;
|
|
309
220
|
};
|
|
310
|
-
let warnedSendFailed = false;
|
|
311
|
-
let pingsSent = 0;
|
|
312
221
|
worker.port.addEventListener("message", (event) => {
|
|
313
222
|
const data = event.data;
|
|
314
223
|
if (data?.type !== "hello" && data?.type !== "pong")
|
|
315
224
|
return;
|
|
316
225
|
lastHeardAt = Date.now();
|
|
317
226
|
warnedUnresponsive = false;
|
|
318
|
-
// The port spoke — any outstanding probe is moot.
|
|
319
227
|
closeProbe();
|
|
320
228
|
if (instanceId === undefined) {
|
|
321
229
|
instanceId = data.instanceId;
|
|
322
|
-
|
|
230
|
+
lifecycleLog("automerge SharedWorker instance %s (via %s)", data.instanceId, data.type);
|
|
323
231
|
}
|
|
324
232
|
else if (data.instanceId && data.instanceId !== instanceId) {
|
|
325
|
-
|
|
326
|
-
`was ${instanceId})`);
|
|
233
|
+
lifecycleLog("automerge SharedWorker instance changed (instance %s, was %s)", data.instanceId, instanceId);
|
|
327
234
|
instanceId = data.instanceId;
|
|
328
235
|
}
|
|
329
236
|
});
|
|
330
|
-
// Fires when the SharedWorker is destroyed (where supported).
|
|
331
237
|
worker.port.addEventListener("close", () => {
|
|
332
238
|
if (disposed)
|
|
333
239
|
return;
|
|
334
|
-
|
|
240
|
+
lifecycleLog("automerge SharedWorker control port closed");
|
|
335
241
|
void recoverAutomergeWorker("control port closed", worker);
|
|
336
242
|
});
|
|
337
|
-
// Not gated on the
|
|
338
|
-
//
|
|
339
|
-
worker.addEventListener("error", event => {
|
|
340
|
-
|
|
341
|
-
console.error(`[lifecycle] ${stamp()} automerge SharedWorker error:`, error.message || event, error.filename ? `(${error.filename}:${error.lineno})` : "");
|
|
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));
|
|
342
247
|
});
|
|
343
|
-
// On probe hello, give the suspect port this long to also speak before
|
|
344
|
-
// concluding it's stranded: after a slow worker boot both connections hello
|
|
345
|
-
// at roughly the same moment and cross-port delivery order isn't guaranteed.
|
|
346
|
-
const PROBE_GRACE_MS = 500;
|
|
347
248
|
const startProbe = (reason) => {
|
|
348
249
|
if (probe || disposed)
|
|
349
250
|
return;
|
|
350
|
-
|
|
251
|
+
lifecycleLog("automerge SharedWorker %s; probing with a second connection", reason);
|
|
351
252
|
const startedAt = Date.now();
|
|
352
|
-
const p = new SharedWorker(
|
|
253
|
+
const p = new SharedWorker(automergeWorkerPath, {
|
|
353
254
|
name: "patchwork-automerge",
|
|
354
255
|
type: "module",
|
|
355
256
|
});
|
|
@@ -360,39 +261,27 @@ function installWorkerDeathDetection(worker) {
|
|
|
360
261
|
return;
|
|
361
262
|
setTimeout(() => {
|
|
362
263
|
if (disposed || probe !== p)
|
|
363
|
-
return;
|
|
264
|
+
return;
|
|
364
265
|
closeProbe();
|
|
365
|
-
// The suspect spoke while
|
|
366
|
-
//
|
|
266
|
+
// The suspect spoke while the probe ran: it was merely busy, and
|
|
267
|
+
// everything queued on it has been delivered.
|
|
367
268
|
if (lastHeardAt >= startedAt)
|
|
368
269
|
return;
|
|
369
270
|
void recoverAutomergeWorker(`port unresponsive on a live worker (${reason}; probe confirmed)`, worker);
|
|
370
271
|
}, PROBE_GRACE_MS);
|
|
371
272
|
});
|
|
372
|
-
// No hello on the probe means the instance is loading or busy
|
|
373
|
-
// waits indefinitely
|
|
374
|
-
// lands) — never tear anything down on a timer.
|
|
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.
|
|
375
275
|
};
|
|
376
|
-
// A silent-too-long port while the tab is visible starts a probe (a miss
|
|
377
|
-
// while hidden is more likely suspension). Before first contact the budget
|
|
378
|
-
// is tighter: an idle worker hellos within milliseconds of connecting, so
|
|
379
|
-
// probing early costs nothing and rescues genuinely stranded boots fast.
|
|
380
|
-
const HEARTBEAT_MS = 5_000;
|
|
381
|
-
const HEARTBEAT_TIMEOUT_MS = 25_000;
|
|
382
|
-
const FIRST_CONTACT_TIMEOUT_MS = 4_000;
|
|
383
|
-
let seq = 0;
|
|
384
276
|
const heartbeat = setInterval(() => {
|
|
385
277
|
try {
|
|
386
278
|
worker.port.postMessage({ type: "ping", id: ++seq });
|
|
387
|
-
pingsSent++;
|
|
388
279
|
}
|
|
389
280
|
catch (error) {
|
|
390
|
-
//
|
|
391
|
-
// worker in the "no pong" warning below. Once, not every heartbeat.
|
|
281
|
+
// Without this a failed send is indistinguishable from a dead worker.
|
|
392
282
|
if (!warnedSendFailed) {
|
|
393
283
|
warnedSendFailed = true;
|
|
394
|
-
console.error(
|
|
395
|
-
`after ${pingsSent} sent:`, error);
|
|
284
|
+
console.error("automerge SharedWorker ping send threw", error);
|
|
396
285
|
}
|
|
397
286
|
}
|
|
398
287
|
const neverHeard = instanceId === undefined;
|
|
@@ -400,21 +289,24 @@ function installWorkerDeathDetection(worker) {
|
|
|
400
289
|
const timeoutMs = neverHeard
|
|
401
290
|
? FIRST_CONTACT_TIMEOUT_MS
|
|
402
291
|
: HEARTBEAT_TIMEOUT_MS;
|
|
403
|
-
|
|
292
|
+
if (silentMs <= timeoutMs)
|
|
293
|
+
return;
|
|
404
294
|
// First contact probes regardless of visibility: SharedWorkers don't
|
|
405
|
-
// suspend with tab
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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.
|
|
298
|
+
const visible = typeof document === "undefined" || document.visibilityState === "visible";
|
|
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) {
|
|
306
|
+
warnedUnresponsive = true;
|
|
307
|
+
lifecycleLog("automerge SharedWorker %s (tab visible)", reason);
|
|
417
308
|
}
|
|
309
|
+
startProbe(reason);
|
|
418
310
|
}, HEARTBEAT_MS);
|
|
419
311
|
return () => {
|
|
420
312
|
disposed = true;
|
|
@@ -424,10 +316,7 @@ function installWorkerDeathDetection(worker) {
|
|
|
424
316
|
}
|
|
425
317
|
const syncStateListeners = new Map();
|
|
426
318
|
function dispatchSyncState(update) {
|
|
427
|
-
const
|
|
428
|
-
if (!listeners)
|
|
429
|
-
return;
|
|
430
|
-
for (const listener of listeners) {
|
|
319
|
+
for (const listener of syncStateListeners.get(update.documentId) ?? []) {
|
|
431
320
|
try {
|
|
432
321
|
listener(update);
|
|
433
322
|
}
|
|
@@ -441,26 +330,24 @@ export function subscribeSyncState(documentId, listener) {
|
|
|
441
330
|
let listeners = syncStateListeners.get(documentId);
|
|
442
331
|
if (!listeners) {
|
|
443
332
|
syncStateListeners.set(documentId, (listeners = new Set()));
|
|
444
|
-
// First local watcher for this doc — ask the worker to start pushing it.
|
|
445
333
|
worker.port.postMessage({ type: "sync-sub", documentId });
|
|
446
334
|
}
|
|
447
335
|
listeners.add(listener);
|
|
448
336
|
let active = true;
|
|
449
337
|
return () => {
|
|
450
338
|
if (!active)
|
|
451
|
-
return;
|
|
339
|
+
return;
|
|
452
340
|
active = false;
|
|
453
341
|
const set = syncStateListeners.get(documentId);
|
|
454
342
|
if (!set)
|
|
455
343
|
return;
|
|
456
344
|
set.delete(listener);
|
|
457
|
-
if (set.size
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
}
|
|
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 });
|
|
464
351
|
};
|
|
465
352
|
}
|
|
466
353
|
export function connectClassicSync(server = readClassicSyncServer()) {
|
|
@@ -478,146 +365,134 @@ export function connectClassicSync(server = readClassicSyncServer()) {
|
|
|
478
365
|
port1.onmessage = (event) => {
|
|
479
366
|
clearTimeout(timeout);
|
|
480
367
|
port1.close();
|
|
481
|
-
if (event.data?.type === "connect-classic-sync-ready")
|
|
368
|
+
if (event.data?.type === "connect-classic-sync-ready")
|
|
482
369
|
resolve();
|
|
483
|
-
|
|
484
|
-
else {
|
|
370
|
+
else
|
|
485
371
|
reject(new Error(event.data?.error ?? "connect-classic-sync failed"));
|
|
486
|
-
}
|
|
487
372
|
};
|
|
488
373
|
worker.port.postMessage({ type: "connect-classic-sync", server: url }, [
|
|
489
374
|
port2,
|
|
490
375
|
]);
|
|
491
376
|
});
|
|
492
377
|
}
|
|
493
|
-
|
|
494
|
-
function waitForActive(reg) {
|
|
495
|
-
if (reg.active)
|
|
496
|
-
return Promise.resolve(reg.active);
|
|
497
|
-
const worker = reg.installing || reg.waiting;
|
|
498
|
-
if (!worker)
|
|
499
|
-
return Promise.reject(new Error("no service worker in registration"));
|
|
500
|
-
return new Promise((resolve) => {
|
|
501
|
-
worker.addEventListener("statechange", () => {
|
|
502
|
-
if (worker.state === "activated")
|
|
503
|
-
resolve(worker);
|
|
504
|
-
});
|
|
505
|
-
});
|
|
506
|
-
}
|
|
507
|
-
async function openRepoChannel() {
|
|
508
|
-
const worker = getAutomergeWorker();
|
|
509
|
-
// Send a MessagePort so the worker's repo can sync with this tab, and wait
|
|
510
|
-
// for the worker to confirm its repo is constructed before returning. The
|
|
511
|
-
// MessageChannel adapter's whenReady() force-resolves after 100ms regardless
|
|
512
|
-
// of the other end's state, so it can't be used as a real readiness signal
|
|
513
|
-
// on first boot (when the worker still has to fetch wasm and build its repo).
|
|
514
|
-
const id = ++nextRepoChannelId;
|
|
378
|
+
function sendRepoPort(id) {
|
|
515
379
|
const { port1, port2 } = new MessageChannel();
|
|
516
|
-
|
|
517
|
-
|
|
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) => {
|
|
518
391
|
const cleanup = () => {
|
|
519
392
|
clearTimeout(timeout);
|
|
520
|
-
|
|
393
|
+
control.removeEventListener("message", listener);
|
|
521
394
|
};
|
|
522
395
|
const listener = (event) => {
|
|
523
396
|
if (event.data?.id !== id)
|
|
524
397
|
return;
|
|
525
|
-
if (event.data
|
|
398
|
+
if (event.data.type === "port-ready") {
|
|
526
399
|
cleanup();
|
|
527
400
|
resolve();
|
|
528
401
|
}
|
|
529
|
-
else if (event.data
|
|
402
|
+
else if (event.data.type === "port-failed") {
|
|
530
403
|
cleanup();
|
|
531
404
|
reject(new Error(`automerge worker init failed: ${event.data.error}`));
|
|
532
405
|
}
|
|
533
406
|
};
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
// the issue and let the rest of the site come up rather than hanging on a
|
|
537
|
-
// blank page.
|
|
538
|
-
timeout = setTimeout(() => {
|
|
407
|
+
control.addEventListener("message", listener);
|
|
408
|
+
const timeout = setTimeout(() => {
|
|
539
409
|
cleanup();
|
|
540
410
|
reject(new Error("automerge worker port-ready timeout"));
|
|
541
411
|
}, 30_000);
|
|
542
412
|
});
|
|
543
|
-
|
|
413
|
+
}
|
|
414
|
+
async function openRepoChannel() {
|
|
415
|
+
const id = ++nextRepoChannelId;
|
|
416
|
+
const ready = awaitPortReady(getAutomergeWorker().port, id);
|
|
417
|
+
const port = sendRepoPort(id);
|
|
544
418
|
try {
|
|
545
|
-
await
|
|
419
|
+
await ready;
|
|
546
420
|
}
|
|
547
421
|
catch (err) {
|
|
422
|
+
// Surface the problem and let the rest of the site come up rather than
|
|
423
|
+
// hanging on a blank page.
|
|
548
424
|
console.warn("proceeding without worker ready ack:", err instanceof Error ? err.message : err);
|
|
549
425
|
}
|
|
550
|
-
return
|
|
426
|
+
return port;
|
|
551
427
|
}
|
|
552
428
|
/** Open a fresh repo sync port to the automerge worker (dev console). */
|
|
553
429
|
function getRepoChannel() {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
+
});
|
|
558
449
|
}
|
|
559
450
|
export default async function setupServiceWorker(options) {
|
|
560
|
-
// Attach the
|
|
561
|
-
//
|
|
451
|
+
// Attach the log bridge first so the controlling worker's boot/install/
|
|
452
|
+
// activate markers are rendered here.
|
|
562
453
|
installServiceWorkerLogForwarding();
|
|
563
|
-
localStorage.removeItem(
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
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.
|
|
568
459
|
void navigator.storage?.persist?.().catch(() => { });
|
|
569
460
|
if (options?.workerPath)
|
|
570
461
|
automergeWorkerPath = options.workerPath;
|
|
571
|
-
// Start the automerge worker
|
|
462
|
+
// Start the automerge worker now so it boots wasm and its repo while the
|
|
572
463
|
// service worker installs.
|
|
573
464
|
const shared = getAutomergeWorker();
|
|
574
|
-
|
|
575
|
-
const
|
|
576
|
-
// No controller at this point means the page loaded without a service
|
|
577
|
-
// worker — i.e. this is a first-time install (or a hard reload). Wait for
|
|
578
|
-
// activation so the app boots with the SW in control of generated fetches.
|
|
579
|
-
const reg = await navigator.serviceWorker.register(path, { type: "module" });
|
|
580
|
-
// If there's an update waiting or installing, wait for it to activate
|
|
581
|
-
let active = reg.active;
|
|
582
|
-
if (reg.installing || reg.waiting) {
|
|
583
|
-
active = await waitForActive(reg);
|
|
584
|
-
}
|
|
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;
|
|
585
467
|
configureServiceWorker(active);
|
|
586
|
-
//
|
|
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.
|
|
587
471
|
if (!navigator.serviceWorker.controller) {
|
|
588
472
|
await new Promise((resolve) => {
|
|
589
473
|
navigator.serviceWorker.addEventListener("controllerchange", () => resolve(), { once: true });
|
|
590
474
|
});
|
|
591
475
|
}
|
|
592
|
-
// A replacement
|
|
593
|
-
//
|
|
476
|
+
// A replacement worker boots with the default cache name, so reconfigure
|
|
477
|
+
// whenever a new one takes control.
|
|
594
478
|
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
|
595
479
|
configureServiceWorker(navigator.serviceWorker.controller);
|
|
596
480
|
});
|
|
597
481
|
console.log("service worker alive, loading %c patchwork system ", "background: #fcf2f0; color: #333; border: 2px solid; border-radius: 4px");
|
|
598
|
-
// todon't
|
|
599
|
-
window.killsw = () => {
|
|
600
|
-
if (automergeWorker) {
|
|
601
|
-
automergeWorker.port.close();
|
|
602
|
-
automergeWorker = undefined;
|
|
603
|
-
}
|
|
604
|
-
};
|
|
605
482
|
return {
|
|
606
483
|
shared,
|
|
607
484
|
connectClassicSync,
|
|
608
485
|
getRepoChannel,
|
|
609
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".
|
|
610
490
|
async subscribeToRepoChannel(listener) {
|
|
611
|
-
// Called once with the boot port. If the automerge worker later dies
|
|
612
|
-
// and is recreated (recoverAutomergeWorker), the listener is called
|
|
613
|
-
// again with a fresh port — treat every call as "(re)wire your repo's
|
|
614
|
-
// sync onto this port".
|
|
615
491
|
repoChannelListeners.add(listener);
|
|
616
492
|
const generation = workerGeneration;
|
|
617
493
|
const port = await openRepoChannel();
|
|
618
|
-
// If the worker was replaced while this channel was opening
|
|
619
|
-
// port
|
|
620
|
-
// already delivered a good port to this listener), drop the stale one
|
|
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
|
|
621
496
|
// rather than wiring the repo to a dead channel.
|
|
622
497
|
if (generation === workerGeneration)
|
|
623
498
|
await listener(port);
|
|
@@ -627,3 +502,4 @@ export default async function setupServiceWorker(options) {
|
|
|
627
502
|
},
|
|
628
503
|
};
|
|
629
504
|
}
|
|
505
|
+
window.bumpServiceWorkerCache = bumpServiceWorkerCache;
|