@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/src/setup.ts CHANGED
@@ -9,6 +9,10 @@ import {
9
9
  DEFAULT_CLASSIC_SYNC_SERVER,
10
10
  } from "./sync-config.js";
11
11
  import debug from "debug";
12
+ import {
13
+ donatePort,
14
+ isWorkerErrorMessage,
15
+ } from "@automerge/automerge-repo/worker-port";
12
16
 
13
17
  const serviceWorkerDebugging = debug.enabled("patchwork:serviceworker");
14
18
  const workerDebugging = debug.enabled("patchwork:automergeworker");
@@ -42,6 +46,7 @@ function installServiceWorkerLogForwarding(): void {
42
46
  }
43
47
 
44
48
  const key = "patchworkServiceWorkerCacheVersion";
49
+ const defaultServiceWorkerCacheName = "patchwork";
45
50
  let nextRepoChannelId = 0;
46
51
 
47
52
  function bumpServiceWorkerCacheVersion() {
@@ -54,19 +59,13 @@ function getServiceWorkerCacheVersion() {
54
59
  return localStorage.getItem(key);
55
60
  }
56
61
 
57
- function getOrCreateServiceWorkerCacheVersion() {
58
- const existing = getServiceWorkerCacheVersion();
59
- if (existing) return existing;
60
- return bumpServiceWorkerCacheVersion();
61
- }
62
-
63
62
  function setServiceWorkerCacheName(sw: ServiceWorker | null) {
64
63
  if (!sw) {
65
64
  throw new Error("no service worker!");
66
65
  }
67
66
  sw.postMessage({
68
67
  type: "cachename",
69
- cachename: getOrCreateServiceWorkerCacheVersion(),
68
+ cachename: getServiceWorkerCacheVersion() ?? defaultServiceWorkerCacheName,
70
69
  });
71
70
  }
72
71
 
@@ -82,8 +81,10 @@ export function bumpServiceWorkerCache(
82
81
  function configureServiceWorker(sw: ServiceWorker | null) {
83
82
  if (!sw) return;
84
83
  sw.postMessage({ type: "debug", debug: serviceWorkerDebugging });
85
- const cachename = getServiceWorkerCacheVersion();
86
- if (cachename) sw.postMessage({ type: "cachename", cachename });
84
+ sw.postMessage({
85
+ type: "cachename",
86
+ cachename: getServiceWorkerCacheVersion() ?? defaultServiceWorkerCacheName,
87
+ });
87
88
  }
88
89
 
89
90
  // ── The automerge worker ───────────────────────────────────────────────
@@ -96,9 +97,42 @@ function configureServiceWorker(sw: ServiceWorker | null) {
96
97
  let automergeWorkerPath = "/automerge-worker.js";
97
98
  let automergeWorker: SharedWorker | undefined;
98
99
 
100
+ // SharedWorker proxy entry that owns the subduction WebSocket. Chrome can't
101
+ // spawn workers from inside a SharedWorker, so each tab offers this proxy's
102
+ // port to the automerge worker (which requests one via its port provider).
103
+ // Being a SharedWorker itself, the proxy — and the donated worker↔worker
104
+ // port — outlives the donor tab. Emitted at /packages/... via externals.ts.
105
+ const SUBDUCTION_IO_WORKER_URL =
106
+ "/packages/@automerge/automerge-repo/subduction-websocket-worker-shared.js";
107
+
108
+ // Bench toggles for the subduction socket (see getSubductionEndpoints in
109
+ // automerge-worker.ts), passed as query params because SharedWorker scope
110
+ // has no localStorage — which also gives each configuration its own worker
111
+ // instance, so bench arms can't share state.
112
+ // localStorage["patchwork:ws-mode"] = "inline" → socket on worker thread
113
+ // localStorage["patchwork:ws-window"] = "16" → WorkerWebSocketEndpoint
114
+ // windowFrames override
115
+ function workerBenchParams(): string {
116
+ const params = new URLSearchParams();
117
+ try {
118
+ for (const [key, param] of [
119
+ ["patchwork:ws-mode", "ws-mode"],
120
+ ["patchwork:ws-window", "ws-window"],
121
+ ] as const) {
122
+ const value = globalThis.localStorage?.getItem(key);
123
+ if (value) params.set(param, value);
124
+ }
125
+ } catch {
126
+ // No localStorage (shouldn't happen in a tab) — use defaults.
127
+ }
128
+ const qs = params.toString();
129
+ return qs ? `?${qs}` : "";
130
+ }
131
+
99
132
  export function getAutomergeWorker(): SharedWorker {
100
133
  if (!automergeWorker) {
101
- automergeWorker = new SharedWorker(automergeWorkerPath, {
134
+ const workerUrl = `${automergeWorkerPath}${workerBenchParams()}`;
135
+ automergeWorker = new SharedWorker(workerUrl, {
102
136
  name: "patchwork-automerge",
103
137
  type: "module",
104
138
  });
@@ -112,6 +146,21 @@ export function getAutomergeWorker(): SharedWorker {
112
146
  dispatchSyncState(event.data as SyncStateDocMessage);
113
147
  return;
114
148
  }
149
+ if (isWorkerErrorMessage(event.data)) {
150
+ // Crash/skew reports relayed from the subduction io proxy (e.g.
151
+ // protocol-mismatch from a stale SW-cached worker chunk). Surface
152
+ // loudly — these otherwise only exist in chrome://inspect.
153
+ console.error("[subduction-io]", event.data);
154
+ return;
155
+ }
156
+ if (event.data?.type === "drift-samples") {
157
+ // Keepalive-drift samples from the worker's bench probe. Kept on a
158
+ // bounded window global for the Playwright bench to harvest.
159
+ const sink = ((window as any).__driftSamples ??= []) as number[];
160
+ sink.push(...event.data.samples);
161
+ if (sink.length > 10_000) sink.splice(0, sink.length - 10_000);
162
+ return;
163
+ }
115
164
  if (event.data?.type !== "console") return;
116
165
  const { level, args } = event.data;
117
166
  // Gate forwarded [lifecycle] logs on the toggle too.
@@ -135,6 +184,16 @@ export function getAutomergeWorker(): SharedWorker {
135
184
  });
136
185
  automergeWorker.port.postMessage({ type: "debug", debug: workerDebugging });
137
186
 
187
+ // Offer the subduction io proxy's port; the worker's port provider pulls
188
+ // it when (re)constructing its WorkerWebSocketEndpoint.
189
+ donatePort(automergeWorker.port, () => {
190
+ const io = new SharedWorker(SUBDUCTION_IO_WORKER_URL, {
191
+ type: "module",
192
+ name: "subduction-websocket",
193
+ });
194
+ return io.port;
195
+ });
196
+
138
197
  installWorkerDeathDetection(automergeWorker);
139
198
  }
140
199
  return automergeWorker;
@@ -363,6 +422,7 @@ export default async function setupServiceWorker(
363
422
  // Attach the SW→tab [lifecycle] log bridge as early as possible so boot /
364
423
  // install / activate markers from the controlling worker are rendered here.
365
424
  installServiceWorkerLogForwarding();
425
+ localStorage.removeItem(key);
366
426
 
367
427
  if (options?.workerPath) automergeWorkerPath = options.workerPath;
368
428
 
package/src/site.ts CHANGED
@@ -17,7 +17,6 @@ import {
17
17
  isValidAutomergeUrl,
18
18
  isValidDocumentId,
19
19
  MessageChannelNetworkAdapter,
20
- parseAutomergeUrl,
21
20
  Repo,
22
21
  stringifyAutomergeUrl,
23
22
  type AutomergeUrl,
@@ -172,9 +171,12 @@ export interface BootResult {
172
171
  accountDocHandle: DocHandle<AccountDoc>;
173
172
  }
174
173
 
175
- // Legacy big-patchwork hash shape: `slug--<documentId>[?=type]`.
174
+ // Legacy big-patchwork hash shape: `<slug>--<documentId>[?…]`. The slug can
175
+ // contain characters we don't otherwise permit (e.g. `drawing-(branch-1)`), so
176
+ // we anchor on the `--` before the base58 document id and allow any non-query
177
+ // characters ahead of it rather than a strict slug charset.
176
178
  const BIG_PATCHWORK_HASH_REGEX =
177
- /(?<title>[A-Za-z0-9-]+)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)(?<type>\?=[^&?]+)?/;
179
+ /^(?<title>[^=&?/#]*)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)/;
178
180
 
179
181
  const [automergeWasm, subductionWasm] = await Promise.all([
180
182
  fetch("/automerge.wasm?main").then((r) => r.bytes()),
@@ -355,7 +357,6 @@ export async function bootPatchworkSite(
355
357
  rootElement,
356
358
  repo,
357
359
  accountDocHandle,
358
- moduleWatcher,
359
360
  titleSuffix: config.titleSuffix,
360
361
  });
361
362
 
@@ -625,12 +626,12 @@ async function uncache(match: string): Promise<void> {
625
626
  }
626
627
  }
627
628
 
628
- // Keys whose values are automerge URLs — we keep the `:` (and any `#`/`|`
629
- // heads) literal rather than percent-encoding them so links stay readable.
630
- const RAW_HASH_KEYS = new Set(["doc", "package"]);
631
- // Emit hash params in a stable order so re-serializing the same logical
632
- // params yields a byte-identical string (avoids spurious `hashchange`).
633
- const HASH_KEY_ORDER = ["doc", "package", "tool", "type", "title", "frame"];
629
+ // The `doc=` value is an automerge URL — we keep its `:` (and any `#`/`|`
630
+ // heads) literal rather than percent-encoding it so links stay readable.
631
+ const RAW_HASH_KEYS = new Set(["doc"]);
632
+ // Emit hash params in a stable order so re-serializing the same logical params
633
+ // yields a byte-identical string (avoids spurious `hashchange` round-trips).
634
+ const HASH_KEY_ORDER = ["doc", "tool", "type", "title", "frame"];
634
635
 
635
636
  function serializeHashParams(params: URLSearchParams): string {
636
637
  const emitted = new Set<string>();
@@ -640,7 +641,9 @@ function serializeHashParams(params: URLSearchParams): string {
640
641
  const value = params.get(key);
641
642
  if (!value) return;
642
643
  emitted.add(key);
643
- parts.push(`${key}=${RAW_HASH_KEYS.has(key) ? value : encodeURIComponent(value)}`);
644
+ parts.push(
645
+ `${key}=${RAW_HASH_KEYS.has(key) ? value : encodeURIComponent(value)}`
646
+ );
644
647
  };
645
648
  for (const key of HASH_KEY_ORDER) emit(key);
646
649
  for (const key of params.keys()) emit(key);
@@ -649,12 +652,14 @@ function serializeHashParams(params: URLSearchParams): string {
649
652
 
650
653
  /**
651
654
  * Coerce a `doc=` hash param to a full automerge URL. Accepts a full URL
652
- * (`automerge:<id>[#heads]`) or a bare document id for backwards
653
- * compatibility with older links.
655
+ * (`automerge:<id>[#heads]`) or a bare document id for backwards compatibility
656
+ * with older links.
654
657
  */
655
658
  function docParamToUrl(docParam: string | null): AutomergeUrl | undefined {
656
659
  if (!docParam) return undefined;
657
- if (isValidAutomergeUrl(docParam as AutomergeUrl)) return docParam as AutomergeUrl;
660
+ if (isValidAutomergeUrl(docParam as AutomergeUrl)) {
661
+ return docParam as AutomergeUrl;
662
+ }
658
663
  const documentId = docParam.replace(/^automerge:/, "");
659
664
  if (isValidDocumentId(documentId)) {
660
665
  return stringifyAutomergeUrl({ documentId: documentId as DocumentId });
@@ -666,13 +671,11 @@ interface HashRoutingParams {
666
671
  rootElement: HTMLElement;
667
672
  repo: Repo;
668
673
  accountDocHandle: DocHandle<AccountDoc>;
669
- moduleWatcher: ModuleWatcher;
670
674
  titleSuffix: string;
671
675
  }
672
676
 
673
677
  function installHashRouting(params: HashRoutingParams): void {
674
- const { rootElement, repo, accountDocHandle, moduleWatcher, titleSuffix } =
675
- params;
678
+ const { rootElement, repo, accountDocHandle, titleSuffix } = params;
676
679
 
677
680
  rootElement.addEventListener("patchwork:open-document", async (event) => {
678
681
  const params = new URLSearchParams(window.location.hash.slice(1));
@@ -682,8 +685,8 @@ function installHashRouting(params: HashRoutingParams): void {
682
685
  type?: string;
683
686
  title?: string;
684
687
  };
685
- // `doc` is now the full automerge URL (heads, if any, live in it). The
686
- // in-use package is recorded separately in `package=` when a tool mounts.
688
+ // `doc` is now the full automerge URL heads, if any, live inside it, so
689
+ // the separate `heads=` param is gone.
687
690
  params.delete("heads");
688
691
  params.set("doc", url);
689
692
  if (toolId) params.set("tool", toolId);
@@ -695,8 +698,9 @@ function installHashRouting(params: HashRoutingParams): void {
695
698
  window.location.hash = serializeHashParams(params);
696
699
 
697
700
  try {
698
- const docHandle =
699
- await repo.find<{ "@patchwork"?: { type?: string } }>(url);
701
+ const docHandle = await repo.find<{ "@patchwork"?: { type?: string } }>(
702
+ url
703
+ );
700
704
  const doc = docHandle.doc();
701
705
  const docType = type || doc?.["@patchwork"]?.type;
702
706
  if (!docType) return;
@@ -714,29 +718,6 @@ function installHashRouting(params: HashRoutingParams): void {
714
718
  }
715
719
  });
716
720
 
717
- // When a tool mounts we know the package actually rendering the top-level
718
- // document, so record its (heads-pinned) importUrl in `package=`.
719
- const recordInUsePackage = (event: Event) => {
720
- const detail = (event as CustomEvent).detail;
721
- if (!detail || !("url" in detail) || !detail.importUrl) return;
722
- const params = new URLSearchParams(window.location.hash.slice(1));
723
- const docUrl = docParamToUrl(params.get("doc"));
724
- if (!docUrl) return;
725
- // Ignore nested branch / side-by-side views: only the top-level doc's
726
- // package belongs in the hash.
727
- if (
728
- parseAutomergeUrl(docUrl).documentId !==
729
- parseAutomergeUrl(detail.url as AutomergeUrl).documentId
730
- ) {
731
- return;
732
- }
733
- if (params.get("package") === detail.importUrl) return;
734
- params.set("package", detail.importUrl);
735
- // Replace rather than push: recording the in-use package shouldn't add a
736
- // back-button entry (and replaceState avoids a `hashchange` round-trip).
737
- history.replaceState(null, "", `#${serializeHashParams(params)}`);
738
- };
739
-
740
721
  let firstMount = true;
741
722
  const reveal = () => {
742
723
  if (!firstMount) return;
@@ -746,7 +727,6 @@ function installHashRouting(params: HashRoutingParams): void {
746
727
  };
747
728
 
748
729
  rootElement.addEventListener("patchwork:mounted", (event) => {
749
- recordInUsePackage(event);
750
730
  handleHashChange();
751
731
  if (event.target !== rootElement) return;
752
732
  console.info("root element mounted");
@@ -762,13 +742,17 @@ function installHashRouting(params: HashRoutingParams): void {
762
742
 
763
743
  const handleHashChange = async () => {
764
744
  const hash = window.location.hash.slice(1);
765
- const legacy = BIG_PATCHWORK_HASH_REGEX.exec(hash);
766
745
 
767
- if (legacy) {
768
- const documentId = legacy.groups?.docId;
769
- if (isValidDocumentId(documentId)) {
770
- openDocument(rootElement, stringifyAutomergeUrl({ documentId }));
771
- }
746
+ // Legacy big-patchwork link (`<slug>--<docId>?…`): if the hash carries a
747
+ // `--` followed by a valid document id, normalize it to the canonical
748
+ // `#doc=automerge:<docId>` form and let routing re-run on the hashchange.
749
+ const legacyDocId = BIG_PATCHWORK_HASH_REGEX.exec(hash)?.groups?.docId;
750
+ if (legacyDocId && isValidDocumentId(legacyDocId)) {
751
+ window.location.hash = serializeHashParams(
752
+ new URLSearchParams({
753
+ doc: stringifyAutomergeUrl({ documentId: legacyDocId as DocumentId }),
754
+ })
755
+ );
772
756
  return;
773
757
  }
774
758
 
@@ -782,18 +766,10 @@ function installHashRouting(params: HashRoutingParams): void {
782
766
 
783
767
  const params = new URLSearchParams(hash);
784
768
  const docUrl = docParamToUrl(params.get("doc"));
785
- const packageUrl = params.get("package");
786
769
  const toolId = params.get("tool");
787
770
  const title = params.get("title");
788
771
  const type = params.get("type");
789
772
  const frame = params.get("frame");
790
-
791
- // Load the package that produced this document so its tool is available
792
- // even when it isn't in the user's module settings.
793
- if (packageUrl) {
794
- void moduleWatcher.loadModules([packageUrl]);
795
- }
796
-
797
773
  if (frame) {
798
774
  const frameDocUrl = docUrl ?? accountDocHandle.url;
799
775
  if (