@inkandswitch/patchwork-bootloader 0.0.7 → 0.0.9

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/externals.js CHANGED
@@ -6,6 +6,8 @@ const externals = [
6
6
  "@automerge/automerge/slim",
7
7
  "@automerge/automerge-repo",
8
8
  "@automerge/automerge-repo/slim",
9
+ "@automerge/automerge-repo-network-messagechannel",
10
+ "@automerge/automerge-repo-storage-indexeddb",
9
11
  "@automerge/automerge-repo-keyhive",
10
12
  "@automerge/automerge-subduction",
11
13
  "@automerge/automerge-subduction/slim",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inkandswitch/patchwork-bootloader",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "author": "chee",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -52,7 +52,7 @@
52
52
  "resolve.exports": "^2.0.3",
53
53
  "service-worker-types": "npm:@types/serviceworker@^0.0.153",
54
54
  "tinyargs": "^0.1.4",
55
- "@inkandswitch/patchwork-elements": "^0.0.6",
55
+ "@inkandswitch/patchwork-elements": "^0.0.7",
56
56
  "@inkandswitch/patchwork-filesystem": "^0.0.5",
57
57
  "@inkandswitch/patchwork-plugins": "^0.0.7"
58
58
  },
package/src/externals.ts CHANGED
@@ -6,6 +6,8 @@ const externals = [
6
6
  "@automerge/automerge/slim",
7
7
  "@automerge/automerge-repo",
8
8
  "@automerge/automerge-repo/slim",
9
+ "@automerge/automerge-repo-network-messagechannel",
10
+ "@automerge/automerge-repo-storage-indexeddb",
9
11
  "@automerge/automerge-repo-keyhive",
10
12
  "@automerge/automerge-subduction",
11
13
  "@automerge/automerge-subduction/slim",
@@ -21,11 +21,7 @@ import {
21
21
  stringifyAutomergeUrl,
22
22
  type PeerId,
23
23
  } from "@automerge/automerge-repo/slim";
24
- import {
25
- findHandleInFolderHandle,
26
- resolvePackageExport,
27
- type FolderDoc,
28
- } from "@inkandswitch/patchwork-filesystem";
24
+ import { resolvePath } from "@inkandswitch/patchwork-filesystem";
29
25
 
30
26
  // Small adapters — bundled directly into the SW
31
27
  import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb";
@@ -37,6 +33,7 @@ let cachename = "default";
37
33
  let debugging = false;
38
34
 
39
35
  const SUBDUCTION_ENDPOINTS = ["wss://subduction.sync.inkandswitch.com"];
36
+ const RESOLVE_TIMEOUT_MS = 30_000;
40
37
 
41
38
  // ── Persistent logger ───────────────────────────────────────────────────
42
39
  // Initialized eagerly so it's available for the entire SW lifetime.
@@ -66,9 +63,7 @@ const slog = SwLogger.open().then((logger) => {
66
63
  return logger;
67
64
  });
68
65
 
69
- const cacheableStatuses = [
70
- 200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501,
71
- ];
66
+ const cacheableStatuses = [200, 203, 204, 206];
72
67
 
73
68
  function log(...args: any[]) {
74
69
  if (!debugging) return;
@@ -103,12 +98,13 @@ let repoPromise: Promise<Repo> | null = null;
103
98
 
104
99
  function getRepo() {
105
100
  if (!repoPromise) {
106
- repoPromise = (async () => {
101
+ const p: Promise<Repo> = (async () => {
107
102
  const logger = await slog;
103
+ logger.info("getRepo: starting");
108
104
 
109
105
  logger.info("fetching wasm modules");
110
106
  const [amWasmBuf, sdnWasmBuf] = await Promise.all([
111
- fetch("/automerge.wasm").then((r) => r.arrayBuffer()),
107
+ fetch("/automerge.wasm?sw").then((r) => r.arrayBuffer()),
112
108
  fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
113
109
  ]);
114
110
  initSubductionSync(new Uint8Array(sdnWasmBuf));
@@ -127,7 +123,7 @@ function getRepo() {
127
123
  },
128
124
  enableRemoteHeadsGossiping: true,
129
125
  subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
130
- network: [new WebSocketClientAdapter("wss://sync3.automerge.org")],
126
+ //network: [new WebSocketClientAdapter("wss://sync3.automerge.org")],
131
127
  });
132
128
 
133
129
  (self as any).repo = repo;
@@ -143,6 +139,13 @@ function getRepo() {
143
139
 
144
140
  return repo;
145
141
  })();
142
+ // If construction fails (e.g. wasm fetch errors out because the SW was
143
+ // terminated mid-flight), don't permanently cache the rejection — clear
144
+ // the slot so the next caller can retry from scratch.
145
+ p.catch(() => {
146
+ if (repoPromise === p) repoPromise = null;
147
+ });
148
+ repoPromise = p;
146
149
  }
147
150
  return repoPromise;
148
151
  }
@@ -173,7 +176,20 @@ self.addEventListener("message", async (event) => {
173
176
  } else if (event.data.type == "port") {
174
177
  log("received messagechannel");
175
178
  const [port] = event.ports;
176
- connectPort(port);
179
+ const source = event.source as Client | null;
180
+ // event.waitUntil keeps the SW alive until the work completes. Without
181
+ // it, the browser can terminate the SW the moment this synchronous block
182
+ // returns, killing the in-flight wasm fetch.
183
+ (event as unknown as FetchEvent).waitUntil(
184
+ connectPort(port).then(
185
+ () => source?.postMessage({ type: "port-ready" }),
186
+ (err) => {
187
+ console.error("connectPort failed", err);
188
+ // Tell the client we failed so it doesn't hang forever.
189
+ source?.postMessage({ type: "port-failed", error: String(err) });
190
+ }
191
+ )
192
+ );
177
193
  } else if (event.data.type == "cachename") {
178
194
  const nextCachename = event.data.cachename;
179
195
  if (cachename == nextCachename) {
@@ -190,11 +206,6 @@ self.addEventListener("message", async (event) => {
190
206
  }
191
207
  });
192
208
 
193
- interface FileDoc {
194
- content: string | Uint8Array;
195
- mimeType?: string;
196
- }
197
-
198
209
  // ── Automerge URL resolution ───────────────────────────────────────────
199
210
 
200
211
  async function resolveAutomergeUrl(automergeURL: URL): Promise<Response> {
@@ -210,10 +221,10 @@ async function resolveAutomergeUrl(automergeURL: URL): Promise<Response> {
210
221
  if (path.length && !path[path.length - 1]) path.pop();
211
222
 
212
223
  const { heads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
224
+ const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
213
225
 
214
226
  if (!heads) {
215
- // Redirect to pinned-heads URL
216
- const folder = await repo.find(maybeAutomergeUrl);
227
+ const folder = await repo.find(maybeAutomergeUrl, { signal });
217
228
  const latestHeads = folder.heads();
218
229
  const url = stringifyAutomergeUrl({ documentId, heads: latestHeads });
219
230
  let location = `/${encodeURIComponent(url)}`;
@@ -221,93 +232,26 @@ async function resolveAutomergeUrl(automergeURL: URL): Promise<Response> {
221
232
  return Response.redirect(location, 307);
222
233
  }
223
234
 
224
- // If no path, check if this is a package with exports to resolve
225
- // e.g. /automerge%3Adocid/abc → resolve "abc" via package.json exports
226
- const folderHandle = await repo.find<FolderDoc>(maybeAutomergeUrl);
227
-
228
- let fileHandle;
229
- if (path.length) {
230
- // Try direct file navigation first
231
- fileHandle = await findHandleInFolderHandle<FileDoc>(
232
- repo,
233
- folderHandle,
234
- path.map(decodeURIComponent)
235
- );
235
+ const rootHandle = await repo.find(maybeAutomergeUrl, { signal });
236
236
 
237
- // If not found as a direct path, try resolving as a package subpath export
238
- // e.g. /automerge%3Adocid/abc → exports["./abc"] → "./dist/abc.js"
239
- if (!fileHandle) {
240
- const subpath = "./" + path.map(decodeURIComponent).join("/");
241
- const pkgFileHandle = await findHandleInFolderHandle<FileDoc>(
242
- repo,
243
- folderHandle,
244
- ["package.json"]
245
- );
246
- if (pkgFileHandle) {
247
- const pkgDoc = pkgFileHandle.doc() as FileDoc | undefined;
248
- if (pkgDoc?.content) {
249
- const pkgJson = JSON.parse(String(pkgDoc.content));
250
- try {
251
- const resolved = resolvePackageExport(pkgJson, subpath);
252
- if (resolved) {
253
- const resolvedPath = resolved.replace(/^\.\//, "").split("/");
254
- fileHandle = await findHandleInFolderHandle<FileDoc>(
255
- repo,
256
- folderHandle,
257
- resolvedPath
258
- );
259
- }
260
- } catch {
261
- // not a valid export subpath, fall through to error
262
- }
263
- }
264
- }
265
- }
266
- } else {
267
- // No path — resolve the root export (like "." in package.json)
268
- const pkgFileHandle = await findHandleInFolderHandle<FileDoc>(
269
- repo,
270
- folderHandle,
271
- ["package.json"]
272
- );
273
- if (pkgFileHandle) {
274
- const pkgDoc = pkgFileHandle.doc() as FileDoc | undefined;
275
- if (pkgDoc?.content) {
276
- const pkgJson = JSON.parse(String(pkgDoc.content));
277
- try {
278
- const resolved = resolvePackageExport(pkgJson);
279
- if (resolved) {
280
- const resolvedPath = resolved.replace(/^\.\//, "").split("/");
281
- fileHandle = await findHandleInFolderHandle<FileDoc>(
282
- repo,
283
- folderHandle,
284
- resolvedPath
285
- );
286
- }
287
- } catch {}
288
- }
289
- }
290
- }
237
+ const resolved = await resolvePath(
238
+ repo,
239
+ rootHandle,
240
+ path.map(decodeURIComponent)
241
+ );
291
242
 
292
- if (!fileHandle) {
243
+ if (!resolved) {
293
244
  throw new Error(
294
245
  `couldn't resolve ${path.join("/")} in folder at ${maybeAutomergeUrl}`
295
246
  );
296
247
  }
297
248
 
298
- const fileDoc = fileHandle.doc() as unknown as FileDoc;
299
- const content = fileDoc?.content;
300
- if (!content) {
301
- throw new Error(`file at ${href} has no content`);
302
- }
303
-
304
- let body: BodyInit =
305
- content instanceof Uint8Array
306
- ? (new Uint8Array(content) as BlobPart)
307
- : String(content);
308
- const mimeType = fileDoc.mimeType ?? "text/plain";
249
+ const body: BodyInit =
250
+ resolved.content instanceof Uint8Array
251
+ ? (new Uint8Array(resolved.content) as BlobPart)
252
+ : resolved.content;
309
253
 
310
- const headers = new Headers({ "content-type": mimeType });
254
+ const headers = new Headers({ "content-type": resolved.type });
311
255
  headers.set("cross-origin-embedder-policy", "credentialless");
312
256
  headers.set("cross-origin-resource-policy", "cross-origin");
313
257
 
@@ -353,7 +297,18 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
353
297
  });
354
298
  }
355
299
 
356
- const response = await resolveAutomergeUrl(specialURL);
300
+ const response = await Promise.race([
301
+ resolveAutomergeUrl(specialURL),
302
+ new Promise<never>((_, reject) =>
303
+ setTimeout(
304
+ () =>
305
+ reject(
306
+ new Error(`resolve timeout after ${RESOLVE_TIMEOUT_MS}ms`)
307
+ ),
308
+ RESOLVE_TIMEOUT_MS
309
+ )
310
+ ),
311
+ ]);
357
312
 
358
313
  if (response.status === 307) {
359
314
  return response;
package/src/setup.ts CHANGED
@@ -73,6 +73,11 @@ export default async function setupServiceWorker(
73
73
  });
74
74
 
75
75
  const path = options?.path ?? "/service-worker.js";
76
+ // No controller at this point means the page loaded without a service
77
+ // worker — i.e. this is a first-time install (or a hard reload). We'll
78
+ // reload after activation so the page boots with the SW in control of its
79
+ // initial fetches.
80
+ //const isFirstInstall = !navigator.serviceWorker.controller;
76
81
  const reg = await navigator.serviceWorker.register(path, { type: "module" });
77
82
 
78
83
  // If there's an update waiting or installing, wait for it to activate
@@ -94,9 +99,45 @@ export default async function setupServiceWorker(
94
99
  });
95
100
  }
96
101
 
97
- // Send a MessagePort so the SW's repo can sync with clients
102
+ // Send a MessagePort so the SW's repo can sync with clients, and wait for
103
+ // the SW to confirm its repo is constructed before returning. The
104
+ // MessageChannel adapter's whenReady() force-resolves after 100ms regardless
105
+ // of the other end's state, so it can't be used as a real readiness signal
106
+ // on first install (when the SW still has to fetch wasm and build its repo).
98
107
  const { port1, port2 } = new MessageChannel();
108
+ const swReady = new Promise<void>((resolve, reject) => {
109
+ let timeout: ReturnType<typeof setTimeout>;
110
+ const cleanup = () => {
111
+ clearTimeout(timeout);
112
+ navigator.serviceWorker.removeEventListener("message", listener);
113
+ };
114
+ const listener = (event: MessageEvent) => {
115
+ if (event.data?.type === "port-ready") {
116
+ cleanup();
117
+ resolve();
118
+ } else if (event.data?.type === "port-failed") {
119
+ cleanup();
120
+ reject(new Error(`service worker init failed: ${event.data.error}`));
121
+ }
122
+ };
123
+ navigator.serviceWorker.addEventListener("message", listener);
124
+ // Failsafe: don't block boot forever if the SW never replies. Surface the
125
+ // issue and let the rest of the site come up rather than hanging on a
126
+ // blank page.
127
+ timeout = setTimeout(() => {
128
+ cleanup();
129
+ reject(new Error("service worker port-ready timeout"));
130
+ }, 30_000);
131
+ });
99
132
  navigator.serviceWorker.controller!.postMessage({ type: "port" }, [port2]);
133
+ try {
134
+ await swReady;
135
+ } catch (err) {
136
+ console.warn(
137
+ "proceeding without SW ready ack:",
138
+ err instanceof Error ? err.message : err
139
+ );
140
+ }
100
141
 
101
142
  // Keepalive — Chromium idles out service workers after ~30s of inactivity,
102
143
  // which tears down the in-memory Repo and forces a cold restart on the next
package/src/site.ts CHANGED
@@ -44,11 +44,14 @@ import {
44
44
  getRegistry,
45
45
  registerPlugins,
46
46
  resolveAccountHandle,
47
+ unregisterPlugins,
47
48
  } from "@inkandswitch/patchwork-plugins";
48
49
  import * as plugins from "@inkandswitch/patchwork-plugins";
49
50
 
50
51
  import setupServiceWorker from "./setup.js";
51
52
  import { SwLogReader } from "./sw-logger.js";
53
+ import debug from "debug";
54
+ const log = debug("patchwork:bootloader:site");
52
55
 
53
56
  declare global {
54
57
  interface Window {
@@ -59,7 +62,7 @@ declare global {
59
62
  getRepoChannel: () => MessagePort;
60
63
  patchwork: {
61
64
  repo: Repo;
62
- modules: ModuleWatcher;
65
+ packages: ModuleWatcher;
63
66
  plugins: typeof plugins;
64
67
  accountDocHandle: DocHandle<AccountDoc>;
65
68
  sw: {
@@ -125,6 +128,11 @@ const DEFAULT_REMOTE_STORAGE_ID =
125
128
  const BIG_PATCHWORK_HASH_REGEX =
126
129
  /(?<title>[A-Za-z0-9-]+)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)(?<type>\?=[^&?]+)?/;
127
130
 
131
+ const [automergeWasm, subductionWasm] = await Promise.all([
132
+ fetch("/automerge.wasm?main").then((r) => r.bytes()),
133
+ fetch("/subduction.wasm").then((r) => r.bytes()),
134
+ ]);
135
+
128
136
  /**
129
137
  * Boot a Patchwork browser site.
130
138
  *
@@ -138,12 +146,8 @@ export async function bootPatchworkSite(
138
146
  config: SiteConfig
139
147
  ): Promise<BootResult> {
140
148
  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
- ]);
149
+ showLoadingAnimation();
150
+ log(`booting`, config);
147
151
  await initializeWasm(automergeWasm);
148
152
  initSubductionSync(subductionWasm);
149
153
 
@@ -153,7 +157,10 @@ export async function bootPatchworkSite(
153
157
  return peerId.includes("service-worker");
154
158
  },
155
159
  enableRemoteHeadsGossiping: true,
160
+ peerId:
161
+ `${config.titleSuffix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId,
156
162
  });
163
+
157
164
  repo.subscribeToRemotes(
158
165
  config.remoteStorageIds ?? [DEFAULT_REMOTE_STORAGE_ID]
159
166
  );
@@ -173,8 +180,9 @@ export async function bootPatchworkSite(
173
180
  // is added lazily once it appears on the account doc — see below.
174
181
  const moduleWatcher = new ModuleWatcher(
175
182
  repo,
176
- [defaultModulesUrl],
177
- onModuleLoaded
183
+ { system: defaultModulesUrl },
184
+ onModuleLoaded,
185
+ unregisterPlugins
178
186
  );
179
187
 
180
188
  const accountDocHandle = await resolveAccountHandle(repo, {
@@ -197,7 +205,7 @@ export async function bootPatchworkSite(
197
205
 
198
206
  window.patchwork = {
199
207
  repo,
200
- modules: moduleWatcher,
208
+ packages: moduleWatcher,
201
209
  plugins,
202
210
  accountDocHandle,
203
211
  sw: buildSwLogApi(),
@@ -247,14 +255,14 @@ function installDevConsoleGlobals(repo: Repo): void {
247
255
 
248
256
  function onModuleLoaded(name: string, mod: any): void {
249
257
  if (Array.isArray(mod.plugins)) {
250
- console.log(
251
- `[site] registering ${mod.plugins.length} plugin(s) from ${name.slice(0, 30)}...`,
258
+ log(
259
+ `registering ${mod.plugins.length} plugin(s) from ${name.slice(0, 30)}...`,
252
260
  mod.plugins.map((p: any) => `${p.type}:${p.id}`)
253
261
  );
254
262
  registerPlugins(mod.plugins, name);
255
263
  } else {
256
264
  console.warn(
257
- `[site] module ${name.slice(0, 30)}... has no plugins array`,
265
+ `module ${name.slice(0, 30)}... has no plugins array`,
258
266
  Object.keys(mod)
259
267
  );
260
268
  }
@@ -272,7 +280,7 @@ function wireModuleSettingsWhenReady(
272
280
  const wire = () => {
273
281
  const url = accountDocHandle.doc()?.moduleSettingsUrl;
274
282
  if (!url) return;
275
- void moduleWatcher.addUrl(url);
283
+ void moduleWatcher.addUrl("user", url);
276
284
  accountDocHandle.off("change", wire);
277
285
  };
278
286
  wire();
@@ -291,7 +299,6 @@ function primeRootElement(
291
299
  accountDocHandle: DocHandle<AccountDoc>
292
300
  ): void {
293
301
  rootElement.style.visibility = "hidden";
294
- showLoadingAnimation();
295
302
 
296
303
  const initialParams = new URLSearchParams(location.hash.slice(1));
297
304
  if (initialParams.has("frame")) {
@@ -312,13 +319,13 @@ function logToolRegistryWhenLoaded(moduleWatcher: ModuleWatcher): void {
312
319
  .then(() => {
313
320
  const toolReg = getRegistry("patchwork:tool");
314
321
  const tools = toolReg.all();
315
- console.log(
316
- `[site] doneLoading: ${tools.length} tools registered:`,
322
+ log(
323
+ `doneLoading: ${tools.length} tools registered:`,
317
324
  tools.map((t: any) => t.id)
318
325
  );
319
326
  })
320
327
  .catch((err: unknown) => {
321
- console.error("[site] doneLoading rejected:", err);
328
+ console.error("doneLoading rejected:", err);
322
329
  });
323
330
  }
324
331
 
@@ -328,10 +335,10 @@ function buildSwLogApi(): Window["patchwork"]["sw"] {
328
335
  const entries = await SwLogReader.tail(n);
329
336
  for (const e of entries) {
330
337
  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);
338
+ if (e.data !== undefined) log(prefix, e.msg, e.data);
339
+ else log(prefix, e.msg);
333
340
  }
334
- console.log(`--- ${entries.length} entries ---`);
341
+ log(`--- ${entries.length} entries ---`);
335
342
  },
336
343
  tailLogs: (n = 200) => SwLogReader.tail(n),
337
344
  exportLogs: () => SwLogReader.exportAll(),
@@ -363,6 +370,10 @@ function showLoadingAnimation(): void {
363
370
  radial-gradient(ellipse 65% 55% at 50% 50%, #f1e6f6, transparent 80%);
364
371
  animation: pw-bootloader-pulse 3.5s ease-in-out infinite;
365
372
  transition: opacity 0.6s ease-out;
373
+ top: 0;
374
+ left: 0;
375
+ right: 0;
376
+ bottom: 0;
366
377
  }
367
378
  @media (prefers-color-scheme: dark) {
368
379
  #${LOADING_ELEMENT_ID} {