@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/dist/site.js CHANGED
@@ -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";
@@ -59,6 +60,7 @@ export async function bootPatchworkSite(config) {
59
60
  log("workers ready");
60
61
  let hive;
61
62
  let repo;
63
+ let tabSignerIdentity;
62
64
  // If a Repo is already on `window` — an embedding context provided one before
63
65
  // this entry ran — reuse it and its keyhive instead of standing up a fresh
64
66
  // realm-local Repo, so we share the same documents and sync/keyhive context.
@@ -102,15 +104,26 @@ export async function bootPatchworkSite(config) {
102
104
  }
103
105
  else {
104
106
  log("creating repo");
107
+ // Pass an explicit signer (instead of the Repo's internal default) so we
108
+ // can expose tab signer identity on window.patchwork for dev inspection.
109
+ // The tab never connects via Subduction (no endpoints/adapters), so this
110
+ // id never goes on the wire.
111
+ const tabSigner = new MemorySigner();
105
112
  repo = new Repo({
106
113
  network: [new MessageChannelNetworkAdapter(workerPort)],
107
114
  storage: new IndexedDBWorkerStorageAdapter(),
115
+ signer: tabSigner,
108
116
  async sharePolicy(peerId) {
109
117
  return peerId.includes("automerge-worker");
110
118
  },
111
119
  enableRemoteHeadsGossiping: true,
112
120
  peerId: `${config.titleSuffix}-tab-${crypto.randomUUID()}`,
113
121
  });
122
+ tabSignerIdentity = {
123
+ peerId: tabSigner.peerId().toString(),
124
+ verifyingKey: tabSigner.verifyingKey().toHex(),
125
+ };
126
+ console.log("[patchwork] tab subduction identity:", tabSignerIdentity);
114
127
  log("repo created");
115
128
  }
116
129
  }
@@ -157,9 +170,11 @@ export async function bootPatchworkSite(config) {
157
170
  packages: moduleWatcher,
158
171
  plugins,
159
172
  accountDocHandle,
173
+ ...(tabSignerIdentity ? { signer: tabSignerIdentity } : {}),
160
174
  sw: {
161
175
  connectClassicSync: sw.connectClassicSync,
162
176
  subscribeToRepoChannel: sw.subscribeToRepoChannel,
177
+ subscribeSyncState: sw.subscribeSyncState,
163
178
  },
164
179
  };
165
180
  window.uncache = uncache;
@@ -296,10 +311,7 @@ function primeRootElement(rootElement, accountDocHandle) {
296
311
  const initialParams = new URLSearchParams(location.hash.slice(1));
297
312
  if (initialParams.has("frame")) {
298
313
  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;
314
+ const docUrl = docParamToUrl(initialParams.get("doc")) ?? accountDocHandle.url;
303
315
  rootElement.setAttribute("doc-url", docUrl);
304
316
  }
305
317
  else {
@@ -385,20 +397,55 @@ async function uncache(match) {
385
397
  }
386
398
  }
387
399
  }
