@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/dist/site.js ADDED
@@ -0,0 +1,365 @@
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 { IndexedDBStorageAdapter, initializeWasm, isValidAutomergeUrl, isValidDocumentId, MessageChannelNetworkAdapter, parseAutomergeUrl, Repo, stringifyAutomergeUrl, } from "@automerge/vanillajs/slim";
15
+ import * as Automerge from "@automerge/automerge/slim";
16
+ import * as AutomergeRepo from "@automerge/automerge-repo/slim";
17
+ // eslint-disable-next-line
18
+ // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
19
+ import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
20
+ import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
21
+ import { openDocument, registerPatchworkViewElement, } from "@inkandswitch/patchwork-elements";
22
+ import { getRegistry, registerPlugins, resolveAccountHandle, } from "@inkandswitch/patchwork-plugins";
23
+ import * as plugins from "@inkandswitch/patchwork-plugins";
24
+ import setupServiceWorker from "./setup.js";
25
+ import { SwLogReader } from "./sw-logger.js";
26
+ const DEFAULT_REMOTE_STORAGE_ID = "3760df37-a4c6-4f66-9ecd-732039a9385d";
27
+ // Legacy big-patchwork hash shape: `slug--<documentId>[?=type]`.
28
+ const BIG_PATCHWORK_HASH_REGEX = /(?<title>[A-Za-z0-9-]+)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)(?<type>\?=[^&?]+)?/;
29
+ /**
30
+ * Boot a Patchwork browser site.
31
+ *
32
+ * Performs the full application-shell setup: service worker + port, Repo,
33
+ * plugin/module loading, account resolution, URL-hash routing, and dev-console
34
+ * globals (`window.repo`, `window.patchwork`, `window.uncache`). Returns the
35
+ * constructed Repo, ModuleWatcher and account handle for sites that want to
36
+ * do additional wiring after boot.
37
+ */
38
+ export async function bootPatchworkSite(config) {
39
+ const defaultModulesUrl = resolveDefaultModulesUrl(config.defaultModulesUrl);
40
+ // Fetch both Wasm binaries in parallel, then compile
41
+ const [automergeWasm, subductionWasm] = await Promise.all([
42
+ fetch("/automerge.wasm").then((r) => r.bytes()),
43
+ fetch("/subduction.wasm").then((r) => r.bytes()),
44
+ ]);
45
+ await initializeWasm(automergeWasm);
46
+ initSubductionSync(subductionWasm);
47
+ const repo = new Repo({
48
+ storage: new IndexedDBStorageAdapter(),
49
+ async sharePolicy(peerId) {
50
+ return peerId.includes("service-worker");
51
+ },
52
+ enableRemoteHeadsGossiping: true,
53
+ });
54
+ repo.subscribeToRemotes(config.remoteStorageIds ?? [DEFAULT_REMOTE_STORAGE_ID]);
55
+ const sw = await setupServiceWorker();
56
+ if (!sw)
57
+ throw new Error("Failed to set up service worker");
58
+ const net = new MessageChannelNetworkAdapter(sw.port);
59
+ repo.networkSubsystem.addNetworkAdapter(net);
60
+ await net.whenReady();
61
+ installDevConsoleGlobals(repo);
62
+ registerPatchworkViewElement({ repo });
63
+ // The watcher is started with the site's default-tools bundle alone so that
64
+ // `resolveAccountHandle` below has something to await on (the `account`
65
+ // datatype lives in that bundle today). The user's own module-settings URL
66
+ // is added lazily once it appears on the account doc — see below.
67
+ const moduleWatcher = new ModuleWatcher(repo, [defaultModulesUrl], onModuleLoaded);
68
+ const accountDocHandle = await resolveAccountHandle(repo, {
69
+ storageKey: config.accountStorageKey,
70
+ });
71
+ window.accountDocHandle = accountDocHandle;
72
+ wireModuleSettingsWhenReady(accountDocHandle, moduleWatcher);
73
+ const rootElement = document.getElementById(config.rootElementId ?? "root");
74
+ if (!rootElement) {
75
+ throw new Error(`bootPatchworkSite: no element with id="${config.rootElementId ?? "root"}"`);
76
+ }
77
+ primeRootElement(rootElement, accountDocHandle);
78
+ logToolRegistryWhenLoaded(moduleWatcher);
79
+ window.patchwork = {
80
+ repo,
81
+ modules: moduleWatcher,
82
+ plugins,
83
+ accountDocHandle,
84
+ sw: buildSwLogApi(),
85
+ };
86
+ window.uncache = uncache;
87
+ installHashRouting({
88
+ rootElement,
89
+ repo,
90
+ accountDocHandle,
91
+ moduleWatcher,
92
+ titleSuffix: config.titleSuffix,
93
+ });
94
+ return { repo, moduleWatcher, accountDocHandle };
95
+ }
96
+ // ─── Internals ──────────────────────────────────────────────────────────
97
+ function resolveDefaultModulesUrl(builtin) {
98
+ const override = globalThis.localStorage?.getItem("defaultToolsUrl");
99
+ if (!override)
100
+ return builtin;
101
+ if (isValidAutomergeUrl(override)) {
102
+ if (override !== builtin) {
103
+ console.info(`using defaultToolsUrl override from localStorage: ${override}`);
104
+ }
105
+ return override;
106
+ }
107
+ console.warn(`ignoring invalid defaultToolsUrl in localStorage: ${override}; using built-in default`);
108
+ return builtin;
109
+ }
110
+ function installDevConsoleGlobals(repo) {
111
+ window.repo = repo;
112
+ window.Automerge = Automerge;
113
+ window.AutomergeRepo = AutomergeRepo;
114
+ window.getRepoChannel = () => {
115
+ const { port1, port2 } = new MessageChannel();
116
+ navigator.serviceWorker.controller.postMessage({ type: "port" }, [port2]);
117
+ return port1;
118
+ };
119
+ }
120
+ function onModuleLoaded(name, mod) {
121
+ if (Array.isArray(mod.plugins)) {
122
+ console.log(`[site] registering ${mod.plugins.length} plugin(s) from ${name.slice(0, 30)}...`, mod.plugins.map((p) => `${p.type}:${p.id}`));
123
+ registerPlugins(mod.plugins, name);
124
+ }
125
+ else {
126
+ console.warn(`[site] module ${name.slice(0, 30)}... has no plugins array`, Object.keys(mod));
127
+ }
128
+ }
129
+ /**
130
+ * The frame lazy-creates `moduleSettingsUrl` on first mount. Watch for it to
131
+ * appear on the account doc and feed it into the ModuleWatcher so the user's
132
+ * own tool bundle loads alongside the site default. Idempotent.
133
+ */
134
+ function wireModuleSettingsWhenReady(accountDocHandle, moduleWatcher) {
135
+ const wire = () => {
136
+ const url = accountDocHandle.doc()?.moduleSettingsUrl;
137
+ if (!url)
138
+ return;
139
+ void moduleWatcher.addUrl(url);
140
+ accountDocHandle.off("change", wire);
141
+ };
142
+ wire();
143
+ if (!accountDocHandle.doc()?.moduleSettingsUrl) {
144
+ accountDocHandle.on("change", wire);
145
+ }
146
+ }
147
+ /**
148
+ * Set initial `tool-id` / `doc-url` attributes on the root `<patchwork-view>`
149
+ * based on the URL hash (if it specifies a frame override) or the account
150
+ * doc's configured frame tool + the account doc itself.
151
+ */
152
+ function primeRootElement(rootElement, accountDocHandle) {
153
+ rootElement.style.visibility = "hidden";
154
+ showLoadingAnimation();
155
+ const initialParams = new URLSearchParams(location.hash.slice(1));
156
+ if (initialParams.has("frame")) {
157
+ rootElement.setAttribute("tool-id", initialParams.get("frame"));
158
+ const docId = initialParams.get("doc");
159
+ const docUrl = docId
160
+ ? stringifyAutomergeUrl({ documentId: docId })
161
+ : accountDocHandle.url;
162
+ rootElement.setAttribute("doc-url", docUrl);
163
+ }
164
+ else {
165
+ rootElement.setAttribute("tool-id", accountDocHandle.doc().frameToolId);
166
+ rootElement.setAttribute("doc-url", accountDocHandle.url);
167
+ }
168
+ }
169
+ function logToolRegistryWhenLoaded(moduleWatcher) {
170
+ moduleWatcher.doneLoading
171
+ .then(() => {
172
+ const toolReg = getRegistry("patchwork:tool");
173
+ const tools = toolReg.all();
174
+ console.log(`[site] doneLoading: ${tools.length} tools registered:`, tools.map((t) => t.id));
175
+ })
176
+ .catch((err) => {
177
+ console.error("[site] doneLoading rejected:", err);
178
+ });
179
+ }
180
+ function buildSwLogApi() {
181
+ return {
182
+ printLogs: async (n = 200) => {
183
+ const entries = await SwLogReader.tail(n);
184
+ for (const e of entries) {
185
+ const prefix = `[${e.ts}] [${e.level}]`;
186
+ if (e.data !== undefined)
187
+ console.log(prefix, e.msg, e.data);
188
+ else
189
+ console.log(prefix, e.msg);
190
+ }
191
+ console.log(`--- ${entries.length} entries ---`);
192
+ },
193
+ tailLogs: (n = 200) => SwLogReader.tail(n),
194
+ exportLogs: () => SwLogReader.exportAll(),
195
+ clearLogs: () => SwLogReader.clear(),
196
+ };
197
+ }
198
+ const LOADING_STYLE_ID = "pw-bootloader-loading-styles";
199
+ const LOADING_ELEMENT_ID = "pw-bootloader-loading";
200
+ function showLoadingAnimation() {
201
+ if (!document.getElementById(LOADING_STYLE_ID)) {
202
+ const style = document.createElement("style");
203
+ style.id = LOADING_STYLE_ID;
204
+ style.textContent = `
205
+ @keyframes pw-bootloader-pulse {
206
+ 0%, 100% { opacity: 0.25; }
207
+ 50% { opacity: 0.95; }
208
+ }
209
+ #${LOADING_ELEMENT_ID} {
210
+ position: fixed;
211
+ inset: 0;
212
+ z-index: 0;
213
+ pointer-events: none;
214
+ background-color: #fff;
215
+ background-image:
216
+ radial-gradient(ellipse 55% 45% at 28% 35%, #fde4ec, transparent 70%),
217
+ radial-gradient(ellipse 50% 55% at 72% 65%, #e0f0fb, transparent 70%),
218
+ radial-gradient(ellipse 65% 55% at 50% 50%, #f1e6f6, transparent 80%);
219
+ animation: pw-bootloader-pulse 3.5s ease-in-out infinite;
220
+ transition: opacity 0.6s ease-out;
221
+ }
222
+ @media (prefers-color-scheme: dark) {
223
+ #${LOADING_ELEMENT_ID} {
224
+ background-color: #000;
225
+ background-image:
226
+ radial-gradient(ellipse 55% 45% at 28% 35%, #2a1d33, transparent 70%),
227
+ radial-gradient(ellipse 50% 55% at 72% 65%, #1a2738, transparent 70%),
228
+ radial-gradient(ellipse 65% 55% at 50% 50%, #221a2e, transparent 80%);
229
+ }
230
+ }
231
+ #${LOADING_ELEMENT_ID}.pw-bootloader-fading {
232
+ opacity: 0;
233
+ animation: none;
234
+ }
235
+ `;
236
+ document.head.appendChild(style);
237
+ }
238
+ if (document.getElementById(LOADING_ELEMENT_ID))
239
+ return;
240
+ const el = document.createElement("div");
241
+ el.id = LOADING_ELEMENT_ID;
242
+ document.body.appendChild(el);
243
+ }
244
+ function hideLoadingAnimation() {
245
+ const el = document.getElementById(LOADING_ELEMENT_ID);
246
+ if (!el)
247
+ return;
248
+ el.classList.add("pw-bootloader-fading");
249
+ setTimeout(() => el.remove(), 700);
250
+ }
251
+ async function uncache(match) {
252
+ for (const name of await caches.keys()) {
253
+ const cache = await caches.open(name);
254
+ for (const request of await cache.keys()) {
255
+ if (request.url.includes(match)) {
256
+ cache.delete(request);
257
+ }
258
+ }
259
+ }
260
+ }
261
+ function installHashRouting(params) {
262
+ const { rootElement, repo, accountDocHandle, moduleWatcher, titleSuffix } = params;
263
+ rootElement.addEventListener("patchwork:no-tool", (event) => {
264
+ moduleWatcher.loadSuggestedImportUrl(event.detail.url);
265
+ });
266
+ rootElement.addEventListener("patchwork:open-document", async (event) => {
267
+ const params = new URLSearchParams(window.location.hash.slice(1));
268
+ const { url, toolId, type, title } = event.detail;
269
+ const { documentId, heads } = parseAutomergeUrl(url);
270
+ params.set("doc", documentId);
271
+ if (heads)
272
+ params.set("heads", heads.join("|"));
273
+ else
274
+ params.delete("heads");
275
+ if (toolId)
276
+ params.set("tool", toolId);
277
+ else
278
+ params.delete("tool");
279
+ if (title)
280
+ params.set("title", title);
281
+ else
282
+ params.delete("title");
283
+ if (type)
284
+ params.set("type", type);
285
+ else
286
+ params.delete("type");
287
+ window.location.hash = params.toString();
288
+ try {
289
+ const docHandle = await repo.find(stringifyAutomergeUrl({ documentId, heads }));
290
+ const doc = docHandle.doc();
291
+ const docType = type || doc?.["@patchwork"]?.type;
292
+ if (!docType)
293
+ return;
294
+ const registry = getRegistry("patchwork:datatype");
295
+ const datatype = await registry.load(docType);
296
+ if (!datatype)
297
+ return;
298
+ const docTitle = datatype.module.getTitle(doc);
299
+ if (docTitle) {
300
+ document.title = `${docTitle} | ${titleSuffix}`;
301
+ }
302
+ }
303
+ catch (e) {
304
+ console.error("Failed to update document title", e);
305
+ }
306
+ });
307
+ let firstMount = true;
308
+ const reveal = () => {
309
+ if (!firstMount)
310
+ return;
311
+ firstMount = false;
312
+ rootElement.style.visibility = "visible";
313
+ hideLoadingAnimation();
314
+ };
315
+ rootElement.addEventListener("patchwork:mounted", (event) => {
316
+ handleHashChange();
317
+ if (event.target !== rootElement)
318
+ return;
319
+ console.info("root element mounted");
320
+ reveal();
321
+ // Re-resolve routing after a beat so deep-links from freshly-loaded tools
322
+ // get a second chance to render.
323
+ setTimeout(handleHashChange, 1000);
324
+ });
325
+ // Failsafe: if nothing ever mounts, reveal the element anyway after 12s so
326
+ // the user sees *something* rather than a blank page.
327
+ setTimeout(reveal, 12_000);
328
+ const handleHashChange = async () => {
329
+ const hash = window.location.hash.slice(1);
330
+ const legacy = BIG_PATCHWORK_HASH_REGEX.exec(hash);
331
+ if (legacy) {
332
+ const documentId = legacy.groups?.docId;
333
+ if (isValidDocumentId(documentId)) {
334
+ openDocument(rootElement, stringifyAutomergeUrl({ documentId }));
335
+ }
336
+ return;
337
+ }
338
+ const params = new URLSearchParams(hash);
339
+ const documentId = params.get("doc");
340
+ const heads = params.get("heads")?.split("|");
341
+ const toolId = params.get("tool");
342
+ const title = params.get("title");
343
+ const type = params.get("type");
344
+ const frame = params.get("frame");
345
+ if (frame) {
346
+ const docUrl = params.get("doc") ?? accountDocHandle.url;
347
+ if (rootElement.getAttribute("tool-id") !== frame ||
348
+ rootElement.getAttribute("doc-url") !== docUrl) {
349
+ rootElement.setAttribute("tool-id", frame);
350
+ rootElement.setAttribute("doc-url", docUrl);
351
+ }
352
+ }
353
+ if (isValidDocumentId(documentId)) {
354
+ rootElement.dispatchEvent(new CustomEvent("patchwork:open-document", {
355
+ detail: {
356
+ url: stringifyAutomergeUrl({ documentId, heads }),
357
+ toolId,
358
+ title,
359
+ type,
360
+ },
361
+ }));
362
+ }
363
+ };
364
+ window.addEventListener("hashchange", handleHashChange);
365
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Persistent ring-buffer logger for service workers.
3
+ *
4
+ * Stores log entries in a dedicated IndexedDB database (`sw-logs`) that is
5
+ * completely separate from the `automerge` database used by the Repo, so
6
+ * writes here never contend with storage or hydration transactions.
7
+ *
8
+ * Entries are accumulated in memory and batch-flushed to IDB periodically
9
+ * (every {@link FLUSH_INTERVAL_MS}) or when the buffer reaches
10
+ * {@link FLUSH_THRESHOLD} entries — whichever comes first.
11
+ *
12
+ * The on-disk store is a ring buffer capped at {@link MAX_ENTRIES}. Oldest
13
+ * entries are pruned on each flush when the cap is exceeded.
14
+ *
15
+ * ## Usage
16
+ *
17
+ * ```ts
18
+ * import { SwLogger } from "./sw-logger.js"
19
+ *
20
+ * const log = await SwLogger.open()
21
+ * log.info("repo initialized")
22
+ * log.warn("connection dropped", { url })
23
+ * log.error("sync threw", error)
24
+ *
25
+ * // From the SW inspector console:
26
+ * self.printLogs() // prints last 200 entries
27
+ * self.printLogs(5000) // prints last 5 000 entries
28
+ * self.tailLogs(100) // returns last 100 entries as an array
29
+ * self.exportLogs() // returns all entries as JSON string
30
+ * self.clearLogs() // wipes the log database
31
+ * ```
32
+ */
33
+ export interface LogEntry {
34
+ /** Auto-incremented IDB key (doubles as ordering index). */
35
+ id?: number;
36
+ /** ISO-8601 timestamp */
37
+ ts: string;
38
+ /** Monotonic high-res timestamp (ms since SW start) */
39
+ hrt: number;
40
+ /** Log level */
41
+ level: "debug" | "info" | "warn" | "error";
42
+ /** Log message */
43
+ msg: string;
44
+ /** Optional structured data (must be cloneable) */
45
+ data?: unknown;
46
+ }
47
+ export interface SwLoggerInterface {
48
+ debug(msg: string, data?: unknown): void;
49
+ info(msg: string, data?: unknown): void;
50
+ warn(msg: string, data?: unknown): void;
51
+ error(msg: string, data?: unknown): void;
52
+ flush(): Promise<void>;
53
+ tail(n?: number): Promise<LogEntry[]>;
54
+ exportAll(): Promise<string>;
55
+ clear(): Promise<void>;
56
+ dispose(): void;
57
+ }
58
+ export declare class SwLogger implements SwLoggerInterface {
59
+ #private;
60
+ private constructor();
61
+ /**
62
+ * Open (or create) the log database and return a ready logger.
63
+ * If the database cannot be opened (quota, permissions, etc.),
64
+ * returns a {@link NoopLogger} that writes to the console only.
65
+ */
66
+ static open(): Promise<SwLoggerInterface>;
67
+ debug(msg: string, data?: unknown): void;
68
+ info(msg: string, data?: unknown): void;
69
+ warn(msg: string, data?: unknown): void;
70
+ error(msg: string, data?: unknown): void;
71
+ /** Force an immediate flush of the in-memory buffer to IDB. */
72
+ flush(): Promise<void>;
73
+ /** Read the last `n` entries (default 200). */
74
+ tail(n?: number): Promise<LogEntry[]>;
75
+ /** Return all entries as a JSON string (for copy-paste from console). */
76
+ exportAll(): Promise<string>;
77
+ /** Delete all log entries. */
78
+ clear(): Promise<void>;
79
+ /** Stop the periodic flush timer. */
80
+ dispose(): void;
81
+ }
82
+ /**
83
+ * Read-only accessor for the SW log database.
84
+ *
85
+ * Unlike {@link SwLogger}, this class does not hold a persistent IDB
86
+ * connection — each method opens a fresh connection and closes it after
87
+ * use. This avoids interfering with the SW's write transactions.
88
+ *
89
+ * @example
90
+ * ```ts
91
+ * import { SwLogReader } from "@inkandswitch/patchwork-bootloader/sw-logger"
92
+ *
93
+ * const last100 = await SwLogReader.tail(100)
94
+ * const json = await SwLogReader.exportAll()
95
+ * await SwLogReader.clear()
96
+ * ```
97
+ */
98
+ export declare class SwLogReader {
99
+ /** Read the last `n` entries (default 200), oldest-first. */
100
+ static tail(n?: number): Promise<LogEntry[]>;
101
+ /** Return all entries as a JSON string. */
102
+ static exportAll(): Promise<string>;
103
+ /** Delete all log entries. */
104
+ static clear(): Promise<void>;
105
+ }