@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/CHANGELOG.md +16 -0
- package/dist/automerge-worker.js +434 -15
- package/dist/externals.js +5 -0
- package/dist/service-worker.js +76 -29
- package/dist/setup.d.ts +4 -1
- package/dist/setup.js +109 -11
- package/dist/site.d.ts +6 -1
- package/dist/site.js +86 -40
- package/dist/types.d.ts +64 -0
- package/package.json +13 -13
- package/src/automerge-worker.ts +480 -15
- package/src/externals.ts +5 -0
- package/src/service-worker.ts +92 -32
- package/src/setup.ts +123 -10
- package/src/site.ts +104 -44
- package/src/types.ts +88 -0
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 {
|
|
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:
|
|
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
|
-
|
|
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
|
|
507
|
-
|
|
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,
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
|
698
|
+
window.location.hash = serializeHashParams(params);
|
|
636
699
|
|
|
637
700
|
try {
|
|
638
701
|
const docHandle = await repo.find<{ "@patchwork"?: { type?: string } }>(
|
|
639
|
-
|
|
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
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
|
761
|
+
const url = hash as AutomergeUrl;
|
|
695
762
|
window.location.hash = "";
|
|
696
|
-
openDocument(rootElement,
|
|
763
|
+
openDocument(rootElement, url);
|
|
697
764
|
return;
|
|
698
765
|
}
|
|
699
766
|
|
|
700
767
|
const params = new URLSearchParams(hash);
|
|
701
|
-
const
|
|
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") !==
|
|
777
|
+
rootElement.getAttribute("doc-url") !== frameDocUrl
|
|
713
778
|
) {
|
|
714
779
|
rootElement.setAttribute("tool-id", frame);
|
|
715
|
-
rootElement.setAttribute("doc-url",
|
|
780
|
+
rootElement.setAttribute("doc-url", frameDocUrl);
|
|
716
781
|
}
|
|
717
782
|
}
|
|
718
|
-
if (
|
|
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
|
};
|