@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/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
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
|
461
|
+
window.location.hash = serializeHashParams(params);
|
|
415
462
|
try {
|
|
416
|
-
const docHandle = await repo.find(
|
|
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
|
|
538
|
+
const url = hash;
|
|
468
539
|
window.location.hash = "";
|
|
469
|
-
openDocument(rootElement,
|
|
540
|
+
openDocument(rootElement, url);
|
|
470
541
|
return;
|
|
471
542
|
}
|
|
472
543
|
const params = new URLSearchParams(hash);
|
|
473
|
-
const
|
|
474
|
-
const
|
|
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
|
|
556
|
+
const frameDocUrl = docUrl ?? accountDocHandle.url;
|
|
481
557
|
if (rootElement.getAttribute("tool-id") !== frame ||
|
|
482
|
-
rootElement.getAttribute("doc-url") !==
|
|
558
|
+
rootElement.getAttribute("doc-url") !== frameDocUrl) {
|
|
483
559
|
rootElement.setAttribute("tool-id", frame);
|
|
484
|
-
rootElement.setAttribute("doc-url",
|
|
560
|
+
rootElement.setAttribute("doc-url", frameDocUrl);
|
|
485
561
|
}
|
|
486
562
|
}
|
|
487
|
-
if (
|
|
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.
|
|
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-
|
|
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",
|