@inkandswitch/patchwork-bootloader 0.0.3 → 0.0.5

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 ADDED
@@ -0,0 +1,530 @@
1
+ /**
2
+ * High-level browser-app boot sequence for a Patchwork site.
3
+ *
4
+ * Layers on top of {@link setupServiceWorker} (the package default export) to
5
+ * construct the Repo, wire up the service-worker port, load plugins via the
6
+ * ModuleWatcher, resolve the user's account document, and hand control to the
7
+ * configured root tool.
8
+ *
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 port handoff).
13
+ */
14
+ import {
15
+ type DocHandle,
16
+ IndexedDBStorageAdapter,
17
+ initializeWasm,
18
+ isValidAutomergeUrl,
19
+ isValidDocumentId,
20
+ MessageChannelNetworkAdapter,
21
+ parseAutomergeUrl,
22
+ Repo,
23
+ stringifyAutomergeUrl,
24
+ type AutomergeUrl,
25
+ type DocumentId,
26
+ type StorageId,
27
+ type UrlHeads,
28
+ } from "@automerge/vanillajs/slim";
29
+ import * as Automerge from "@automerge/automerge/slim";
30
+ import * as AutomergeRepo from "@automerge/automerge-repo/slim";
31
+ // eslint-disable-next-line
32
+ // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
33
+ import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
34
+
35
+ import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
36
+ import {
37
+ openDocument,
38
+ registerPatchworkViewElement,
39
+ } from "@inkandswitch/patchwork-elements";
40
+ import {
41
+ type AccountDoc,
42
+ type DatatypeDescription,
43
+ type DatatypeImplementation,
44
+ getRegistry,
45
+ registerPlugins,
46
+ resolveAccountHandle,
47
+ } from "@inkandswitch/patchwork-plugins";
48
+ import * as plugins from "@inkandswitch/patchwork-plugins";
49
+
50
+ import setupServiceWorker from "./setup.js";
51
+ import { SwLogReader } from "./sw-logger.js";
52
+
53
+ declare global {
54
+ interface Window {
55
+ accountDocHandle: DocHandle<AccountDoc>;
56
+ Automerge: typeof import("@automerge/automerge");
57
+ AutomergeRepo: typeof import("@automerge/automerge-repo");
58
+ repo: Repo;
59
+ getRepoChannel: () => MessagePort;
60
+ patchwork: {
61
+ repo: Repo;
62
+ modules: ModuleWatcher;
63
+ plugins: typeof plugins;
64
+ accountDocHandle: DocHandle<AccountDoc>;
65
+ sw: {
66
+ printLogs: (n?: number) => Promise<void>;
67
+ tailLogs: (n?: number) => ReturnType<typeof SwLogReader.tail>;
68
+ exportLogs: () => Promise<string>;
69
+ clearLogs: () => Promise<void>;
70
+ };
71
+ };
72
+ uncache: (match: string) => Promise<void>;
73
+ }
74
+ }
75
+
76
+ export interface SiteConfig {
77
+ /**
78
+ * Automerge URL of the site's default module-settings document — the bundle
79
+ * of tools every user of this site gets out of the box. Must contribute at
80
+ * least a `patchwork:datatype` registration for `"account"` (typically the
81
+ * one supplied by `@inkandswitch/patchwork-frame`).
82
+ *
83
+ * Can be overridden at runtime by setting `localStorage.defaultToolsUrl` to
84
+ * another automerge: URL — useful for local development against an
85
+ * unpublished tool set.
86
+ */
87
+ defaultModulesUrl: AutomergeUrl;
88
+
89
+ /**
90
+ * `localStorage` key under which this site remembers which account document
91
+ * belongs to the current user. Sites sharing an origin MUST use distinct
92
+ * keys so they do not clobber each other's accounts.
93
+ */
94
+ accountStorageKey: string;
95
+
96
+ /**
97
+ * Brand word appended to the document title as `"<doc> | <titleSuffix>"`
98
+ * when a document is open. The separator is provided for you.
99
+ */
100
+ titleSuffix: string;
101
+
102
+ /**
103
+ * DOM id of the `<patchwork-view>` element that will host the root tool.
104
+ * Defaults to `"root"`.
105
+ */
106
+ rootElementId?: string;
107
+
108
+ /**
109
+ * Storage IDs to subscribe to for remote-heads gossiping. Defaults to
110
+ * Ink & Switch's production Subduction storage.
111
+ */
112
+ remoteStorageIds?: StorageId[];
113
+ }
114
+
115
+ export interface BootResult {
116
+ repo: Repo;
117
+ moduleWatcher: ModuleWatcher;
118
+ accountDocHandle: DocHandle<AccountDoc>;
119
+ }
120
+
121
+ const DEFAULT_REMOTE_STORAGE_ID =
122
+ "3760df37-a4c6-4f66-9ecd-732039a9385d" as StorageId;
123
+
124
+ // Legacy big-patchwork hash shape: `slug--<documentId>[?=type]`.
125
+ const BIG_PATCHWORK_HASH_REGEX =
126
+ /(?<title>[A-Za-z0-9-]+)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)(?<type>\?=[^&?]+)?/;
127
+
128
+ /**
129
+ * Boot a Patchwork browser site.
130
+ *
131
+ * Performs the full application-shell setup: service worker + port, Repo,
132
+ * plugin/module loading, account resolution, URL-hash routing, and dev-console
133
+ * globals (`window.repo`, `window.patchwork`, `window.uncache`). Returns the
134
+ * constructed Repo, ModuleWatcher and account handle for sites that want to
135
+ * do additional wiring after boot.
136
+ */
137
+ export async function bootPatchworkSite(
138
+ config: SiteConfig
139
+ ): Promise<BootResult> {
140
+ const defaultModulesUrl = resolveDefaultModulesUrl(config.defaultModulesUrl);
141
+
142
+ // Fetch both Wasm binaries in parallel, then compile
143
+ const [automergeWasm, subductionWasm] = await Promise.all([
144
+ fetch("/automerge.wasm").then((r) => r.bytes()),
145
+ fetch("/subduction.wasm").then((r) => r.bytes()),
146
+ ]);
147
+ await initializeWasm(automergeWasm);
148
+ initSubductionSync(subductionWasm);
149
+
150
+ const repo = new Repo({
151
+ storage: new IndexedDBStorageAdapter(),
152
+ async sharePolicy(peerId) {
153
+ return peerId.includes("service-worker");
154
+ },
155
+ enableRemoteHeadsGossiping: true,
156
+ });
157
+ repo.subscribeToRemotes(
158
+ config.remoteStorageIds ?? [DEFAULT_REMOTE_STORAGE_ID]
159
+ );
160
+
161
+ const sw = await setupServiceWorker();
162
+ if (!sw) throw new Error("Failed to set up service worker");
163
+ const net = new MessageChannelNetworkAdapter(sw.port);
164
+ repo.networkSubsystem.addNetworkAdapter(net);
165
+ await net.whenReady();
166
+
167
+ installDevConsoleGlobals(repo);
168
+ registerPatchworkViewElement({ repo });
169
+
170
+ // The watcher is started with the site's default-tools bundle alone so that
171
+ // `resolveAccountHandle` below has something to await on (the `account`
172
+ // datatype lives in that bundle today). The user's own module-settings URL
173
+ // is added lazily once it appears on the account doc — see below.
174
+ const moduleWatcher = new ModuleWatcher(
175
+ repo,
176
+ [defaultModulesUrl],
177
+ onModuleLoaded
178
+ );
179
+
180
+ const accountDocHandle = await resolveAccountHandle(repo, {
181
+ storageKey: config.accountStorageKey,
182
+ });
183
+
184
+ window.accountDocHandle = accountDocHandle;
185
+
186
+ wireModuleSettingsWhenReady(accountDocHandle, moduleWatcher);
187
+
188
+ const rootElement = document.getElementById(config.rootElementId ?? "root");
189
+ if (!rootElement) {
190
+ throw new Error(
191
+ `bootPatchworkSite: no element with id="${config.rootElementId ?? "root"}"`
192
+ );
193
+ }
194
+
195
+ primeRootElement(rootElement, accountDocHandle);
196
+ logToolRegistryWhenLoaded(moduleWatcher);
197
+
198
+ window.patchwork = {
199
+ repo,
200
+ modules: moduleWatcher,
201
+ plugins,
202
+ accountDocHandle,
203
+ sw: buildSwLogApi(),
204
+ };
205
+ window.uncache = uncache;
206
+
207
+ installHashRouting({
208
+ rootElement,
209
+ repo,
210
+ accountDocHandle,
211
+ moduleWatcher,
212
+ titleSuffix: config.titleSuffix,
213
+ });
214
+
215
+ return { repo, moduleWatcher, accountDocHandle };
216
+ }
217
+
218
+ // ─── Internals ──────────────────────────────────────────────────────────
219
+
220
+ function resolveDefaultModulesUrl(builtin: AutomergeUrl): AutomergeUrl {
221
+ const override = globalThis.localStorage?.getItem("defaultToolsUrl");
222
+ if (!override) return builtin;
223
+ if (isValidAutomergeUrl(override)) {
224
+ if (override !== builtin) {
225
+ console.info(
226
+ `using defaultToolsUrl override from localStorage: ${override}`
227
+ );
228
+ }
229
+ return override;
230
+ }
231
+ console.warn(
232
+ `ignoring invalid defaultToolsUrl in localStorage: ${override}; using built-in default`
233
+ );
234
+ return builtin;
235
+ }
236
+
237
+ function installDevConsoleGlobals(repo: Repo): void {
238
+ window.repo = repo;
239
+ window.Automerge = Automerge;
240
+ window.AutomergeRepo = AutomergeRepo;
241
+ window.getRepoChannel = () => {
242
+ const { port1, port2 } = new MessageChannel();
243
+ navigator.serviceWorker.controller!.postMessage({ type: "port" }, [port2]);
244
+ return port1;
245
+ };
246
+ }
247
+
248
+ function onModuleLoaded(name: string, mod: any): void {
249
+ if (Array.isArray(mod.plugins)) {
250
+ console.log(
251
+ `[site] registering ${mod.plugins.length} plugin(s) from ${name.slice(0, 30)}...`,
252
+ mod.plugins.map((p: any) => `${p.type}:${p.id}`)
253
+ );
254
+ registerPlugins(mod.plugins, name);
255
+ } else {
256
+ console.warn(
257
+ `[site] module ${name.slice(0, 30)}... has no plugins array`,
258
+ Object.keys(mod)
259
+ );
260
+ }
261
+ }
262
+
263
+ /**
264
+ * The frame lazy-creates `moduleSettingsUrl` on first mount. Watch for it to
265
+ * appear on the account doc and feed it into the ModuleWatcher so the user's
266
+ * own tool bundle loads alongside the site default. Idempotent.
267
+ */
268
+ function wireModuleSettingsWhenReady(
269
+ accountDocHandle: DocHandle<AccountDoc>,
270
+ moduleWatcher: ModuleWatcher
271
+ ): void {
272
+ const wire = () => {
273
+ const url = accountDocHandle.doc()?.moduleSettingsUrl;
274
+ if (!url) return;
275
+ void moduleWatcher.addUrl(url);
276
+ accountDocHandle.off("change", wire);
277
+ };
278
+ wire();
279
+ if (!accountDocHandle.doc()?.moduleSettingsUrl) {
280
+ accountDocHandle.on("change", wire);
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Set initial `tool-id` / `doc-url` attributes on the root `<patchwork-view>`
286
+ * based on the URL hash (if it specifies a frame override) or the account
287
+ * doc's configured frame tool + the account doc itself.
288
+ */
289
+ function primeRootElement(
290
+ rootElement: HTMLElement,
291
+ accountDocHandle: DocHandle<AccountDoc>
292
+ ): void {
293
+ rootElement.style.visibility = "hidden";
294
+ showLoadingAnimation();
295
+
296
+ const initialParams = new URLSearchParams(location.hash.slice(1));
297
+ if (initialParams.has("frame")) {
298
+ rootElement.setAttribute("tool-id", initialParams.get("frame")!);
299
+ const docId = initialParams.get("doc");
300
+ const docUrl = docId
301
+ ? stringifyAutomergeUrl({ documentId: docId as DocumentId })
302
+ : accountDocHandle.url;
303
+ rootElement.setAttribute("doc-url", docUrl);
304
+ } else {
305
+ rootElement.setAttribute("tool-id", accountDocHandle.doc().frameToolId);
306
+ rootElement.setAttribute("doc-url", accountDocHandle.url);
307
+ }
308
+ }
309
+
310
+ function logToolRegistryWhenLoaded(moduleWatcher: ModuleWatcher): void {
311
+ moduleWatcher.doneLoading
312
+ .then(() => {
313
+ const toolReg = getRegistry("patchwork:tool");
314
+ const tools = toolReg.all();
315
+ console.log(
316
+ `[site] doneLoading: ${tools.length} tools registered:`,
317
+ tools.map((t: any) => t.id)
318
+ );
319
+ })
320
+ .catch((err: unknown) => {
321
+ console.error("[site] doneLoading rejected:", err);
322
+ });
323
+ }
324
+
325
+ function buildSwLogApi(): Window["patchwork"]["sw"] {
326
+ return {
327
+ printLogs: async (n = 200) => {
328
+ const entries = await SwLogReader.tail(n);
329
+ for (const e of entries) {
330
+ const prefix = `[${e.ts}] [${e.level}]`;
331
+ if (e.data !== undefined) console.log(prefix, e.msg, e.data);
332
+ else console.log(prefix, e.msg);
333
+ }
334
+ console.log(`--- ${entries.length} entries ---`);
335
+ },
336
+ tailLogs: (n = 200) => SwLogReader.tail(n),
337
+ exportLogs: () => SwLogReader.exportAll(),
338
+ clearLogs: () => SwLogReader.clear(),
339
+ };
340
+ }
341
+
342
+ const LOADING_STYLE_ID = "pw-bootloader-loading-styles";
343
+ const LOADING_ELEMENT_ID = "pw-bootloader-loading";
344
+
345
+ function showLoadingAnimation(): void {
346
+ if (!document.getElementById(LOADING_STYLE_ID)) {
347
+ const style = document.createElement("style");
348
+ style.id = LOADING_STYLE_ID;
349
+ style.textContent = `
350
+ @keyframes pw-bootloader-pulse {
351
+ 0%, 100% { opacity: 0.25; }
352
+ 50% { opacity: 0.95; }
353
+ }
354
+ #${LOADING_ELEMENT_ID} {
355
+ position: fixed;
356
+ inset: 0;
357
+ z-index: 0;
358
+ pointer-events: none;
359
+ background-color: #fff;
360
+ background-image:
361
+ radial-gradient(ellipse 55% 45% at 28% 35%, #fde4ec, transparent 70%),
362
+ radial-gradient(ellipse 50% 55% at 72% 65%, #e0f0fb, transparent 70%),
363
+ radial-gradient(ellipse 65% 55% at 50% 50%, #f1e6f6, transparent 80%);
364
+ animation: pw-bootloader-pulse 3.5s ease-in-out infinite;
365
+ transition: opacity 0.6s ease-out;
366
+ }
367
+ @media (prefers-color-scheme: dark) {
368
+ #${LOADING_ELEMENT_ID} {
369
+ background-color: #000;
370
+ background-image:
371
+ radial-gradient(ellipse 55% 45% at 28% 35%, #2a1d33, transparent 70%),
372
+ radial-gradient(ellipse 50% 55% at 72% 65%, #1a2738, transparent 70%),
373
+ radial-gradient(ellipse 65% 55% at 50% 50%, #221a2e, transparent 80%);
374
+ }
375
+ }
376
+ #${LOADING_ELEMENT_ID}.pw-bootloader-fading {
377
+ opacity: 0;
378
+ animation: none;
379
+ }
380
+ `;
381
+ document.head.appendChild(style);
382
+ }
383
+ if (document.getElementById(LOADING_ELEMENT_ID)) return;
384
+ const el = document.createElement("div");
385
+ el.id = LOADING_ELEMENT_ID;
386
+ document.body.appendChild(el);
387
+ }
388
+
389
+ function hideLoadingAnimation(): void {
390
+ const el = document.getElementById(LOADING_ELEMENT_ID);
391
+ if (!el) return;
392
+ el.classList.add("pw-bootloader-fading");
393
+ setTimeout(() => el.remove(), 700);
394
+ }
395
+
396
+ async function uncache(match: string): Promise<void> {
397
+ for (const name of await caches.keys()) {
398
+ const cache = await caches.open(name);
399
+ for (const request of await cache.keys()) {
400
+ if (request.url.includes(match)) {
401
+ cache.delete(request);
402
+ }
403
+ }
404
+ }
405
+ }
406
+
407
+ interface HashRoutingParams {
408
+ rootElement: HTMLElement;
409
+ repo: Repo;
410
+ accountDocHandle: DocHandle<AccountDoc>;
411
+ moduleWatcher: ModuleWatcher;
412
+ titleSuffix: string;
413
+ }
414
+
415
+ function installHashRouting(params: HashRoutingParams): void {
416
+ const { rootElement, repo, accountDocHandle, moduleWatcher, titleSuffix } =
417
+ params;
418
+
419
+ rootElement.addEventListener("patchwork:no-tool", (event) => {
420
+ moduleWatcher.loadSuggestedImportUrl(event.detail.url);
421
+ });
422
+
423
+ rootElement.addEventListener("patchwork:open-document", async (event) => {
424
+ const params = new URLSearchParams(window.location.hash.slice(1));
425
+ const { url, toolId, type, title } = event.detail as {
426
+ url: AutomergeUrl;
427
+ toolId?: string;
428
+ type?: string;
429
+ title?: string;
430
+ };
431
+ const { documentId, heads } = parseAutomergeUrl(url);
432
+ params.set("doc", documentId);
433
+ if (heads) params.set("heads", heads.join("|"));
434
+ else params.delete("heads");
435
+ if (toolId) params.set("tool", toolId);
436
+ else params.delete("tool");
437
+ if (title) params.set("title", title);
438
+ else params.delete("title");
439
+ if (type) params.set("type", type);
440
+ else params.delete("type");
441
+ window.location.hash = params.toString();
442
+
443
+ try {
444
+ const docHandle = await repo.find<{ "@patchwork"?: { type?: string } }>(
445
+ stringifyAutomergeUrl({ documentId, heads })
446
+ );
447
+ const doc = docHandle.doc();
448
+ const docType = type || doc?.["@patchwork"]?.type;
449
+ if (!docType) return;
450
+ const registry = getRegistry<DatatypeDescription>("patchwork:datatype");
451
+ const datatype = await registry.load(docType);
452
+ if (!datatype) return;
453
+ const docTitle = (datatype.module as DatatypeImplementation).getTitle(
454
+ doc
455
+ );
456
+ if (docTitle) {
457
+ document.title = `${docTitle} | ${titleSuffix}`;
458
+ }
459
+ } catch (e) {
460
+ console.error("Failed to update document title", e);
461
+ }
462
+ });
463
+
464
+ let firstMount = true;
465
+ const reveal = () => {
466
+ if (!firstMount) return;
467
+ firstMount = false;
468
+ rootElement.style.visibility = "visible";
469
+ hideLoadingAnimation();
470
+ };
471
+
472
+ rootElement.addEventListener("patchwork:mounted", (event) => {
473
+ handleHashChange();
474
+ if (event.target !== rootElement) return;
475
+ console.info("root element mounted");
476
+ reveal();
477
+ // Re-resolve routing after a beat so deep-links from freshly-loaded tools
478
+ // get a second chance to render.
479
+ setTimeout(handleHashChange, 1000);
480
+ });
481
+
482
+ // Failsafe: if nothing ever mounts, reveal the element anyway after 12s so
483
+ // the user sees *something* rather than a blank page.
484
+ setTimeout(reveal, 12_000);
485
+
486
+ const handleHashChange = async () => {
487
+ const hash = window.location.hash.slice(1);
488
+ const legacy = BIG_PATCHWORK_HASH_REGEX.exec(hash);
489
+
490
+ if (legacy) {
491
+ const documentId = legacy.groups?.docId;
492
+ if (isValidDocumentId(documentId)) {
493
+ openDocument(rootElement, stringifyAutomergeUrl({ documentId }));
494
+ }
495
+ return;
496
+ }
497
+
498
+ const params = new URLSearchParams(hash);
499
+ const documentId = params.get("doc");
500
+ const heads = params.get("heads")?.split("|") as UrlHeads | undefined;
501
+ const toolId = params.get("tool");
502
+ const title = params.get("title");
503
+ const type = params.get("type");
504
+ const frame = params.get("frame");
505
+ if (frame) {
506
+ const docUrl = params.get("doc") ?? accountDocHandle.url;
507
+ if (
508
+ rootElement.getAttribute("tool-id") !== frame ||
509
+ rootElement.getAttribute("doc-url") !== docUrl
510
+ ) {
511
+ rootElement.setAttribute("tool-id", frame);
512
+ rootElement.setAttribute("doc-url", docUrl);
513
+ }
514
+ }
515
+ if (isValidDocumentId(documentId)) {
516
+ rootElement.dispatchEvent(
517
+ new CustomEvent("patchwork:open-document", {
518
+ detail: {
519
+ url: stringifyAutomergeUrl({ documentId, heads }),
520
+ toolId,
521
+ title,
522
+ type,
523
+ },
524
+ })
525
+ );
526
+ }
527
+ };
528
+
529
+ window.addEventListener("hashchange", handleHashChange);
530
+ }