@inkandswitch/patchwork-bootloader 0.3.1 → 0.3.2

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
@@ -22,7 +22,6 @@ import {
22
22
  stringifyAutomergeUrl,
23
23
  type AutomergeUrl,
24
24
  type DocumentId,
25
- type UrlHeads,
26
25
  } from "@automerge/vanillajs/slim";
27
26
  import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
28
27
  import * as Automerge from "@automerge/automerge/slim";
@@ -35,6 +34,7 @@ import {
35
34
  // eslint-disable-next-line
36
35
  // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
37
36
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
37
+ import { MemorySigner } from "@automerge/automerge-subduction/slim";
38
38
 
39
39
  declare const __SITE_NAME__: string;
40
40
  const siteName =
@@ -70,7 +70,10 @@ import setupServiceWorker, {
70
70
  getAutomergeWorker,
71
71
  lifecycleLoggingEnabled,
72
72
  } from "./setup.js";
73
- import type { ServiceWorkerRepoChannelListener } from "./types.js";
73
+ import type {
74
+ ServiceWorkerRepoChannelListener,
75
+ SyncStateDocMessage,
76
+ } from "./types.js";
74
77
  import debug from "debug";
75
78
  const log = debug("patchwork:bootloader:site");
76
79
 
@@ -87,11 +90,19 @@ declare global {
87
90
  packages: ModuleWatcher;
88
91
  plugins: typeof plugins;
89
92
  accountDocHandle: DocHandle<AccountDoc>;
93
+ signer?: {
94
+ peerId: string;
95
+ verifyingKey: string;
96
+ };
90
97
  sw: {
91
98
  connectClassicSync: (server?: string) => Promise<void>;
92
99
  subscribeToRepoChannel: (
93
100
  listener: ServiceWorkerRepoChannelListener
94
101
  ) => Promise<() => void>;
102
+ subscribeSyncState: (
103
+ documentId: string,
104
+ listener: (update: SyncStateDocMessage) => void
105
+ ) => () => void;
95
106
  };
96
107
  };
97
108
  uncache: (match: string) => Promise<void>;
@@ -196,6 +207,7 @@ export async function bootPatchworkSite(
196
207
 
197
208
  let hive: AutomergeRepoKeyhive | undefined;
198
209
  let repo: Repo;
210
+ let tabSignerIdentity: { peerId: string; verifyingKey: string } | undefined;
199
211
 
200
212
  // If a Repo is already on `window` — an embedding context provided one before
201
213
  // this entry ran — reuse it and its keyhive instead of standing up a fresh
@@ -240,9 +252,15 @@ export async function bootPatchworkSite(
240
252
  log("keyhive setup complete");
241
253
  } else {
242
254
  log("creating repo");
255
+ // Pass an explicit signer (instead of the Repo's internal default) so we
256
+ // can expose tab signer identity on window.patchwork for dev inspection.
257
+ // The tab never connects via Subduction (no endpoints/adapters), so this
258
+ // id never goes on the wire.
259
+ const tabSigner = new MemorySigner();
243
260
  repo = new Repo({
244
261
  network: [new MessageChannelNetworkAdapter(workerPort)],
245
262
  storage: new IndexedDBWorkerStorageAdapter(),
263
+ signer: tabSigner,
246
264
  async sharePolicy(peerId) {
247
265
  return peerId.includes("automerge-worker");
248
266
  },
@@ -250,6 +268,15 @@ export async function bootPatchworkSite(
250
268
  peerId:
251
269
  `${config.titleSuffix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId,
252
270
  });
271
+ tabSignerIdentity = {
272
+ peerId: tabSigner.peerId().toString(),
273
+ verifyingKey: (
274
+ tabSigner.verifyingKey() as Uint8Array<ArrayBufferLike> & {
275
+ toHex(): string;
276
+ }
277
+ ).toHex(),
278
+ };
279
+ console.log("[patchwork] tab subduction identity:", tabSignerIdentity);
253
280
  log("repo created");
254
281
  }
255
282
  }
@@ -315,9 +342,11 @@ export async function bootPatchworkSite(
315
342
  packages: moduleWatcher,
316
343
  plugins,
317
344
  accountDocHandle,
345
+ ...(tabSignerIdentity ? { signer: tabSignerIdentity } : {}),
318
346
  sw: {
319
347
  connectClassicSync: sw.connectClassicSync,
320
348
  subscribeToRepoChannel: sw.subscribeToRepoChannel,
349
+ subscribeSyncState: sw.subscribeSyncState,
321
350
  },
322
351
  };
323
352
  window.uncache = uncache;
@@ -503,10 +532,8 @@ function primeRootElement(
503
532
  const initialParams = new URLSearchParams(location.hash.slice(1));
504
533
  if (initialParams.has("frame")) {
505
534
  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;
535
+ const docUrl =
536
+ docParamToUrl(initialParams.get("doc")) ?? accountDocHandle.url;
510
537
  rootElement.setAttribute("doc-url", docUrl);
511
538
  } else {
512
539
  rootElement.setAttribute("tool-id", accountDocHandle.doc().frameToolId);
@@ -598,6 +625,43 @@ async function uncache(match: string): Promise<void> {
598
625
  }
599
626
  }
600
627
 
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"];
634
+
635
+ function serializeHashParams(params: URLSearchParams): string {
636
+ const emitted = new Set<string>();
637
+ const parts: string[] = [];
638
+ const emit = (key: string) => {
639
+ if (emitted.has(key)) return;
640
+ const value = params.get(key);
641
+ if (!value) return;
642
+ emitted.add(key);
643
+ parts.push(`${key}=${RAW_HASH_KEYS.has(key) ? value : encodeURIComponent(value)}`);
644
+ };
645
+ for (const key of HASH_KEY_ORDER) emit(key);
646
+ for (const key of params.keys()) emit(key);
647
+ return parts.join("&");
648
+ }
649
+
650
+ /**
651
+ * 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.
654
+ */
655
+ function docParamToUrl(docParam: string | null): AutomergeUrl | undefined {
656
+ if (!docParam) return undefined;
657
+ if (isValidAutomergeUrl(docParam as AutomergeUrl)) return docParam as AutomergeUrl;
658
+ const documentId = docParam.replace(/^automerge:/, "");
659
+ if (isValidDocumentId(documentId)) {
660
+ return stringifyAutomergeUrl({ documentId: documentId as DocumentId });
661
+ }
662
+ return undefined;
663
+ }
664
+
601
665
  interface HashRoutingParams {
602
666
  rootElement: HTMLElement;
603
667
  repo: Repo;
@@ -610,10 +674,6 @@ function installHashRouting(params: HashRoutingParams): void {
610
674
  const { rootElement, repo, accountDocHandle, moduleWatcher, titleSuffix } =
611
675
  params;
612
676
 
613
- rootElement.addEventListener("patchwork:no-tool", (event) => {
614
- moduleWatcher.loadSuggestedImportUrl(event.detail.url);
615
- });
616
-
617
677
  rootElement.addEventListener("patchwork:open-document", async (event) => {
618
678
  const params = new URLSearchParams(window.location.hash.slice(1));
619
679
  const { url, toolId, type, title } = event.detail as {
@@ -622,22 +682,21 @@ function installHashRouting(params: HashRoutingParams): void {
622
682
  type?: string;
623
683
  title?: string;
624
684
  };
625
- const { documentId, heads } = parseAutomergeUrl(url);
626
- params.set("doc", documentId);
627
- if (heads) params.set("heads", heads.join("|"));
628
- else params.delete("heads");
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.
687
+ params.delete("heads");
688
+ params.set("doc", url);
629
689
  if (toolId) params.set("tool", toolId);
630
690
  else params.delete("tool");
631
691
  if (title) params.set("title", title);
632
692
  else params.delete("title");
633
693
  if (type) params.set("type", type);
634
694
  else params.delete("type");
635
- window.location.hash = params.toString();
695
+ window.location.hash = serializeHashParams(params);
636
696
 
637
697
  try {
638
- const docHandle = await repo.find<{ "@patchwork"?: { type?: string } }>(
639
- stringifyAutomergeUrl({ documentId, heads })
640
- );
698
+ const docHandle =
699
+ await repo.find<{ "@patchwork"?: { type?: string } }>(url);
641
700
  const doc = docHandle.doc();
642
701
  const docType = type || doc?.["@patchwork"]?.type;
643
702
  if (!docType) return;
@@ -655,6 +714,29 @@ function installHashRouting(params: HashRoutingParams): void {
655
714
  }
656
715
  });
657
716
 
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
+
658
740
  let firstMount = true;
659
741
  const reveal = () => {
660
742
  if (!firstMount) return;
@@ -664,6 +746,7 @@ function installHashRouting(params: HashRoutingParams): void {
664
746
  };
665
747
 
666
748
  rootElement.addEventListener("patchwork:mounted", (event) => {
749
+ recordInUsePackage(event);
667
750
  handleHashChange();
668
751
  if (event.target !== rootElement) return;
669
752
  console.info("root element mounted");
@@ -691,39 +774,40 @@ function installHashRouting(params: HashRoutingParams): void {
691
774
 
692
775
  // Bare automerge URL in hash: /#automerge:<documentId>
693
776
  if (isValidAutomergeUrl(hash as AutomergeUrl)) {
694
- const { documentId, heads } = parseAutomergeUrl(hash as AutomergeUrl);
777
+ const url = hash as AutomergeUrl;
695
778
  window.location.hash = "";
696
- openDocument(rootElement, stringifyAutomergeUrl({ documentId, heads }));
779
+ openDocument(rootElement, url);
697
780
  return;
698
781
  }
699
782
 
700
783
  const params = new URLSearchParams(hash);
701
- const documentId = params.get("doc")?.replace(/^automerge:/, "");
702
- const heads = params.get("heads")?.split("|") as UrlHeads | undefined;
784
+ const docUrl = docParamToUrl(params.get("doc"));
785
+ const packageUrl = params.get("package");
703
786
  const toolId = params.get("tool");
704
787
  const title = params.get("title");
705
788
  const type = params.get("type");
706
789
  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
+
707
797
  if (frame) {
708
- const docUrl =
709
- params.get("doc")?.replace(/^automerge:/, "") ?? accountDocHandle.url;
798
+ const frameDocUrl = docUrl ?? accountDocHandle.url;
710
799
  if (
711
800
  rootElement.getAttribute("tool-id") !== frame ||
712
- rootElement.getAttribute("doc-url") !== docUrl
801
+ rootElement.getAttribute("doc-url") !== frameDocUrl
713
802
  ) {
714
803
  rootElement.setAttribute("tool-id", frame);
715
- rootElement.setAttribute("doc-url", docUrl);
804
+ rootElement.setAttribute("doc-url", frameDocUrl);
716
805
  }
717
806
  }
718
- if (isValidDocumentId(documentId)) {
807
+ if (docUrl) {
719
808
  rootElement.dispatchEvent(
720
809
  new CustomEvent("patchwork:open-document", {
721
- detail: {
722
- url: stringifyAutomergeUrl({ documentId, heads }),
723
- toolId,
724
- title,
725
- type,
726
- },
810
+ detail: { url: docUrl, toolId, title, type },
727
811
  })
728
812
  );
729
813
  }
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
  };