@inkandswitch/patchwork-bootloader 0.3.2 → 0.4.1

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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # @inkandswitch/patchwork-bootloader
2
2
 
3
+ ## 0.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [2d39c84]
8
+ - @inkandswitch/patchwork-providers@0.4.0
9
+ - @inkandswitch/patchwork-elements@3.0.0
10
+
11
+ ## 0.4.0
12
+
13
+ ### Minor Changes
14
+
15
+ - b1bd763: Hash routing: `doc=` now holds the full (un-encoded) automerge URL — heads, if
16
+ any, live inside it — and the separate `heads=` param is gone. `doc=` values
17
+ that are a bare document id are still accepted for backwards compatibility, and
18
+ legacy big-patchwork links (`<slug>--<docId>?…`, including slugs with
19
+ characters like `drawing-(branch-1)`) are normalized to `#doc=automerge:<docId>`.
20
+
3
21
  ## 0.3.2
4
22
 
5
23
  ### Patch Changes
@@ -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 { Repo, isValidAutomergeUrl, parseAutomergeUrl, stringifyAutomergeUrl, } from "@automerge/automerge-repo/slim";
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 SUBDUCTION_ENDPOINTS = [
164
- useKeyhiveSyncServer
165
- ? "wss://keyhive.sync.automerge.org"
166
- : "wss://subduction.sync.inkandswitch.com",
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: SUBDUCTION_ENDPOINTS,
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: SUBDUCTION_ENDPOINTS,
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
- const headers = new Headers({ "content-type": resolved.type });
816
- headers.set("cross-origin-embedder-policy", "credentialless");
817
- headers.set("cross-origin-resource-policy", "cross-origin");
818
- return new Response(body, { status: 200, headers });
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",
@@ -23,6 +28,7 @@ const externals = [
23
28
  "@codemirror/state",
24
29
  "@codemirror/view",
25
30
  "@codemirror/language",
31
+ "@codemirror/commands",
26
32
  // rip
27
33
  "solid-js",
28
34
  "solid-js/html",
@@ -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
- let cachename = "default";
9
+ const DEFAULT_CACHE_NAME = "patchwork";
10
+ let cachename = DEFAULT_CACHE_NAME;
10
11
  let debugging = false;
11
- const cacheableStatuses = [200, 203, 204];
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 clearOldCaches() {
56
- const cacheWhitelist = [cachename];
57
- const cacheNames = await caches.keys();
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 clearOldCaches();
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.put(client.url, response);
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(`deleting ${cachename} and setting cache name to ${nextCachename}`);
97
- caches.delete(cachename);
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 withSpecialHeaders(response) {
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 withSpecialHeaders(match);
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 withSpecialHeaders(reply.response);
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 withSpecialHeaders(cached);
242
+ return cached;
212
243
  }
213
244
  else {
214
- const response = await fetch(request).catch(() => null);
215
- if (response) {
216
- if (cacheableStatuses.includes(response.status) &&
217
- response.url.match(/^https?\:/)) {
218
- await cache.put(request, response.clone()).catch((error) => {
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 ${response.url}`);
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("couldnt fetch and no stale", { status: 503 });
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: getOrCreateServiceWorkerCacheVersion(),
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
- const cachename = getServiceWorkerCacheVersion();
71
- if (cachename)
72
- sw.postMessage({ type: "cachename", cachename });
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
- automergeWorker = new SharedWorker(automergeWorkerPath, {
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