@inkandswitch/patchwork-bootloader 0.3.2 → 0.4.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 +10 -0
- package/dist/automerge-worker.js +91 -15
- package/dist/externals.js +5 -0
- package/dist/service-worker.js +76 -29
- package/dist/setup.js +66 -11
- package/dist/site.js +27 -52
- package/package.json +11 -11
- package/src/automerge-worker.ts +104 -16
- package/src/externals.ts +5 -0
- package/src/service-worker.ts +92 -32
- package/src/setup.ts +70 -10
- package/src/site.ts +35 -59
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @inkandswitch/patchwork-bootloader
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- b1bd763: Hash routing: `doc=` now holds the full (un-encoded) automerge URL — heads, if
|
|
8
|
+
any, live inside it — and the separate `heads=` param is gone. `doc=` values
|
|
9
|
+
that are a bare document id are still accepted for backwards compatibility, and
|
|
10
|
+
legacy big-patchwork links (`<slug>--<docId>?…`, including slugs with
|
|
11
|
+
characters like `drawing-(branch-1)`) are normalized to `#doc=automerge:<docId>`.
|
|
12
|
+
|
|
3
13
|
## 0.3.2
|
|
4
14
|
|
|
5
15
|
### Patch Changes
|
package/dist/automerge-worker.js
CHANGED
|
@@ -17,7 +17,8 @@ import { initializeWasm, hasHeads } from "@automerge/automerge/slim";
|
|
|
17
17
|
// @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
|
|
18
18
|
import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
|
|
19
19
|
import { WebCryptoSigner } from "@automerge/automerge-subduction/slim";
|
|
20
|
-
import {
|
|
20
|
+
import { makePortProvider } from "@automerge/automerge-repo/worker-port";
|
|
21
|
+
import { Repo, WorkerWebSocketEndpoint, isValidAutomergeUrl, parseAutomergeUrl, stringifyAutomergeUrl, } from "@automerge/automerge-repo/slim";
|
|
21
22
|
import { resolvePath } from "@inkandswitch/patchwork-filesystem";
|
|
22
23
|
// Small adapters — bundled directly into the worker
|
|
23
24
|
import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
|
|
@@ -36,6 +37,35 @@ const WORKER_BOOT_TIME = Date.now();
|
|
|
36
37
|
// → shared workers). Patch console.* and the global error handlers to also
|
|
37
38
|
// post back over every connected tab's control port, tagged [automerge-worker].
|
|
38
39
|
const controlPorts = new Set();
|
|
40
|
+
// ── Keepalive-drift probe (bench instrumentation) ───────────────────────
|
|
41
|
+
// Measures how late a 1s timer fires on this thread — i.e. how late an
|
|
42
|
+
// in-thread keepalive would be under sync/wasm load. Cheap (one Date.now()
|
|
43
|
+
// per second); samples are batched to every connected tab as drift-samples
|
|
44
|
+
// messages, which setup.ts accumulates on window.__driftSamples for the
|
|
45
|
+
// Playwright bench (e2e/tests/bench-ws.spec.ts).
|
|
46
|
+
const DRIFT_INTERVAL_MS = 1_000;
|
|
47
|
+
const DRIFT_BATCH_SIZE = 5;
|
|
48
|
+
{
|
|
49
|
+
let expected = Date.now() + DRIFT_INTERVAL_MS;
|
|
50
|
+
let batch = [];
|
|
51
|
+
setInterval(() => {
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
batch.push(Math.max(0, now - expected));
|
|
54
|
+
expected = now + DRIFT_INTERVAL_MS;
|
|
55
|
+
if (batch.length >= DRIFT_BATCH_SIZE) {
|
|
56
|
+
const samples = batch;
|
|
57
|
+
batch = [];
|
|
58
|
+
for (const port of controlPorts) {
|
|
59
|
+
try {
|
|
60
|
+
port.postMessage({ type: "drift-samples", samples });
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Port torn down mid-iteration — its close handler cleans up.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}, DRIFT_INTERVAL_MS);
|
|
68
|
+
}
|
|
39
69
|
// ── Per-tab sync-state subscriptions ────────────────────────────────────
|
|
40
70
|
// Each tab's control port subscribes to the documents it cares about; we push
|
|
41
71
|
// only those docs' heads back down that port (addressed — tab A never sees tab
|
|
@@ -160,11 +190,49 @@ if (useKeyhiveSyncServer) {
|
|
|
160
190
|
KEYHIVE_SERVER_IDENTITY: "keyhive-sync",
|
|
161
191
|
};
|
|
162
192
|
}
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
193
|
+
const SUBDUCTION_SYNC_URL = useKeyhiveSyncServer
|
|
194
|
+
? "wss://keyhive.sync.automerge.org"
|
|
195
|
+
: "wss://subduction.sync.inkandswitch.com";
|
|
196
|
+
// The subduction WebSocket lives in its own worker so socket I/O (and
|
|
197
|
+
// keepalive pongs) keep flowing even when this SharedWorker's thread is busy
|
|
198
|
+
// syncing. We can't spawn that worker ourselves — Chrome doesn't expose the
|
|
199
|
+
// Worker constructor inside SharedWorkerGlobalScope — so tabs spawn the
|
|
200
|
+
// shipped SharedWorker proxy entry and donate its port to us (donatePort in
|
|
201
|
+
// setup.ts). The provider hands WorkerWebSocketEndpoint whichever port is
|
|
202
|
+
// current, healing across late arrival and proxy-worker restarts.
|
|
203
|
+
const subductionPortProvider = makePortProvider();
|
|
204
|
+
// A/B bench toggle: the tab appends ?ws-mode=inline to our URL (SharedWorker
|
|
205
|
+
// scope has no localStorage; see getAutomergeWorker in setup.ts). "inline"
|
|
206
|
+
// passes the bare URL string so the socket lives on this thread — the
|
|
207
|
+
// pre-worker behaviour — as the control arm for benchmarking the
|
|
208
|
+
// worker-based endpoint. Default: "worker".
|
|
209
|
+
const WS_MODE = new URL(self.location.href).searchParams.get("ws-mode") === "inline"
|
|
210
|
+
? "inline"
|
|
211
|
+
: "worker";
|
|
212
|
+
// Optional windowFrames override (bench knob — max un-acked frames the io
|
|
213
|
+
// proxy delivers before pausing; endpoint default is 128).
|
|
214
|
+
const WS_WINDOW_FRAMES = Number(new URL(self.location.href).searchParams.get("ws-window")) ||
|
|
215
|
+
undefined;
|
|
216
|
+
// Memoized so a repo-construction retry (getRepoHive clears its promise on
|
|
217
|
+
// failure) reuses the same endpoint instead of leaking one per attempt.
|
|
218
|
+
let subductionEndpoints = null;
|
|
219
|
+
function getSubductionEndpoints() {
|
|
220
|
+
if (!subductionEndpoints) {
|
|
221
|
+
log(`subduction websocket mode: ${WS_MODE}`);
|
|
222
|
+
subductionEndpoints =
|
|
223
|
+
WS_MODE === "inline"
|
|
224
|
+
? [SUBDUCTION_SYNC_URL]
|
|
225
|
+
: [
|
|
226
|
+
new WorkerWebSocketEndpoint(SUBDUCTION_SYNC_URL, {
|
|
227
|
+
worker: subductionPortProvider.source,
|
|
228
|
+
...(WS_WINDOW_FRAMES
|
|
229
|
+
? { windowFrames: WS_WINDOW_FRAMES }
|
|
230
|
+
: {}),
|
|
231
|
+
}),
|
|
232
|
+
];
|
|
233
|
+
}
|
|
234
|
+
return subductionEndpoints;
|
|
235
|
+
}
|
|
168
236
|
const RESOLVE_TIMEOUT_MS = 30_000;
|
|
169
237
|
// Backoff re-sync of stuck/diverged docs. Only this worker is connected to the
|
|
170
238
|
// sync server, so it's the only place that can notice a doc whose heads have
|
|
@@ -232,7 +300,6 @@ function getRepoHive() {
|
|
|
232
300
|
peerId: signer.peerId().toString(),
|
|
233
301
|
verifyingKey: signer.verifyingKey().toHex(),
|
|
234
302
|
};
|
|
235
|
-
console.log("[patchwork] shared-worker subduction identity:", identity);
|
|
236
303
|
const repo = new Repo({
|
|
237
304
|
storage: new IndexedDBWorkerStorageAdapter(),
|
|
238
305
|
signer,
|
|
@@ -244,8 +311,9 @@ function getRepoHive() {
|
|
|
244
311
|
return peerId.includes("storage-server");
|
|
245
312
|
},
|
|
246
313
|
enableRemoteHeadsGossiping: true,
|
|
247
|
-
subductionWebsocketEndpoints:
|
|
314
|
+
subductionWebsocketEndpoints: getSubductionEndpoints(),
|
|
248
315
|
});
|
|
316
|
+
console.log("[patchwork] shared-worker subduction identity:", identity, "networkSubsystem.adapters:", repo.networkSubsystem.adapters.length);
|
|
249
317
|
self.repo = repo;
|
|
250
318
|
self.syncIdentity = identity;
|
|
251
319
|
setupSyncStateBroadcast(repo, identity);
|
|
@@ -269,7 +337,7 @@ function getRepoHive() {
|
|
|
269
337
|
...(useKeyhiveSyncServer ? { syncServer: "keyhive" } : {}),
|
|
270
338
|
repo: {
|
|
271
339
|
storage: new IndexedDBWorkerStorageAdapter(),
|
|
272
|
-
subductionWebsocketEndpoints:
|
|
340
|
+
subductionWebsocketEndpoints: getSubductionEndpoints(),
|
|
273
341
|
enableRemoteHeadsGossiping: true,
|
|
274
342
|
},
|
|
275
343
|
});
|
|
@@ -592,11 +660,15 @@ function dropRepoChannel(repo, channel) {
|
|
|
592
660
|
try {
|
|
593
661
|
channel.mcAdapter.disconnect();
|
|
594
662
|
}
|
|
595
|
-
catch {
|
|
663
|
+
catch {
|
|
664
|
+
// Already disconnected by removeNetworkAdapter above.
|
|
665
|
+
}
|
|
596
666
|
try {
|
|
597
667
|
channel.port.close();
|
|
598
668
|
}
|
|
599
|
-
catch {
|
|
669
|
+
catch {
|
|
670
|
+
// Port already closed by the departing tab.
|
|
671
|
+
}
|
|
600
672
|
}
|
|
601
673
|
async function dropConnection(connection) {
|
|
602
674
|
if (!connection.channels.size || !repoHivePromise)
|
|
@@ -708,6 +780,10 @@ self.addEventListener("connect", (event) => {
|
|
|
708
780
|
controlPort.addEventListener("message", (messageEvent) => {
|
|
709
781
|
handleControlMessage(messageEvent, controlPort, connection);
|
|
710
782
|
});
|
|
783
|
+
// Let the subduction port provider negotiate over this tab's control port
|
|
784
|
+
// (the tab side runs donatePort; the messages are channel-tagged so they
|
|
785
|
+
// coexist with our control protocol above).
|
|
786
|
+
subductionPortProvider.attachClient(controlPort);
|
|
711
787
|
// Fires when the owning page is destroyed. Browsers without the close
|
|
712
788
|
// event fall back to the adapters' lazy useWeakRef cleanup.
|
|
713
789
|
controlPort.addEventListener("close", () => {
|
|
@@ -812,10 +888,10 @@ async function resolveAutomergeUrl(automergeURL) {
|
|
|
812
888
|
const body = resolved.content instanceof Uint8Array
|
|
813
889
|
? new Uint8Array(resolved.content)
|
|
814
890
|
: resolved.content;
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
891
|
+
return new Response(body, {
|
|
892
|
+
status: 200,
|
|
893
|
+
headers: { "content-type": resolved.type },
|
|
894
|
+
});
|
|
819
895
|
}
|
|
820
896
|
// ── Handoff: resolve special URLs for the service worker ──────────────
|
|
821
897
|
const handoffChannel = new BroadcastChannel(HANDOFF_CHANNEL);
|
package/dist/externals.js
CHANGED
|
@@ -6,6 +6,11 @@ const externals = [
|
|
|
6
6
|
"@automerge/automerge/slim",
|
|
7
7
|
"@automerge/automerge-repo",
|
|
8
8
|
"@automerge/automerge-repo/slim",
|
|
9
|
+
// Port-donation plumbing for WorkerWebSocketEndpoint: tabs spawn the shared
|
|
10
|
+
// proxy entry and donate its port to the automerge worker (Chrome can't
|
|
11
|
+
// spawn workers from inside a SharedWorker). See setup.ts/automerge-worker.ts.
|
|
12
|
+
"@automerge/automerge-repo/worker-port",
|
|
13
|
+
"@automerge/automerge-repo/subduction-websocket-worker-shared",
|
|
9
14
|
"@automerge/automerge-repo-network-messagechannel",
|
|
10
15
|
"@automerge/automerge-repo-network-websocket",
|
|
11
16
|
"@automerge/automerge-repo-storage-indexeddb",
|
package/dist/service-worker.js
CHANGED
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
// "cached" (or "response" for errors and other things that shouldn't be
|
|
7
7
|
// cached).
|
|
8
8
|
import { HANDOFF_CHANNEL, } from "./types.js";
|
|
9
|
-
|
|
9
|
+
const DEFAULT_CACHE_NAME = "patchwork";
|
|
10
|
+
let cachename = DEFAULT_CACHE_NAME;
|
|
10
11
|
let debugging = false;
|
|
11
|
-
|
|
12
|
+
// 0 is an opaque response, that also needs cached
|
|
13
|
+
const cacheableStatuses = [0, 200, 203, 204];
|
|
12
14
|
// The automerge worker times its own resolution out after 30s and replies
|
|
13
15
|
// with an error, so this only fires when nobody is listening at all.
|
|
14
16
|
const HANDOFF_TIMEOUT_MS = 35_000;
|
|
@@ -52,20 +54,16 @@ self.addEventListener("install", (event) => {
|
|
|
52
54
|
// every old tab closes.
|
|
53
55
|
event.waitUntil(self.skipWaiting());
|
|
54
56
|
});
|
|
55
|
-
async function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const deletePromises = cacheNames.map((cacheName) => {
|
|
59
|
-
if (!cacheWhitelist.includes(cacheName)) {
|
|
57
|
+
async function clearOtherCaches() {
|
|
58
|
+
await Promise.all((await caches.keys()).map((cacheName) => {
|
|
59
|
+
if (cacheName !== cachename)
|
|
60
60
|
return caches.delete(cacheName);
|
|
61
|
-
|
|
62
|
-
});
|
|
63
|
-
await Promise.all(deletePromises);
|
|
61
|
+
}));
|
|
64
62
|
}
|
|
65
63
|
self.addEventListener("activate", (event) => {
|
|
66
64
|
lifecycle("info", "activate (claiming clients)");
|
|
67
65
|
event.waitUntil((async () => {
|
|
68
|
-
await
|
|
66
|
+
await clearOtherCaches();
|
|
69
67
|
await self.clients.claim();
|
|
70
68
|
// Pre-cache pages of already-open clients so they survive going offline
|
|
71
69
|
// before the next navigation.
|
|
@@ -77,7 +75,7 @@ self.addEventListener("activate", (event) => {
|
|
|
77
75
|
if (!existing) {
|
|
78
76
|
const response = await fetch(client.url);
|
|
79
77
|
if (cacheableStatuses.includes(response.status)) {
|
|
80
|
-
await cache
|
|
78
|
+
await cachePage(cache, client.url, response);
|
|
81
79
|
}
|
|
82
80
|
}
|
|
83
81
|
}
|
|
@@ -93,9 +91,18 @@ self.addEventListener("message", async (event) => {
|
|
|
93
91
|
if (cachename == nextCachename) {
|
|
94
92
|
return;
|
|
95
93
|
}
|
|
96
|
-
console.info(`
|
|
97
|
-
|
|
94
|
+
console.info(`moving from cache ${cachename} to ${nextCachename}`);
|
|
95
|
+
if (cachename === DEFAULT_CACHE_NAME) {
|
|
96
|
+
const defaultCache = await caches.open(cachename);
|
|
97
|
+
const nextCache = await caches.open(nextCachename);
|
|
98
|
+
await Promise.all((await defaultCache.keys()).map(async (request) => {
|
|
99
|
+
const response = await defaultCache.match(request);
|
|
100
|
+
if (response)
|
|
101
|
+
await nextCache.put(request, response);
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
98
104
|
cachename = nextCachename;
|
|
105
|
+
await clearOtherCaches();
|
|
99
106
|
}
|
|
100
107
|
else if (event.data.type == "debug") {
|
|
101
108
|
debugging = event.data.debug;
|
|
@@ -157,15 +164,39 @@ function handoff(request, handoffURL) {
|
|
|
157
164
|
pendingHandoffs.delete(id);
|
|
158
165
|
});
|
|
159
166
|
}
|
|
160
|
-
function
|
|
161
|
-
const headers = new Headers(response.headers);
|
|
162
|
-
headers.set("cross-origin-embedder-policy", "credentialless");
|
|
163
|
-
headers.set("cross-origin-resource-policy", "cross-origin");
|
|
167
|
+
function makeResponse(response) {
|
|
164
168
|
return new Response(response.body ?? null, {
|
|
165
169
|
status: response.status ?? 200,
|
|
166
|
-
headers,
|
|
170
|
+
headers: response.headers,
|
|
167
171
|
});
|
|
168
172
|
}
|
|
173
|
+
function indexRequestFor(request) {
|
|
174
|
+
const url = new URL(typeof request === "string" ? request : request.url);
|
|
175
|
+
if (url.origin !== self.location.origin)
|
|
176
|
+
return undefined;
|
|
177
|
+
url.pathname = "/index.html";
|
|
178
|
+
url.search = "";
|
|
179
|
+
url.hash = "";
|
|
180
|
+
return new Request(url.href);
|
|
181
|
+
}
|
|
182
|
+
function rootRequestFor(request) {
|
|
183
|
+
const url = new URL(typeof request === "string" ? request : request.url);
|
|
184
|
+
if (url.origin !== self.location.origin)
|
|
185
|
+
return undefined;
|
|
186
|
+
url.pathname = "/";
|
|
187
|
+
url.search = "";
|
|
188
|
+
url.hash = "";
|
|
189
|
+
return new Request(url.href);
|
|
190
|
+
}
|
|
191
|
+
async function cachePage(cache, request, response) {
|
|
192
|
+
const indexRequest = indexRequestFor(request);
|
|
193
|
+
if (indexRequest)
|
|
194
|
+
await cache.put(indexRequest, response.clone());
|
|
195
|
+
const rootRequest = rootRequestFor(request);
|
|
196
|
+
if (rootRequest)
|
|
197
|
+
await cache.put(rootRequest, response.clone());
|
|
198
|
+
await cache.put(request, response);
|
|
199
|
+
}
|
|
169
200
|
// ── Fetch handler ──────────────────────────────────────────────────────
|
|
170
201
|
self.addEventListener("fetch", (fetchEvent) => {
|
|
171
202
|
log("fetch event", fetchEvent.request.url);
|
|
@@ -190,7 +221,7 @@ self.addEventListener("fetch", (fetchEvent) => {
|
|
|
190
221
|
if (handoffURL) {
|
|
191
222
|
if (match) {
|
|
192
223
|
log(`serving ${handoffURL} from cache ${cachename}`);
|
|
193
|
-
return
|
|
224
|
+
return match;
|
|
194
225
|
}
|
|
195
226
|
log(`handing ${handoffURL} off to the automerge worker`);
|
|
196
227
|
const replyPromise = handoff(request, handoffURL);
|
|
@@ -199,7 +230,7 @@ self.addEventListener("fetch", (fetchEvent) => {
|
|
|
199
230
|
if (reply.type === "response") {
|
|
200
231
|
// errors, redirects and other things that shouldn't be cached
|
|
201
232
|
log(`serving handed-off response for ${handoffURL}`, reply);
|
|
202
|
-
return
|
|
233
|
+
return makeResponse(reply.response);
|
|
203
234
|
}
|
|
204
235
|
// reply.type === "cached": the automerge worker has put the
|
|
205
236
|
// response in our cache
|
|
@@ -208,25 +239,41 @@ self.addEventListener("fetch", (fetchEvent) => {
|
|
|
208
239
|
return new Response(`the automerge worker reported ${handoffURL} cached, but it has no match in ${cachename}`, { status: 555 });
|
|
209
240
|
}
|
|
210
241
|
log(`serving ${handoffURL} from cache ${cachename} after handoff`);
|
|
211
|
-
return
|
|
242
|
+
return cached;
|
|
212
243
|
}
|
|
213
244
|
else {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
245
|
+
// fetch() rejects on network error / abort rather than resolving;
|
|
246
|
+
// keep the error so we can surface it in the 503 body below.
|
|
247
|
+
const result = await fetch(request).catch((error) => error instanceof Error ? error : new Error(String(error)));
|
|
248
|
+
if (result instanceof Response) {
|
|
249
|
+
const response = result;
|
|
250
|
+
// Tool subresources (<link>/<script>) are requested from srcdoc
|
|
251
|
+
// frames whose origin is "null", so they come back as opaque
|
|
252
|
+
// cross-origin `no-cors` responses: status 0 and an empty url. They
|
|
253
|
+
// render fine while online but were being excluded from the cache,
|
|
254
|
+
// so e.g. a theme stylesheet vanished on an offline refresh. Opaque
|
|
255
|
+
// responses are cacheable and replay to the same no-cors consumer,
|
|
256
|
+
// so treat status 0 as cacheable and gate the scheme on request.url
|
|
257
|
+
// (an opaque response's own url is "").
|
|
258
|
+
if ((response.status === 0 ||
|
|
259
|
+
cacheableStatuses.includes(response.status)) &&
|
|
260
|
+
/^https?:/.test(request.url)) {
|
|
261
|
+
const cachedResponse = response.clone();
|
|
262
|
+
await (request.mode === "navigate" ||
|
|
263
|
+
request.destination === "document"
|
|
264
|
+
? cachePage(cache, request, cachedResponse)
|
|
265
|
+
: cache.put(request, cachedResponse)).catch((error) => {
|
|
219
266
|
log(`error caching ${request.url} in ${cachename}`, error);
|
|
220
267
|
});
|
|
221
268
|
}
|
|
222
269
|
else {
|
|
223
|
-
log(`skipping uncacheable response code from cache: ${response.status} for ${
|
|
270
|
+
log(`skipping uncacheable response code from cache: ${response.status} for ${request.url}`);
|
|
224
271
|
}
|
|
225
272
|
return response;
|
|
226
273
|
}
|
|
227
274
|
if (match)
|
|
228
275
|
return match;
|
|
229
|
-
return new Response(
|
|
276
|
+
return new Response(`couldnt fetch ${request.url} and no stale copy in ${cachename}\n\n${result.stack ?? result.message}`, { status: 503, headers: { "content-type": "text/plain" } });
|
|
230
277
|
}
|
|
231
278
|
}
|
|
232
279
|
catch (error) {
|
package/dist/setup.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readClassicSyncServer, DEFAULT_CLASSIC_SYNC_SERVER, } from "./sync-config.js";
|
|
2
2
|
import debug from "debug";
|
|
3
|
+
import { donatePort, isWorkerErrorMessage, } from "@automerge/automerge-repo/worker-port";
|
|
3
4
|
const serviceWorkerDebugging = debug.enabled("patchwork:serviceworker");
|
|
4
5
|
const workerDebugging = debug.enabled("patchwork:automergeworker");
|
|
5
6
|
// Diagnostic [lifecycle] logging, on by default. Disable via
|
|
@@ -34,6 +35,7 @@ function installServiceWorkerLogForwarding() {
|
|
|
34
35
|
});
|
|
35
36
|
}
|
|
36
37
|
const key = "patchworkServiceWorkerCacheVersion";
|
|
38
|
+
const defaultServiceWorkerCacheName = "patchwork";
|
|
37
39
|
let nextRepoChannelId = 0;
|
|
38
40
|
function bumpServiceWorkerCacheVersion() {
|
|
39
41
|
const version = new Date().valueOf().toString(36);
|
|
@@ -43,19 +45,13 @@ function bumpServiceWorkerCacheVersion() {
|
|
|
43
45
|
function getServiceWorkerCacheVersion() {
|
|
44
46
|
return localStorage.getItem(key);
|
|
45
47
|
}
|
|
46
|
-
function getOrCreateServiceWorkerCacheVersion() {
|
|
47
|
-
const existing = getServiceWorkerCacheVersion();
|
|
48
|
-
if (existing)
|
|
49
|
-
return existing;
|
|
50
|
-
return bumpServiceWorkerCacheVersion();
|
|
51
|
-
}
|
|
52
48
|
function setServiceWorkerCacheName(sw) {
|
|
53
49
|
if (!sw) {
|
|
54
50
|
throw new Error("no service worker!");
|
|
55
51
|
}
|
|
56
52
|
sw.postMessage({
|
|
57
53
|
type: "cachename",
|
|
58
|
-
cachename:
|
|
54
|
+
cachename: getServiceWorkerCacheVersion() ?? defaultServiceWorkerCacheName,
|
|
59
55
|
});
|
|
60
56
|
}
|
|
61
57
|
export function bumpServiceWorkerCache(sw = navigator.serviceWorker.controller) {
|
|
@@ -67,9 +63,10 @@ function configureServiceWorker(sw) {
|
|
|
67
63
|
if (!sw)
|
|
68
64
|
return;
|
|
69
65
|
sw.postMessage({ type: "debug", debug: serviceWorkerDebugging });
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
66
|
+
sw.postMessage({
|
|
67
|
+
type: "cachename",
|
|
68
|
+
cachename: getServiceWorkerCacheVersion() ?? defaultServiceWorkerCacheName,
|
|
69
|
+
});
|
|
73
70
|
}
|
|
74
71
|
// ── The automerge worker ───────────────────────────────────────────────
|
|
75
72
|
// The automerge repo lives in a SharedWorker (not the service worker). One
|
|
@@ -79,9 +76,41 @@ function configureServiceWorker(sw) {
|
|
|
79
76
|
// port; it talks to the service worker over a BroadcastChannel.
|
|
80
77
|
let automergeWorkerPath = "/automerge-worker.js";
|
|
81
78
|
let automergeWorker;
|
|
79
|
+
// SharedWorker proxy entry that owns the subduction WebSocket. Chrome can't
|
|
80
|
+
// spawn workers from inside a SharedWorker, so each tab offers this proxy's
|
|
81
|
+
// port to the automerge worker (which requests one via its port provider).
|
|
82
|
+
// Being a SharedWorker itself, the proxy — and the donated worker↔worker
|
|
83
|
+
// port — outlives the donor tab. Emitted at /packages/... via externals.ts.
|
|
84
|
+
const SUBDUCTION_IO_WORKER_URL = "/packages/@automerge/automerge-repo/subduction-websocket-worker-shared.js";
|
|
85
|
+
// Bench toggles for the subduction socket (see getSubductionEndpoints in
|
|
86
|
+
// automerge-worker.ts), passed as query params because SharedWorker scope
|
|
87
|
+
// has no localStorage — which also gives each configuration its own worker
|
|
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();
|
|
94
|
+
try {
|
|
95
|
+
for (const [key, param] of [
|
|
96
|
+
["patchwork:ws-mode", "ws-mode"],
|
|
97
|
+
["patchwork:ws-window", "ws-window"],
|
|
98
|
+
]) {
|
|
99
|
+
const value = globalThis.localStorage?.getItem(key);
|
|
100
|
+
if (value)
|
|
101
|
+
params.set(param, value);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// No localStorage (shouldn't happen in a tab) — use defaults.
|
|
106
|
+
}
|
|
107
|
+
const qs = params.toString();
|
|
108
|
+
return qs ? `?${qs}` : "";
|
|
109
|
+
}
|
|
82
110
|
export function getAutomergeWorker() {
|
|
83
111
|
if (!automergeWorker) {
|
|
84
|
-
|
|
112
|
+
const workerUrl = `${automergeWorkerPath}${workerBenchParams()}`;
|
|
113
|
+
automergeWorker = new SharedWorker(workerUrl, {
|
|
85
114
|
name: "patchwork-automerge",
|
|
86
115
|
type: "module",
|
|
87
116
|
});
|
|
@@ -95,6 +124,22 @@ export function getAutomergeWorker() {
|
|
|
95
124
|
dispatchSyncState(event.data);
|
|
96
125
|
return;
|
|
97
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
|
+
}
|
|
98
143
|
if (event.data?.type !== "console")
|
|
99
144
|
return;
|
|
100
145
|
const { level, args } = event.data;
|
|
@@ -117,6 +162,15 @@ export function getAutomergeWorker() {
|
|
|
117
162
|
}
|
|
118
163
|
});
|
|
119
164
|
automergeWorker.port.postMessage({ type: "debug", debug: workerDebugging });
|
|
165
|
+
// Offer the subduction io proxy's port; the worker's port provider pulls
|
|
166
|
+
// it when (re)constructing its WorkerWebSocketEndpoint.
|
|
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
|
+
});
|
|
120
174
|
installWorkerDeathDetection(automergeWorker);
|
|
121
175
|
}
|
|
122
176
|
return automergeWorker;
|
|
@@ -320,6 +374,7 @@ export default async function setupServiceWorker(options) {
|
|
|
320
374
|
// Attach the SW→tab [lifecycle] log bridge as early as possible so boot /
|
|
321
375
|
// install / activate markers from the controlling worker are rendered here.
|
|
322
376
|
installServiceWorkerLogForwarding();
|
|
377
|
+
localStorage.removeItem(key);
|
|
323
378
|
if (options?.workerPath)
|
|
324
379
|
automergeWorkerPath = options.workerPath;
|
|
325
380
|
// Start the automerge worker right away so it boots (wasm, repo) while the
|