400
+ // Keys whose values are automerge URLs — we keep the `:` (and any `#`/`|`
401
+ // heads) literal rather than percent-encoding them so links stay readable.
402
+ const RAW_HASH_KEYS = new Set(["doc", "package"]);
403
+ // Emit hash params in a stable order so re-serializing the same logical
404
+ // params yields a byte-identical string (avoids spurious `hashchange`).
405
+ const HASH_KEY_ORDER = ["doc", "package", "tool", "type", "title", "frame"];
406
+ function serializeHashParams(params) {
407
+ const emitted = new Set();
408
+ const parts = [];
409
+ const emit = (key) => {
410
+ if (emitted.has(key))
411
+ return;
412
+ const value = params.get(key);
413
+ if (!value)
414
+ return;
415
+ emitted.add(key);
416
+ parts.push(`${key}=${RAW_HASH_KEYS.has(key) ? value : encodeURIComponent(value)}`);
417
+ };
418
+ for (const key of HASH_KEY_ORDER)
419
+ emit(key);
420
+ for (const key of params.keys())
421
+ emit(key);
422
+ return parts.join("&");
423
+ }
424
+ /**
425
+ * Coerce a `doc=` hash param to a full automerge URL. Accepts a full URL
426
+ * (`automerge:<id>[#heads]`) or a bare document id for backwards
427
+ * compatibility with older links.
428
+ */
429
+ function docParamToUrl(docParam) {
430
+ if (!docParam)
431
+ return undefined;
432
+ if (isValidAutomergeUrl(docParam))
433
+ return docParam;
434
+ const documentId = docParam.replace(/^automerge:/, "");
435
+ if (isValidDocumentId(documentId)) {
436
+ return stringifyAutomergeUrl({ documentId: documentId });
437
+ }
438
+ return undefined;
439
+ }
388
440
  function installHashRouting(params) {
389
441
  const { rootElement, repo, accountDocHandle, moduleWatcher, titleSuffix } = params;
390
- rootElement.addEventListener("patchwork:no-tool", (event) => {
391
- moduleWatcher.loadSuggestedImportUrl(event.detail.url);
392
- });
393
442
  rootElement.addEventListener("patchwork:open-document", async (event) => {
394
443
  const params = new URLSearchParams(window.location.hash.slice(1));
395
444
  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");
445
+ // `doc` is now the full automerge URL (heads, if any, live in it). The
446
+ // in-use package is recorded separately in `package=` when a tool mounts.
447
+ params.delete("heads");
448
+ params.set("doc", url);
402
449
  if (toolId)
403
450
  params.set("tool", toolId);
404
451
  else
@@ -411,9 +458,9 @@ function installHashRouting(params) {
411
458
  params.set("type", type);
412
459
  else
413
460
  params.delete("type");
414
- window.location.hash = params.toString();
461
+ window.location.hash = serializeHashParams(params);
415
462
  try {
416
- const docHandle = await repo.find(stringifyAutomergeUrl({ documentId, heads }));
463
+ const docHandle = await repo.find(url);
417
464
  const doc = docHandle.doc();
418
465
  const docType = type || doc?.["@patchwork"]?.type;
419
466
  if (!docType)
@@ -431,6 +478,29 @@ function installHashRouting(params) {
431
478
  console.error("Failed to update document title", e);
432
479
  }
433
480
  });
481
+ // When a tool mounts we know the package actually rendering the top-level
482
+ // document, so record its (heads-pinned) importUrl in `package=`.
483
+ const recordInUsePackage = (event) => {
484
+ const detail = event.detail;
485
+ if (!detail || !("url" in detail) || !detail.importUrl)
486
+ return;
487
+ const params = new URLSearchParams(window.location.hash.slice(1));
488
+ const docUrl = docParamToUrl(params.get("doc"));
489
+ if (!docUrl)
490
+ return;
491
+ // Ignore nested branch / side-by-side views: only the top-level doc's
492
+ // package belongs in the hash.
493
+ if (parseAutomergeUrl(docUrl).documentId !==
494
+ parseAutomergeUrl(detail.url).documentId) {
495
+ return;
496
+ }
497
+ if (params.get("package") === detail.importUrl)
498
+ return;
499
+ params.set("package", detail.importUrl);
500
+ // Replace rather than push: recording the in-use package shouldn't add a
501
+ // back-button entry (and replaceState avoids a `hashchange` round-trip).
502
+ history.replaceState(null, "", `#${serializeHashParams(params)}`);
503
+ };
434
504
  let firstMount = true;
435
505
  const reveal = () => {
436
506
  if (!firstMount)
@@ -440,6 +510,7 @@ function installHashRouting(params) {
440
510
  hideLoadingAnimation();
441
511
  };
442
512
  rootElement.addEventListener("patchwork:mounted", (event) => {
513
+ recordInUsePackage(event);
443
514
  handleHashChange();
444
515
  if (event.target !== rootElement)
445
516
  return;
@@ -464,34 +535,34 @@ function installHashRouting(params) {
464
535
  }
465
536
  // Bare automerge URL in hash: /#automerge:<documentId>
466
537
  if (isValidAutomergeUrl(hash)) {
467
- const { documentId, heads } = parseAutomergeUrl(hash);
538
+ const url = hash;
468
539
  window.location.hash = "";
469
- openDocument(rootElement, stringifyAutomergeUrl({ documentId, heads }));
540
+ openDocument(rootElement, url);
470
541
  return;
471
542
  }
472
543
  const params = new URLSearchParams(hash);
473
- const documentId = params.get("doc")?.replace(/^automerge:/, "");
474
- const heads = params.get("heads")?.split("|");
544
+ const docUrl = docParamToUrl(params.get("doc"));
545
+ const packageUrl = params.get("package");
475
546
  const toolId = params.get("tool");
476
547
  const title = params.get("title");
477
548
  const type = params.get("type");
478
549
  const frame = params.get("frame");
550
+ // Load the package that produced this document so its tool is available
551
+ // even when it isn't in the user's module settings.
552
+ if (packageUrl) {
553
+ void moduleWatcher.loadModules([packageUrl]);
554
+ }
479
555
  if (frame) {
480
- const docUrl = params.get("doc")?.replace(/^automerge:/, "") ?? accountDocHandle.url;
556
+ const frameDocUrl = docUrl ?? accountDocHandle.url;
481
557
  if (rootElement.getAttribute("tool-id") !== frame ||
482
- rootElement.getAttribute("doc-url") !== docUrl) {
558
+ rootElement.getAttribute("doc-url") !== frameDocUrl) {
483
559
  rootElement.setAttribute("tool-id", frame);
484
- rootElement.setAttribute("doc-url", docUrl);
560
+ rootElement.setAttribute("doc-url", frameDocUrl);
485
561
  }
486
562
  }
487
- if (isValidDocumentId(documentId)) {
563
+ if (docUrl) {
488
564
  rootElement.dispatchEvent(new CustomEvent("patchwork:open-document", {
489
- detail: {
490
- url: stringifyAutomergeUrl({ documentId, heads }),
491
- toolId,
492
- title,
493
- type,
494
- },
565
+ detail: { url: docUrl, toolId, title, type },
495
566
  }));
496
567
  }
497
568
  };
package/dist/types.d.ts CHANGED
@@ -11,6 +11,62 @@ export declare const HANDOFF_CHANNEL = "@patchwork/handoff";
11
11
  * informed of sync progress without repo-to-repo gossiping.
12
12
  */
13
13
  export declare const SYNCSTATE_CHANNEL = "@patchwork/syncstate";
14
+ /**
15
+ * Worker → tabs: the worker's Subduction link to the sync server flipped.
16
+ * `serverPeerIds` are the directly-connected sync-server peer ids (their
17
+ * verifying keys), so a tab can tell which peer rows are *the server* and
18
+ * judge "synced" against them specifically.
19
+ */
20
+ export interface SyncStateConnectionMessage {
21
+ type: "connection";
22
+ connected: boolean;
23
+ serverPeerIds: string[];
24
+ }
25
+ /**
26
+ * Worker → tabs: the shared worker's own Subduction identity, so a tab can
27
+ * tell which peer rows are "us". `peerId` is `signer.peerId().toString()` (the
28
+ * value that shows up as a peer id); `verifyingKey` is its hex Ed25519 key.
29
+ */
30
+ export interface SyncStateWhoAmIMessage {
31
+ type: "whoami";
32
+ peerId: string;
33
+ verifyingKey: string;
34
+ }
35
+ export type SyncStateBroadcast = SyncStateConnectionMessage | SyncStateWhoAmIMessage;
36
+ /**
37
+ * Tab → worker: please replay the current global sync signals (whoami +
38
+ * connection) so a freshly-opened tab can orient immediately. Per-document
39
+ * heads are no longer replayed here — a tab subscribes to the specific docs it
40
+ * cares about over its control port instead (see {@link SyncSubscribeMessage}).
41
+ */
42
+ export interface SyncStateRequestMessage {
43
+ type: "request";
44
+ /** @deprecated ignored — per-doc state is delivered via sync-sub now. */
45
+ documentId?: string;
46
+ }
47
+ /** Tab → worker: start pushing me this document's heads (replays current state). */
48
+ export interface SyncSubscribeMessage {
49
+ type: "sync-sub";
50
+ documentId: string;
51
+ }
52
+ /** Tab → worker: stop pushing me this document's heads. */
53
+ export interface SyncUnsubscribeMessage {
54
+ type: "sync-unsub";
55
+ documentId: string;
56
+ }
57
+ /**
58
+ * Worker → tab (control port): a peer's heads for a subscribed document — the
59
+ * worker's own (keyed by its peerId) or a Subduction peer's (keyed by its
60
+ * verifying-key storageId). Same payload as the old broadcast remote-heads
61
+ * message, but delivered only to the tabs that asked for this document.
62
+ */
63
+ export interface SyncStateDocMessage {
64
+ type: "sync-state";
65
+ documentId: string;
66
+ storageId: string;
67
+ heads: string[];
68
+ timestamp: number;
69
+ }
14
70
  /**
15
71
  * The special URL to resolve, plus enough of the {@link Request} the service
16
72
  * worker is holding that the automerge worker can construct one that
@@ -106,4 +162,12 @@ export type SetupServiceWorkerResult = {
106
162
  subscribeToRepoChannel: (listener: ServiceWorkerRepoChannelListener) => Promise<() => void>;
107
163
  /** Open a fresh repo sync port to the automerge worker (dev console). */
108
164
  getRepoChannel: () => MessagePort;
165
+ /**
166
+ * Watch one document's sync heads (this tab's own and each Subduction peer's,
167
+ * as the worker learns them). Calls `listener` on every update for that doc,
168
+ * replaying the current state on subscribe. Returns an unsubscribe function;
169
+ * the worker stops pushing the doc once the last local watcher drops it (and
170
+ * automatically if this tab goes away).
171
+ */
172
+ subscribeSyncState: (documentId: string, listener: (update: SyncStateDocMessage) => void) => () => void;
109
173
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inkandswitch/patchwork-bootloader",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "author": "chee",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -57,10 +57,10 @@
57
57
  "resolve.exports": "^2.0.3",
58
58
  "service-worker-types": "npm:@types/serviceworker@^0.0.153",
59
59
  "tinyargs": "^0.1.4",
60
- "@inkandswitch/patchwork-filesystem": "^0.1.1",
61
60
  "@inkandswitch/patchwork-elements": "^2.0.0",
62
- "@inkandswitch/patchwork-providers": "^0.3.0",
63
- "@inkandswitch/patchwork-plugins": "^0.0.11"
61
+ "@inkandswitch/patchwork-filesystem": "^0.1.1",
62
+ "@inkandswitch/patchwork-plugins": "^0.0.11",
63
+ "@inkandswitch/patchwork-providers": "^0.3.0"
64
64
  },
65
65
  "peerDependencies": {
66
66
  "@automerge/automerge": "3.3.0-fragments.1",