@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.
package/src/site.ts CHANGED
@@ -17,12 +17,10 @@ import {
17
17
  isValidAutomergeUrl,
18
18
  isValidDocumentId,
19
19
  MessageChannelNetworkAdapter,
20
- parseAutomergeUrl,
21
20
  Repo,
22
21
  stringifyAutomergeUrl,
23
22
  type AutomergeUrl,
24
23
  type DocumentId,
25
- type UrlHeads,
26
24
  } from "@automerge/vanillajs/slim";
27
25
  import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
28
26
  import * as Automerge from "@automerge/automerge/slim";
@@ -35,6 +33,7 @@ import {
35
33
  // eslint-disable-next-line
36
34
  // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
37
35
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
36
+ import { MemorySigner } from "@automerge/automerge-subduction/slim";
38
37
 
39
38
  declare const __SITE_NAME__: string;
40
39
  const siteName =
@@ -70,7 +69,10 @@ import setupServiceWorker, {
70
69
  getAutomergeWorker,
71
70
  lifecycleLoggingEnabled,
72
71
  } from "./setup.js";
73
- import type { ServiceWorkerRepoChannelListener } from "./types.js";
72
+ import type {
73
+ ServiceWorkerRepoChannelListener,
74
+ SyncStateDocMessage,
75
+ } from "./types.js";
74
76
  import debug from "debug";
75
77
  const log = debug("patchwork:bootloader:site");
76
78
 
@@ -87,11 +89,19 @@ declare global {
87
89
  packages: ModuleWatcher;
88
90
  plugins: typeof plugins;
89
91
  accountDocHandle: DocHandle<AccountDoc>;
92
+ signer?: {
93
+ peerId: string;
94
+ verifyingKey: string;
95
+ };
90
96
  sw: {
91
97
  connectClassicSync: (server?: string) => Promise<void>;
92
98
  subscribeToRepoChannel: (
93
99
  listener: ServiceWorkerRepoChannelListener
94
100
  ) => Promise<() => void>;
101
+ subscribeSyncState: (
102
+ documentId: string,
103
+ listener: (update: SyncStateDocMessage) => void
104
+ ) => () => void;
95
105
  };
96
106
  };
97
107
  uncache: (match: string) => Promise<void>;
@@ -161,9 +171,12 @@ export interface BootResult {
161
171
  accountDocHandle: DocHandle<AccountDoc>;
162
172
  }
163
173
 
164
- // 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.
165
178
  const BIG_PATCHWORK_HASH_REGEX =
166
- /(?<title>[A-Za-z0-9-]+)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)(?<type>\?=[^&?]+)?/;
179
+ /^(?<title>[^=&?/#]*)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)/;
167
180
 
168
181
  const [automergeWasm, subductionWasm] = await Promise.all([
169
182
  fetch("/automerge.wasm?main").then((r) => r.bytes()),
@@ -196,6 +209,7 @@ export async function bootPatchworkSite(
196
209
 
197
210
  let hive: AutomergeRepoKeyhive | undefined;
198
211
  let repo: Repo;
212
+ let tabSignerIdentity: { peerId: string; verifyingKey: string } | undefined;
199
213
 
200
214
  // If a Repo is already on `window` — an embedding context provided one before
201
215
  // this entry ran — reuse it and its keyhive instead of standing up a fresh
@@ -240,9 +254,15 @@ export async function bootPatchworkSite(
240
254
  log("keyhive setup complete");
241
255
  } else {
242
256
  log("creating repo");
257
+ // Pass an explicit signer (instead of the Repo's internal default) so we
258
+ // can expose tab signer identity on window.patchwork for dev inspection.
259
+ // The tab never connects via Subduction (no endpoints/adapters), so this
260
+ // id never goes on the wire.
261
+ const tabSigner = new MemorySigner();
243
262
  repo = new Repo({
244
263
  network: [new MessageChannelNetworkAdapter(workerPort)],
245
264
  storage: new IndexedDBWorkerStorageAdapter(),
265
+ signer: tabSigner,
246
266
  async sharePolicy(peerId) {
247
267
  return peerId.includes("automerge-worker");
248
268
  },
@@ -250,6 +270,15 @@ export async function bootPatchworkSite(
250
270
  peerId:
251
271
  `${config.titleSuffix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId,
252
272
  });
273
+ tabSignerIdentity = {
274
+ peerId: tabSigner.peerId().toString(),
275
+ verifyingKey: (
276
+ tabSigner.verifyingKey() as Uint8Array<ArrayBufferLike> & {
277
+ toHex(): string;
278
+ }
279
+ ).toHex(),
280
+ };
281
+ console.log("[patchwork] tab subduction identity:", tabSignerIdentity);
253
282
  log("repo created");
254
283
  }
255
284
  }
@@ -315,9 +344,11 @@ export async function bootPatchworkSite(
315
344
  packages: moduleWatcher,
316
345
  plugins,
317
346
  accountDocHandle,
347
+ ...(tabSignerIdentity ? { signer: tabSignerIdentity } : {}),
318
348
  sw: {
319
349
  connectClassicSync: sw.connectClassicSync,
320
350
  subscribeToRepoChannel: sw.subscribeToRepoChannel,
351
+ subscribeSyncState: sw.subscribeSyncState,
321
352
  },
322
353
  };
323
354
  window.uncache = uncache;
@@ -326,7 +357,6 @@ export async function bootPatchworkSite(
326
357
  rootElement,
327
358
  repo,
328
359
  accountDocHandle,
329
- moduleWatcher,
330
360
  titleSuffix: config.titleSuffix,
331
361
  });
332
362
 
@@ -503,10 +533,8 @@ function primeRootElement(
503
533
  const initialParams = new URLSearchParams(location.hash.slice(1));
504
534
  if (initialParams.has("frame")) {
505
535
  rootElement.setAttribute("tool-id", initialParams.get("frame")!);
506
- const docId = initialParams.get("doc")?.replace(/^automerge:/, "");
507
- const docUrl = docId
508
- ? stringifyAutomergeUrl({ documentId: docId as DocumentId })
509
- : accountDocHandle.url;
536
+ const docUrl =
537
+ docParamToUrl(initialParams.get("doc")) ?? accountDocHandle.url;
510
538
  rootElement.setAttribute("doc-url", docUrl);
511
539
  } else {
512
540
  rootElement.setAttribute("tool-id", accountDocHandle.doc().frameToolId);
@@ -598,21 +626,56 @@ async function uncache(match: string): Promise<void> {
598
626
  }
599
627
  }
600
628
 
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"];
635
+
636
+ function serializeHashParams(params: URLSearchParams): string {
637
+ const emitted = new Set<string>();
638
+ const parts: string[] = [];
639
+ const emit = (key: string) => {
640
+ if (emitted.has(key)) return;
641
+ const value = params.get(key);
642
+ if (!value) return;
643
+ emitted.add(key);
644
+ parts.push(
645
+ `${key}=${RAW_HASH_KEYS.has(key) ? value : encodeURIComponent(value)}`
646
+ );
647
+ };
648
+ for (const key of HASH_KEY_ORDER) emit(key);
649
+ for (const key of params.keys()) emit(key);
650
+ return parts.join("&");
651
+ }
652
+
653
+ /**
654
+ * Coerce a `doc=` hash param to a full automerge URL. Accepts a full URL
655
+ * (`automerge:<id>[#heads]`) or a bare document id for backwards compatibility
656
+ * with older links.
657
+ */
658
+ function docParamToUrl(docParam: string | null): AutomergeUrl | undefined {
659
+ if (!docParam) return undefined;
660
+ if (isValidAutomergeUrl(docParam as AutomergeUrl)) {
661
+ return docParam as AutomergeUrl;
662
+ }
663
+ const documentId = docParam.replace(/^automerge:/, "");
664
+ if (isValidDocumentId(documentId)) {
665
+ return stringifyAutomergeUrl({ documentId: documentId as DocumentId });
666
+ }
667
+ return undefined;
668
+ }
669
+
601
670
  interface HashRoutingParams {
602
671
  rootElement: HTMLElement;
603
672
  repo: Repo;
604
673
  accountDocHandle: DocHandle<AccountDoc>;
605
- moduleWatcher: ModuleWatcher;
606
674
  titleSuffix: string;
607
675
  }
608
676
 
609
677
  function installHashRouting(params: HashRoutingParams): void {
610
- const { rootElement, repo, accountDocHandle, moduleWatcher, titleSuffix } =
611
- params;
612
-
613
- rootElement.addEventListener("patchwork:no-tool", (event) => {
614
- moduleWatcher.loadSuggestedImportUrl(event.detail.url);
615
- });
678
+ const { rootElement, repo, accountDocHandle, titleSuffix } = params;
616
679
 
617
680
  rootElement.addEventListener("patchwork:open-document", async (event) => {
618
681
  const params = new URLSearchParams(window.location.hash.slice(1));
@@ -622,21 +685,21 @@ function installHashRouting(params: HashRoutingParams): void {
622
685
  type?: string;
623
686
  title?: string;
624
687
  };
625
- const { documentId, heads } = parseAutomergeUrl(url);
626
- params.set("doc", documentId);
627
- if (heads) params.set("heads", heads.join("|"));
628
- else params.delete("heads");
688
+ // `doc` is now the full automerge URL — heads, if any, live inside it, so
689
+ // the separate `heads=` param is gone.
690
+ params.delete("heads");
691
+ params.set("doc", url);
629
692
  if (toolId) params.set("tool", toolId);
630
693
  else params.delete("tool");
631
694
  if (title) params.set("title", title);
632
695
  else params.delete("title");
633
696
  if (type) params.set("type", type);
634
697
  else params.delete("type");
635
- window.location.hash = params.toString();
698
+ window.location.hash = serializeHashParams(params);
636
699
 
637
700
  try {
638
701
  const docHandle = await repo.find<{ "@patchwork"?: { type?: string } }>(
639
- stringifyAutomergeUrl({ documentId, heads })
702
+ url
640
703
  );
641
704
  const doc = docHandle.doc();
642
705
  const docType = type || doc?.["@patchwork"]?.type;
@@ -679,51 +742,48 @@ function installHashRouting(params: HashRoutingParams): void {
679
742
 
680
743
  const handleHashChange = async () => {
681
744
  const hash = window.location.hash.slice(1);
682
- const legacy = BIG_PATCHWORK_HASH_REGEX.exec(hash);
683
745
 
684
- if (legacy) {
685
- const documentId = legacy.groups?.docId;
686
- if (isValidDocumentId(documentId)) {
687
- openDocument(rootElement, stringifyAutomergeUrl({ documentId }));
688
- }
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
+ );
689
756
  return;
690
757
  }
691
758
 
692
759
  // Bare automerge URL in hash: /#automerge:<documentId>
693
760
  if (isValidAutomergeUrl(hash as AutomergeUrl)) {
694
- const { documentId, heads } = parseAutomergeUrl(hash as AutomergeUrl);
761
+ const url = hash as AutomergeUrl;
695
762
  window.location.hash = "";
696
- openDocument(rootElement, stringifyAutomergeUrl({ documentId, heads }));
763
+ openDocument(rootElement, url);
697
764
  return;
698
765
  }
699
766
 
700
767
  const params = new URLSearchParams(hash);
701
- const documentId = params.get("doc")?.replace(/^automerge:/, "");
702
- const heads = params.get("heads")?.split("|") as UrlHeads | undefined;
768
+ const docUrl = docParamToUrl(params.get("doc"));
703
769
  const toolId = params.get("tool");
704
770
  const title = params.get("title");
705
771
  const type = params.get("type");
706
772
  const frame = params.get("frame");
707
773
  if (frame) {
708
- const docUrl =
709
- params.get("doc")?.replace(/^automerge:/, "") ?? accountDocHandle.url;
774
+ const frameDocUrl = docUrl ?? accountDocHandle.url;
710
775
  if (
711
776
  rootElement.getAttribute("tool-id") !== frame ||
712
- rootElement.getAttribute("doc-url") !== docUrl
777
+ rootElement.getAttribute("doc-url") !== frameDocUrl
713
778
  ) {
714
779
  rootElement.setAttribute("tool-id", frame);
715
- rootElement.setAttribute("doc-url", docUrl);
780
+ rootElement.setAttribute("doc-url", frameDocUrl);
716
781
  }
717
782
  }
718
- if (isValidDocumentId(documentId)) {
783
+ if (docUrl) {
719
784
  rootElement.dispatchEvent(
720
785
  new CustomEvent("patchwork:open-document", {
721
- detail: {
722
- url: stringifyAutomergeUrl({ documentId, heads }),
723
- toolId,
724
- title,
725
- type,
726
- },
786
+ detail: { url: docUrl, toolId, title, type },
727
787
  })
728
788
  );
729
789
  }
package/src/types.ts CHANGED
@@ -13,6 +13,83 @@ export const HANDOFF_CHANNEL = "@patchwork/handoff";
13
13
  */
14
14
  export const SYNCSTATE_CHANNEL = "@patchwork/syncstate";
15
15
 
16
+ /**
17
+ * Worker → tabs: the worker's Subduction link to the sync server flipped.
18
+ * `serverPeerIds` are the directly-connected sync-server peer ids (their
19
+ * verifying keys), so a tab can tell which peer rows are *the server* and
20
+ * judge "synced" against them specifically.
21
+ */
22
+ export interface SyncStateConnectionMessage {
23
+ type: "connection";
24
+ connected: boolean;
25
+ serverPeerIds: string[];
26
+ }
27
+
28
+ /**
29
+ * Worker → tabs: the shared worker's own Subduction identity, so a tab can
30
+ * tell which peer rows are "us". `peerId` is `signer.peerId().toString()` (the
31
+ * value that shows up as a peer id); `verifyingKey` is its hex Ed25519 key.
32
+ */
33
+ export interface SyncStateWhoAmIMessage {
34
+ type: "whoami";
35
+ peerId: string;
36
+ verifyingKey: string;
37
+ }
38
+
39
+ // What the worker broadcasts on SYNCSTATE_CHANNEL: only the *global* signals
40
+ // now. Per-document heads are addressed to subscribers over the control port
41
+ // instead (see SyncStateDocMessage) rather than fanned out to every tab.
42
+ export type SyncStateBroadcast =
43
+ | SyncStateConnectionMessage
44
+ | SyncStateWhoAmIMessage;
45
+
46
+ /**
47
+ * Tab → worker: please replay the current global sync signals (whoami +
48
+ * connection) so a freshly-opened tab can orient immediately. Per-document
49
+ * heads are no longer replayed here — a tab subscribes to the specific docs it
50
+ * cares about over its control port instead (see {@link SyncSubscribeMessage}).
51
+ */
52
+ export interface SyncStateRequestMessage {
53
+ type: "request";
54
+ /** @deprecated ignored — per-doc state is delivered via sync-sub now. */
55
+ documentId?: string;
56
+ }
57
+
58
+ // ── Per-tab sync-state subscription (over the SharedWorker control port) ──
59
+ //
60
+ // The broadcast SyncState* messages above are global (connection/whoami).
61
+ // Per-document heads, by contrast, are addressed: a tab subscribes its control
62
+ // port to just the documents it cares about and the worker pushes only those
63
+ // docs' heads back down that port. The worker drops a port's whole
64
+ // subscription set automatically when the port closes (the tab went away), so
65
+ // there's no reference counting or heartbeat to leak.
66
+
67
+ /** Tab → worker: start pushing me this document's heads (replays current state). */
68
+ export interface SyncSubscribeMessage {
69
+ type: "sync-sub";
70
+ documentId: string;
71
+ }
72
+
73
+ /** Tab → worker: stop pushing me this document's heads. */
74
+ export interface SyncUnsubscribeMessage {
75
+ type: "sync-unsub";
76
+ documentId: string;
77
+ }
78
+
79
+ /**
80
+ * Worker → tab (control port): a peer's heads for a subscribed document — the
81
+ * worker's own (keyed by its peerId) or a Subduction peer's (keyed by its
82
+ * verifying-key storageId). Same payload as the old broadcast remote-heads
83
+ * message, but delivered only to the tabs that asked for this document.
84
+ */
85
+ export interface SyncStateDocMessage {
86
+ type: "sync-state";
87
+ documentId: string;
88
+ storageId: string;
89
+ heads: string[];
90
+ timestamp: number;
91
+ }
92
+
16
93
  /**
17
94
  * The special URL to resolve, plus enough of the {@link Request} the service
18
95
  * worker is holding that the automerge worker can construct one that
@@ -121,4 +198,15 @@ export type SetupServiceWorkerResult = {
121
198
  ) => Promise<() => void>;
122
199
  /** Open a fresh repo sync port to the automerge worker (dev console). */
123
200
  getRepoChannel: () => MessagePort;
201
+ /**
202
+ * Watch one document's sync heads (this tab's own and each Subduction peer's,
203
+ * as the worker learns them). Calls `listener` on every update for that doc,
204
+ * replaying the current state on subscribe. Returns an unsubscribe function;
205
+ * the worker stops pushing the doc once the last local watcher drops it (and
206
+ * automatically if this tab goes away).
207
+ */
208
+ subscribeSyncState: (
209
+ documentId: string,
210
+ listener: (update: SyncStateDocMessage) => void
211
+ ) => () => void;
124
212
  };