@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/CHANGELOG.md +20 -0
- package/dist/automerge-worker.js +468 -9
- package/dist/externals.js +1 -2
- package/dist/module-loader-worker.d.ts +1 -0
- package/dist/module-loader-worker.js +55 -0
- package/dist/module-loader.d.ts +13 -0
- package/dist/module-loader.js +72 -0
- package/dist/service-worker.js +58 -6
- package/dist/setup.d.ts +6 -1
- package/dist/setup.js +177 -2
- package/dist/site.d.ts +7 -7
- package/dist/site.js +194 -71
- package/dist/types.d.ts +72 -0
- package/dist/types.js +6 -0
- package/dist/vite/service-worker-plugin.js +4 -0
- package/package.json +18 -14
- package/src/automerge-worker.ts +512 -9
- package/src/externals.ts +1 -2
- package/src/module-loader-worker.ts +68 -0
- package/src/module-loader.ts +85 -0
- package/src/service-worker.ts +78 -6
- package/src/setup.ts +194 -7
- package/src/site.ts +230 -92
- package/src/types.ts +97 -0
- package/src/vite/service-worker-plugin.ts +4 -0
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
|
|
70
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
243
|
-
|
|
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
|
|
451
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
|
695
|
+
window.location.hash = serializeHashParams(params);
|
|
580
696
|
|
|
581
697
|
try {
|
|
582
|
-
const docHandle =
|
|
583
|
-
|
|
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
|
|
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
|
|
649
|
-
const
|
|
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
|
|
798
|
+
const frameDocUrl = docUrl ?? accountDocHandle.url;
|
|
656
799
|
if (
|
|
657
800
|
rootElement.getAttribute("tool-id") !== frame ||
|
|
658
|
-
rootElement.getAttribute("doc-url") !==
|
|
801
|
+
rootElement.getAttribute("doc-url") !== frameDocUrl
|
|
659
802
|
) {
|
|
660
803
|
rootElement.setAttribute("tool-id", frame);
|
|
661
|
-
rootElement.setAttribute("doc-url",
|
|
804
|
+
rootElement.setAttribute("doc-url", frameDocUrl);
|
|
662
805
|
}
|
|
663
806
|
}
|
|
664
|
-
if (
|
|
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 {
|