@inkandswitch/patchwork-bootloader 0.4.3 → 0.4.4

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 CHANGED
@@ -1,15 +1,13 @@
1
1
  /**
2
- * High-level browser-app boot sequence for a Patchwork site.
2
+ * Browser-app boot sequence for a Patchwork site.
3
3
  *
4
- * Layers on top of {@link setupServiceWorker} (the package default export) to
5
- * construct the Repo, wire up the automerge-worker port, load plugins via the
6
- * ModuleWatcher, resolve the user's account document, and hand control to the
7
- * configured root tool.
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.
8
7
  *
9
- * This entry point pulls in DOM- and plugin-layer dependencies (patchwork
10
- * elements, plugins, filesystem) and is intended for use only from a browser
11
- * site's `main.ts`. Non-UI consumers should import the package default (which
12
- * only does SW registration and the automerge-worker handoff).
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.
13
11
  */
14
12
  import {
15
13
  type DocHandle,
@@ -35,18 +33,6 @@ import {
35
33
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
36
34
  import { MemorySigner } from "@automerge/automerge-subduction/slim";
37
35
 
38
- declare const __SITE_NAME__: string;
39
- const siteName =
40
- typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "patchwork.inkandswitch.com";
41
-
42
- // Sync-server selection for keyhive. Defaults to "subduction". Build with
43
- // KEYHIVE_SYNC_SERVER=true to target keyhive.sync.automerge.org. This must match
44
- // the automerge-worker (SharedWorker) selection so the tab and the SW grant relay
45
- // access to the same server.
46
- declare const __KEYHIVE_SYNC_SERVER__: boolean;
47
- const useKeyhiveSyncServer =
48
- typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
49
-
50
36
  import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
51
37
  import { importAutomergePackageViaWorker } from "./module-loader.js";
52
38
  import {
@@ -65,14 +51,29 @@ import {
65
51
  } from "@inkandswitch/patchwork-plugins";
66
52
  import * as plugins from "@inkandswitch/patchwork-plugins";
67
53
 
68
- import setupServiceWorker, { lifecycleLoggingEnabled } from "./setup.js";
54
+ import setupServiceWorker, { lifecycleLog } from "./setup.js";
69
55
  import type {
70
56
  ServiceWorkerRepoChannelListener,
71
57
  SyncStateDocMessage,
72
58
  } from "./types.js";
73
59
  import debug from "debug";
60
+
74
61
  const log = debug("patchwork:bootloader:site");
75
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
+
76
77
  declare global {
77
78
  interface Window {
78
79
  accountDocHandle: DocHandle<AccountDoc>;
@@ -86,10 +87,7 @@ declare global {
86
87
  packages: ModuleWatcher;
87
88
  plugins: typeof plugins;
88
89
  accountDocHandle: DocHandle<AccountDoc>;
89
- signer?: {
90
- peerId: string;
91
- verifyingKey: string;
92
- };
90
+ signer?: SignerIdentity;
93
91
  sw: {
94
92
  connectClassicSync: (server?: string) => Promise<void>;
95
93
  subscribeToRepoChannel: (
@@ -123,9 +121,7 @@ export interface SiteConfig {
123
121
  * folder docs or plain HTTP(S) bundles, so deployment targets can be freely
124
122
  * mixed.
125
123
  *
126
- * Can be overridden at runtime by setting `localStorage.systemPackageListURL` to
127
- * another `automerge:` URL or manifest URL — useful for local development
128
- * against an unpublished tool set.
124
+ * Overridable at runtime with `localStorage.systemPackageListURL`.
129
125
  */
130
126
  defaultModules?: string | string[];
131
127
 
@@ -148,16 +144,12 @@ export interface SiteConfig {
148
144
  */
149
145
  titleSuffix: string;
150
146
 
151
- /**
152
- * DOM id of the `<patchwork-view>` element that will host the root tool.
153
- * Defaults to `"root"`.
154
- */
147
+ /** DOM id of the `<patchwork-view>` hosting the root tool. Defaults to "root". */
155
148
  rootElementId?: string;
156
149
 
157
150
  /**
158
- * When true, initialize keyhive for access control.
159
- * The Repo will use keyhive's network adapter, peerId, and idFactory
160
- * instead of a sharePolicy.
151
+ * Initialize keyhive for access control. The Repo then uses keyhive's network
152
+ * adapter, peerId and idFactory instead of a sharePolicy.
161
153
  */
162
154
  keyhive?: boolean;
163
155
  }
@@ -168,208 +160,106 @@ export interface BootResult {
168
160
  accountDocHandle: DocHandle<AccountDoc>;
169
161
  }
170
162
 
171
- // Legacy big-patchwork hash shape: `<slug>--<documentId>[?…]`. The slug can
172
- // contain characters we don't otherwise permit (e.g. `drawing-(branch-1)`), so
173
- // we anchor on the `--` before the base58 document id and allow any non-query
174
- // characters ahead of it rather than a strict slug charset.
175
- const BIG_PATCHWORK_HASH_REGEX =
176
- /^(?<title>[^=&?/#]*)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)/;
177
-
178
- const [automergeWasm, subductionWasm] = await Promise.all([
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([
179
167
  fetch("/automerge.wasm").then((r) => r.bytes()),
180
168
  fetch("/subduction.wasm").then((r) => r.bytes()),
181
169
  ]);
170
+ wasmFetches.catch(() => {});
182
171
 
183
- /**
184
- * Boot a Patchwork browser site.
185
- *
186
- * Performs the full application-shell setup: service worker + port, Repo,
187
- * plugin/module loading, account resolution, URL-hash routing, and dev-console
188
- * globals (`window.repo`, `window.patchwork`, `window.uncache`). Returns the
189
- * constructed Repo, ModuleWatcher and account handle for sites that want to
190
- * do additional wiring after boot.
191
- */
192
172
  export async function bootPatchworkSite(
193
173
  config: SiteConfig
194
174
  ): Promise<BootResult> {
195
- const defaultModuleSources = resolveDefaultModules(config);
175
+ const moduleSources = resolveDefaultModules(config);
196
176
  showLoadingAnimation();
197
- log(`booting`, config);
177
+ log("booting", config);
198
178
  installLifecycleLogging();
179
+
180
+ const [automergeWasm, subductionWasm] = await wasmFetches;
199
181
  await initializeWasm(automergeWasm);
200
182
  initSubductionSync(subductionWasm);
201
183
 
202
- log("enabling workers");
203
184
  const sw = await setupServiceWorker();
204
185
  if (!sw) throw new Error("Failed to set up service worker");
205
186
  log("workers ready");
206
187
 
207
188
  let hive: AutomergeRepoKeyhive | undefined;
208
189
  let repo: Repo;
209
- let tabSignerIdentity: { peerId: string; verifyingKey: string } | undefined;
210
-
211
- // Called with a fresh port when the automerge worker dies and is recreated
212
- // (see recoverAutomergeWorker in setup.ts); assigned once the repo exists.
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.
213
193
  let onWorkerPortRenewed: ((port: MessagePort) => void) | undefined;
214
194
 
215
- // If a Repo is already on `window` an embedding context provided one before
216
- // this entry ran — reuse it and its keyhive instead of standing up a fresh
217
- // realm-local Repo, so we share the same documents and sync/keyhive context.
218
- // Otherwise create our own below.
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.
219
197
  if (window.repo) {
220
198
  log("using existing Repo from window");
221
199
  repo = window.repo;
222
200
  hive = window.hive;
223
201
  } else {
224
- // Get the initial automerge-worker port via subscribeToRepoChannel,
225
- // then pass it to keyhive init which wraps it in its own network adapter.
226
- // The listener is called again with a fresh port if the worker is ever
227
- // recreated after dying.
228
- let resolveFirstPort!: (port: MessagePort) => void;
229
- const firstPortPromise = new Promise<MessagePort>((r) => {
230
- resolveFirstPort = r;
231
- });
232
- let seenFirstPort = false;
233
- log("subscribing to repo channel");
234
- // Deliberately not awaited: subscribeToRepoChannel resolves only after
235
- // the boot channel's port-ready handshake, which can take its full 30s
236
- // timeout against a stranded worker connection. Boot should block on the
237
- // first *delivered* port instead — if the boot channel stalls, worker
238
- // recovery hands the listener a good port long before that timeout.
239
- void sw.subscribeToRepoChannel((port) => {
240
- if (!seenFirstPort) {
241
- seenFirstPort = true;
242
- resolveFirstPort(port);
243
- } else if (onWorkerPortRenewed) {
244
- onWorkerPortRenewed(port);
245
- } else {
202
+ const workerPort = await firstRepoPort(sw, (port) => {
203
+ if (onWorkerPortRenewed) onWorkerPortRenewed(port);
204
+ else {
246
205
  console.warn(
247
206
  "automerge worker port renewed before the repo existed; dropping it"
248
207
  );
249
208
  }
250
209
  });
251
- const workerPort = await firstPortPromise;
252
- log("repo channel subscribed");
253
- let workerAdapter = new MessageChannelNetworkAdapter(workerPort);
254
210
 
255
- if (config.keyhive) {
256
- log("setting up keyhive");
257
- initKeyhiveWasm();
258
-
259
- ({ hive, repo } = await initializeAutomergeRepoKeyhiveWithRepo({
260
- createRepo: (config) => new Repo(config),
261
- storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
262
- peerIdSuffix: siteName + Math.random().toString(36).slice(2),
263
- networkAdapter: workerAdapter,
264
- automaticArchiveIngestion: true,
265
- cachingMode: "periodic",
266
- onlyShareWithHardcodedServerPeerId: false,
267
- // ARK selects the relay via `syncServer` ("keyhive" | "subduction").
268
- // Defaults to "subduction".
269
- ...(useKeyhiveSyncServer ? { syncServer: "keyhive" as const } : {}),
270
- repo: {
271
- storage: new IndexedDBWorkerStorageAdapter(),
272
- enableRemoteHeadsGossiping: true,
273
- },
274
- }));
275
- log("keyhive setup complete");
276
- } else {
277
- log("creating repo");
278
- // Pass an explicit signer (instead of the Repo's internal default) so we
279
- // can expose tab signer identity on window.patchwork for dev inspection.
280
- // The tab never connects via Subduction (no endpoints/adapters), so this
281
- // id never goes on the wire.
282
- const tabSigner = new MemorySigner();
283
- repo = new Repo({
284
- network: [workerAdapter],
285
- storage: new IndexedDBWorkerStorageAdapter(),
286
- signer: tabSigner,
287
- async sharePolicy(peerId) {
288
- return peerId.includes("automerge-worker");
289
- },
290
- enableRemoteHeadsGossiping: true,
291
- peerId:
292
- `${config.titleSuffix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId,
293
- });
294
- tabSignerIdentity = {
295
- peerId: tabSigner.peerId().toString(),
296
- verifyingKey: (
297
- tabSigner.verifyingKey() as Uint8Array<ArrayBufferLike> & {
298
- toHex(): string;
299
- }
300
- ).toHex(),
301
- };
302
- console.log("[patchwork] tab subduction identity:", tabSignerIdentity);
303
- log("repo created");
304
- }
211
+ let workerAdapter = new MessageChannelNetworkAdapter(workerPort);
212
+ ({ repo, hive, signerIdentity } = await createRepo(config, workerAdapter));
305
213
 
306
214
  // The worker was recreated with cold state: wire the repo onto the fresh
307
- // port and drop the adapter stranded on the dead one. `repo`/`hive` are
308
- // settled by the time this can fire (recovery needs a missed heartbeat).
215
+ // port and drop the adapter stranded on the dead one.
309
216
  const bootHive = hive;
310
217
  onWorkerPortRenewed = (port) => {
311
218
  const fresh = new MessageChannelNetworkAdapter(port);
312
219
  // Mirror the boot wiring: a keyhive repo talks to the worker through a
313
- // keyhive adapter wrapped around the message channel (same parameters
314
- // the worker uses for its side of the pair).
220
+ // keyhive adapter wrapped around the message channel.
315
221
  const registered = bootHive
316
222
  ? bootHive.createKeyhiveNetworkAdapter(fresh, false, false, 2000)
317
223
  : fresh;
318
224
  repo.networkSubsystem.addNetworkAdapter(registered as any);
319
- for (const adapter of [...repo.networkSubsystem.adapters]) {
320
- if (adapter === (registered as any)) continue;
321
- // The keyhive wrapper keeps the wrapped adapter on `.networkAdapter`.
322
- const base = (adapter as any).networkAdapter ?? adapter;
323
- if (base !== workerAdapter) continue;
324
- try {
325
- repo.networkSubsystem.removeNetworkAdapter(adapter as any);
326
- } catch (err) {
327
- console.error("failed to remove stale worker network adapter", err);
328
- }
329
- }
225
+ removeAdapterFor(repo, workerAdapter, registered);
330
226
  workerAdapter = fresh;
331
- console.warn(
332
- `[lifecycle] ${new Date().toISOString()} repo re-wired to the ` +
333
- `recreated automerge worker`
334
- );
227
+ lifecycleLog("repo re-wired to the recreated automerge worker");
335
228
  };
336
229
  }
337
- log("popping repo on window");
230
+
338
231
  window.repo = repo;
339
- log("await repo.networkSubsystem.whenReady()");
232
+ window.Automerge = Automerge;
233
+ window.AutomergeRepo = AutomergeRepo;
234
+ window.getRepoChannel = sw.getRepoChannel;
235
+ if (hive) window.hive = hive;
340
236
 
341
237
  await repo.networkSubsystem.whenReady();
342
238
  log("networkSubsystem ready");
343
-
344
- if (hive) {
345
- (hive.networkAdapter as any).syncKeyhive?.();
346
- }
347
-
348
- installDevConsoleGlobals(repo, hive, sw.getRepoChannel);
239
+ (hive?.networkAdapter as any)?.syncKeyhive?.();
349
240
 
350
241
  registerRepoProviderElement(repo as any);
351
242
 
352
- const rootElement = document.getElementById(config.rootElementId ?? "root");
243
+ const rootElementId = config.rootElementId ?? "root";
244
+ const rootElement = document.getElementById(rootElementId);
353
245
  if (!rootElement) {
354
- throw new Error(
355
- `bootPatchworkSite: no element with id="${config.rootElementId ?? "root"}"`
356
- );
246
+ throw new Error(`bootPatchworkSite: no element with id="${rootElementId}"`);
357
247
  }
248
+
358
249
  // `<repo-provider>` sits above the root and answers `repo:handle-descriptor`
359
- // for any view outside a remapper (resolving to the requested url unchanged).
250
+ // for any view outside a remapper, resolving to the requested url unchanged.
360
251
  const repoProvider = document.createElement("repo-provider");
361
252
  rootElement.parentElement!.insertBefore(repoProvider, rootElement);
362
253
  repoProvider.appendChild(rootElement);
363
254
 
364
255
  registerPatchworkViewElement({ hive, repo });
365
256
 
366
- // The watcher is started with the site's default-tools bundle alone so that
367
- // `resolveAccountHandle` below has something to await on (the `account`
368
- // datatype lives in that bundle today). The user's own module-settings URL
369
- // is added lazily once it appears on the account doc — see below.
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.
370
260
  const moduleWatcher = new ModuleWatcher(
371
261
  repo,
372
- buildSystemSources(defaultModuleSources),
262
+ nameSources(moduleSources),
373
263
  onModuleLoaded,
374
264
  unregisterPlugins,
375
265
  // Discover an Automerge package's plugin descriptors off the main thread;
@@ -381,29 +271,35 @@ export async function bootPatchworkSite(
381
271
  storageKey: config.accountStorageKey,
382
272
  hive,
383
273
  })) as DocHandle<AccountDoc>;
384
- // TODO: something we (Orion & pvh) changed in the types made this necessary
385
- // fix this before merging to main!
386
274
 
387
275
  window.accountDocHandle = accountDocHandle;
388
-
389
- wireModuleSettingsWhenReady(accountDocHandle, moduleWatcher);
390
-
391
- primeRootElement(rootElement, accountDocHandle);
392
- logToolRegistryWhenLoaded(moduleWatcher);
393
-
276
+ window.uncache = uncache;
394
277
  window.patchwork = {
395
278
  repo,
396
279
  packages: moduleWatcher,
397
280
  plugins,
398
281
  accountDocHandle,
399
- ...(tabSignerIdentity ? { signer: tabSignerIdentity } : {}),
282
+ ...(signerIdentity ? { signer: signerIdentity } : {}),
400
283
  sw: {
401
284
  connectClassicSync: sw.connectClassicSync,
402
285
  subscribeToRepoChannel: sw.subscribeToRepoChannel,
403
286
  subscribeSyncState: sw.subscribeSyncState,
404
287
  },
405
288
  };
406
- window.uncache = uncache;
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
+ );
407
303
 
408
304
  installHashRouting({
409
305
  rootElement,
@@ -415,160 +311,214 @@ export async function bootPatchworkSite(
415
311
  return { repo, moduleWatcher, accountDocHandle };
416
312
  }
417
313
 
418
- // ─── Internals ──────────────────────────────────────────────────────────
419
-
420
314
  /**
421
- * A module-list source is valid if it is an Automerge URL or looks like an
422
- * HTTP(S)/site-relative manifest URL.
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.
423
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
+
424
414
  function isValidModuleSource(source: string): boolean {
425
- if (isValidAutomergeUrl(source)) return true;
426
- return (
427
- source.startsWith("/") ||
428
- source.startsWith("http://") ||
429
- source.startsWith("https://") ||
430
- source.startsWith("./")
431
- );
415
+ return isValidAutomergeUrl(source) || /^(https?:\/\/|\.?\/)/.test(source);
432
416
  }
433
417
 
434
418
  /**
435
- * Resolve the site's default module-list sources, honouring the
436
- * `localStorage.systemPackageListURL` dev override (which replaces the entire
437
- * built-in default bundle).
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.
438
422
  */
439
423
  function resolveDefaultModules(config: SiteConfig): string[] {
440
- const builtin = config.defaultModules ?? config.defaultModulesUrl ?? [];
441
- const builtinList = (Array.isArray(builtin) ? builtin : [builtin]).filter(
442
- Boolean
443
- );
424
+ const configured = config.defaultModules ?? config.defaultModulesUrl ?? [];
425
+ const builtin = (
426
+ Array.isArray(configured) ? configured : [configured]
427
+ ).filter(Boolean);
444
428
 
445
429
  const storage = globalThis.localStorage;
446
- // `defaultToolsUrl` is the pre-rename key, still honoured for existing browsers.
447
430
  const override =
448
431
  storage?.getItem("systemPackageListURL") ??
449
432
  storage?.getItem("defaultToolsUrl");
433
+
434
+ if (override && isValidModuleSource(override)) {
435
+ console.info(`using systemPackageListURL from localStorage: ${override}`);
436
+ return [override];
437
+ }
450
438
  if (override) {
451
- if (isValidModuleSource(override)) {
452
- if (!builtinList.includes(override)) {
453
- console.info(
454
- `using systemPackageListURL override from localStorage: ${override}`
455
- );
456
- }
457
- return [override];
458
- }
459
439
  console.warn(
460
- `ignoring invalid systemPackageListURL in localStorage: ${override}; using built-in default`
440
+ `ignoring invalid systemPackageListURL in localStorage: ${override}`
461
441
  );
462
442
  }
463
443
 
464
- if (builtinList.length === 0) {
444
+ if (builtin.length === 0) {
465
445
  throw new Error(
466
446
  "bootPatchworkSite: no default module sources configured (set `defaultModules`)"
467
447
  );
468
448
  }
469
- return builtinList;
449
+ return builtin;
470
450
  }
471
451
 
472
452
  /**
473
- * Turn an ordered list of module-list sources into the name-keyed map the
474
- * ModuleWatcher expects. The first source keeps the canonical `system` name;
475
- * additional sources get suffixed names. None may be `user` (reserved for the
476
- * per-account settings doc, which has branch-override precedence).
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.
477
456
  */
478
- function buildSystemSources(sources: string[]): Record<string, string> {
479
- const map: Record<string, string> = {};
480
- sources.forEach((source, index) => {
481
- const name = index === 0 ? "system" : `system-${index}`;
482
- map[name] = source;
483
- });
484
- return map;
485
- }
486
-
487
- function installDevConsoleGlobals(
488
- repo: Repo,
489
- hive: AutomergeRepoKeyhive | undefined,
490
- getRepoChannel: () => MessagePort
491
- ): void {
492
- window.repo = repo;
493
- window.Automerge = Automerge;
494
- window.AutomergeRepo = AutomergeRepo;
495
- if (hive) {
496
- window.hive = hive;
497
- }
498
- window.getRepoChannel = getRepoChannel;
457
+ function nameSources(sources: string[]): Record<string, string> {
458
+ return Object.fromEntries(
459
+ sources.map((source, i) => [i === 0 ? "system" : `system-${i}`, source])
460
+ );
499
461
  }
500
462
 
501
- /**
502
- * Log this tab's Page Lifecycle + connectivity transitions (visibility,
503
- * freeze, bfcache, online/offline) so they line up against the SharedWorker's
504
- * sync-socket reaps. [lifecycle]-tagged, on by default.
505
- */
463
+ /** Page Lifecycle and connectivity transitions, to line up against the
464
+ * SharedWorker's sync-socket reaps. */
506
465
  function installLifecycleLogging(): void {
507
466
  if (typeof document === "undefined") return;
508
467
  const opts = { capture: true } as const;
509
- const note = (label: string, extra?: unknown) => {
510
- if (!lifecycleLoggingEnabled()) return;
511
- const msg = `[lifecycle] ${new Date().toISOString()} ${label}`;
512
- if (extra === undefined) console.info(msg);
513
- else console.info(msg, extra);
514
- };
468
+ const persisted = (e: Event) => (e as PageTransitionEvent).persisted;
515
469
 
516
470
  document.addEventListener(
517
471
  "visibilitychange",
518
- () => note(`visibilitychange → ${document.visibilityState}`),
472
+ () => lifecycleLog("visibilitychange → %s", document.visibilityState),
519
473
  opts
520
474
  );
521
475
  document.addEventListener(
522
476
  "freeze",
523
- () => note("freeze (tab suspended)"),
477
+ () => lifecycleLog("freeze (tab suspended)"),
524
478
  opts
525
479
  );
526
480
  document.addEventListener(
527
481
  "resume",
528
- () => note("resume (tab unsuspended)"),
482
+ () => lifecycleLog("resume (tab unsuspended)"),
529
483
  opts
530
484
  );
531
485
  window.addEventListener(
532
486
  "pageshow",
533
- (e) =>
534
- note("pageshow", { persisted: (e as PageTransitionEvent).persisted }),
487
+ (e) => lifecycleLog("pageshow persisted=%s", persisted(e)),
535
488
  opts
536
489
  );
537
490
  window.addEventListener(
538
491
  "pagehide",
539
- (e) =>
540
- note("pagehide", { persisted: (e as PageTransitionEvent).persisted }),
492
+ (e) => lifecycleLog("pagehide persisted=%s", persisted(e)),
541
493
  opts
542
494
  );
543
- window.addEventListener("online", () => note("online"), opts);
544
- window.addEventListener("offline", () => note("offline"), opts);
495
+ window.addEventListener("online", () => lifecycleLog("online"), opts);
496
+ window.addEventListener("offline", () => lifecycleLog("offline"), opts);
545
497
 
546
- note(
547
- `lifecycle logging installed (visibilityState=${document.visibilityState}, hasFocus=${document.hasFocus()})`
498
+ lifecycleLog(
499
+ "logging installed (visibilityState=%s, hasFocus=%s)",
500
+ document.visibilityState,
501
+ document.hasFocus()
548
502
  );
549
503
  }
550
504
 
551
505
  function onModuleLoaded(name: string, mod: any): void {
552
- if (Array.isArray(mod.plugins)) {
553
- log(
554
- `registering ${mod.plugins.length} plugin(s) from ${name.slice(0, 30)}...`,
555
- mod.plugins.map((p: any) => `${p.type}:${p.id}`)
556
- );
557
- registerPlugins(mod.plugins, name);
558
- } else {
559
- console.warn(
560
- `module ${name.slice(0, 30)}... has no plugins array`,
561
- Object.keys(mod)
562
- );
506
+ if (!Array.isArray(mod.plugins)) {
507
+ console.warn(`module ${name} has no plugins array`, Object.keys(mod));
508
+ return;
563
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);
564
515
  }
565
516
 
566
517
  /**
567
- * The frame lazy-creates `moduleSettingsUrl` on first mount. Watch for it to
568
- * appear on the account doc and feed it into the ModuleWatcher so the user's
569
- * own tool bundle loads alongside the site default. Idempotent.
518
+ * The frame lazy-creates `moduleSettingsUrl` on first mount, so watch for it to
519
+ * appear and feed it to the ModuleWatcher.
570
520
  */
571
- function wireModuleSettingsWhenReady(
521
+ function wireModuleSettings(
572
522
  accountDocHandle: DocHandle<AccountDoc>,
573
523
  moduleWatcher: ModuleWatcher
574
524
  ): void {
@@ -584,88 +534,66 @@ function wireModuleSettingsWhenReady(
584
534
  }
585
535
  }
586
536
 
587
- /**
588
- * Set initial `tool-id` / `doc-url` attributes on the root
589
- * `<patchwork-view>` based on the URL hash (if it specifies a frame
590
- * override) or the account doc's configured frame tool + the account doc
591
- * itself.
592
- */
593
537
  function primeRootElement(
594
538
  rootElement: HTMLElement,
595
539
  accountDocHandle: DocHandle<AccountDoc>
596
540
  ): void {
597
541
  rootElement.style.visibility = "hidden";
598
-
599
- const initialParams = new URLSearchParams(location.hash.slice(1));
600
- if (initialParams.has("frame")) {
601
- rootElement.setAttribute("tool-id", initialParams.get("frame")!);
602
- const docUrl =
603
- docParamToUrl(initialParams.get("doc")) ?? accountDocHandle.url;
604
- rootElement.setAttribute("doc-url", docUrl);
605
- } else {
606
- rootElement.setAttribute("tool-id", accountDocHandle.doc().frameToolId);
607
- rootElement.setAttribute("doc-url", accountDocHandle.url);
608
- }
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
+ );
609
552
  }
610
553
 
611
- function logToolRegistryWhenLoaded(moduleWatcher: ModuleWatcher): void {
612
- moduleWatcher.doneLoading
613
- .then(() => {
614
- const toolReg = getRegistry("patchwork:tool");
615
- const tools = toolReg.all();
616
- log(
617
- `doneLoading: ${tools.length} tools registered:`,
618
- tools.map((t: any) => t.id)
619
- );
620
- })
621
- .catch((err: unknown) => {
622
- console.error("doneLoading rejected:", err);
623
- });
624
- }
554
+ // ── Loading animation ───────────────────────────────────────────────────
625
555
 
626
556
  const LOADING_STYLE_ID = "pw-bootloader-loading-styles";
627
557
  const LOADING_ELEMENT_ID = "pw-bootloader-loading";
628
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
+
629
592
  function showLoadingAnimation(): void {
630
593
  if (!document.getElementById(LOADING_STYLE_ID)) {
631
594
  const style = document.createElement("style");
632
595
  style.id = LOADING_STYLE_ID;
633
- style.textContent = `
634
- @keyframes pw-bootloader-pulse {
635
- 0%, 100% { opacity: 0.25; }
636
- 50% { opacity: 0.95; }
637
- }
638
- #${LOADING_ELEMENT_ID} {
639
- position: fixed;
640
- inset: 0;
641
- z-index: 0;
642
- pointer-events: none;
643
- background-color: #fff;
644
- background-image:
645
- radial-gradient(ellipse 55% 45% at 28% 35%, #fde4ec, transparent 70%),
646
- radial-gradient(ellipse 50% 55% at 72% 65%, #e0f0fb, transparent 70%),
647
- radial-gradient(ellipse 65% 55% at 50% 50%, #f1e6f6, transparent 80%);
648
- animation: pw-bootloader-pulse 3.5s ease-in-out infinite;
649
- transition: opacity 0.6s ease-out;
650
- top: 0;
651
- left: 0;
652
- right: 0;
653
- bottom: 0;
654
- }
655
- @media (prefers-color-scheme: dark) {
656
- #${LOADING_ELEMENT_ID} {
657
- background-color: #000;
658
- background-image:
659
- radial-gradient(ellipse 55% 45% at 28% 35%, #2a1d33, transparent 70%),
660
- radial-gradient(ellipse 50% 55% at 72% 65%, #1a2738, transparent 70%),
661
- radial-gradient(ellipse 65% 55% at 50% 50%, #221a2e, transparent 80%);
662
- }
663
- }
664
- #${LOADING_ELEMENT_ID}.pw-bootloader-fading {
665
- opacity: 0;
666
- animation: none;
667
- }
668
- `;
596
+ style.textContent = LOADING_CSS;
669
597
  document.head.appendChild(style);
670
598
  }
671
599
  if (document.getElementById(LOADING_ELEMENT_ID)) return;
@@ -685,41 +613,46 @@ async function uncache(match: string): Promise<void> {
685
613
  for (const name of await caches.keys()) {
686
614
  const cache = await caches.open(name);
687
615
  for (const request of await cache.keys()) {
688
- if (request.url.includes(match)) {
689
- cache.delete(request);
690
- }
616
+ if (request.url.includes(match)) cache.delete(request);
691
617
  }
692
618
  }
693
619
  }
694
620
 
695
- // The `doc=` value is an automerge URL — we keep its `:` (and any `#`/`|`
696
- // heads) literal rather than percent-encoding it so links stay readable.
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.
697
632
  const RAW_HASH_KEYS = new Set(["doc"]);
698
- // Emit hash params in a stable order so re-serializing the same logical params
699
- // yields a byte-identical string (avoids spurious `hashchange` round-trips).
633
+ // A stable order means re-serializing the same logical params is
634
+ // byte-identical, avoiding spurious `hashchange` round-trips.
700
635
  const HASH_KEY_ORDER = ["doc", "tool", "type", "title", "frame"];
701
636
 
702
637
  function serializeHashParams(params: URLSearchParams): string {
703
- const emitted = new Set<string>();
638
+ const keys = [...HASH_KEY_ORDER, ...params.keys()];
704
639
  const parts: string[] = [];
705
- const emit = (key: string) => {
706
- if (emitted.has(key)) return;
640
+ const emitted = new Set<string>();
641
+ for (const key of keys) {
642
+ if (emitted.has(key)) continue;
707
643
  const value = params.get(key);
708
- if (!value) return;
644
+ if (!value) continue;
709
645
  emitted.add(key);
710
646
  parts.push(
711
647
  `${key}=${RAW_HASH_KEYS.has(key) ? value : encodeURIComponent(value)}`
712
648
  );
713
- };
714
- for (const key of HASH_KEY_ORDER) emit(key);
715
- for (const key of params.keys()) emit(key);
649
+ }
716
650
  return parts.join("&");
717
651
  }
718
652
 
719
653
  /**
720
654
  * Coerce a `doc=` hash param to a full automerge URL. Accepts a full URL
721
- * (`automerge:<id>[#heads]`) or a bare document id for backwards compatibility
722
- * with older links.
655
+ * (`automerge:<id>[#heads]`) or a bare document id, for older links.
723
656
  */
724
657
  function docParamToUrl(docParam: string | null): AutomergeUrl | undefined {
725
658
  if (!docParam) return undefined;
@@ -727,10 +660,8 @@ function docParamToUrl(docParam: string | null): AutomergeUrl | undefined {
727
660
  return docParam as AutomergeUrl;
728
661
  }
729
662
  const documentId = docParam.replace(/^automerge:/, "");
730
- if (isValidDocumentId(documentId)) {
731
- return stringifyAutomergeUrl({ documentId: documentId as DocumentId });
732
- }
733
- return undefined;
663
+ if (!isValidDocumentId(documentId)) return undefined;
664
+ return stringifyAutomergeUrl({ documentId: documentId as DocumentId });
734
665
  }
735
666
 
736
667
  interface HashRoutingParams {
@@ -740,27 +671,84 @@ interface HashRoutingParams {
740
671
  titleSuffix: string;
741
672
  }
742
673
 
743
- function installHashRouting(params: HashRoutingParams): void {
744
- const { rootElement, repo, accountDocHandle, titleSuffix } = params;
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
+ };
745
730
 
746
731
  rootElement.addEventListener("patchwork:open-document", async (event) => {
747
- const params = new URLSearchParams(window.location.hash.slice(1));
748
732
  const { url, toolId, type, title } = event.detail as {
749
733
  url: AutomergeUrl;
750
734
  toolId?: string;
751
735
  type?: string;
752
736
  title?: string;
753
737
  };
754
- // `doc` is now the full automerge URL — heads, if any, live inside it, so
755
- // the separate `heads=` param is gone.
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.
756
742
  params.delete("heads");
757
743
  params.set("doc", url);
758
- if (toolId) params.set("tool", toolId);
759
- else params.delete("tool");
760
- if (title) params.set("title", title);
761
- else params.delete("title");
762
- if (type) params.set("type", type);
763
- else params.delete("type");
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
+ }
764
752
  window.location.hash = serializeHashParams(params);
765
753
 
766
754
  try {
@@ -770,90 +758,40 @@ function installHashRouting(params: HashRoutingParams): void {
770
758
  const doc = docHandle.doc();
771
759
  const docType = type || doc?.["@patchwork"]?.type;
772
760
  if (!docType) return;
773
- const registry = getRegistry<DatatypeDescription>("patchwork:datatype");
774
- const datatype = await registry.load(docType);
761
+ const datatype =
762
+ await getRegistry<DatatypeDescription>("patchwork:datatype").load(
763
+ docType
764
+ );
775
765
  if (!datatype) return;
776
766
  const docTitle = (datatype.module as DatatypeImplementation).getTitle(
777
767
  doc
778
768
  );
779
- if (docTitle) {
780
- document.title = `${docTitle} | ${titleSuffix}`;
781
- }
769
+ if (docTitle) document.title = `${docTitle} | ${titleSuffix}`;
782
770
  } catch (e) {
783
771
  console.error("Failed to update document title", e);
784
772
  }
785
773
  });
786
774
 
787
- let firstMount = true;
775
+ let revealed = false;
788
776
  const reveal = () => {
789
- if (!firstMount) return;
790
- firstMount = false;
777
+ if (revealed) return;
778
+ revealed = true;
791
779
  rootElement.style.visibility = "visible";
792
780
  hideLoadingAnimation();
793
781
  };
794
782
 
795
783
  rootElement.addEventListener("patchwork:mounted", (event) => {
796
- handleHashChange();
797
784
  if (event.target !== rootElement) return;
798
- console.info("root element mounted");
785
+ log("root element mounted");
786
+ void handleHashChange();
799
787
  reveal();
800
- // Re-resolve routing after a beat so deep-links from freshly-loaded tools
801
- // get a second chance to render.
788
+ // Deep-links from freshly-loaded tools get a second chance to render.
802
789
  setTimeout(handleHashChange, 1000);
803
790
  });
804
791
 
805
- // Failsafe: if nothing ever mounts, reveal the element anyway after 12s so
806
- // the user sees *something* rather than a blank page.
792
+ // If nothing ever mounts, reveal anyway so the user sees something rather
793
+ // than a blank page.
807
794
  setTimeout(reveal, 12_000);
808
795
 
809
- const handleHashChange = async () => {
810
- const hash = window.location.hash.slice(1);
811
-
812
- // Legacy big-patchwork link (`<slug>--<docId>?…`): if the hash carries a
813
- // `--` followed by a valid document id, normalize it to the canonical
814
- // `#doc=automerge:<docId>` form and let routing re-run on the hashchange.
815
- const legacyDocId = BIG_PATCHWORK_HASH_REGEX.exec(hash)?.groups?.docId;
816
- if (legacyDocId && isValidDocumentId(legacyDocId)) {
817
- window.location.hash = serializeHashParams(
818
- new URLSearchParams({
819
- doc: stringifyAutomergeUrl({ documentId: legacyDocId as DocumentId }),
820
- })
821
- );
822
- return;
823
- }
824
-
825
- // Bare automerge URL in hash: /#automerge:<documentId>
826
- if (isValidAutomergeUrl(hash as AutomergeUrl)) {
827
- const url = hash as AutomergeUrl;
828
- window.location.hash = "";
829
- openDocument(rootElement, url);
830
- return;
831
- }
832
-
833
- const params = new URLSearchParams(hash);
834
- const docUrl = docParamToUrl(params.get("doc"));
835
- const toolId = params.get("tool");
836
- const title = params.get("title");
837
- const type = params.get("type");
838
- const frame = params.get("frame");
839
- if (frame) {
840
- const frameDocUrl = docUrl ?? accountDocHandle.url;
841
- if (
842
- rootElement.getAttribute("tool-id") !== frame ||
843
- rootElement.getAttribute("doc-url") !== frameDocUrl
844
- ) {
845
- rootElement.setAttribute("tool-id", frame);
846
- rootElement.setAttribute("doc-url", frameDocUrl);
847
- }
848
- }
849
- if (docUrl) {
850
- rootElement.dispatchEvent(
851
- new CustomEvent("patchwork:open-document", {
852
- detail: { url: docUrl, toolId, title, type },
853
- })
854
- );
855
- }
856
- };
857
-
858
796
  window.addEventListener("hashchange", handleHashChange);
859
797
  }