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