@inkandswitch/patchwork-bootloader 0.4.1 → 0.4.3

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/package.json CHANGED
@@ -1,11 +1,10 @@
1
1
  {
2
2
  "name": "@inkandswitch/patchwork-bootloader",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "author": "chee",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "devDependencies": {
8
- "@automerge/automerge-repo-keyhive": "0.3.0-alpha.sub.8b",
9
8
  "esbuild": "^0.23.1",
10
9
  "rollup": "^4.61.1"
11
10
  },
@@ -46,27 +45,47 @@
46
45
  "dependencies": {
47
46
  "@automerge/automerge": "3.3.2",
48
47
  "@automerge/automerge-repo": "2.6.0-subduction.46",
49
- "@automerge/automerge-repo-network-messagechannel": "2.6.0-subduction.45",
50
- "@automerge/automerge-repo-network-websocket": "2.6.0-subduction.45",
51
- "@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.45",
48
+ "@automerge/automerge-repo-keyhive": "0.3.0-alpha.sub.8b",
49
+ "@automerge/automerge-repo-network-messagechannel": "2.6.0-subduction.46",
50
+ "@automerge/automerge-repo-network-websocket": "2.6.0-subduction.46",
51
+ "@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.46",
52
52
  "@automerge/automerge-subduction": "0.16.0",
53
- "@automerge/vanillajs": "2.6.0-subduction.45",
53
+ "@automerge/vanillajs": "2.6.0-subduction.46",
54
+ "@codemirror/commands": "^6.10.4",
55
+ "@codemirror/language": "^6.12.4",
56
+ "@codemirror/state": "^6.7.1",
57
+ "@codemirror/view": "^6.43.6",
54
58
  "@keyhive/keyhive": "0.1.0-alpha.5",
55
59
  "@types/debug": "^4.1.13",
56
60
  "debug": "^4.4.3",
57
61
  "resolve.exports": "^2.0.3",
58
62
  "service-worker-types": "npm:@types/serviceworker@^0.0.153",
63
+ "solid-js": "^1.9.13",
59
64
  "tinyargs": "^0.1.4",
60
- "@inkandswitch/patchwork-elements": "^3.0.0",
61
- "@inkandswitch/patchwork-filesystem": "^0.1.3",
62
- "@inkandswitch/patchwork-providers": "^0.4.0",
63
- "@inkandswitch/patchwork-plugins": "^0.0.11"
65
+ "@inkandswitch/patchwork-elements": "^4.0.0",
66
+ "@inkandswitch/patchwork-filesystem": "^0.2.1",
67
+ "@inkandswitch/patchwork-plugins": "^1.0.1",
68
+ "@inkandswitch/patchwork-providers": "^0.4.1"
64
69
  },
65
70
  "peerDependencies": {
66
71
  "@automerge/automerge": "3.3.2",
67
72
  "@automerge/automerge-repo": "2.6.0-subduction.46",
68
73
  "@automerge/automerge-repo-keyhive": "0.3.0-alpha.sub.8b",
69
- "@automerge/vanillajs": "2.6.0-subduction.45"
74
+ "@automerge/vanillajs": "2.6.0-subduction.46"
75
+ },
76
+ "peerDependenciesMeta": {
77
+ "@automerge/automerge": {
78
+ "optional": true
79
+ },
80
+ "@automerge/automerge-repo": {
81
+ "optional": true
82
+ },
83
+ "@automerge/automerge-repo-keyhive": {
84
+ "optional": true
85
+ },
86
+ "@automerge/vanillajs": {
87
+ "optional": true
88
+ }
70
89
  },
71
90
  "scripts": {
72
91
  "build": "tsc",
@@ -49,6 +49,7 @@ import {
49
49
  type HandoffCachedMessage,
50
50
  type HandoffOnlineMessage,
51
51
  type HandoffRequest,
52
+ type HandoffAbortMessage,
52
53
  type HandoffRequestMessage,
53
54
  type HandoffResponseMessage,
54
55
  type SyncStateBroadcast,
@@ -219,11 +220,9 @@ setInterval(() => {
219
220
  watchdogLast = now;
220
221
  if (gap > WATCHDOG_TICK_MS * WATCHDOG_GAP_FACTOR) {
221
222
  console.warn(
222
- `[lifecycle] worker resumed after ~${Math.round(gap / 1000)}s gap ` +
223
- `(timer expected every ${WATCHDOG_TICK_MS / 1000}s) likely ` +
224
- `suspended/frozen/throttled; WebSocket keepalive pongs were not sent ` +
225
- `during this window, so the sync server may have reaped us. at ` +
226
- `${new Date(now).toISOString()}`
223
+ `[lifecycle] ${new Date(now).toISOString()} watchdog timer gap ` +
224
+ `~${Math.round(gap / 1000)}s (expected every ` +
225
+ `${WATCHDOG_TICK_MS / 1000}s)`
227
226
  );
228
227
  }
229
228
  }, WATCHDOG_TICK_MS);
@@ -283,9 +282,7 @@ function getSubductionEndpoints(): (WorkerWebSocketEndpoint | string)[] {
283
282
  : [
284
283
  new WorkerWebSocketEndpoint(SUBDUCTION_SYNC_URL, {
285
284
  worker: subductionPortProvider.source,
286
- ...(WS_WINDOW_FRAMES
287
- ? { windowFrames: WS_WINDOW_FRAMES }
288
- : {}),
285
+ ...(WS_WINDOW_FRAMES ? { windowFrames: WS_WINDOW_FRAMES } : {}),
289
286
  }),
290
287
  ];
291
288
  }
@@ -339,7 +336,7 @@ async function connectClassicSyncNetwork(server: string): Promise<void> {
339
336
  }
340
337
 
341
338
  const siteName =
342
- typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "tiny-patchwork";
339
+ typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "patchwork.inkandswitch.com";
343
340
 
344
341
  const cacheableStatuses = [200, 203, 204];
345
342
 
@@ -365,10 +362,9 @@ function getRepoHive() {
365
362
  if (!repoHivePromise) {
366
363
  repoHivePromise = (async () => {
367
364
  log("getRepo: starting");
368
-
369
365
  log("fetching wasm modules");
370
366
  const [amWasmBuf, sdnWasmBuf] = await Promise.all([
371
- fetch("/automerge.wasm?worker").then((r) => r.arrayBuffer()),
367
+ fetch("/automerge.wasm").then((r) => r.arrayBuffer()),
372
368
  fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
373
369
  ]);
374
370
  initSubductionSync(new Uint8Array(sdnWasmBuf));
@@ -646,8 +642,9 @@ function setupSyncStateBroadcast(
646
642
  >();
647
643
  // Inspectable from the SharedWorker console as `self.patchworkResync` to see
648
644
  // whether/how often a doc is being re-synced and against which server heads.
649
- const resyncDiag: { fires: number; byDoc: Record<string, unknown> } =
650
- ((self as any).patchworkResync ??= { fires: 0, byDoc: {} });
645
+ const resyncDiag: { fires: number; byDoc: Record<string, unknown> } = ((
646
+ self as any
647
+ ).patchworkResync ??= { fires: 0, byDoc: {} });
651
648
  const reviewResync = (documentId: string) => {
652
649
  if (!identity || !connected) {
653
650
  resyncState.delete(documentId);
@@ -732,8 +729,12 @@ function setupSyncStateBroadcast(
732
729
  byStorage.set(storageId, { heads: headsCopy, timestamp });
733
730
  postHeads(documentId, storageId, headsCopy, timestamp);
734
731
  // A doc the server reported is one we hold — make sure we're advertising
735
- // our own heads for it too.
736
- scanOwnHandles();
732
+ // our own heads for it too. Track just this doc: a full scanOwnHandles()
733
+ // per event is O(all handles) and goes quadratic during sync bursts,
734
+ // starving the thread that's doing the syncing. The 3s tick still covers
735
+ // general discovery.
736
+ const handle = repo.handles[documentId as DocumentId];
737
+ if (handle) trackOwnHandle(handle as never);
737
738
  reviewResync(documentId);
738
739
  }
739
740
  );
@@ -870,6 +871,16 @@ function handleControlMessage(
870
871
  connection: Connection
871
872
  ) {
872
873
  const data = event.data;
874
+ // Tally of control messages received, readable from the SharedWorker console
875
+ // as `self.patchworkControl`. Not using log(): that's gated on `debugging`,
876
+ // which is only enabled by a {type:"debug"} message arriving over this same
877
+ // channel.
878
+ const stats = ((self as any).patchworkControl ??= {
879
+ connects: 0,
880
+ byType: {} as Record<string, number>,
881
+ });
882
+ stats.byType[String(data?.type ?? "<untyped>")] =
883
+ (stats.byType[String(data?.type ?? "<untyped>")] ?? 0) + 1;
873
884
  if (data?.type === "port") {
874
885
  log("received repo channel");
875
886
  const [repoPort] = event.ports;
@@ -930,6 +941,7 @@ function handleControlMessage(
930
941
  self.addEventListener("connect", (event) => {
931
942
  const controlPort = (event as MessageEvent).ports[0];
932
943
  const connection: Connection = { channels: new Set() };
944
+ ((self as any).patchworkControl ??= { connects: 0, byType: {} }).connects++;
933
945
 
934
946
  controlPort.addEventListener("message", (messageEvent) => {
935
947
  handleControlMessage(messageEvent as MessageEvent, controlPort, connection);
@@ -1010,6 +1022,13 @@ function waitForHeads(
1010
1022
  });
1011
1023
  }
1012
1024
 
1025
+ /**
1026
+ * Thrown instead of returning a Response when the request should fail as a
1027
+ * network error rather than resolve to something the caller can memoize.
1028
+ * See {@link HandoffAbortMessage}.
1029
+ */
1030
+ class AbortHandoff extends Error {}
1031
+
1013
1032
  async function resolveAutomergeUrl(automergeURL: URL): Promise<Response> {
1014
1033
  const { repo } = await getRepoHive();
1015
1034
  const href = automergeURL.href;
@@ -1043,7 +1062,12 @@ async function resolveAutomergeUrl(automergeURL: URL): Promise<Response> {
1043
1062
  // The heads may not have synced to us yet — give them the rest of the
1044
1063
  // resolve window to arrive before giving up.
1045
1064
  if (!(await waitForHeads(baseHandle, hexHeads ?? [], signal))) {
1046
- return new Response("heads not found", { status: 404 });
1065
+ // Not a 404: the heads may still be on their way, and this exact URL will
1066
+ // be requested again once they land. Fail it as a network error so the
1067
+ // caller doesn't memoize the miss.
1068
+ throw new AbortHandoff(
1069
+ `heads not found for ${maybeAutomergeUrl} within ${RESOLVE_TIMEOUT_MS}ms`
1070
+ );
1047
1071
  }
1048
1072
  const rootHandle = baseHandle.view(heads);
1049
1073
 
@@ -1137,6 +1161,14 @@ async function handleHandoffRequest(message: HandoffRequestMessage) {
1137
1161
  ),
1138
1162
  ]);
1139
1163
  } catch (error) {
1164
+ if (error instanceof AbortHandoff) {
1165
+ handoffChannel.postMessage({
1166
+ id,
1167
+ type: "abort",
1168
+ reason: error.message,
1169
+ } satisfies HandoffAbortMessage);
1170
+ return;
1171
+ }
1140
1172
  const body =
1141
1173
  error instanceof Error
1142
1174
  ? `${error.message}\n\n${error.stack}`
@@ -11,7 +11,7 @@
11
11
  // Created with type:"module"; its dynamic `import()` of `/<automergeUrl>/…`
12
12
  // entry points is served by the service worker that controls this worker.
13
13
 
14
- import { importModuleFromFolderDocUrl } from "@inkandswitch/patchwork-filesystem";
14
+ import { importPackageFromFolderDocUrl } from "@inkandswitch/patchwork-filesystem";
15
15
  import type { AutomergeUrl } from "@automerge/automerge-repo/slim";
16
16
 
17
17
  type DiscoverRequest = {
@@ -45,7 +45,7 @@ self.addEventListener("message", (event: MessageEvent) => {
45
45
  if (!isDiscoverRequest(data)) return;
46
46
  const { id, url } = data;
47
47
 
48
- importModuleFromFolderDocUrl(url)
48
+ importPackageFromFolderDocUrl(url)
49
49
  .then((mod) => {
50
50
  const plugins: any[] = Array.isArray(mod?.plugins) ? mod.plugins : [];
51
51
  const descriptors = plugins.map(toDescriptor);
@@ -1,6 +1,6 @@
1
1
  // Main-thread client for the module-loader worker (see module-loader-worker.ts).
2
2
  //
3
- // `importAutomergeModuleViaWorker` is wired into the ModuleWatcher in place of
3
+ // `importAutomergePackageViaWorker` is wired into the ModuleWatcher in place of
4
4
  // its default (direct, main-thread) package import. It asks the worker to
5
5
  // import the package entry point and report which plugins it exports, then
6
6
  // returns the same `{ plugins }` shape the watcher already feeds to
@@ -62,11 +62,11 @@ function discoverDescriptors(urlAtHeads: AutomergeUrl): Promise<Descriptor[]> {
62
62
  }
63
63
 
64
64
  /**
65
- * ModuleWatcher `importAutomergeModule` hook: discover descriptors in the
65
+ * ModuleWatcher `importAutomergePackage` hook: discover descriptors in the
66
66
  * worker, then return the `{ plugins }` shape with a main-thread `load()` per
67
67
  * plugin that imports the package at heads and calls its real loader.
68
68
  */
69
- export async function importAutomergeModuleViaWorker(
69
+ export async function importAutomergePackageViaWorker(
70
70
  urlAtHeads: string
71
71
  ): Promise<{ plugins: Descriptor[] }> {
72
72
  const url = urlAtHeads as AutomergeUrl;
@@ -184,6 +184,9 @@ handoffChannel.addEventListener("message", (event) => {
184
184
  }
185
185
  });
186
186
 
187
+ /** Signals that respondWith should reject; see {@link HandoffAbortMessage}. */
188
+ class HandoffAborted extends Error {}
189
+
187
190
  function handoff(
188
191
  request: Request,
189
192
  handoffURL: URL
@@ -259,10 +262,34 @@ async function cachePage(
259
262
  response: Response
260
263
  ) {
261
264
  const indexRequest = indexRequestFor(request);
262
- if (indexRequest) await cache.put(indexRequest, response.clone());
263
265
  const rootRequest = rootRequestFor(request);
264
- if (rootRequest) await cache.put(rootRequest, response.clone());
265
- await cache.put(request, response);
266
+ await Promise.all([
267
+ indexRequest && cache.put(indexRequest, response.clone()),
268
+ rootRequest && cache.put(rootRequest, response.clone()),
269
+ cache.put(request, response),
270
+ ]);
271
+ }
272
+
273
+ // Write to the cache without blocking the response: cache.put only resolves
274
+ // once the whole body has been consumed and persisted, so awaiting it before
275
+ // returning would turn time-to-first-byte into time-to-last-byte-plus-disk
276
+ // for every proxied asset. waitUntil keeps the worker alive for the write.
277
+ function cacheInBackground(
278
+ fetchEvent: FetchEvent,
279
+ cache: Cache,
280
+ request: Request,
281
+ response: Response
282
+ ) {
283
+ fetchEvent.waitUntil(
284
+ (request.mode === "navigate" || request.destination === "document"
285
+ ? cachePage(cache, request, response)
286
+ : cache.put(request, response)
287
+ ).catch((error) => {
288
+ // Always loud (not gated on debugging): a QuotaExceededError here is
289
+ // the first sign the origin is under storage pressure.
290
+ console.warn(`error caching ${request.url} in ${cachename}`, error);
291
+ })
292
+ );
266
293
  }
267
294
 
268
295
  // ── Fetch handler ──────────────────────────────────────────────────────
@@ -270,7 +297,9 @@ async function cachePage(
270
297
  self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
271
298
  log("fetch event", fetchEvent.request.url);
272
299
  const request = fetchEvent.request;
273
- if (request.method !== "GET") return fetchEvent.respondWith(fetch(request));
300
+ // Not calling respondWith at all lets the browser handle non-GETs natively
301
+ // instead of proxying their bodies through this worker.
302
+ if (request.method !== "GET") return;
274
303
  const url = new URL(fetchEvent.request.url);
275
304
 
276
305
  let handoffURL: URL | undefined;
@@ -303,6 +332,14 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
303
332
  fetchEvent.waitUntil(replyPromise.catch(() => {}));
304
333
  const reply = await replyPromise;
305
334
 
335
+ if (reply.type === "abort") {
336
+ // Rejecting respondWith gives the caller a network error rather
337
+ // than a response it can memoize. Rethrown past the catch below,
338
+ // which would otherwise turn this into a 556.
339
+ log(`aborting ${handoffURL}: ${reply.reason}`);
340
+ throw new HandoffAborted(reply.reason);
341
+ }
342
+
306
343
  if (reply.type === "response") {
307
344
  // errors, redirects and other things that shouldn't be cached
308
345
  log(`serving handed-off response for ${handoffURL}`, reply);
@@ -341,15 +378,7 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
341
378
  cacheableStatuses.includes(response.status)) &&
342
379
  /^https?:/.test(request.url)
343
380
  ) {
344
- const cachedResponse = response.clone();
345
- await (
346
- request.mode === "navigate" ||
347
- request.destination === "document"
348
- ? cachePage(cache, request, cachedResponse)
349
- : cache.put(request, cachedResponse)
350
- ).catch((error) => {
351
- log(`error caching ${request.url} in ${cachename}`, error);
352
- });
381
+ cacheInBackground(fetchEvent, cache, request, response.clone());
353
382
  } else {
354
383
  log(
355
384
  `skipping uncacheable response code from cache: ${response.status} for ${request.url}`
@@ -366,6 +395,8 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
366
395
  );
367
396
  }
368
397
  } catch (error) {
398
+ // Deliberate: fail the request as a network error, no response.
399
+ if (error instanceof HandoffAborted) throw error;
369
400
  const message =
370
401
  error instanceof Error
371
402
  ? `${error.message}\n\n${error.stack}`