@inkandswitch/patchwork-bootloader 0.4.4 → 0.5.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/src/site.ts DELETED
@@ -1,797 +0,0 @@
1
- /**
2
- * Browser-app boot sequence for a Patchwork site.
3
- *
4
- * Layers on top of {@link setupServiceWorker} to construct the Repo, wire up
5
- * the automerge-worker port, load plugins via the ModuleWatcher, resolve the
6
- * user's account document, and hand control to the configured root tool.
7
- *
8
- * Pulls in DOM- and plugin-layer dependencies, so it is for a browser site's
9
- * `main.ts` only. Non-UI consumers should import the package default, which
10
- * does SW registration and the automerge-worker handoff and nothing else.
11
- */
12
- import {
13
- type DocHandle,
14
- initializeWasm,
15
- isValidAutomergeUrl,
16
- isValidDocumentId,
17
- MessageChannelNetworkAdapter,
18
- Repo,
19
- stringifyAutomergeUrl,
20
- type AutomergeUrl,
21
- type DocumentId,
22
- } from "@automerge/vanillajs/slim";
23
- import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
24
- import * as Automerge from "@automerge/automerge/slim";
25
- import * as AutomergeRepo from "@automerge/automerge-repo/slim";
26
- import {
27
- initKeyhiveWasm,
28
- initializeAutomergeRepoKeyhiveWithRepo,
29
- type AutomergeRepoKeyhive,
30
- } from "@automerge/automerge-repo-keyhive";
31
- // eslint-disable-next-line
32
- // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
33
- import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
34
- import { MemorySigner } from "@automerge/automerge-subduction/slim";
35
-
36
- import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
37
- import { importAutomergePackageViaWorker } from "./module-loader.js";
38
- import {
39
- openDocument,
40
- registerPatchworkViewElement,
41
- } from "@inkandswitch/patchwork-elements";
42
- import { registerRepoProviderElement } from "@inkandswitch/patchwork-providers";
43
- import {
44
- type AccountDoc,
45
- type DatatypeDescription,
46
- type DatatypeImplementation,
47
- getRegistry,
48
- registerPlugins,
49
- resolveAccountHandle,
50
- unregisterPlugins,
51
- } from "@inkandswitch/patchwork-plugins";
52
- import * as plugins from "@inkandswitch/patchwork-plugins";
53
-
54
- import setupServiceWorker, { lifecycleLog } from "./setup.js";
55
- import type {
56
- ServiceWorkerRepoChannelListener,
57
- SyncStateDocMessage,
58
- } from "./types.js";
59
- import debug from "debug";
60
-
61
- const log = debug("patchwork:bootloader:site");
62
-
63
- declare const __SITE_NAME__: string;
64
- const siteName =
65
- typeof __SITE_NAME__ !== "undefined"
66
- ? __SITE_NAME__
67
- : "patchwork.inkandswitch.com";
68
-
69
- // Must match the automerge-worker's selection, or the tab and the SW grant
70
- // relay access to different servers.
71
- declare const __KEYHIVE_SYNC_SERVER__: boolean;
72
- const useKeyhiveSyncServer =
73
- typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
74
-
75
- type SignerIdentity = { peerId: string; verifyingKey: string };
76
-
77
- declare global {
78
- interface Window {
79
- accountDocHandle: DocHandle<AccountDoc>;
80
- Automerge: typeof import("@automerge/automerge");
81
- AutomergeRepo: typeof import("@automerge/automerge-repo");
82
- repo: Repo;
83
- hive?: AutomergeRepoKeyhive;
84
- getRepoChannel: () => MessagePort;
85
- patchwork: {
86
- repo: Repo;
87
- packages: ModuleWatcher;
88
- plugins: typeof plugins;
89
- accountDocHandle: DocHandle<AccountDoc>;
90
- signer?: SignerIdentity;
91
- sw: {
92
- connectClassicSync: (server?: string) => Promise<void>;
93
- subscribeToRepoChannel: (
94
- listener: ServiceWorkerRepoChannelListener
95
- ) => Promise<() => void>;
96
- subscribeSyncState: (
97
- documentId: string,
98
- listener: (update: SyncStateDocMessage) => void
99
- ) => () => void;
100
- };
101
- };
102
- uncache: (match: string) => Promise<void>;
103
- }
104
- }
105
-
106
- export interface SiteConfig {
107
- /**
108
- * The site's default tool bundle — the tools every user of this site gets
109
- * out of the box. Must collectively contribute at least a
110
- * `patchwork:datatype` registration for `"account"` (typically the one
111
- * supplied by `@inkandswitch/patchwork-frame`).
112
- *
113
- * Each entry is a *module-list source* and may be either:
114
- * - an Automerge module-settings doc URL (`automerge:...`), which is
115
- * live-reloaded, or
116
- * - an HTTP(S) URL (absolute or site-relative, e.g. `/modules.json`) to a
117
- * static JSON manifest of the shape `{ modules: string[], branches? }`,
118
- * fetched once at boot.
119
- *
120
- * The module URLs *inside* either kind of source may themselves be Automerge
121
- * folder docs or plain HTTP(S) bundles, so deployment targets can be freely
122
- * mixed.
123
- *
124
- * Overridable at runtime with `localStorage.systemPackageListURL`.
125
- */
126
- defaultModules?: string | string[];
127
-
128
- /**
129
- * @deprecated Use {@link SiteConfig.defaultModules}. Retained for backwards
130
- * compatibility with existing sites.
131
- */
132
- defaultModulesUrl?: AutomergeUrl;
133
-
134
- /**
135
- * `localStorage` key under which this site remembers which account document
136
- * belongs to the current user. Sites sharing an origin MUST use distinct
137
- * keys so they do not clobber each other's accounts.
138
- */
139
- accountStorageKey: string;
140
-
141
- /**
142
- * Brand word appended to the document title as `"<doc> | <titleSuffix>"`
143
- * when a document is open. The separator is provided for you.
144
- */
145
- titleSuffix: string;
146
-
147
- /** DOM id of the `<patchwork-view>` hosting the root tool. Defaults to "root". */
148
- rootElementId?: string;
149
-
150
- /**
151
- * Initialize keyhive for access control. The Repo then uses keyhive's network
152
- * adapter, peerId and idFactory instead of a sharePolicy.
153
- */
154
- keyhive?: boolean;
155
- }
156
-
157
- export interface BootResult {
158
- repo: Repo;
159
- moduleWatcher: ModuleWatcher;
160
- accountDocHandle: DocHandle<AccountDoc>;
161
- }
162
-
163
- // Started at module evaluation but not top-level awaited: awaiting here would
164
- // hold up everything importing this module, so the loading animation couldn't
165
- // appear until the biggest download of the boot had already finished.
166
- const wasmFetches = Promise.all([
167
- fetch("/automerge.wasm").then((r) => r.bytes()),
168
- fetch("/subduction.wasm").then((r) => r.bytes()),
169
- ]);
170
- wasmFetches.catch(() => {});
171
-
172
- export async function bootPatchworkSite(
173
- config: SiteConfig
174
- ): Promise<BootResult> {
175
- const moduleSources = resolveDefaultModules(config);
176
- showLoadingAnimation();
177
- log("booting", config);
178
- installLifecycleLogging();
179
-
180
- const [automergeWasm, subductionWasm] = await wasmFetches;
181
- await initializeWasm(automergeWasm);
182
- initSubductionSync(subductionWasm);
183
-
184
- const sw = await setupServiceWorker();
185
- if (!sw) throw new Error("Failed to set up service worker");
186
- log("workers ready");
187
-
188
- let hive: AutomergeRepoKeyhive | undefined;
189
- let repo: Repo;
190
- let signerIdentity: SignerIdentity | undefined;
191
- // Called with a fresh port when the automerge worker dies and is recreated.
192
- // Assigned once the repo exists.
193
- let onWorkerPortRenewed: ((port: MessagePort) => void) | undefined;
194
-
195
- // An embedding context may have provided a Repo before this entry ran. Reuse
196
- // it and its keyhive so we share the same documents and sync context.
197
- if (window.repo) {
198
- log("using existing Repo from window");
199
- repo = window.repo;
200
- hive = window.hive;
201
- } else {
202
- const workerPort = await firstRepoPort(sw, (port) => {
203
- if (onWorkerPortRenewed) onWorkerPortRenewed(port);
204
- else {
205
- console.warn(
206
- "automerge worker port renewed before the repo existed; dropping it"
207
- );
208
- }
209
- });
210
-
211
- let workerAdapter = new MessageChannelNetworkAdapter(workerPort);
212
- ({ repo, hive, signerIdentity } = await createRepo(config, workerAdapter));
213
-
214
- // The worker was recreated with cold state: wire the repo onto the fresh
215
- // port and drop the adapter stranded on the dead one.
216
- const bootHive = hive;
217
- onWorkerPortRenewed = (port) => {
218
- const fresh = new MessageChannelNetworkAdapter(port);
219
- // Mirror the boot wiring: a keyhive repo talks to the worker through a
220
- // keyhive adapter wrapped around the message channel.
221
- const registered = bootHive
222
- ? bootHive.createKeyhiveNetworkAdapter(fresh, false, false, 2000)
223
- : fresh;
224
- repo.networkSubsystem.addNetworkAdapter(registered as any);
225
- removeAdapterFor(repo, workerAdapter, registered);
226
- workerAdapter = fresh;
227
- lifecycleLog("repo re-wired to the recreated automerge worker");
228
- };
229
- }
230
-
231
- window.repo = repo;
232
- window.Automerge = Automerge;
233
- window.AutomergeRepo = AutomergeRepo;
234
- window.getRepoChannel = sw.getRepoChannel;
235
- if (hive) window.hive = hive;
236
-
237
- await repo.networkSubsystem.whenReady();
238
- log("networkSubsystem ready");
239
- (hive?.networkAdapter as any)?.syncKeyhive?.();
240
-
241
- registerRepoProviderElement(repo as any);
242
-
243
- const rootElementId = config.rootElementId ?? "root";
244
- const rootElement = document.getElementById(rootElementId);
245
- if (!rootElement) {
246
- throw new Error(`bootPatchworkSite: no element with id="${rootElementId}"`);
247
- }
248
-
249
- // `<repo-provider>` sits above the root and answers `repo:handle-descriptor`
250
- // for any view outside a remapper, resolving to the requested url unchanged.
251
- const repoProvider = document.createElement("repo-provider");
252
- rootElement.parentElement!.insertBefore(repoProvider, rootElement);
253
- repoProvider.appendChild(rootElement);
254
-
255
- registerPatchworkViewElement({ hive, repo });
256
-
257
- // Started with the site bundle alone so resolveAccountHandle has something to
258
- // await on — the `account` datatype lives there. The user's own
259
- // module-settings URL is added lazily once it appears on the account doc.
260
- const moduleWatcher = new ModuleWatcher(
261
- repo,
262
- nameSources(moduleSources),
263
- onModuleLoaded,
264
- unregisterPlugins,
265
- // Discover an Automerge package's plugin descriptors off the main thread;
266
- // each plugin's load() re-imports the package (at heads) on this thread.
267
- importAutomergePackageViaWorker
268
- );
269
-
270
- const accountDocHandle = (await resolveAccountHandle(repo, {
271
- storageKey: config.accountStorageKey,
272
- hive,
273
- })) as DocHandle<AccountDoc>;
274
-
275
- window.accountDocHandle = accountDocHandle;
276
- window.uncache = uncache;
277
- window.patchwork = {
278
- repo,
279
- packages: moduleWatcher,
280
- plugins,
281
- accountDocHandle,
282
- ...(signerIdentity ? { signer: signerIdentity } : {}),
283
- sw: {
284
- connectClassicSync: sw.connectClassicSync,
285
- subscribeToRepoChannel: sw.subscribeToRepoChannel,
286
- subscribeSyncState: sw.subscribeSyncState,
287
- },
288
- };
289
-
290
- wireModuleSettings(accountDocHandle, moduleWatcher);
291
- primeRootElement(rootElement, accountDocHandle);
292
-
293
- moduleWatcher.doneLoading.then(
294
- () =>
295
- log(
296
- "doneLoading, tools registered:",
297
- getRegistry("patchwork:tool")
298
- .all()
299
- .map((t: any) => t.id)
300
- ),
301
- (err: unknown) => console.error("doneLoading rejected:", err)
302
- );
303
-
304
- installHashRouting({
305
- rootElement,
306
- repo,
307
- accountDocHandle,
308
- titleSuffix: config.titleSuffix,
309
- });
310
-
311
- return { repo, moduleWatcher, accountDocHandle };
312
- }
313
-
314
- /**
315
- * Resolve with the first repo port the worker delivers, calling `onRenewed` for
316
- * every later one.
317
- *
318
- * subscribeToRepoChannel is deliberately not awaited: it resolves only after
319
- * the boot channel's port-ready handshake, which can take its full 30s timeout
320
- * against a stranded worker connection. Boot blocks on the first *delivered*
321
- * port instead — if the boot channel stalls, worker recovery hands the listener
322
- * a good port long before that timeout.
323
- */
324
- function firstRepoPort(
325
- sw: Awaited<ReturnType<typeof setupServiceWorker>>,
326
- onRenewed: (port: MessagePort) => void
327
- ): Promise<MessagePort> {
328
- return new Promise<MessagePort>((resolve) => {
329
- let seen = false;
330
- void sw.subscribeToRepoChannel((port) => {
331
- if (seen) return onRenewed(port);
332
- seen = true;
333
- resolve(port);
334
- });
335
- });
336
- }
337
-
338
- async function createRepo(
339
- config: SiteConfig,
340
- workerAdapter: MessageChannelNetworkAdapter
341
- ): Promise<{
342
- repo: Repo;
343
- hive?: AutomergeRepoKeyhive;
344
- signerIdentity?: SignerIdentity;
345
- }> {
346
- if (config.keyhive) {
347
- log("setting up keyhive");
348
- initKeyhiveWasm();
349
- const { hive, repo } = await initializeAutomergeRepoKeyhiveWithRepo({
350
- createRepo: (repoConfig) => new Repo(repoConfig),
351
- storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
352
- peerIdSuffix: siteName + Math.random().toString(36).slice(2),
353
- networkAdapter: workerAdapter,
354
- automaticArchiveIngestion: true,
355
- cachingMode: "periodic",
356
- onlyShareWithHardcodedServerPeerId: false,
357
- // ARK selects the relay via `syncServer`, defaulting to "subduction".
358
- ...(useKeyhiveSyncServer ? { syncServer: "keyhive" as const } : {}),
359
- repo: {
360
- storage: new IndexedDBWorkerStorageAdapter(),
361
- enableRemoteHeadsGossiping: true,
362
- },
363
- });
364
- log("keyhive setup complete");
365
- return { repo, hive };
366
- }
367
-
368
- // An explicit signer, rather than the Repo's internal default, so the tab's
369
- // identity can be exposed on window.patchwork. The tab never connects via
370
- // Subduction, so this id never goes on the wire.
371
- const signer = new MemorySigner();
372
- const repo = new Repo({
373
- network: [workerAdapter],
374
- storage: new IndexedDBWorkerStorageAdapter(),
375
- signer,
376
- async sharePolicy(peerId) {
377
- return peerId.includes("automerge-worker");
378
- },
379
- enableRemoteHeadsGossiping: true,
380
- peerId:
381
- `${config.titleSuffix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId,
382
- });
383
- const signerIdentity = {
384
- peerId: signer.peerId().toString(),
385
- verifyingKey: (
386
- signer.verifyingKey() as Uint8Array<ArrayBufferLike> & {
387
- toHex(): string;
388
- }
389
- ).toHex(),
390
- };
391
- log("repo created, tab subduction identity:", signerIdentity);
392
- return { repo, signerIdentity };
393
- }
394
-
395
- /** Drop the adapter sitting on the dead worker port, leaving `keep` in place. */
396
- function removeAdapterFor(
397
- repo: Repo,
398
- stale: MessageChannelNetworkAdapter,
399
- keep: unknown
400
- ): void {
401
- for (const adapter of [...repo.networkSubsystem.adapters]) {
402
- if (adapter === keep) continue;
403
- // The keyhive wrapper keeps the wrapped adapter on `.networkAdapter`.
404
- const base = (adapter as any).networkAdapter ?? adapter;
405
- if (base !== stale) continue;
406
- try {
407
- repo.networkSubsystem.removeNetworkAdapter(adapter as any);
408
- } catch (err) {
409
- console.error("failed to remove stale worker network adapter", err);
410
- }
411
- }
412
- }
413
-
414
- function isValidModuleSource(source: string): boolean {
415
- return isValidAutomergeUrl(source) || /^(https?:\/\/|\.?\/)/.test(source);
416
- }
417
-
418
- /**
419
- * The site's default module-list sources, honouring the
420
- * `localStorage.systemPackageListURL` dev override, which replaces the entire
421
- * built-in bundle. `defaultToolsUrl` is the pre-rename key.
422
- */
423
- function resolveDefaultModules(config: SiteConfig): string[] {
424
- const configured = config.defaultModules ?? config.defaultModulesUrl ?? [];
425
- const builtin = (
426
- Array.isArray(configured) ? configured : [configured]
427
- ).filter(Boolean);
428
-
429
- const storage = globalThis.localStorage;
430
- const override =
431
- storage?.getItem("systemPackageListURL") ??
432
- storage?.getItem("defaultToolsUrl");
433
-
434
- if (override && isValidModuleSource(override)) {
435
- console.info(`using systemPackageListURL from localStorage: ${override}`);
436
- return [override];
437
- }
438
- if (override) {
439
- console.warn(
440
- `ignoring invalid systemPackageListURL in localStorage: ${override}`
441
- );
442
- }
443
-
444
- if (builtin.length === 0) {
445
- throw new Error(
446
- "bootPatchworkSite: no default module sources configured (set `defaultModules`)"
447
- );
448
- }
449
- return builtin;
450
- }
451
-
452
- /**
453
- * Name the sources for the ModuleWatcher. The first keeps the canonical
454
- * `system` name; the rest get suffixed. None may be `user`, which is reserved
455
- * for the per-account settings doc and has branch-override precedence.
456
- */
457
- function nameSources(sources: string[]): Record<string, string> {
458
- return Object.fromEntries(
459
- sources.map((source, i) => [i === 0 ? "system" : `system-${i}`, source])
460
- );
461
- }
462
-
463
- /** Page Lifecycle and connectivity transitions, to line up against the
464
- * SharedWorker's sync-socket reaps. */
465
- function installLifecycleLogging(): void {
466
- if (typeof document === "undefined") return;
467
- const opts = { capture: true } as const;
468
- const persisted = (e: Event) => (e as PageTransitionEvent).persisted;
469
-
470
- document.addEventListener(
471
- "visibilitychange",
472
- () => lifecycleLog("visibilitychange → %s", document.visibilityState),
473
- opts
474
- );
475
- document.addEventListener(
476
- "freeze",
477
- () => lifecycleLog("freeze (tab suspended)"),
478
- opts
479
- );
480
- document.addEventListener(
481
- "resume",
482
- () => lifecycleLog("resume (tab unsuspended)"),
483
- opts
484
- );
485
- window.addEventListener(
486
- "pageshow",
487
- (e) => lifecycleLog("pageshow persisted=%s", persisted(e)),
488
- opts
489
- );
490
- window.addEventListener(
491
- "pagehide",
492
- (e) => lifecycleLog("pagehide persisted=%s", persisted(e)),
493
- opts
494
- );
495
- window.addEventListener("online", () => lifecycleLog("online"), opts);
496
- window.addEventListener("offline", () => lifecycleLog("offline"), opts);
497
-
498
- lifecycleLog(
499
- "logging installed (visibilityState=%s, hasFocus=%s)",
500
- document.visibilityState,
501
- document.hasFocus()
502
- );
503
- }
504
-
505
- function onModuleLoaded(name: string, mod: any): void {
506
- if (!Array.isArray(mod.plugins)) {
507
- console.warn(`module ${name} has no plugins array`, Object.keys(mod));
508
- return;
509
- }
510
- log(
511
- `registering ${mod.plugins.length} plugin(s) from ${name}`,
512
- mod.plugins.map((p: any) => `${p.type}:${p.id}`)
513
- );
514
- registerPlugins(mod.plugins, name);
515
- }
516
-
517
- /**
518
- * The frame lazy-creates `moduleSettingsUrl` on first mount, so watch for it to
519
- * appear and feed it to the ModuleWatcher.
520
- */
521
- function wireModuleSettings(
522
- accountDocHandle: DocHandle<AccountDoc>,
523
- moduleWatcher: ModuleWatcher
524
- ): void {
525
- const wire = () => {
526
- const url = accountDocHandle.doc()?.moduleSettingsUrl;
527
- if (!url) return;
528
- void moduleWatcher.addUrl("user", url);
529
- accountDocHandle.off("change", wire);
530
- };
531
- wire();
532
- if (!accountDocHandle.doc()?.moduleSettingsUrl) {
533
- accountDocHandle.on("change", wire);
534
- }
535
- }
536
-
537
- function primeRootElement(
538
- rootElement: HTMLElement,
539
- accountDocHandle: DocHandle<AccountDoc>
540
- ): void {
541
- rootElement.style.visibility = "hidden";
542
- const params = new URLSearchParams(location.hash.slice(1));
543
- const frame = params.get("frame");
544
- rootElement.setAttribute(
545
- "tool-id",
546
- frame ?? accountDocHandle.doc().frameToolId
547
- );
548
- rootElement.setAttribute(
549
- "doc-url",
550
- (frame && docParamToUrl(params.get("doc"))) || accountDocHandle.url
551
- );
552
- }
553
-
554
- // ── Loading animation ───────────────────────────────────────────────────
555
-
556
- const LOADING_STYLE_ID = "pw-bootloader-loading-styles";
557
- const LOADING_ELEMENT_ID = "pw-bootloader-loading";
558
-
559
- const LOADING_CSS = `
560
- @keyframes pw-bootloader-pulse {
561
- 0%, 100% { opacity: 0.25; }
562
- 50% { opacity: 0.95; }
563
- }
564
- #${LOADING_ELEMENT_ID} {
565
- position: fixed;
566
- inset: 0;
567
- z-index: 0;
568
- pointer-events: none;
569
- background-color: #fff;
570
- background-image:
571
- radial-gradient(ellipse 55% 45% at 28% 35%, #fde4ec, transparent 70%),
572
- radial-gradient(ellipse 50% 55% at 72% 65%, #e0f0fb, transparent 70%),
573
- radial-gradient(ellipse 65% 55% at 50% 50%, #f1e6f6, transparent 80%);
574
- animation: pw-bootloader-pulse 3.5s ease-in-out infinite;
575
- transition: opacity 0.6s ease-out;
576
- }
577
- @media (prefers-color-scheme: dark) {
578
- #${LOADING_ELEMENT_ID} {
579
- background-color: #000;
580
- background-image:
581
- radial-gradient(ellipse 55% 45% at 28% 35%, #2a1d33, transparent 70%),
582
- radial-gradient(ellipse 50% 55% at 72% 65%, #1a2738, transparent 70%),
583
- radial-gradient(ellipse 65% 55% at 50% 50%, #221a2e, transparent 80%);
584
- }
585
- }
586
- #${LOADING_ELEMENT_ID}.pw-bootloader-fading {
587
- opacity: 0;
588
- animation: none;
589
- }
590
- `;
591
-
592
- function showLoadingAnimation(): void {
593
- if (!document.getElementById(LOADING_STYLE_ID)) {
594
- const style = document.createElement("style");
595
- style.id = LOADING_STYLE_ID;
596
- style.textContent = LOADING_CSS;
597
- document.head.appendChild(style);
598
- }
599
- if (document.getElementById(LOADING_ELEMENT_ID)) return;
600
- const el = document.createElement("div");
601
- el.id = LOADING_ELEMENT_ID;
602
- document.body.appendChild(el);
603
- }
604
-
605
- function hideLoadingAnimation(): void {
606
- const el = document.getElementById(LOADING_ELEMENT_ID);
607
- if (!el) return;
608
- el.classList.add("pw-bootloader-fading");
609
- setTimeout(() => el.remove(), 700);
610
- }
611
-
612
- async function uncache(match: string): Promise<void> {
613
- for (const name of await caches.keys()) {
614
- const cache = await caches.open(name);
615
- for (const request of await cache.keys()) {
616
- if (request.url.includes(match)) cache.delete(request);
617
- }
618
- }
619
- }
620
-
621
- // ── Hash routing ────────────────────────────────────────────────────────
622
-
623
- // Legacy big-patchwork hash shape: `<slug>--<documentId>[?…]`. The slug can
624
- // contain characters we don't otherwise permit (e.g. `drawing-(branch-1)`), so
625
- // anchor on the `--` before the base58 document id rather than a strict slug
626
- // charset.
627
- const BIG_PATCHWORK_HASH_REGEX =
628
- /^(?<title>[^=&?/#]*)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)/;
629
-
630
- // The `doc=` value is an automerge URL, kept literal rather than
631
- // percent-encoded so links stay readable.
632
- const RAW_HASH_KEYS = new Set(["doc"]);
633
- // A stable order means re-serializing the same logical params is
634
- // byte-identical, avoiding spurious `hashchange` round-trips.
635
- const HASH_KEY_ORDER = ["doc", "tool", "type", "title", "frame"];
636
-
637
- function serializeHashParams(params: URLSearchParams): string {
638
- const keys = [...HASH_KEY_ORDER, ...params.keys()];
639
- const parts: string[] = [];
640
- const emitted = new Set<string>();
641
- for (const key of keys) {
642
- if (emitted.has(key)) continue;
643
- const value = params.get(key);
644
- if (!value) continue;
645
- emitted.add(key);
646
- parts.push(
647
- `${key}=${RAW_HASH_KEYS.has(key) ? value : encodeURIComponent(value)}`
648
- );
649
- }
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 older links.
656
- */
657
- function docParamToUrl(docParam: string | null): AutomergeUrl | undefined {
658
- if (!docParam) return undefined;
659
- if (isValidAutomergeUrl(docParam as AutomergeUrl)) {
660
- return docParam as AutomergeUrl;
661
- }
662
- const documentId = docParam.replace(/^automerge:/, "");
663
- if (!isValidDocumentId(documentId)) return undefined;
664
- return stringifyAutomergeUrl({ documentId: documentId as DocumentId });
665
- }
666
-
667
- interface HashRoutingParams {
668
- rootElement: HTMLElement;
669
- repo: Repo;
670
- accountDocHandle: DocHandle<AccountDoc>;
671
- titleSuffix: string;
672
- }
673
-
674
- function installHashRouting({
675
- rootElement,
676
- repo,
677
- accountDocHandle,
678
- titleSuffix,
679
- }: HashRoutingParams): void {
680
- const handleHashChange = async () => {
681
- const hash = window.location.hash.slice(1);
682
-
683
- // Legacy big-patchwork link: normalize to `#doc=automerge:<docId>` and let
684
- // routing re-run on the resulting hashchange.
685
- const legacyDocId = BIG_PATCHWORK_HASH_REGEX.exec(hash)?.groups?.docId;
686
- if (legacyDocId && isValidDocumentId(legacyDocId)) {
687
- window.location.hash = serializeHashParams(
688
- new URLSearchParams({
689
- doc: stringifyAutomergeUrl({ documentId: legacyDocId as DocumentId }),
690
- })
691
- );
692
- return;
693
- }
694
-
695
- // Bare automerge URL: /#automerge:<documentId>
696
- if (isValidAutomergeUrl(hash as AutomergeUrl)) {
697
- window.location.hash = "";
698
- openDocument(rootElement, hash as AutomergeUrl);
699
- return;
700
- }
701
-
702
- const params = new URLSearchParams(hash);
703
- const docUrl = docParamToUrl(params.get("doc"));
704
- const frame = params.get("frame");
705
-
706
- if (frame) {
707
- const frameDocUrl = docUrl ?? accountDocHandle.url;
708
- if (
709
- rootElement.getAttribute("tool-id") !== frame ||
710
- rootElement.getAttribute("doc-url") !== frameDocUrl
711
- ) {
712
- rootElement.setAttribute("tool-id", frame);
713
- rootElement.setAttribute("doc-url", frameDocUrl);
714
- }
715
- }
716
-
717
- if (docUrl) {
718
- rootElement.dispatchEvent(
719
- new CustomEvent("patchwork:open-document", {
720
- detail: {
721
- url: docUrl,
722
- toolId: params.get("tool"),
723
- title: params.get("title"),
724
- type: params.get("type"),
725
- },
726
- })
727
- );
728
- }
729
- };
730
-
731
- rootElement.addEventListener("patchwork:open-document", async (event) => {
732
- const { url, toolId, type, title } = event.detail as {
733
- url: AutomergeUrl;
734
- toolId?: string;
735
- type?: string;
736
- title?: string;
737
- };
738
-
739
- const params = new URLSearchParams(window.location.hash.slice(1));
740
- // `doc` is the full automerge URL, so heads live inside it and the separate
741
- // `heads=` param is gone.
742
- params.delete("heads");
743
- params.set("doc", url);
744
- for (const [key, value] of [
745
- ["tool", toolId],
746
- ["title", title],
747
- ["type", type],
748
- ] as const) {
749
- if (value) params.set(key, value);
750
- else params.delete(key);
751
- }
752
- window.location.hash = serializeHashParams(params);
753
-
754
- try {
755
- const docHandle = await repo.find<{ "@patchwork"?: { type?: string } }>(
756
- url
757
- );
758
- const doc = docHandle.doc();
759
- const docType = type || doc?.["@patchwork"]?.type;
760
- if (!docType) return;
761
- const datatype =
762
- await getRegistry<DatatypeDescription>("patchwork:datatype").load(
763
- docType
764
- );
765
- if (!datatype) return;
766
- const docTitle = (datatype.module as DatatypeImplementation).getTitle(
767
- doc
768
- );
769
- if (docTitle) document.title = `${docTitle} | ${titleSuffix}`;
770
- } catch (e) {
771
- console.error("Failed to update document title", e);
772
- }
773
- });
774
-
775
- let revealed = false;
776
- const reveal = () => {
777
- if (revealed) return;
778
- revealed = true;
779
- rootElement.style.visibility = "visible";
780
- hideLoadingAnimation();
781
- };
782
-
783
- rootElement.addEventListener("patchwork:mounted", (event) => {
784
- if (event.target !== rootElement) return;
785
- log("root element mounted");
786
- void handleHashChange();
787
- reveal();
788
- // Deep-links from freshly-loaded tools get a second chance to render.
789
- setTimeout(handleHashChange, 1000);
790
- });
791
-
792
- // If nothing ever mounts, reveal anyway so the user sees something rather
793
- // than a blank page.
794
- setTimeout(reveal, 12_000);
795
-
796
- window.addEventListener("hashchange", handleHashChange);
797
- }