@inkandswitch/patchwork-bootloader 0.3.1 → 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.
@@ -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.d.ts CHANGED
@@ -1,6 +1,9 @@
1
- import type { SetupServiceWorkerOptions, SetupServiceWorkerResult } from "./types.js";
1
+ import type { SetupServiceWorkerOptions, SetupServiceWorkerResult, SyncStateDocMessage } from "./types.js";
2
2
  export declare function lifecycleLoggingEnabled(): boolean;
3
3
  export declare function bumpServiceWorkerCache(sw?: ServiceWorker | null): void;
4
4
  export declare function getAutomergeWorker(): SharedWorker;
5
+ type SyncStateListener = (update: SyncStateDocMessage) => void;
6
+ export declare function subscribeSyncState(documentId: string, listener: SyncStateListener): () => void;
5
7
  export declare function connectClassicSync(server?: string): Promise<void>;
6
8
  export default function setupServiceWorker(options?: SetupServiceWorkerOptions): Promise<SetupServiceWorkerResult>;
9
+ export {};
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
  });
@@ -91,6 +120,26 @@ export function getAutomergeWorker() {
91
120
  // Surface the SharedWorker's console output and uncaught errors in this
92
121
  // tab's console (it has its own console that's awkward to find otherwise).
93
122
  automergeWorker.port.addEventListener("message", (event) => {
123
+ if (event.data?.type === "sync-state") {
124
+ dispatchSyncState(event.data);
125
+ return;
126
+ }
127
+ if (isWorkerErrorMessage(event.data)) {
128
+ // Crash/skew reports relayed from the subduction io proxy (e.g.
129
+ // protocol-mismatch from a stale SW-cached worker chunk). Surface
130
+ // loudly — these otherwise only exist in chrome://inspect.
131
+ console.error("[subduction-io]", event.data);
132
+ return;
133
+ }
134
+ if (event.data?.type === "drift-samples") {
135
+ // Keepalive-drift samples from the worker's bench probe. Kept on a
136
+ // bounded window global for the Playwright bench to harvest.
137
+ const sink = (window.__driftSamples ??= []);
138
+ sink.push(...event.data.samples);
139
+ if (sink.length > 10_000)
140
+ sink.splice(0, sink.length - 10_000);
141
+ return;
142
+ }
94
143
  if (event.data?.type !== "console")
95
144
  return;
96
145
  const { level, args } = event.data;
@@ -113,6 +162,15 @@ export function getAutomergeWorker() {
113
162
  }
114
163
  });
115
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
+ });
116
174
  installWorkerDeathDetection(automergeWorker);
117
175
  }
118
176
  return automergeWorker;
@@ -181,6 +239,44 @@ function installWorkerDeathDetection(worker) {
181
239
  }
182
240
  }, HEARTBEAT_MS);
183
241
  }
