@inkandswitch/patchwork-bootloader 0.3.0 → 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
@@ -13,7 +13,6 @@
13
13
  */
14
14
  import {
15
15
  type DocHandle,
16
- IndexedDBStorageAdapter,
17
16
  initializeWasm,
18
17
  isValidAutomergeUrl,
19
18
  isValidDocumentId,
@@ -23,9 +22,8 @@ import {
23
22
  stringifyAutomergeUrl,
24
23
  type AutomergeUrl,
25
24
  type DocumentId,
26
- type StorageId,
27
- type UrlHeads,
28
25
  } from "@automerge/vanillajs/slim";
26
+ import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
29
27
  import * as Automerge from "@automerge/automerge/slim";
30
28
  import * as AutomergeRepo from "@automerge/automerge-repo/slim";
31
29
  import {
@@ -36,6 +34,7 @@ import {
36
34
  // eslint-disable-next-line
37
35
  // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
38
36
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
37
+ import { MemorySigner } from "@automerge/automerge-subduction/slim";
39
38
 
40
39
  declare const __SITE_NAME__: string;
41
40
  const siteName =
@@ -50,6 +49,7 @@ const useKeyhiveSyncServer =
50
49
  typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
51
50
 
52
51
  import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
52
+ import { importAutomergeModuleViaWorker } from "./module-loader.js";
53
53
  import {
54
54
  openDocument,
55
55
  registerPatchworkViewElement,
@@ -66,8 +66,14 @@ import {
66
66
  } from "@inkandswitch/patchwork-plugins";
67
67
  import * as plugins from "@inkandswitch/patchwork-plugins";
68
68
 
69
- import setupServiceWorker from "./setup.js";
70
- import type { ServiceWorkerRepoChannelListener } from "./types.js";
69
+ import setupServiceWorker, {
70
+ getAutomergeWorker,
71
+ lifecycleLoggingEnabled,
72
+ } from "./setup.js";
73
+ import type {
74
+ ServiceWorkerRepoChannelListener,
75
+ SyncStateDocMessage,
76
+ } from "./types.js";
71
77
  import debug from "debug";
72
78
  const log = debug("patchwork:bootloader:site");
73
79
 
@@ -84,11 +90,19 @@ declare global {
84
90
  packages: ModuleWatcher;
85
91
  plugins: typeof plugins;
86
92
  accountDocHandle: DocHandle<AccountDoc>;
93
+ signer?: {
94
+ peerId: string;
95
+ verifyingKey: string;
96
+ };
87
97
  sw: {
88
98
  connectClassicSync: (server?: string) => Promise<void>;
89
99
  subscribeToRepoChannel: (
90
100
  listener: ServiceWorkerRepoChannelListener
91
101
  ) => Promise<() => void>;
102
+ subscribeSyncState: (
103
+ documentId: string,
104
+ listener: (update: SyncStateDocMessage) => void
105
+ ) => () => void;
92
106
  };
93
107
  };
94
108
  uncache: (match: string) => Promise<void>;
@@ -144,12 +158,6 @@ export interface SiteConfig {
144
158
  */
145
159
  rootElementId?: string;
146
160
 
147
- /**
148
- * Storage IDs to subscribe to for remote-heads gossiping. Defaults to
149
- * Ink & Switch's production Subduction storage.
150
- */
151
- remoteStorageIds?: StorageId[];
152
-
153
161
  /**
154
162
  * When true, initialize keyhive for access control.
155
163
  * The Repo will use keyhive's network adapter, peerId, and idFactory
@@ -164,9 +172,6 @@ export interface BootResult {
164
172
  accountDocHandle: DocHandle<AccountDoc>;
165
173
  }
166
174
 
167
- const DEFAULT_REMOTE_STORAGE_ID =
168
- "3760df37-a4c6-4f66-9ecd-732039a9385d" as StorageId;
169
-
170
175
  // Legacy big-patchwork hash shape: `slug--<documentId>[?=type]`.
171
176
  const BIG_PATCHWORK_HASH_REGEX =
172
177
  /(?<title>[A-Za-z0-9-]+)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)(?<type>\?=[^&?]+)?/;
@@ -191,59 +196,97 @@ export async function bootPatchworkSite(
191
196
  const defaultModuleSources = resolveDefaultModules(config);
192
197
  showLoadingAnimation();
193
198
  log(`booting`, config);
199
+ installLifecycleLogging();
194
200
  await initializeWasm(automergeWasm);
195
201
  initSubductionSync(subductionWasm);
196
202
 
203
+ log("enabling workers");
197
204
  const sw = await setupServiceWorker();
198
205
  if (!sw) throw new Error("Failed to set up service worker");
206
+ log("workers ready");
199
207
 
200
208
  let hive: AutomergeRepoKeyhive | undefined;
201
- // Get the initial automerge-worker port via subscribeToRepoChannel,
202
- // then pass it to keyhive init which wraps it in its own network adapter.
203
- let resolvePort!: (port: MessagePort) => void;
204
- const portPromise = new Promise<MessagePort>((r) => {
205
- resolvePort = r;
206
- });
207
- await sw.subscribeToRepoChannel(resolvePort);
208
- const workerPort = await portPromise;
209
-
210
209
  let repo: Repo;
211
- if (config.keyhive) {
212
- initKeyhiveWasm();
213
-
214
- ({ hive, repo } = await initializeAutomergeRepoKeyhiveWithRepo({
215
- createRepo: (config) => new Repo(config),
216
- storage: new IndexedDBStorageAdapter(`${siteName}-keyhive`),
217
- peerIdSuffix: siteName + Math.random().toString(36).slice(2),
218
- networkAdapter: new MessageChannelNetworkAdapter(workerPort),
219
- automaticArchiveIngestion: true,
220
- cachingMode: "periodic",
221
- onlyShareWithHardcodedServerPeerId: false,
222
- // ARK selects the relay via `syncServer` ("keyhive" | "subduction").
223
- // Defaults to "subduction".
224
- ...(useKeyhiveSyncServer ? { syncServer: "keyhive" as const } : {}),
225
- repo: {
226
- storage: new IndexedDBStorageAdapter(),
227
- enableRemoteHeadsGossiping: true,
228
- },
229
- }));
210
+ let tabSignerIdentity: { peerId: string; verifyingKey: string } | undefined;
211
+
212
+ // If a Repo is already on `window` — an embedding context provided one before
213
+ // this entry ran reuse it and its keyhive instead of standing up a fresh
214
+ // realm-local Repo, so we share the same documents and sync/keyhive context.
215
+ // Otherwise create our own below.
216
+ if (window.repo) {
217
+ log("using existing Repo from window");
218
+ repo = window.repo;
219
+ hive = window.hive;
230
220
  } else {
231
- repo = new Repo({
232
- network: [new MessageChannelNetworkAdapter(workerPort)],
233
- storage: new IndexedDBStorageAdapter(),
234
- async sharePolicy(peerId) {
235
- return peerId.includes("automerge-worker");
236
- },
237
- enableRemoteHeadsGossiping: true,
238
- peerId:
239
- `${config.titleSuffix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId,
221
+ // Get the initial automerge-worker port via subscribeToRepoChannel,
222
+ // then pass it to keyhive init which wraps it in its own network adapter.
223
+ let resolvePort!: (port: MessagePort) => void;
224
+ const portPromise = new Promise<MessagePort>((r) => {
225
+ resolvePort = r;
240
226
  });
227
+ log("subscribing to repo channel");
228
+ await sw.subscribeToRepoChannel(resolvePort);
229
+ log("repo channel subscribed");
230
+ const workerPort = await portPromise;
231
+
232
+ if (config.keyhive) {
233
+ log("setting up keyhive");
234
+ initKeyhiveWasm();
235
+
236
+ ({ hive, repo } = await initializeAutomergeRepoKeyhiveWithRepo({
237
+ createRepo: (config) => new Repo(config),
238
+ storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
239
+ peerIdSuffix: siteName + Math.random().toString(36).slice(2),
240
+ networkAdapter: new MessageChannelNetworkAdapter(workerPort),
241
+ automaticArchiveIngestion: true,
242
+ cachingMode: "periodic",
243
+ onlyShareWithHardcodedServerPeerId: false,
244
+ // ARK selects the relay via `syncServer` ("keyhive" | "subduction").
245
+ // Defaults to "subduction".
246
+ ...(useKeyhiveSyncServer ? { syncServer: "keyhive" as const } : {}),
247
+ repo: {
248
+ storage: new IndexedDBWorkerStorageAdapter(),
249
+ enableRemoteHeadsGossiping: true,
250
+ },
251
+ }));
252
+ log("keyhive setup complete");
253
+ } else {
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();
260
+ repo = new Repo({
261
+ network: [new MessageChannelNetworkAdapter(workerPort)],
262
+ storage: new IndexedDBWorkerStorageAdapter(),
263
+ signer: tabSigner,
264
+ async sharePolicy(peerId) {
265
+ return peerId.includes("automerge-worker");
266
+ },
267
+ enableRemoteHeadsGossiping: true,
268
+ peerId:
269
+ `${config.titleSuffix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId,
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);
280
+ log("repo created");
281
+ }
241
282
  }
242
- repo.subscribeToRemotes(
243
- config.remoteStorageIds ?? [DEFAULT_REMOTE_STORAGE_ID]
244
- );
283
+ log("popping repo on window");
284
+ window.repo = repo;
285
+ log("await repo.networkSubsystem.whenReady()");
245
286
 
246
287
  await repo.networkSubsystem.whenReady();
288
+ log("networkSubsystem ready");
289
+
247
290
  if (hive) {
248
291
  (hive.networkAdapter as any).syncKeyhive?.();
249
292
  }
@@ -274,7 +317,10 @@ export async function bootPatchworkSite(
274
317
  repo,
275
318
  buildSystemSources(defaultModuleSources),
276
319
  onModuleLoaded,
277
- unregisterPlugins
320
+ unregisterPlugins,
321
+ // Discover an Automerge package's plugin descriptors off the main thread;
322
+ // each plugin's load() re-imports the package (at heads) on this thread.
323
+ importAutomergeModuleViaWorker
278
324
  );
279
325
 
280
326
  const accountDocHandle = (await resolveAccountHandle(repo, {
@@ -296,9 +342,11 @@ export async function bootPatchworkSite(
296
342
  packages: moduleWatcher,
297
343
  plugins,
298
344
  accountDocHandle,
345
+ ...(tabSignerIdentity ? { signer: tabSignerIdentity } : {}),
299
346
  sw: {
300
347
  connectClassicSync: sw.connectClassicSync,
301
348
  subscribeToRepoChannel: sw.subscribeToRepoChannel,
349
+ subscribeSyncState: sw.subscribeSyncState,
302
350
  },
303
351
  };
304
352
  window.uncache = uncache;
@@ -336,10 +384,7 @@ function isValidModuleSource(source: string): boolean {
336
384
  * built-in default bundle).
337
385
  */
338
386
  function resolveDefaultModules(config: SiteConfig): string[] {
339
- const builtin =
340
- config.defaultModules ??
341
- config.defaultModulesUrl ??
342
- [];
387
+ const builtin = config.defaultModules ?? config.defaultModulesUrl ?? [];
343
388
  const builtinList = (Array.isArray(builtin) ? builtin : [builtin]).filter(
344
389
  Boolean
345
390
  );
@@ -396,6 +441,46 @@ function installDevConsoleGlobals(
396
441
  window.getRepoChannel = getRepoChannel;
397
442
  }
398
443
 
444
+ /**
445
+ * Log this tab's Page Lifecycle + connectivity transitions (visibility,
446
+ * freeze, bfcache, online/offline) so they line up against the SharedWorker's
447
+ * sync-socket reaps. [lifecycle]-tagged, on by default.
448
+ */
449
+ function installLifecycleLogging(): void {
450
+ if (typeof document === "undefined") return;
451
+ const opts = { capture: true } as const;
452
+ const note = (label: string, extra?: unknown) => {
453
+ if (!lifecycleLoggingEnabled()) return;
454
+ const msg = `[lifecycle] ${new Date().toISOString()} ${label}`;
455
+ if (extra === undefined) console.info(msg);
456
+ else console.info(msg, extra);
457
+ };
458
+
459
+ document.addEventListener(
460
+ "visibilitychange",
461
+ () => note(`visibilitychange → ${document.visibilityState}`),
462
+ opts
463
+ );
464
+ document.addEventListener("freeze", () => note("freeze (tab suspended)"), opts);
465
+ document.addEventListener("resume", () => note("resume (tab unsuspended)"), opts);
466
+ window.addEventListener(
467
+ "pageshow",
468
+ e => note("pageshow", { persisted: (e as PageTransitionEvent).persisted }),
469
+ opts
470
+ );
471
+ window.addEventListener(
472
+ "pagehide",
473
+ e => note("pagehide", { persisted: (e as PageTransitionEvent).persisted }),
474
+ opts
475
+ );
476
+ window.addEventListener("online", () => note("online"), opts);
477
+ window.addEventListener("offline", () => note("offline"), opts);
478
+
479
+ note(
480
+ `lifecycle logging installed (visibilityState=${document.visibilityState}, hasFocus=${document.hasFocus()})`
481
+ );
482
+ }
483
+
399
484
  function onModuleLoaded(name: string, mod: any): void {
400
485
  if (Array.isArray(mod.plugins)) {
401
486
  log(
@@ -447,10 +532,8 @@ function primeRootElement(
447
532
  const initialParams = new URLSearchParams(location.hash.slice(1));
448
533
  if (initialParams.has("frame")) {
449
534
  rootElement.setAttribute("tool-id", initialParams.get("frame")!);
450
- const docId = initialParams.get("doc")?.replace(/^automerge:/, "");
451
- const docUrl = docId
452
- ? stringifyAutomergeUrl({ documentId: docId as DocumentId })
453
- : accountDocHandle.url;
535
+ const docUrl =
536
+ docParamToUrl(initialParams.get("doc")) ?? accountDocHandle.url;
454
537
  rootElement.setAttribute("doc-url", docUrl);
455
538
  } else {
456
539
  rootElement.setAttribute("tool-id", accountDocHandle.doc().frameToolId);
@@ -542,6 +625,43 @@ async function uncache(match: string): Promise<void> {
542
625
  }
543
626
  }
544
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
+
545
665
  interface HashRoutingParams {
546
666
  rootElement: HTMLElement;
547
667
  repo: Repo;
@@ -554,10 +674,6 @@ function installHashRouting(params: HashRoutingParams): void {
554
674
  const { rootElement, repo, accountDocHandle, moduleWatcher, titleSuffix } =
555
675
  params;
556
676
 
557
- rootElement.addEventListener("patchwork:no-tool", (event) => {
558
- moduleWatcher.loadSuggestedImportUrl(event.detail.url);
559
- });
560
-
561
677
  rootElement.addEventListener("patchwork:open-document", async (event) => {
562
678
  const params = new URLSearchParams(window.location.hash.slice(1));
563
679
  const { url, toolId, type, title } = event.detail as {
@@ -566,22 +682,21 @@ function installHashRouting(params: HashRoutingParams): void {
566
682
  type?: string;
567
683
  title?: string;
568
684
  };
569
- const { documentId, heads } = parseAutomergeUrl(url);
570
- params.set("doc", documentId);
571
- if (heads) params.set("heads", heads.join("|"));
572
- 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);
573
689
  if (toolId) params.set("tool", toolId);
574
690
  else params.delete("tool");
575
691
  if (title) params.set("title", title);
576
692
  else params.delete("title");
577
693
  if (type) params.set("type", type);
578
694
  else params.delete("type");
579
- window.location.hash = params.toString();
695
+ window.location.hash = serializeHashParams(params);
580
696
 
581
697
  try {
582
- const docHandle = await repo.find<{ "@patchwork"?: { type?: string } }>(
583
- stringifyAutomergeUrl({ documentId, heads })
584
- );
698
+ const docHandle =
699
+ await repo.find<{ "@patchwork"?: { type?: string } }>(url);
585
700
  const doc = docHandle.doc();
586
701
  const docType = type || doc?.["@patchwork"]?.type;
587
702
  if (!docType) return;
@@ -599,6 +714,29 @@ function installHashRouting(params: HashRoutingParams): void {
599
714
  }
600
715
  });
601
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
+
602
740
  let firstMount = true;
603
741
  const reveal = () => {
604
742
  if (!firstMount) return;
@@ -608,6 +746,7 @@ function installHashRouting(params: HashRoutingParams): void {
608
746
  };
609
747
 
610
748
  rootElement.addEventListener("patchwork:mounted", (event) => {
749
+ recordInUsePackage(event);
611
750
  handleHashChange();
612
751
  if (event.target !== rootElement) return;
613
752
  console.info("root element mounted");
@@ -635,41 +774,40 @@ function installHashRouting(params: HashRoutingParams): void {
635
774
 
636
775
  // Bare automerge URL in hash: /#automerge:<documentId>
637
776
  if (isValidAutomergeUrl(hash as AutomergeUrl)) {
638
- const { documentId, heads } = parseAutomergeUrl(hash as AutomergeUrl);
777
+ const url = hash as AutomergeUrl;
639
778
  window.location.hash = "";
640
- openDocument(
641
- rootElement,
642
- stringifyAutomergeUrl({ documentId, heads })
643
- );
779
+ openDocument(rootElement, url);
644
780
  return;
645
781
  }
646
782
 
647
783
  const params = new URLSearchParams(hash);
648
- const documentId = params.get("doc")?.replace(/^automerge:/, "");
649
- const heads = params.get("heads")?.split("|") as UrlHeads | undefined;
784
+ const docUrl = docParamToUrl(params.get("doc"));
785
+ const packageUrl = params.get("package");
650
786
  const toolId = params.get("tool");
651
787
  const title = params.get("title");
652
788
  const type = params.get("type");
653
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
+
654
797
  if (frame) {
655
- const docUrl = params.get("doc")?.replace(/^automerge:/, "") ?? accountDocHandle.url;
798
+ const frameDocUrl = docUrl ?? accountDocHandle.url;
656
799
  if (
657
800
  rootElement.getAttribute("tool-id") !== frame ||
658
- rootElement.getAttribute("doc-url") !== docUrl
801
+ rootElement.getAttribute("doc-url") !== frameDocUrl
659
802
  ) {
660
803
  rootElement.setAttribute("tool-id", frame);
661
- rootElement.setAttribute("doc-url", docUrl);
804
+ rootElement.setAttribute("doc-url", frameDocUrl);
662
805
  }
663
806
  }
664
- if (isValidDocumentId(documentId)) {
807
+ if (docUrl) {
665
808
  rootElement.dispatchEvent(
666
809
  new CustomEvent("patchwork:open-document", {
667
- detail: {
668
- url: stringifyAutomergeUrl({ documentId, heads }),
669
- toolId,
670
- title,
671
- type,
672
- },
810
+ detail: { url: docUrl, toolId, title, type },
673
811
  })
674
812
  );
675
813
  }
package/src/types.ts CHANGED
@@ -6,6 +6,90 @@
6
6
  */
7
7
  export const HANDOFF_CHANNEL = "@patchwork/handoff";
8
8
 
9
+ /**
10
+ * BroadcastChannel on which the automerge shared worker announces remote
11
+ * heads it learns about from the sync server. Any tab can listen to stay
12
+ * informed of sync progress without repo-to-repo gossiping.
13
+ */
14
+ export const SYNCSTATE_CHANNEL = "@patchwork/syncstate";
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
+
9
93
  /**
10
94
  * The special URL to resolve, plus enough of the {@link Request} the service
11
95
  * worker is holding that the automerge worker can construct one that
@@ -105,6 +189,8 @@ export type ServiceWorkerRepoChannelListener = (
105
189
  ) => void | Promise<void>;
106
190
 
107
191
  export type SetupServiceWorkerResult = {
192
+ shared?: SharedWorker;
193
+ kill?: () => void;
108
194
  /** Open a classic Automerge sync WebSocket from the automerge worker. */
109
195
  connectClassicSync: (server?: string) => Promise<void>;
110
196
  subscribeToRepoChannel: (
@@ -112,4 +198,15 @@ export type SetupServiceWorkerResult = {
112
198
  ) => Promise<() => void>;
113
199
  /** Open a fresh repo sync port to the automerge worker (dev console). */
114
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;
115
212
  };
@@ -14,6 +14,10 @@ const workers = [
14
14
  specifier: "@inkandswitch/patchwork-bootloader/automerge-worker",
15
15
  fileName: "automerge-worker.js",
16
16
  },
17
+ {
18
+ specifier: "@inkandswitch/patchwork-bootloader/module-loader-worker",
19
+ fileName: "module-loader-worker.js",
20
+ },
17
21
  ];
18
22
 
19
23
  export function serviceworker(): Plugin {