@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/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,141 +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__ : "tiny-patchwork";
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([
40
- fetch("/automerge.wasm?main").then((r) => r.bytes()),
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([
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
- // If a Repo is already on `window` an embedding context provided one before
68
- // this entry ran reuse it and its keyhive instead of standing up a fresh
69
- // realm-local Repo, so we share the same documents and sync/keyhive context.
70
- // Otherwise create our own below.
56
+ let signerIdentity;
57
+ // Called with a fresh port when the automerge worker dies and is recreated.
58
+ // Assigned once the repo exists.
59
+ let onWorkerPortRenewed;
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.
71
62
  if (window.repo) {
72
63
  log("using existing Repo from window");
73
64
  repo = window.repo;
74
65
  hive = window.hive;
75
66
  }
76
67
  else {
77
- // Get the initial automerge-worker port via subscribeToRepoChannel,
78
- // then pass it to keyhive init which wraps it in its own network adapter.
79
- let resolvePort;
80
- const portPromise = new Promise((r) => {
81
- resolvePort = r;
68
+ const workerPort = await firstRepoPort(sw, (port) => {
69
+ if (onWorkerPortRenewed)
70
+ onWorkerPortRenewed(port);
71
+ else {
72
+ console.warn("automerge worker port renewed before the repo existed; dropping it");
73
+ }
82
74
  });
83
- log("subscribing to repo channel");
84
- await sw.subscribeToRepoChannel(resolvePort);
85
- log("repo channel subscribed");
86
- const workerPort = await portPromise;
87
- if (config.keyhive) {
88
- log("setting up keyhive");
89
- initKeyhiveWasm();
90
- ({ hive, repo } = await initializeAutomergeRepoKeyhiveWithRepo({
91
- createRepo: (config) => new Repo(config),
92
- storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
93
- peerIdSuffix: siteName + Math.random().toString(36).slice(2),
94
- networkAdapter: new MessageChannelNetworkAdapter(workerPort),
95
- automaticArchiveIngestion: true,
96
- cachingMode: "periodic",
97
- onlyShareWithHardcodedServerPeerId: false,
98
- // ARK selects the relay via `syncServer` ("keyhive" | "subduction").
99
- // Defaults to "subduction".
100
- ...(useKeyhiveSyncServer ? { syncServer: "keyhive" } : {}),
101
- repo: {
102
- storage: new IndexedDBWorkerStorageAdapter(),
103
- enableRemoteHeadsGossiping: true,
104
- },
105
- }));
106
- log("keyhive setup complete");
107
- }
108
- else {
109
- log("creating repo");
110
- // Pass an explicit signer (instead of the Repo's internal default) so we
111
- // can expose tab signer identity on window.patchwork for dev inspection.
112
- // The tab never connects via Subduction (no endpoints/adapters), so this
113
- // id never goes on the wire.
114
- const tabSigner = new MemorySigner();
115
- repo = new Repo({
116
- network: [new MessageChannelNetworkAdapter(workerPort)],
117
- storage: new IndexedDBWorkerStorageAdapter(),
118
- signer: tabSigner,
119
- async sharePolicy(peerId) {
120
- return peerId.includes("automerge-worker");
121
- },
122
- enableRemoteHeadsGossiping: true,
123
- peerId: `${config.titleSuffix}-tab-${crypto.randomUUID()}`,
124
- });
125
- tabSignerIdentity = {
126
- peerId: tabSigner.peerId().toString(),
127
- verifyingKey: tabSigner.verifyingKey().toHex(),
128
- };
129
- console.log("[patchwork] tab subduction identity:", tabSignerIdentity);
130
- log("repo created");
131
- }
75
+ let workerAdapter = new MessageChannelNetworkAdapter(workerPort);
76
+ ({ repo, hive, signerIdentity } = await createRepo(config, workerAdapter));
77
+ // The worker was recreated with cold state: wire the repo onto the fresh
78
+ // port and drop the adapter stranded on the dead one.
79
+ const bootHive = hive;
80
+ onWorkerPortRenewed = (port) => {
81
+ const fresh = new MessageChannelNetworkAdapter(port);
82
+ // Mirror the boot wiring: a keyhive repo talks to the worker through a
83
+ // keyhive adapter wrapped around the message channel.
84
+ const registered = bootHive
85
+ ? bootHive.createKeyhiveNetworkAdapter(fresh, false, false, 2000)
86
+ : fresh;
87
+ repo.networkSubsystem.addNetworkAdapter(registered);
88
+ removeAdapterFor(repo, workerAdapter, registered);
89
+ workerAdapter = fresh;
90
+ lifecycleLog("repo re-wired to the recreated automerge worker");
91
+ };
132
92
  }
133
- log("popping repo on window");
134
93
  window.repo = repo;
135
- 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;
136
99
  await repo.networkSubsystem.whenReady();
137
100
  log("networkSubsystem ready");
138
- if (hive) {
139
- hive.networkAdapter.syncKeyhive?.();
140
- }
141
- installDevConsoleGlobals(repo, hive, sw.getRepoChannel);
101
+ hive?.networkAdapter?.syncKeyhive?.();
142
102
  registerRepoProviderElement(repo);
143
- const rootElement = document.getElementById(config.rootElementId ?? "root");
103
+ const rootElementId = config.rootElementId ?? "root";
104
+ const rootElement = document.getElementById(rootElementId);
144
105
  if (!rootElement) {
145
- throw new Error(`bootPatchworkSite: no element with id="${config.rootElementId ?? "root"}"`);
106
+ throw new Error(`bootPatchworkSite: no element with id="${rootElementId}"`);
146
107
  }
147
108
  // `<repo-provider>` sits above the root and answers `repo:handle-descriptor`
148
- // 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.
149
110
  const repoProvider = document.createElement("repo-provider");
150
111
  rootElement.parentElement.insertBefore(repoProvider, rootElement);
151
112
  repoProvider.appendChild(rootElement);
152
113
  registerPatchworkViewElement({ hive, repo });
153
- // The watcher is started with the site's default-tools bundle alone so that
154
- // `resolveAccountHandle` below has something to await on (the `account`
155
- // datatype lives in that bundle today). The user's own module-settings URL
156
- // is added lazily once it appears on the account doc — see below.
157
- 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,
158
118
  // Discover an Automerge package's plugin descriptors off the main thread;
159
119
  // each plugin's load() re-imports the package (at heads) on this thread.
160
120
  importAutomergePackageViaWorker);
@@ -162,25 +122,25 @@ export async function bootPatchworkSite(config) {
162
122
  storageKey: config.accountStorageKey,
163
123
  hive,
164
124
  }));
165
- // TODO: something we (Orion & pvh) changed in the types made this necessary
166
- // fix this before merging to main!
167
125
  window.accountDocHandle = accountDocHandle;
168
- wireModuleSettingsWhenReady(accountDocHandle, moduleWatcher);
169
- primeRootElement(rootElement, accountDocHandle);
170
- logToolRegistryWhenLoaded(moduleWatcher);
126
+ window.uncache = uncache;
171
127
  window.patchwork = {
172
128
  repo,
173
129
  packages: moduleWatcher,
174
130
  plugins,
175
131
  accountDocHandle,
176
- ...(tabSignerIdentity ? { signer: tabSignerIdentity } : {}),
132
+ ...(signerIdentity ? { signer: signerIdentity } : {}),
177
133
  sw: {
178
134
  connectClassicSync: sw.connectClassicSync,
179
135
  subscribeToRepoChannel: sw.subscribeToRepoChannel,
180
136
  subscribeSyncState: sw.subscribeSyncState,
181
137
  },
182
138
  };
183
- 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));
184
144
  installHashRouting({
185
145
  rootElement,
186
146
  repo,
@@ -189,110 +149,150 @@ export async function bootPatchworkSite(config) {
189
149
  });
190
150
  return { repo, moduleWatcher, accountDocHandle };
191
151
  }
192
- // ─── Internals ──────────────────────────────────────────────────────────
193
152
  /**
194
- * A module-list source is valid if it is an Automerge URL or looks like an
195
- * 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.
196
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
+ }
197
233
  function isValidModuleSource(source) {
198
- if (isValidAutomergeUrl(source))
199
- return true;
200
- return (source.startsWith("/") ||
201
- source.startsWith("http://") ||
202
- source.startsWith("https://") ||
203
- source.startsWith("./"));
234
+ return isValidAutomergeUrl(source) || /^(https?:\/\/|\.?\/)/.test(source);
204
235
  }
205
236
  /**
206
- * Resolve the site's default module-list sources, honouring the
207
- * `localStorage.systemPackageListURL` dev override (which replaces the entire
208
- * 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.
209
240
  */
210
241
  function resolveDefaultModules(config) {
211
- const builtin = config.defaultModules ?? config.defaultModulesUrl ?? [];
212
- 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);
213
244
  const storage = globalThis.localStorage;
214
- // `defaultToolsUrl` is the pre-rename key, still honoured for existing browsers.
215
245
  const override = storage?.getItem("systemPackageListURL") ??
216
246
  storage?.getItem("defaultToolsUrl");
247
+ if (override && isValidModuleSource(override)) {
248
+ console.info(`using systemPackageListURL from localStorage: ${override}`);
249
+ return [override];
250
+ }
217
251
  if (override) {
218
- if (isValidModuleSource(override)) {
219
- if (!builtinList.includes(override)) {
220
- console.info(`using systemPackageListURL override from localStorage: ${override}`);
221
- }
222
- return [override];
223
- }
224
- console.warn(`ignoring invalid systemPackageListURL in localStorage: ${override}; using built-in default`);
252
+ console.warn(`ignoring invalid systemPackageListURL in localStorage: ${override}`);
225
253
  }
226
- if (builtinList.length === 0) {
254
+ if (builtin.length === 0) {
227
255
  throw new Error("bootPatchworkSite: no default module sources configured (set `defaultModules`)");
228
256
  }
229
- return builtinList;
257
+ return builtin;
230
258
  }
231
259
  /**
232
- * Turn an ordered list of module-list sources into the name-keyed map the
233
- * ModuleWatcher expects. The first source keeps the canonical `system` name;
234
- * additional sources get suffixed names. None may be `user` (reserved for the
235
- * 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.
236
263
  */
237
- function buildSystemSources(sources) {
238
- const map = {};
239
- sources.forEach((source, index) => {
240
- const name = index === 0 ? "system" : `system-${index}`;
241
- map[name] = source;
242
- });
243
- return map;
264
+ function nameSources(sources) {
265
+ return Object.fromEntries(sources.map((source, i) => [i === 0 ? "system" : `system-${i}`, source]));
244
266
  }
245
- function installDevConsoleGlobals(repo, hive, getRepoChannel) {
246
- window.repo = repo;
247
- window.Automerge = Automerge;
248
- window.AutomergeRepo = AutomergeRepo;
249
- if (hive) {
250
- window.hive = hive;
251
- }
252
- window.getRepoChannel = getRepoChannel;
253
- }
254
- /**
255
- * Log this tab's Page Lifecycle + connectivity transitions (visibility,
256
- * freeze, bfcache, online/offline) so they line up against the SharedWorker's
257
- * sync-socket reaps. [lifecycle]-tagged, on by default.
258
- */
267
+ /** Page Lifecycle and connectivity transitions, to line up against the
268
+ * SharedWorker's sync-socket reaps. */
259
269
  function installLifecycleLogging() {
260
270
  if (typeof document === "undefined")
261
271
  return;
262
272
  const opts = { capture: true };
263
- const note = (label, extra) => {
264
- if (!lifecycleLoggingEnabled())
265
- return;
266
- const msg = `[lifecycle] ${new Date().toISOString()} ${label}`;
267
- if (extra === undefined)
268
- console.info(msg);
269
- else
270
- console.info(msg, extra);
271
- };
272
- document.addEventListener("visibilitychange", () => note(`visibilitychange → ${document.visibilityState}`), opts);
273
- document.addEventListener("freeze", () => note("freeze (tab suspended)"), opts);
274
- document.addEventListener("resume", () => note("resume (tab unsuspended)"), opts);
275
- window.addEventListener("pageshow", e => note("pageshow", { persisted: e.persisted }), opts);
276
- window.addEventListener("pagehide", e => note("pagehide", { persisted: e.persisted }), opts);
277
- window.addEventListener("online", () => note("online"), opts);
278
- window.addEventListener("offline", () => note("offline"), opts);
279
- 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());
280
282
  }
281
283
  function onModuleLoaded(name, mod) {
282
- if (Array.isArray(mod.plugins)) {
283
- log(`registering ${mod.plugins.length} plugin(s) from ${name.slice(0, 30)}...`, mod.plugins.map((p) => `${p.type}:${p.id}`));
284
- registerPlugins(mod.plugins, name);
285
- }
286
- else {
287
- 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;
288
287
  }
288
+ log(`registering ${mod.plugins.length} plugin(s) from ${name}`, mod.plugins.map((p) => `${p.type}:${p.id}`));
289
+ registerPlugins(mod.plugins, name);
289
290
  }
290
291
  /**
291
- * The frame lazy-creates `moduleSettingsUrl` on first mount. Watch for it to
292
- * appear on the account doc and feed it into the ModuleWatcher so the user's
293
- * 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.
294
294
  */
295
- function wireModuleSettingsWhenReady(accountDocHandle, moduleWatcher) {
295
+ function wireModuleSettings(accountDocHandle, moduleWatcher) {
296
296
  const wire = () => {
297
297
  const url = accountDocHandle.doc()?.moduleSettingsUrl;
298
298
  if (!url)
@@ -305,78 +305,53 @@ function wireModuleSettingsWhenReady(accountDocHandle, moduleWatcher) {
305
305
  accountDocHandle.on("change", wire);
306
306
  }
307
307
  }
308
- /**
309
- * Set initial `tool-id` / `doc-url` attributes on the root
310
- * `<patchwork-view>` based on the URL hash (if it specifies a frame
311
- * override) or the account doc's configured frame tool + the account doc
312
- * itself.
313
- */
314
308
  function primeRootElement(rootElement, accountDocHandle) {
315
309
  rootElement.style.visibility = "hidden";
316
- const initialParams = new URLSearchParams(location.hash.slice(1));
317
- if (initialParams.has("frame")) {
318
- rootElement.setAttribute("tool-id", initialParams.get("frame"));
319
- const docUrl = docParamToUrl(initialParams.get("doc")) ?? accountDocHandle.url;
320
- rootElement.setAttribute("doc-url", docUrl);
321
- }
322
- else {
323
- rootElement.setAttribute("tool-id", accountDocHandle.doc().frameToolId);
324
- rootElement.setAttribute("doc-url", accountDocHandle.url);
325
- }
326
- }
327
- function logToolRegistryWhenLoaded(moduleWatcher) {
328
- moduleWatcher.doneLoading
329
- .then(() => {
330
- const toolReg = getRegistry("patchwork:tool");
331
- const tools = toolReg.all();
332
- log(`doneLoading: ${tools.length} tools registered:`, tools.map((t) => t.id));
333
- })
334
- .catch((err) => {
335
- console.error("doneLoading rejected:", err);
336
- });
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);
337
314
  }
315
+ // ── Loading animation ───────────────────────────────────────────────────
338
316
  const LOADING_STYLE_ID = "pw-bootloader-loading-styles";
339
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
+ `;
340
350
  function showLoadingAnimation() {
341
351
  if (!document.getElementById(LOADING_STYLE_ID)) {
342
352
  const style = document.createElement("style");
343
353
  style.id = LOADING_STYLE_ID;
344
- style.textContent = `
345
- @keyframes pw-bootloader-pulse {
346
- 0%, 100% { opacity: 0.25; }
347
- 50% { opacity: 0.95; }
348
- }
349
- #${LOADING_ELEMENT_ID} {
350
- position: fixed;
351
- inset: 0;
352
- z-index: 0;
353
- pointer-events: none;
354
- background-color: #fff;
355
- background-image:
356
- radial-gradient(ellipse 55% 45% at 28% 35%, #fde4ec, transparent 70%),
357
- radial-gradient(ellipse 50% 55% at 72% 65%, #e0f0fb, transparent 70%),
358
- radial-gradient(ellipse 65% 55% at 50% 50%, #f1e6f6, transparent 80%);
359
- animation: pw-bootloader-pulse 3.5s ease-in-out infinite;
360
- transition: opacity 0.6s ease-out;
361
- top: 0;
362
- left: 0;
363
- right: 0;
364
- bottom: 0;
365
- }
366
- @media (prefers-color-scheme: dark) {
367
- #${LOADING_ELEMENT_ID} {
368
- background-color: #000;
369
- background-image:
370
- radial-gradient(ellipse 55% 45% at 28% 35%, #2a1d33, transparent 70%),
371
- radial-gradient(ellipse 50% 55% at 72% 65%, #1a2738, transparent 70%),
372
- radial-gradient(ellipse 65% 55% at 50% 50%, #221a2e, transparent 80%);
373
- }
374
- }
375
- #${LOADING_ELEMENT_ID}.pw-bootloader-fading {
376
- opacity: 0;
377
- animation: none;
378
- }
379
- `;
354
+ style.textContent = LOADING_CSS;
380
355
  document.head.appendChild(style);
381
356
  }
382
357
  if (document.getElementById(LOADING_ELEMENT_ID))
@@ -396,40 +371,41 @@ async function uncache(match) {
396
371
  for (const name of await caches.keys()) {
397
372
  const cache = await caches.open(name);
398
373
  for (const request of await cache.keys()) {
399
- if (request.url.includes(match)) {
374
+ if (request.url.includes(match))
400
375
  cache.delete(request);
401
- }
402
376
  }
403
377
  }
404
378
  }
405
- // The `doc=` value is an automerge URL — we keep its `:` (and any `#`/`|`
406
- // 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.
407
387
  const RAW_HASH_KEYS = new Set(["doc"]);
408
- // Emit hash params in a stable order so re-serializing the same logical params
409
- // 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.
410
390
  const HASH_KEY_ORDER = ["doc", "tool", "type", "title", "frame"];
411
391
  function serializeHashParams(params) {
412
- const emitted = new Set();
392
+ const keys = [...HASH_KEY_ORDER, ...params.keys()];
413
393
  const parts = [];
414
- const emit = (key) => {
394
+ const emitted = new Set();
395
+ for (const key of keys) {
415
396
  if (emitted.has(key))
416
- return;
397
+ continue;
417
398
  const value = params.get(key);
418
399
  if (!value)
419
- return;
400
+ continue;
420
401
  emitted.add(key);
421
402
  parts.push(`${key}=${RAW_HASH_KEYS.has(key) ? value : encodeURIComponent(value)}`);
422
- };
423
- for (const key of HASH_KEY_ORDER)
424
- emit(key);
425
- for (const key of params.keys())
426
- emit(key);
403
+ }
427
404
  return parts.join("&");
428
405
  }
429
406
  /**
430
407
  * Coerce a `doc=` hash param to a full automerge URL. Accepts a full URL
431
- * (`automerge:<id>[#heads]`) or a bare document id for backwards compatibility
432
- * with older links.
408
+ * (`automerge:<id>[#heads]`) or a bare document id, for older links.
433
409
  */
434
410
  function docParamToUrl(docParam) {
435
411
  if (!docParam)
@@ -438,32 +414,67 @@ function docParamToUrl(docParam) {
438
414
  return docParam;
439
415
  }
440
416
  const documentId = docParam.replace(/^automerge:/, "");
441
- if (isValidDocumentId(documentId)) {
442
- return stringifyAutomergeUrl({ documentId: documentId });
443
- }
444
- return undefined;
417
+ if (!isValidDocumentId(documentId))
418
+ return undefined;
419
+ return stringifyAutomergeUrl({ documentId: documentId });
445
420
  }
446
- function installHashRouting(params) {
447
- 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
+ };
448
461
  rootElement.addEventListener("patchwork:open-document", async (event) => {
449
- const params = new URLSearchParams(window.location.hash.slice(1));
450
462
  const { url, toolId, type, title } = event.detail;
451
- // `doc` is now the full automerge URL — heads, if any, live inside it, so
452
- // 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.
453
466
  params.delete("heads");
454
467
  params.set("doc", url);
455
- if (toolId)
456
- params.set("tool", toolId);
457
- else
458
- params.delete("tool");
459
- if (title)
460
- params.set("title", title);
461
- else
462
- params.delete("title");
463
- if (type)
464
- params.set("type", type);
465
- else
466
- 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
+ }
467
478
  window.location.hash = serializeHashParams(params);
468
479
  try {
469
480
  const docHandle = await repo.find(url);
@@ -471,78 +482,36 @@ function installHashRouting(params) {
471
482
  const docType = type || doc?.["@patchwork"]?.type;
472
483
  if (!docType)
473
484
  return;
474
- const registry = getRegistry("patchwork:datatype");
475
- const datatype = await registry.load(docType);
485
+ const datatype = await getRegistry("patchwork:datatype").load(docType);
476
486
  if (!datatype)
477
487
  return;
478
488
  const docTitle = datatype.module.getTitle(doc);
479
- if (docTitle) {
489
+ if (docTitle)
480
490
  document.title = `${docTitle} | ${titleSuffix}`;
481
- }
482
491
  }
483
492
  catch (e) {
484
493
  console.error("Failed to update document title", e);
485
494
  }
486
495
  });
487
- let firstMount = true;
496
+ let revealed = false;
488
497
  const reveal = () => {
489
- if (!firstMount)
498
+ if (revealed)
490
499
  return;
491
- firstMount = false;
500
+ revealed = true;
492
501
  rootElement.style.visibility = "visible";
493
502
  hideLoadingAnimation();
494
503
  };
495
504
  rootElement.addEventListener("patchwork:mounted", (event) => {
496
- handleHashChange();
497
505
  if (event.target !== rootElement)
498
506
  return;
499
- console.info("root element mounted");
507
+ log("root element mounted");
508
+ void handleHashChange();
500
509
  reveal();
501
- // Re-resolve routing after a beat so deep-links from freshly-loaded tools
502
- // get a second chance to render.
510
+ // Deep-links from freshly-loaded tools get a second chance to render.
503
511
  setTimeout(handleHashChange, 1000);
504
512
  });
505
- // Failsafe: if nothing ever mounts, reveal the element anyway after 12s so
506
- // 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.
507
515
  setTimeout(reveal, 12_000);
508
- const handleHashChange = async () => {
509
- const hash = window.location.hash.slice(1);
510
- // Legacy big-patchwork link (`<slug>--<docId>?…`): if the hash carries a
511
- // `--` followed by a valid document id, normalize it to the canonical
512
- // `#doc=automerge:<docId>` form and let routing re-run on the hashchange.
513
- const legacyDocId = BIG_PATCHWORK_HASH_REGEX.exec(hash)?.groups?.docId;
514
- if (legacyDocId && isValidDocumentId(legacyDocId)) {
515
- window.location.hash = serializeHashParams(new URLSearchParams({
516
- doc: stringifyAutomergeUrl({ documentId: legacyDocId }),
517
- }));
518
- return;
519
- }
520
- // Bare automerge URL in hash: /#automerge:<documentId>
521
- if (isValidAutomergeUrl(hash)) {
522
- const url = hash;
523
- window.location.hash = "";
524
- openDocument(rootElement, url);
525
- return;
526
- }
527
- const params = new URLSearchParams(hash);
528
- const docUrl = docParamToUrl(params.get("doc"));
529
- const toolId = params.get("tool");
530
- const title = params.get("title");
531
- const type = params.get("type");
532
- const frame = params.get("frame");
533
- if (frame) {
534
- const frameDocUrl = docUrl ?? accountDocHandle.url;
535
- if (rootElement.getAttribute("tool-id") !== frame ||
536
- rootElement.getAttribute("doc-url") !== frameDocUrl) {
537
- rootElement.setAttribute("tool-id", frame);
538
- rootElement.setAttribute("doc-url", frameDocUrl);
539
- }
540
- }
541
- if (docUrl) {
542
- rootElement.dispatchEvent(new CustomEvent("patchwork:open-document", {
543
- detail: { url: docUrl, toolId, title, type },
544
- }));
545
- }
546
- };
547
516
  window.addEventListener("hashchange", handleHashChange);
548
517
  }