@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/dist/site.js 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 { initializeWasm, isValidAutomergeUrl, isValidDocumentId, MessageChannelNetworkAdapter, Repo, stringifyAutomergeUrl, } from "@automerge/vanillajs/slim";
15
13
  import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
@@ -20,195 +18,103 @@ import { initKeyhiveWasm, initializeAutomergeRepoKeyhiveWithRepo, } from "@autom
20
18
  // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
21
19
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
22
20
  import { MemorySigner } from "@automerge/automerge-subduction/slim";
23
- const siteName = typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "patchwork.inkandswitch.com";
24
- const useKeyhiveSyncServer = typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
25
21
  import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
26
22
  import { importAutomergePackageViaWorker } from "./module-loader.js";
27
23
  import { openDocument, registerPatchworkViewElement, } from "@inkandswitch/patchwork-elements";
28
24
  import { registerRepoProviderElement } from "@inkandswitch/patchwork-providers";
29
25
  import { getRegistry, registerPlugins, resolveAccountHandle, unregisterPlugins, } from "@inkandswitch/patchwork-plugins";
30
26
  import * as plugins from "@inkandswitch/patchwork-plugins";
31
- import setupServiceWorker, { lifecycleLoggingEnabled } from "./setup.js";
27
+ import setupServiceWorker, { lifecycleLog } from "./setup.js";
32
28
  import debug from "debug";
33
29
  const log = debug("patchwork:bootloader:site");