242
+ const syncStateListeners = new Map();
243
+ function dispatchSyncState(update) {
244
+ const listeners = syncStateListeners.get(update.documentId);
245
+ if (!listeners)
246
+ return;
247
+ for (const listener of listeners) {
248
+ try {
249
+ listener(update);
250
+ }
251
+ catch (err) {
252
+ console.error("sync-state listener threw", err);
253
+ }
254
+ }
255
+ }
256
+ export function subscribeSyncState(documentId, listener) {
257
+ const worker = getAutomergeWorker();
258
+ let listeners = syncStateListeners.get(documentId);
259
+ if (!listeners) {
260
+ syncStateListeners.set(documentId, (listeners = new Set()));
261
+ // First local watcher for this doc — ask the worker to start pushing it.
262
+ worker.port.postMessage({ type: "sync-sub", documentId });
263
+ }
264
+ listeners.add(listener);
265
+ let active = true;
266
+ return () => {
267
+ if (!active)
268
+ return; // idempotent
269
+ active = false;
270
+ const set = syncStateListeners.get(documentId);
271
+ if (!set)
272
+ return;
273
+ set.delete(listener);
274
+ if (set.size === 0) {
275
+ syncStateListeners.delete(documentId);
276
+ worker.port.postMessage({ type: "sync-unsub", documentId });
277
+ }
278
+ };
279
+ }
184
280
  export function connectClassicSync(server = readClassicSyncServer()) {
185
281
  const url = server.trim() || DEFAULT_CLASSIC_SYNC_SERVER;
186
282
  if (!/^wss?:\/\//.test(url)) {
@@ -278,6 +374,7 @@ export default async function setupServiceWorker(options) {
278
374
  // Attach the SW→tab [lifecycle] log bridge as early as possible so boot /
279
375
  // install / activate markers from the controlling worker are rendered here.
280
376
  installServiceWorkerLogForwarding();
377
+ localStorage.removeItem(key);
281
378
  if (options?.workerPath)
282
379
  automergeWorkerPath = options.workerPath;
283
380
  // Start the automerge worker right away so it boots (wasm, repo) while the
@@ -318,6 +415,7 @@ export default async function setupServiceWorker(options) {
318
415
  shared,
319
416
  connectClassicSync,
320
417
  getRepoChannel,
418
+ subscribeSyncState,
321
419
  async subscribeToRepoChannel(listener) {
322
420
  // The automerge worker outlives the page, so unlike the old in-service-
323
421
  // worker repo there's nothing to reconnect: one port, handed over once.
package/dist/site.d.ts CHANGED
@@ -16,7 +16,7 @@ import { type AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";
16
16
  import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
17
17
  import { type AccountDoc } from "@inkandswitch/patchwork-plugins";
18
18
  import * as plugins from "@inkandswitch/patchwork-plugins";
19
- import type { ServiceWorkerRepoChannelListener } from "./types.js";
19
+ import type { ServiceWorkerRepoChannelListener, SyncStateDocMessage } from "./types.js";
20
20
  declare global {
21
21
  interface Window {
22
22
  accountDocHandle: DocHandle<AccountDoc>;
@@ -30,9 +30,14 @@ declare global {
30
30
  packages: ModuleWatcher;
31
31
  plugins: typeof plugins;
32
32
  accountDocHandle: DocHandle<AccountDoc>;
33
+ signer?: {
34
+ peerId: string;
35
+ verifyingKey: string;
36
+ };
33
37
  sw: {
34
38
  connectClassicSync: (server?: string) => Promise<void>;
35
39
  subscribeToRepoChannel: (listener: ServiceWorkerRepoChannelListener) => Promise<() => void>;
40
+ subscribeSyncState: (documentId: string, listener: (update: SyncStateDocMessage) => void) => () => void;
36
41
  };
37
42
  };
38
43
  uncache: (match: string) => Promise<void>;
package/dist/site.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * site's `main.ts`. Non-UI consumers should import the package default (which
12
12
  * only does SW registration and the automerge-worker handoff).
13
13
  */
14
- import { initializeWasm, isValidAutomergeUrl, isValidDocumentId, MessageChannelNetworkAdapter, parseAutomergeUrl, Repo, stringifyAutomergeUrl, } from "@automerge/vanillajs/slim";
14
+ import { initializeWasm, isValidAutomergeUrl, isValidDocumentId, MessageChannelNetworkAdapter, Repo, stringifyAutomergeUrl, } from "@automerge/vanillajs/slim";
15
15
  import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
16
16
  import * as Automerge from "@automerge/automerge/slim";
17
17
  import * as AutomergeRepo from "@automerge/automerge-repo/slim";
@@ -19,6 +19,7 @@ import { initKeyhiveWasm, initializeAutomergeRepoKeyhiveWithRepo, } from "@autom
19
19
  // eslint-disable-next-line
20
20
  // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
21
21
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
22
+ import { MemorySigner } from "@automerge/automerge-subduction/slim";
22
23
  const siteName = typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "tiny-patchwork";
23
24
  const useKeyhiveSyncServer = typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
24
25
  import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
@@ -30,8 +31,11 @@ import * as plugins from "@inkandswitch/patchwork-plugins";
30
31
  import setupServiceWorker, { lifecycleLoggingEnabled, } from "./setup.js";
31
32
  import debug from "debug";
32
33
  const log = debug("patchwork:bootloader:site");
33
- // Legacy big-patchwork hash shape: `slug--<documentId>[?=type]`.
34
- const BIG_PATCHWORK_HASH_REGEX = /(?<title>[A-Za-z0-9-]+)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)(?<type>\?=[^&?]+)?/;
34
+ // Legacy big-patchwork hash shape: `<slug>--<documentId>[?…]`. The slug can
35
+ // contain characters we don't otherwise permit (e.g. `drawing-(branch-1)`), so
36
+ // we anchor on the `--` before the base58 document id and allow any non-query
37
+ // characters ahead of it rather than a strict slug charset.
38
+ const BIG_PATCHWORK_HASH_REGEX = /^(?<title>[^=&?/#]*)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)/;
35
39
  const [automergeWasm, subductionWasm] = await Promise.all([
36
40
  fetch("/automerge.wasm?main").then((r) => r.bytes()),
37
41
  fetch("/subduction.wasm").then((r) => r.bytes()),
@@ -59,6 +63,7 @@ export async function bootPatchworkSite(config) {
59
63
  log("workers ready");
60
64
  let hive;
61
65
  let repo;
66
+ let tabSignerIdentity;
62
67
  // If a Repo is already on `window` — an embedding context provided one before
63
68
  // this entry ran — reuse it and its keyhive instead of standing up a fresh
64
69
  // realm-local Repo, so we share the same documents and sync/keyhive context.
@@ -102,15 +107,26 @@ export async function bootPatchworkSite(config) {
102
107
  }
103
108
  else {
104
109
  log("creating repo");
110
+ // Pass an explicit signer (instead of the Repo's internal default) so we
111
+ // can expose tab signer identity on window.patchwork for dev inspection.
112
+ // The tab never connects via Subduction (no endpoints/adapters), so this
113
+ // id never goes on the wire.
114
+ const tabSigner = new MemorySigner();
105
115
  repo = new Repo({
106
116
  network: [new MessageChannelNetworkAdapter(workerPort)],
107
117
  storage: new IndexedDBWorkerStorageAdapter(),
118
+ signer: tabSigner,
108
119
  async sharePolicy(peerId) {
109
120
  return peerId.includes("automerge-worker");
110
121
  },
111
122
  enableRemoteHeadsGossiping: true,
112
123
  peerId: `${config.titleSuffix}-tab-${crypto.randomUUID()}`,
113
124
  });
125
+ tabSignerIdentity = {
126
+ peerId: tabSigner.peerId().toString(),
127
+ verifyingKey: tabSigner.verifyingKey().toHex(),
128
+ };
129
+ console.log("[patchwork] tab subduction identity:", tabSignerIdentity);
114
130
  log("repo created");
115
131
  }
116
132
  }
@@ -157,9 +173,11 @@ export async function bootPatchworkSite(config) {
157
173
  packages: moduleWatcher,
158
174
  plugins,
159
175
  accountDocHandle,
176
+ ...(tabSignerIdentity ? { signer: tabSignerIdentity } : {}),
160
177
  sw: {
161
178
  connectClassicSync: sw.connectClassicSync,
162
179
  subscribeToRepoChannel: sw.subscribeToRepoChannel,
180
+ subscribeSyncState: sw.subscribeSyncState,
163
181
  },
164
182
  };
165
183
  window.uncache = uncache;
@@ -167,7 +185,6 @@ export async function bootPatchworkSite(config) {
167
185
  rootElement,
168
186
  repo,
169
187
  accountDocHandle,
170
- moduleWatcher,
171
188
  titleSuffix: config.titleSuffix,
172
189
  });
173
190
  return { repo, moduleWatcher, accountDocHandle };
@@ -296,10 +313,7 @@ function primeRootElement(rootElement, accountDocHandle) {
296
313
  const initialParams = new URLSearchParams(location.hash.slice(1));
297
314
  if (initialParams.has("frame")) {
298
315
  rootElement.setAttribute("tool-id", initialParams.get("frame"));
299
- const docId = initialParams.get("doc")?.replace(/^automerge:/, "");
300
- const docUrl = docId
301
- ? stringifyAutomergeUrl({ documentId: docId })
302
- : accountDocHandle.url;
316
+ const docUrl = docParamToUrl(initialParams.get("doc")) ?? accountDocHandle.url;
303
317
  rootElement.setAttribute("doc-url", docUrl);
304
318
  }
305
319
  else {
@@ -385,20 +399,56 @@ async function uncache(match) {
385
399
  }
386
400
  }
387
401
  }
402
+ // The `doc=` value is an automerge URL — we keep its `:` (and any `#`/`|`
403
+ // heads) literal rather than percent-encoding it so links stay readable.
404
+ const RAW_HASH_KEYS = new Set(["doc"]);
405
+ // Emit hash params in a stable order so re-serializing the same logical params
406
+ // yields a byte-identical string (avoids spurious `hashchange` round-trips).
407
+ const HASH_KEY_ORDER = ["doc", "tool", "type", "title", "frame"];
408
+ function serializeHashParams(params) {
409
+ const emitted = new Set();
410
+ const parts = [];
411
+ const emit = (key) => {
412
+ if (emitted.has(key))
413
+ return;
414
+ const value = params.get(key);
415
+ if (!value)
416
+ return;
417
+ emitted.add(key);
418
+ parts.push(`${key}=${RAW_HASH_KEYS.has(key) ? value : encodeURIComponent(value)}`);
419
+ };
420
+ for (const key of HASH_KEY_ORDER)
421
+ emit(key);
422
+ for (const key of params.keys())
423
+ emit(key);
424
+ return parts.join("&");
425
+ }
426
+ /**
427
+ * Coerce a `doc=` hash param to a full automerge URL. Accepts a full URL
428
+ * (`automerge:<id>[#heads]`) or a bare document id for backwards compatibility
429
+ * with older links.
430
+ */
431
+ function docParamToUrl(docParam) {
432
+ if (!docParam)
433
+ return undefined;
434
+ if (isValidAutomergeUrl(docParam)) {
435
+ return docParam;
436
+ }
437
+ const documentId = docParam.replace(/^automerge:/, "");
438
+ if (isValidDocumentId(documentId)) {
439
+ return stringifyAutomergeUrl({ documentId: documentId });
440
+ }
441
+ return undefined;
442
+ }
388
443
  function installHashRouting(params) {
389
- const { rootElement, repo, accountDocHandle, moduleWatcher, titleSuffix } = params;
390
- rootElement.addEventListener("patchwork:no-tool", (event) => {
391
- moduleWatcher.loadSuggestedImportUrl(event.detail.url);
392
- });
444
+ const { rootElement, repo, accountDocHandle, titleSuffix } = params;
393
445
  rootElement.addEventListener("patchwork:open-document", async (event) => {
394
446
  const params = new URLSearchParams(window.location.hash.slice(1));
395
447
  const { url, toolId, type, title } = event.detail;
396
- const { documentId, heads } = parseAutomergeUrl(url);
397
- params.set("doc", documentId);
398
- if (heads)
399
- params.set("heads", heads.join("|"));
400
- else
401
- params.delete("heads");
448
+ // `doc` is now the full automerge URL — heads, if any, live inside it, so
449
+ // the separate `heads=` param is gone.
450
+ params.delete("heads");
451
+ params.set("doc", url);
402
452
  if (toolId)
403
453
  params.set("tool", toolId);
404
454
  else
@@ -411,9 +461,9 @@ function installHashRouting(params) {
411
461
  params.set("type", type);
412
462
  else
413
463
  params.delete("type");
414
- window.location.hash = params.toString();
464
+ window.location.hash = serializeHashParams(params);
415
465
  try {
416
- const docHandle = await repo.find(stringifyAutomergeUrl({ documentId, heads }));
466
+ const docHandle = await repo.find(url);
417
467
  const doc = docHandle.doc();
418
468
  const docType = type || doc?.["@patchwork"]?.type;
419
469
  if (!docType)
@@ -454,44 +504,40 @@ function installHashRouting(params) {
454
504
  setTimeout(reveal, 12_000);
455
505
  const handleHashChange = async () => {
456
506
  const hash = window.location.hash.slice(1);
457
- const legacy = BIG_PATCHWORK_HASH_REGEX.exec(hash);
458
- if (legacy) {
459
- const documentId = legacy.groups?.docId;
460
- if (isValidDocumentId(documentId)) {
461
- openDocument(rootElement, stringifyAutomergeUrl({ documentId }));
462
- }
507
+ // Legacy big-patchwork link (`<slug>--<docId>?…`): if the hash carries a
508
+ // `--` followed by a valid document id, normalize it to the canonical
509
+ // `#doc=automerge:<docId>` form and let routing re-run on the hashchange.
510
+ const legacyDocId = BIG_PATCHWORK_HASH_REGEX.exec(hash)?.groups?.docId;
511
+ if (legacyDocId && isValidDocumentId(legacyDocId)) {
512
+ window.location.hash = serializeHashParams(new URLSearchParams({
513
+ doc: stringifyAutomergeUrl({ documentId: legacyDocId }),
514
+ }));
463
515
  return;
464
516
  }
465
517
  // Bare automerge URL in hash: /#automerge:<documentId>
466
518
  if (isValidAutomergeUrl(hash)) {
467
- const { documentId, heads } = parseAutomergeUrl(hash);
519
+ const url = hash;
468
520
  window.location.hash = "";
469
- openDocument(rootElement, stringifyAutomergeUrl({ documentId, heads }));
521
+ openDocument(rootElement, url);
470
522
  return;
471
523
  }
472
524
  const params = new URLSearchParams(hash);
473
- const documentId = params.get("doc")?.replace(/^automerge:/, "");
474
- const heads = params.get("heads")?.split("|");
525
+ const docUrl = docParamToUrl(params.get("doc"));
475
526
  const toolId = params.get("tool");
476
527
  const title = params.get("title");
477
528
  const type = params.get("type");
478
529
  const frame = params.get("frame");
479
530
  if (frame) {
480
- const docUrl = params.get("doc")?.replace(/^automerge:/, "") ?? accountDocHandle.url;
531
+ const frameDocUrl = docUrl ?? accountDocHandle.url;
481
532
  if (rootElement.getAttribute("tool-id") !== frame ||
482
- rootElement.getAttribute("doc-url") !== docUrl) {
533
+ rootElement.getAttribute("doc-url") !== frameDocUrl) {
483
534
  rootElement.setAttribute("tool-id", frame);
484
- rootElement.setAttribute("doc-url", docUrl);
535
+ rootElement.setAttribute("doc-url", frameDocUrl);
485
536
  }
486
537
  }
487
- if (isValidDocumentId(documentId)) {
538
+ if (docUrl) {
488
539
  rootElement.dispatchEvent(new CustomEvent("patchwork:open-document", {
489
- detail: {
490
- url: stringifyAutomergeUrl({ documentId, heads }),
491
- toolId,
492
- title,
493
- type,
494
- },
540
+ detail: { url: docUrl, toolId, title, type },
495
541
  }));
496
542
  }
497
543
  };