@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/CHANGELOG.md +6 -0
- package/dist/automerge-worker.js +344 -1
- package/dist/setup.d.ts +4 -1
- package/dist/setup.js +43 -0
- package/dist/site.d.ts +6 -1
- package/dist/site.js +100 -29
- package/dist/types.d.ts +64 -0
- package/package.json +4 -4
- package/src/automerge-worker.ts +377 -0
- package/src/setup.ts +53 -0
- package/src/site.ts +117 -33
- package/src/types.ts +88 -0
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 {
|
|
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
|
|
507
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
|
695
|
+
window.location.hash = serializeHashParams(params);
|
|
636
696
|
|
|
637
697
|
try {
|
|
638
|
-
const docHandle =
|
|
639
|
-
|
|
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
|
|
777
|
+
const url = hash as AutomergeUrl;
|
|
695
778
|
window.location.hash = "";
|
|
696
|
-
openDocument(rootElement,
|
|
779
|
+
openDocument(rootElement, url);
|
|
697
780
|
return;
|
|
698
781
|
}
|
|
699
782
|
|
|
700
783
|
const params = new URLSearchParams(hash);
|
|
701
|
-
const
|
|
702
|
-
const
|
|
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") !==
|
|
801
|
+
rootElement.getAttribute("doc-url") !== frameDocUrl
|
|
713
802
|
) {
|
|
714
803
|
rootElement.setAttribute("tool-id", frame);
|
|
715
|
-
rootElement.setAttribute("doc-url",
|
|
804
|
+
rootElement.setAttribute("doc-url", frameDocUrl);
|
|
716
805
|
}
|
|
717
806
|
}
|
|
718
|
-
if (
|
|
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
|
};
|