34
- // Legacy big-patchwork hash shape: `<slug>--<documentId>[?…]`. The slug can
35
- // contain characters we don't otherwise permit (e.g. `drawing-(branch-1)`), so
36
- // we anchor on the `--` before the base58 document id and allow any non-query
37
- // characters ahead of it rather than a strict slug charset.
38
- const BIG_PATCHWORK_HASH_REGEX = /^(?<title>[^=&?/#]*)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)/;
39
- const [automergeWasm, subductionWasm] = await Promise.all([
30
+ const siteName = typeof __SITE_NAME__ !== "undefined"
31
+ ? __SITE_NAME__
32
+ : "patchwork.inkandswitch.com";
33
+ const useKeyhiveSyncServer = typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
34
+ // Started at module evaluation but not top-level awaited: awaiting here would
35
+ // hold up everything importing this module, so the loading animation couldn't
36
+ // appear until the biggest download of the boot had already finished.
37
+ const wasmFetches = Promise.all([
40
38
  fetch("/automerge.wasm").then((r) => r.bytes()),
41
39
  fetch("/subduction.wasm").then((r) => r.bytes()),
42
40
  ]);
43
- /**
44
- * Boot a Patchwork browser site.
45
- *
46
- * Performs the full application-shell setup: service worker + port, Repo,
47
- * plugin/module loading, account resolution, URL-hash routing, and dev-console
48
- * globals (`window.repo`, `window.patchwork`, `window.uncache`). Returns the
49
- * constructed Repo, ModuleWatcher and account handle for sites that want to
50
- * do additional wiring after boot.
51
- */
41
+ wasmFetches.catch(() => { });
52
42
  export async function bootPatchworkSite(config) {
53
- const defaultModuleSources = resolveDefaultModules(config);
43
+ const moduleSources = resolveDefaultModules(config);
54
44
  showLoadingAnimation();
55
- log(`booting`, config);
45
+ log("booting", config);
56
46
  installLifecycleLogging();
47
+ const [automergeWasm, subductionWasm] = await wasmFetches;
57
48
  await initializeWasm(automergeWasm);
58
49
  initSubductionSync(subductionWasm);
59
- log("enabling workers");
60
50
  const sw = await setupServiceWorker();
61
51
  if (!sw)
62
52
  throw new Error("Failed to set up service worker");
63
53
  log("workers ready");
64
54
  let hive;
65
55
  let repo;
66
- let tabSignerIdentity;
67
- // Called with a fresh port when the automerge worker dies and is recreated
68
- // (see recoverAutomergeWorker in setup.ts); assigned once the repo exists.
56
+ let signerIdentity;
57
+ // Called with a fresh port when the automerge worker dies and is recreated.
58
+ // Assigned once the repo exists.
69
59
  let onWorkerPortRenewed;
70
- // If a Repo is already on `window` an embedding context provided one before
71
- // this entry ran — reuse it and its keyhive instead of standing up a fresh
72
- // realm-local Repo, so we share the same documents and sync/keyhive context.
73
- // Otherwise create our own below.
60
+ // An embedding context may have provided a Repo before this entry ran. Reuse
61
+ // it and its keyhive so we share the same documents and sync context.
74
62
  if (window.repo) {
75
63
  log("using existing Repo from window");
76
64
  repo = window.repo;
77
65
  hive = window.hive;
78
66
  }
79
67
  else {
80
- // Get the initial automerge-worker port via subscribeToRepoChannel,
81
- // then pass it to keyhive init which wraps it in its own network adapter.
82
- // The listener is called again with a fresh port if the worker is ever
83
- // recreated after dying.
84
- let resolveFirstPort;
85
- const firstPortPromise = new Promise((r) => {
86
- resolveFirstPort = r;
87
- });
88
- let seenFirstPort = false;
89
- log("subscribing to repo channel");
90
- // Deliberately not awaited: subscribeToRepoChannel resolves only after
91
- // the boot channel's port-ready handshake, which can take its full 30s
92
- // timeout against a stranded worker connection. Boot should block on the
93
- // first *delivered* port instead — if the boot channel stalls, worker
94
- // recovery hands the listener a good port long before that timeout.
95
- void sw.subscribeToRepoChannel((port) => {
96
- if (!seenFirstPort) {
97
- seenFirstPort = true;
98
- resolveFirstPort(port);
99
- }
100
- else if (onWorkerPortRenewed) {
68
+ const workerPort = await firstRepoPort(sw, (port) => {
69
+ if (onWorkerPortRenewed)
101
70
  onWorkerPortRenewed(port);
102
- }
103
71
  else {
104
72
  console.warn("automerge worker port renewed before the repo existed; dropping it");
105
73
  }
106
74
  });
107
- const workerPort = await firstPortPromise;
108
- log("repo channel subscribed");
109
75
  let workerAdapter = new MessageChannelNetworkAdapter(workerPort);
110
- if (config.keyhive) {
111
- log("setting up keyhive");
112
- initKeyhiveWasm();
113
- ({ hive, repo } = await initializeAutomergeRepoKeyhiveWithRepo({
114
- createRepo: (config) => new Repo(config),
115
- storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
116
- peerIdSuffix: siteName + Math.random().toString(36).slice(2),
117
- networkAdapter: workerAdapter,
118
- automaticArchiveIngestion: true,
119
- cachingMode: "periodic",
120
- onlyShareWithHardcodedServerPeerId: false,
121
- // ARK selects the relay via `syncServer` ("keyhive" | "subduction").
122
- // Defaults to "subduction".
123
- ...(useKeyhiveSyncServer ? { syncServer: "keyhive" } : {}),
124
- repo: {
125
- storage: new IndexedDBWorkerStorageAdapter(),
126
- enableRemoteHeadsGossiping: true,
127
- },
128
- }));
129
- log("keyhive setup complete");
130
- }
131
- else {
132
- log("creating repo");
133
- // Pass an explicit signer (instead of the Repo's internal default) so we
134
- // can expose tab signer identity on window.patchwork for dev inspection.
135
- // The tab never connects via Subduction (no endpoints/adapters), so this
136
- // id never goes on the wire.
137
- const tabSigner = new MemorySigner();
138
- repo = new Repo({
139
- network: [workerAdapter],
140
- storage: new IndexedDBWorkerStorageAdapter(),
141
- signer: tabSigner,
142
- async sharePolicy(peerId) {
143
- return peerId.includes("automerge-worker");
144
- },
145
- enableRemoteHeadsGossiping: true,
146
- peerId: `${config.titleSuffix}-tab-${crypto.randomUUID()}`,
147
- });
148
- tabSignerIdentity = {
149
- peerId: tabSigner.peerId().toString(),
150
- verifyingKey: tabSigner.verifyingKey().toHex(),
151
- };
152
- console.log("[patchwork] tab subduction identity:", tabSignerIdentity);
153
- log("repo created");
154
- }
76
+ ({ repo, hive, signerIdentity } = await createRepo(config, workerAdapter));
155
77
  // The worker was recreated with cold state: wire the repo onto the fresh
156
- // port and drop the adapter stranded on the dead one. `repo`/`hive` are
157
- // settled by the time this can fire (recovery needs a missed heartbeat).
78
+ // port and drop the adapter stranded on the dead one.
158
79
  const bootHive = hive;
159
80
  onWorkerPortRenewed = (port) => {
160
81
  const fresh = new MessageChannelNetworkAdapter(port);
161
82
  // Mirror the boot wiring: a keyhive repo talks to the worker through a
162
- // keyhive adapter wrapped around the message channel (same parameters
163
- // the worker uses for its side of the pair).
83
+ // keyhive adapter wrapped around the message channel.
164
84
  const registered = bootHive
165
85
  ? bootHive.createKeyhiveNetworkAdapter(fresh, false, false, 2000)
166
86
  : fresh;
167
87
  repo.networkSubsystem.addNetworkAdapter(registered);
168
- for (const adapter of [...repo.networkSubsystem.adapters]) {
169
- if (adapter === registered)
170
- continue;
171
- // The keyhive wrapper keeps the wrapped adapter on `.networkAdapter`.
172
- const base = adapter.networkAdapter ?? adapter;
173
- if (base !== workerAdapter)
174
- continue;
175
- try {
176
- repo.networkSubsystem.removeNetworkAdapter(adapter);
177
- }
178
- catch (err) {
179
- console.error("failed to remove stale worker network adapter", err);
180
- }
181
- }
88
+ removeAdapterFor(repo, workerAdapter, registered);
182
89
  workerAdapter = fresh;
183
- console.warn(`[lifecycle] ${new Date().toISOString()} repo re-wired to the ` +
184
- `recreated automerge worker`);
90
+ lifecycleLog("repo re-wired to the recreated automerge worker");
185
91
  };
186
92
  }
187
- log("popping repo on window");
188
93
  window.repo = repo;
189
- log("await repo.networkSubsystem.whenReady()");
94
+ window.Automerge = Automerge;
95
+ window.AutomergeRepo = AutomergeRepo;
96
+ window.getRepoChannel = sw.getRepoChannel;
97
+ if (hive)
98
+ window.hive = hive;
190
99
  await repo.networkSubsystem.whenReady();
191
100
  log("networkSubsystem ready");
192
- if (hive) {
193
- hive.networkAdapter.syncKeyhive?.();
194
- }
195
- installDevConsoleGlobals(repo, hive, sw.getRepoChannel);
101
+ hive?.networkAdapter?.syncKeyhive?.();
196
102
  registerRepoProviderElement(repo);
197
- const rootElement = document.getElementById(config.rootElementId ?? "root");
103
+ const rootElementId = config.rootElementId ?? "root";
104
+ const rootElement = document.getElementById(rootElementId);
198
105
  if (!rootElement) {
199
- throw new Error(`bootPatchworkSite: no element with id="${config.rootElementId ?? "root"}"`);
106
+ throw new Error(`bootPatchworkSite: no element with id="${rootElementId}"`);
200
107
  }
201
108
  // `<repo-provider>` sits above the root and answers `repo:handle-descriptor`
202
- // for any view outside a remapper (resolving to the requested url unchanged).
109
+ // for any view outside a remapper, resolving to the requested url unchanged.
203
110
  const repoProvider = document.createElement("repo-provider");
204
111
  rootElement.parentElement.insertBefore(repoProvider, rootElement);
205
112
  repoProvider.appendChild(rootElement);
206
113
  registerPatchworkViewElement({ hive, repo });
207
- // The watcher is started with the site's default-tools bundle alone so that
208
- // `resolveAccountHandle` below has something to await on (the `account`
209
- // datatype lives in that bundle today). The user's own module-settings URL
210
- // is added lazily once it appears on the account doc — see below.
211
- const moduleWatcher = new ModuleWatcher(repo, buildSystemSources(defaultModuleSources), onModuleLoaded, unregisterPlugins,
114
+ // Started with the site bundle alone so resolveAccountHandle has something to
115
+ // await on the `account` datatype lives there. The user's own
116
+ // module-settings URL is added lazily once it appears on the account doc.
117
+ const moduleWatcher = new ModuleWatcher(repo, nameSources(moduleSources), onModuleLoaded, unregisterPlugins,
212
118
  // Discover an Automerge package's plugin descriptors off the main thread;
213
119
  // each plugin's load() re-imports the package (at heads) on this thread.
214
120
  importAutomergePackageViaWorker);
@@ -216,25 +122,25 @@ export async function bootPatchworkSite(config) {
216
122
  storageKey: config.accountStorageKey,
217
123
  hive,
218
124
  }));
219
- // TODO: something we (Orion & pvh) changed in the types made this necessary
220
- // fix this before merging to main!
221
125
  window.accountDocHandle = accountDocHandle;
222
- wireModuleSettingsWhenReady(accountDocHandle, moduleWatcher);
223
- primeRootElement(rootElement, accountDocHandle);
224
- logToolRegistryWhenLoaded(moduleWatcher);
126
+ window.uncache = uncache;
225
127
  window.patchwork = {
226
128
  repo,
227
129
  packages: moduleWatcher,
228
130
  plugins,
229
131
  accountDocHandle,
230
- ...(tabSignerIdentity ? { signer: tabSignerIdentity } : {}),
132
+ ...(signerIdentity ? { signer: signerIdentity } : {}),
231
133
  sw: {
232
134
  connectClassicSync: sw.connectClassicSync,
233
135
  subscribeToRepoChannel: sw.subscribeToRepoChannel,
234
136
  subscribeSyncState: sw.subscribeSyncState,
235
137
  },
236
138
  };
237
- window.uncache = uncache;
139
+ wireModuleSettings(accountDocHandle, moduleWatcher);
140
+ primeRootElement(rootElement, accountDocHandle);
141
+ moduleWatcher.doneLoading.then(() => log("doneLoading, tools registered:", getRegistry("patchwork:tool")
142
+ .all()
143
+ .map((t) => t.id)), (err) => console.error("doneLoading rejected:", err));
238
144
  installHashRouting({
239
145
  rootElement,
240
146
  repo,
@@ -243,110 +149,150 @@ export async function bootPatchworkSite(config) {
243
149
  });
244
150
  return { repo, moduleWatcher, accountDocHandle };
245
151
  }
246
- // ─── Internals ──────────────────────────────────────────────────────────
247
152
  /**
248
- * A module-list source is valid if it is an Automerge URL or looks like an
249
- * HTTP(S)/site-relative manifest URL.
153
+ * Resolve with the first repo port the worker delivers, calling `onRenewed` for
154
+ * every later one.
155
+ *
156
+ * subscribeToRepoChannel is deliberately not awaited: it resolves only after
157
+ * the boot channel's port-ready handshake, which can take its full 30s timeout
158
+ * against a stranded worker connection. Boot blocks on the first *delivered*
159
+ * port instead — if the boot channel stalls, worker recovery hands the listener
160
+ * a good port long before that timeout.
250
161
  */
162
+ function firstRepoPort(sw, onRenewed) {
163
+ return new Promise((resolve) => {
164
+ let seen = false;
165
+ void sw.subscribeToRepoChannel((port) => {
166
+ if (seen)
167
+ return onRenewed(port);
168
+ seen = true;
169
+ resolve(port);
170
+ });
171
+ });
172
+ }
173
+ async function createRepo(config, workerAdapter) {
174
+ if (config.keyhive) {
175
+ log("setting up keyhive");
176
+ initKeyhiveWasm();
177
+ const { hive, repo } = await initializeAutomergeRepoKeyhiveWithRepo({
178
+ createRepo: (repoConfig) => new Repo(repoConfig),
179
+ storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
180
+ peerIdSuffix: siteName + Math.random().toString(36).slice(2),
181
+ networkAdapter: workerAdapter,
182
+ automaticArchiveIngestion: true,
183
+ cachingMode: "periodic",
184
+ onlyShareWithHardcodedServerPeerId: false,
185
+ // ARK selects the relay via `syncServer`, defaulting to "subduction".
186
+ ...(useKeyhiveSyncServer ? { syncServer: "keyhive" } : {}),
187
+ repo: {
188
+ storage: new IndexedDBWorkerStorageAdapter(),
189
+ enableRemoteHeadsGossiping: true,
190
+ },
191
+ });
192
+ log("keyhive setup complete");
193
+ return { repo, hive };
194
+ }
195
+ // An explicit signer, rather than the Repo's internal default, so the tab's
196
+ // identity can be exposed on window.patchwork. The tab never connects via
197
+ // Subduction, so this id never goes on the wire.
198
+ const signer = new MemorySigner();
199
+ const repo = new Repo({
200
+ network: [workerAdapter],
201
+ storage: new IndexedDBWorkerStorageAdapter(),
202
+ signer,
203
+ async sharePolicy(peerId) {
204
+ return peerId.includes("automerge-worker");
205
+ },
206
+ enableRemoteHeadsGossiping: true,
207
+ peerId: `${config.titleSuffix}-tab-${crypto.randomUUID()}`,
208
+ });
209
+ const signerIdentity = {
210
+ peerId: signer.peerId().toString(),
211
+ verifyingKey: signer.verifyingKey().toHex(),
212
+ };
213
+ log("repo created, tab subduction identity:", signerIdentity);
214
+ return { repo, signerIdentity };
215
+ }
216
+ /** Drop the adapter sitting on the dead worker port, leaving `keep` in place. */
217
+ function removeAdapterFor(repo, stale, keep) {
218
+ for (const adapter of [...repo.networkSubsystem.adapters]) {
219
+ if (adapter === keep)
220
+ continue;
221
+ // The keyhive wrapper keeps the wrapped adapter on `.networkAdapter`.
222
+ const base = adapter.networkAdapter ?? adapter;
223
+ if (base !== stale)
224
+ continue;
225
+ try {
226
+ repo.networkSubsystem.removeNetworkAdapter(adapter);
227
+ }
228
+ catch (err) {
229
+ console.error("failed to remove stale worker network adapter", err);
230
+ }
231
+ }
232
+ }
251
233
  function isValidModuleSource(source) {
252
- if (isValidAutomergeUrl(source))
253
- return true;
254
- return (source.startsWith("/") ||
255
- source.startsWith("http://") ||
256
- source.startsWith("https://") ||
257
- source.startsWith("./"));
234
+ return isValidAutomergeUrl(source) || /^(https?:\/\/|\.?\/)/.test(source);
258
235
  }
259
236
  /**
260
- * Resolve the site's default module-list sources, honouring the
261
- * `localStorage.systemPackageListURL` dev override (which replaces the entire
262
- * built-in default bundle).
237
+ * The site's default module-list sources, honouring the
238
+ * `localStorage.systemPackageListURL` dev override, which replaces the entire
239
+ * built-in bundle. `defaultToolsUrl` is the pre-rename key.
263
240
  */
264
241
  function resolveDefaultModules(config) {
265
- const builtin = config.defaultModules ?? config.defaultModulesUrl ?? [];
266
- const builtinList = (Array.isArray(builtin) ? builtin : [builtin]).filter(Boolean);
242
+ const configured = config.defaultModules ?? config.defaultModulesUrl ?? [];
243
+ const builtin = (Array.isArray(configured) ? configured : [configured]).filter(Boolean);
267
244
  const storage = globalThis.localStorage;
268
- // `defaultToolsUrl` is the pre-rename key, still honoured for existing browsers.
269
245
  const override = storage?.getItem("systemPackageListURL") ??
270
246
  storage?.getItem("defaultToolsUrl");
247
+ if (override && isValidModuleSource(override)) {
248
+ console.info(`using systemPackageListURL from localStorage: ${override}`);
249
+ return [override];
250
+ }
271
251
  if (override) {
272
- if (isValidModuleSource(override)) {
273
- if (!builtinList.includes(override)) {
274
- console.info(`using systemPackageListURL override from localStorage: ${override}`);
275
- }
276
- return [override];
277
- }
278
- console.warn(`ignoring invalid systemPackageListURL in localStorage: ${override}; using built-in default`);
252
+ console.warn(`ignoring invalid systemPackageListURL in localStorage: ${override}`);
279
253
  }
280
- if (builtinList.length === 0) {
254
+ if (builtin.length === 0) {
281
255
  throw new Error("bootPatchworkSite: no default module sources configured (set `defaultModules`)");
282
256
  }
283
- return builtinList;
257
+ return builtin;
284
258
  }
285
259
  /**
286
- * Turn an ordered list of module-list sources into the name-keyed map the
287
- * ModuleWatcher expects. The first source keeps the canonical `system` name;
288
- * additional sources get suffixed names. None may be `user` (reserved for the
289
- * per-account settings doc, which has branch-override precedence).
260
+ * Name the sources for the ModuleWatcher. The first keeps the canonical
261
+ * `system` name; the rest get suffixed. None may be `user`, which is reserved
262
+ * for the per-account settings doc and has branch-override precedence.
290
263
  */
291
- function buildSystemSources(sources) {
292
- const map = {};
293
- sources.forEach((source, index) => {
294
- const name = index === 0 ? "system" : `system-${index}`;
295
- map[name] = source;
296
- });
297
- return map;
298
- }
299
- function installDevConsoleGlobals(repo, hive, getRepoChannel) {
300
- window.repo = repo;
301
- window.Automerge = Automerge;
302
- window.AutomergeRepo = AutomergeRepo;
303
- if (hive) {
304
- window.hive = hive;
305
- }
306
- window.getRepoChannel = getRepoChannel;
264
+ function nameSources(sources) {
265
+ return Object.fromEntries(sources.map((source, i) => [i === 0 ? "system" : `system-${i}`, source]));
307
266
  }
308
- /**
309
- * Log this tab's Page Lifecycle + connectivity transitions (visibility,
310
- * freeze, bfcache, online/offline) so they line up against the SharedWorker's
311
- * sync-socket reaps. [lifecycle]-tagged, on by default.
312
- */
267
+ /** Page Lifecycle and connectivity transitions, to line up against the
268
+ * SharedWorker's sync-socket reaps. */
313
269
  function installLifecycleLogging() {
314
270
  if (typeof document === "undefined")
315
271
  return;
316
272
  const opts = { capture: true };
317
- const note = (label, extra) => {
318
- if (!lifecycleLoggingEnabled())
319
- return;
320
- const msg = `[lifecycle] ${new Date().toISOString()} ${label}`;
321
- if (extra === undefined)
322
- console.info(msg);
323
- else
324
- console.info(msg, extra);
325
- };
326
- document.addEventListener("visibilitychange", () => note(`visibilitychange → ${document.visibilityState}`), opts);
327
- document.addEventListener("freeze", () => note("freeze (tab suspended)"), opts);
328
- document.addEventListener("resume", () => note("resume (tab unsuspended)"), opts);
329
- window.addEventListener("pageshow", (e) => note("pageshow", { persisted: e.persisted }), opts);
330
- window.addEventListener("pagehide", (e) => note("pagehide", { persisted: e.persisted }), opts);
331
- window.addEventListener("online", () => note("online"), opts);
332
- window.addEventListener("offline", () => note("offline"), opts);
333
- note(`lifecycle logging installed (visibilityState=${document.visibilityState}, hasFocus=${document.hasFocus()})`);
273
+ const persisted = (e) => e.persisted;
274
+ document.addEventListener("visibilitychange", () => lifecycleLog("visibilitychange → %s", document.visibilityState), opts);
275
+ document.addEventListener("freeze", () => lifecycleLog("freeze (tab suspended)"), opts);
276
+ document.addEventListener("resume", () => lifecycleLog("resume (tab unsuspended)"), opts);
277
+ window.addEventListener("pageshow", (e) => lifecycleLog("pageshow persisted=%s", persisted(e)), opts);
278
+ window.addEventListener("pagehide", (e) => lifecycleLog("pagehide persisted=%s", persisted(e)), opts);
279
+ window.addEventListener("online", () => lifecycleLog("online"), opts);
280
+ window.addEventListener("offline", () => lifecycleLog("offline"), opts);
281
+ lifecycleLog("logging installed (visibilityState=%s, hasFocus=%s)", document.visibilityState, document.hasFocus());
334
282
  }
335
283
  function onModuleLoaded(name, mod) {
336
- if (Array.isArray(mod.plugins)) {
337
- log(`registering ${mod.plugins.length} plugin(s) from ${name.slice(0, 30)}...`, mod.plugins.map((p) => `${p.type}:${p.id}`));
338
- registerPlugins(mod.plugins, name);
339
- }
340
- else {
341
- console.warn(`module ${name.slice(0, 30)}... has no plugins array`, Object.keys(mod));
284
+ if (!Array.isArray(mod.plugins)) {
285
+ console.warn(`module ${name} has no plugins array`, Object.keys(mod));
286
+ return;
342
287
  }
288
+ log(`registering ${mod.plugins.length} plugin(s) from ${name}`, mod.plugins.map((p) => `${p.type}:${p.id}`));
289
+ registerPlugins(mod.plugins, name);
343
290
  }
344
291
  /**
345
- * The frame lazy-creates `moduleSettingsUrl` on first mount. Watch for it to
346
- * appear on the account doc and feed it into the ModuleWatcher so the user's
347
- * own tool bundle loads alongside the site default. Idempotent.
292
+ * The frame lazy-creates `moduleSettingsUrl` on first mount, so watch for it to
293
+ * appear and feed it to the ModuleWatcher.
348
294
  */
349
- function wireModuleSettingsWhenReady(accountDocHandle, moduleWatcher) {
295
+ function wireModuleSettings(accountDocHandle, moduleWatcher) {
350
296
  const wire = () => {
351
297
  const url = accountDocHandle.doc()?.moduleSettingsUrl;
352
298
  if (!url)
@@ -359,78 +305,53 @@ function wireModuleSettingsWhenReady(accountDocHandle, moduleWatcher) {
359
305
  accountDocHandle.on("change", wire);
360
306
  }
361
307
  }
362
- /**
363
- * Set initial `tool-id` / `doc-url` attributes on the root
364
- * `<patchwork-view>` based on the URL hash (if it specifies a frame
365
- * override) or the account doc's configured frame tool + the account doc
366
- * itself.
367
- */
368
308
  function primeRootElement(rootElement, accountDocHandle) {
369
309
  rootElement.style.visibility = "hidden";
370
- const initialParams = new URLSearchParams(location.hash.slice(1));
371
- if (initialParams.has("frame")) {
372
- rootElement.setAttribute("tool-id", initialParams.get("frame"));
373
- const docUrl = docParamToUrl(initialParams.get("doc")) ?? accountDocHandle.url;
374
- rootElement.setAttribute("doc-url", docUrl);
375
- }
376
- else {
377
- rootElement.setAttribute("tool-id", accountDocHandle.doc().frameToolId);
378
- rootElement.setAttribute("doc-url", accountDocHandle.url);
379
- }
380
- }
381
- function logToolRegistryWhenLoaded(moduleWatcher) {
382
- moduleWatcher.doneLoading
383
- .then(() => {
384
- const toolReg = getRegistry("patchwork:tool");
385
- const tools = toolReg.all();
386
- log(`doneLoading: ${tools.length} tools registered:`, tools.map((t) => t.id));
387
- })
388
- .catch((err) => {
389
- console.error("doneLoading rejected:", err);
390
- });
310
+ const params = new URLSearchParams(location.hash.slice(1));
311
+ const frame = params.get("frame");
312
+ rootElement.setAttribute("tool-id", frame ?? accountDocHandle.doc().frameToolId);
313
+ rootElement.setAttribute("doc-url", (frame && docParamToUrl(params.get("doc"))) || accountDocHandle.url);
391
314
  }
315
+ // ── Loading animation ───────────────────────────────────────────────────
392
316
  const LOADING_STYLE_ID = "pw-bootloader-loading-styles";
393
317
  const LOADING_ELEMENT_ID = "pw-bootloader-loading";
318
+ const LOADING_CSS = `
319
+ @keyframes pw-bootloader-pulse {
320
+ 0%, 100% { opacity: 0.25; }
321
+ 50% { opacity: 0.95; }
322
+ }
323
+ #${LOADING_ELEMENT_ID} {
324
+ position: fixed;
325
+ inset: 0;
326
+ z-index: 0;
327
+ pointer-events: none;
328
+ background-color: #fff;
329
+ background-image:
330
+ radial-gradient(ellipse 55% 45% at 28% 35%, #fde4ec, transparent 70%),
331
+ radial-gradient(ellipse 50% 55% at 72% 65%, #e0f0fb, transparent 70%),
332
+ radial-gradient(ellipse 65% 55% at 50% 50%, #f1e6f6, transparent 80%);
333
+ animation: pw-bootloader-pulse 3.5s ease-in-out infinite;
334
+ transition: opacity 0.6s ease-out;
335
+ }
336
+ @media (prefers-color-scheme: dark) {
337
+ #${LOADING_ELEMENT_ID} {
338
+ background-color: #000;
339
+ background-image:
340
+ radial-gradient(ellipse 55% 45% at 28% 35%, #2a1d33, transparent 70%),
341
+ radial-gradient(ellipse 50% 55% at 72% 65%, #1a2738, transparent 70%),
342
+ radial-gradient(ellipse 65% 55% at 50% 50%, #221a2e, transparent 80%);
343
+ }
344
+ }
345
+ #${LOADING_ELEMENT_ID}.pw-bootloader-fading {
346
+ opacity: 0;
347
+ animation: none;
348
+ }
349
+ `;
394
350
  function showLoadingAnimation() {
395
351
  if (!document.getElementById(LOADING_STYLE_ID)) {
396
352
  const style = document.createElement("style");
397
353
  style.id = LOADING_STYLE_ID;
398
- style.textContent = `
399
- @keyframes pw-bootloader-pulse {
400
- 0%, 100% { opacity: 0.25; }
401
- 50% { opacity: 0.95; }
402
- }
403
- #${LOADING_ELEMENT_ID} {
404
- position: fixed;
405
- inset: 0;
406
- z-index: 0;
407
- pointer-events: none;
408
- background-color: #fff;
409
- background-image:
410
- radial-gradient(ellipse 55% 45% at 28% 35%, #fde4ec, transparent 70%),
411
- radial-gradient(ellipse 50% 55% at 72% 65%, #e0f0fb, transparent 70%),
412
- radial-gradient(ellipse 65% 55% at 50% 50%, #f1e6f6, transparent 80%);
413
- animation: pw-bootloader-pulse 3.5s ease-in-out infinite;
414
- transition: opacity 0.6s ease-out;
415
- top: 0;
416
- left: 0;
417
- right: 0;
418
- bottom: 0;
419
- }
420
- @media (prefers-color-scheme: dark) {
421
- #${LOADING_ELEMENT_ID} {
422
- background-color: #000;
423
- background-image:
424
- radial-gradient(ellipse 55% 45% at 28% 35%, #2a1d33, transparent 70%),
425
- radial-gradient(ellipse 50% 55% at 72% 65%, #1a2738, transparent 70%),
426
- radial-gradient(ellipse 65% 55% at 50% 50%, #221a2e, transparent 80%);
427
- }
428
- }
429
- #${LOADING_ELEMENT_ID}.pw-bootloader-fading {
430
- opacity: 0;
431
- animation: none;
432
- }
433
- `;
354
+ style.textContent = LOADING_CSS;
434
355
  document.head.appendChild(style);
435
356
  }
436
357
  if (document.getElementById(LOADING_ELEMENT_ID))
@@ -450,40 +371,41 @@ async function uncache(match) {
450
371
  for (const name of await caches.keys()) {
451
372
  const cache = await caches.open(name);
452
373
  for (const request of await cache.keys()) {
453
- if (request.url.includes(match)) {
374
+ if (request.url.includes(match))
454
375
  cache.delete(request);
455
- }
456
376
  }
457
377
  }
458
378
  }
459
- // The `doc=` value is an automerge URL — we keep its `:` (and any `#`/`|`
460
- // heads) literal rather than percent-encoding it so links stay readable.
379
+ // ── Hash routing ────────────────────────────────────────────────────────
380
+ // Legacy big-patchwork hash shape: `<slug>--<documentId>[?…]`. The slug can
381
+ // contain characters we don't otherwise permit (e.g. `drawing-(branch-1)`), so
382
+ // anchor on the `--` before the base58 document id rather than a strict slug
383
+ // charset.
384
+ const BIG_PATCHWORK_HASH_REGEX = /^(?<title>[^=&?/#]*)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)/;
385
+ // The `doc=` value is an automerge URL, kept literal rather than
386
+ // percent-encoded so links stay readable.
461
387
  const RAW_HASH_KEYS = new Set(["doc"]);
462
- // Emit hash params in a stable order so re-serializing the same logical params
463
- // yields a byte-identical string (avoids spurious `hashchange` round-trips).
388
+ // A stable order means re-serializing the same logical params is
389
+ // byte-identical, avoiding spurious `hashchange` round-trips.
464
390
  const HASH_KEY_ORDER = ["doc", "tool", "type", "title", "frame"];
465
391
  function serializeHashParams(params) {
466
- const emitted = new Set();
392
+ const keys = [...HASH_KEY_ORDER, ...params.keys()];
467
393
  const parts = [];
468
- const emit = (key) => {
394
+ const emitted = new Set();
395
+ for (const key of keys) {
469
396
  if (emitted.has(key))
470
- return;
397
+ continue;
471
398
  const value = params.get(key);
472
399
  if (!value)
473
- return;
400
+ continue;
474
401
  emitted.add(key);
475
402
  parts.push(`${key}=${RAW_HASH_KEYS.has(key) ? value : encodeURIComponent(value)}`);
476
- };
477
- for (const key of HASH_KEY_ORDER)
478
- emit(key);
479
- for (const key of params.keys())
480
- emit(key);
403
+ }
481
404
  return parts.join("&");
482
405
  }
483
406
  /**
484
407
  * Coerce a `doc=` hash param to a full automerge URL. Accepts a full URL
485
- * (`automerge:<id>[#heads]`) or a bare document id for backwards compatibility
486
- * with older links.
408
+ * (`automerge:<id>[#heads]`) or a bare document id, for older links.
487
409
  */
488
410
  function docParamToUrl(docParam) {
489
411
  if (!docParam)
@@ -492,32 +414,67 @@ function docParamToUrl(docParam) {
492
414
  return docParam;
493
415
  }
494
416
  const documentId = docParam.replace(/^automerge:/, "");
495
- if (isValidDocumentId(documentId)) {
496
- return stringifyAutomergeUrl({ documentId: documentId });
497
- }
498
- return undefined;
417
+ if (!isValidDocumentId(documentId))
418
+ return undefined;
419
+ return stringifyAutomergeUrl({ documentId: documentId });
499
420
  }
500
- function installHashRouting(params) {
501
- const { rootElement, repo, accountDocHandle, titleSuffix } = params;
421
+ function installHashRouting({ rootElement, repo, accountDocHandle, titleSuffix, }) {
422
+ const handleHashChange = async () => {
423
+ const hash = window.location.hash.slice(1);
424
+ // Legacy big-patchwork link: normalize to `#doc=automerge:<docId>` and let
425
+ // routing re-run on the resulting hashchange.
426
+ const legacyDocId = BIG_PATCHWORK_HASH_REGEX.exec(hash)?.groups?.docId;
427
+ if (legacyDocId && isValidDocumentId(legacyDocId)) {
428
+ window.location.hash = serializeHashParams(new URLSearchParams({
429
+ doc: stringifyAutomergeUrl({ documentId: legacyDocId }),
430
+ }));
431
+ return;
432
+ }
433
+ // Bare automerge URL: /#automerge:<documentId>
434
+ if (isValidAutomergeUrl(hash)) {
435
+ window.location.hash = "";
436
+ openDocument(rootElement, hash);
437
+ return;
438
+ }
439
+ const params = new URLSearchParams(hash);
440
+ const docUrl = docParamToUrl(params.get("doc"));
441
+ const frame = params.get("frame");
442
+ if (frame) {
443
+ const frameDocUrl = docUrl ?? accountDocHandle.url;
444
+ if (rootElement.getAttribute("tool-id") !== frame ||
445
+ rootElement.getAttribute("doc-url") !== frameDocUrl) {
446
+ rootElement.setAttribute("tool-id", frame);
447
+ rootElement.setAttribute("doc-url", frameDocUrl);
448
+ }
449
+ }
450
+ if (docUrl) {
451
+ rootElement.dispatchEvent(new CustomEvent("patchwork:open-document", {
452
+ detail: {
453
+ url: docUrl,
454
+ toolId: params.get("tool"),
455
+ title: params.get("title"),
456
+ type: params.get("type"),
457
+ },
458
+ }));
459
+ }
460
+ };
502
461
  rootElement.addEventListener("patchwork:open-document", async (event) => {
503
- const params = new URLSearchParams(window.location.hash.slice(1));
504
462
  const { url, toolId, type, title } = event.detail;
505
- // `doc` is now the full automerge URL — heads, if any, live inside it, so
506
- // the separate `heads=` param is gone.
463
+ const params = new URLSearchParams(window.location.hash.slice(1));
464
+ // `doc` is the full automerge URL, so heads live inside it and the separate
465
+ // `heads=` param is gone.
507
466
  params.delete("heads");
508
467
  params.set("doc", url);
509
- if (toolId)
510
- params.set("tool", toolId);
511
- else
512
- params.delete("tool");
513
- if (title)
514
- params.set("title", title);
515
- else
516
- params.delete("title");
517
- if (type)
518
- params.set("type", type);
519
- else
520
- params.delete("type");
468
+ for (const [key, value] of [
469
+ ["tool", toolId],
470
+ ["title", title],
471
+ ["type", type],
472
+ ]) {
473
+ if (value)
474
+ params.set(key, value);
475
+ else
476
+ params.delete(key);
477
+ }
521
478
  window.location.hash = serializeHashParams(params);
522
479
  try {
523
480
  const docHandle = await repo.find(url);
@@ -525,78 +482,36 @@ function installHashRouting(params) {
525
482
  const docType = type || doc?.["@patchwork"]?.type;
526
483
  if (!docType)
527
484
  return;
528
- const registry = getRegistry("patchwork:datatype");
529
- const datatype = await registry.load(docType);
485
+ const datatype = await getRegistry("patchwork:datatype").load(docType);
530
486
  if (!datatype)
531
487
  return;
532
488
  const docTitle = datatype.module.getTitle(doc);
533
- if (docTitle) {
489
+ if (docTitle)
534
490
  document.title = `${docTitle} | ${titleSuffix}`;
535
- }
536
491
  }
537
492
  catch (e) {
538
493
  console.error("Failed to update document title", e);
539
494
  }
540
495
  });
541
- let firstMount = true;
496
+ let revealed = false;
542
497
  const reveal = () => {
543
- if (!firstMount)
498
+ if (revealed)
544
499
  return;
545
- firstMount = false;
500
+ revealed = true;
546
501
  rootElement.style.visibility = "visible";
547
502
  hideLoadingAnimation();
548
503
  };
549
504
  rootElement.addEventListener("patchwork:mounted", (event) => {
550
- handleHashChange();
551
505
  if (event.target !== rootElement)
552
506
  return;
553
- console.info("root element mounted");
507
+ log("root element mounted");
508
+ void handleHashChange();
554
509
  reveal();
555
- // Re-resolve routing after a beat so deep-links from freshly-loaded tools
556
- // get a second chance to render.
510
+ // Deep-links from freshly-loaded tools get a second chance to render.
557
511
  setTimeout(handleHashChange, 1000);
558
512
  });
559
- // Failsafe: if nothing ever mounts, reveal the element anyway after 12s so
560
- // the user sees *something* rather than a blank page.
513
+ // If nothing ever mounts, reveal anyway so the user sees something rather
514
+ // than a blank page.
561
515
  setTimeout(reveal, 12_000);
562
- const handleHashChange = async () => {
563
- const hash = window.location.hash.slice(1);
564
- // Legacy big-patchwork link (`<slug>--<docId>?…`): if the hash carries a
565
- // `--` followed by a valid document id, normalize it to the canonical
566
- // `#doc=automerge:<docId>` form and let routing re-run on the hashchange.
567
- const legacyDocId = BIG_PATCHWORK_HASH_REGEX.exec(hash)?.groups?.docId;
568
- if (legacyDocId && isValidDocumentId(legacyDocId)) {
569
- window.location.hash = serializeHashParams(new URLSearchParams({
570
- doc: stringifyAutomergeUrl({ documentId: legacyDocId }),
571
- }));
572
- return;
573
- }
574
- // Bare automerge URL in hash: /#automerge:<documentId>
575
- if (isValidAutomergeUrl(hash)) {
576
- const url = hash;
577
- window.location.hash = "";
578
- openDocument(rootElement, url);
579
- return;
580
- }
581
- const params = new URLSearchParams(hash);
582
- const docUrl = docParamToUrl(params.get("doc"));
583
- const toolId = params.get("tool");
584
- const title = params.get("title");
585
- const type = params.get("type");
586
- const frame = params.get("frame");
587
- if (frame) {
588
- const frameDocUrl = docUrl ?? accountDocHandle.url;
589
- if (rootElement.getAttribute("tool-id") !== frame ||
590
- rootElement.getAttribute("doc-url") !== frameDocUrl) {
591
- rootElement.setAttribute("tool-id", frame);
592
- rootElement.setAttribute("doc-url", frameDocUrl);
593
- }
594
- }
595
- if (docUrl) {
596
- rootElement.dispatchEvent(new CustomEvent("patchwork:open-document", {
597
- detail: { url: docUrl, toolId, title, type },
598
- }));
599
- }
600
- };
601
516
  window.addEventListener("hashchange", handleHashChange);
602
517
  }