@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/CHANGELOG.md CHANGED
@@ -1,5 +1,75 @@
1
1
  # @inkandswitch/patchwork-bootloader
2
2
 
3
+ ## 0.4.3
4
+
5
+ ### Patch Changes
6
+
7
+ - bd9cd3d: Reliability and boot-speed fixes:
8
+
9
+ - The service worker no longer blocks responses on cache writes (they move to
10
+ `waitUntil`), page caching writes its three entries in parallel, non-GET
11
+ requests bypass the worker entirely, cache-write failures (e.g. quota) are
12
+ always logged, the cache is capped at 2000 entries with oldest-first
13
+ trimming, and boot requests persistent storage so cache growth can't trip
14
+ origin-wide eviction of user data.
15
+ - `automerge.wasm` is fetched under one URL from both the tab and the
16
+ automerge worker (the `?main`/`?worker` tracing query strings defeated the
17
+ HTTP cache, the SW cache, and the sites' preload — the ~3MB body downloaded
18
+ twice).
19
+ - Tabs now recover when the automerge SharedWorker dies or its connection is
20
+ stranded. Previously death was only logged and tabs silently stopped syncing
21
+ until reload. Silence alone never tears anything down (a slow-booting or
22
+ busy worker delivers everything queued once it catches up): a silent port
23
+ first starts a non-destructive probe — a second connection to the same
24
+ instance — and only when the probe gets a `hello` while the original port
25
+ stays silent (proving a live instance with a stranded port) is the worker
26
+ handle recreated, with sync-state subscriptions replayed and every
27
+ subscriber's repo re-wired onto a fresh port. This rescues boots whose
28
+ initial SharedWorker port comes up deaf (~6s), including in hidden
29
+ background tabs; a port `close` event still recovers immediately.
30
+ - The worker no longer rescans every doc handle on every
31
+ `subduction-remote-heads` event (quadratic during sync bursts); it tracks
32
+ just the reported doc.
33
+ - `ModuleWatcher` announces are generation-tracked, so a stale retry of an
34
+ older module version can no longer land after a newer version and roll the
35
+ registry back.
36
+ - `resolveAccountHandle` never overwrites a valid stored account pointer when
37
+ `repo.find` fails: it retries briefly and then throws, instead of silently
38
+ creating a fresh account and orphaning the user's workspace.
39
+ - `OverlayRepo` no longer memoizes rejected resolutions: a `find` that failed
40
+ because the doc (or its keyhive access) hadn't synced yet used to pin every
41
+ later `find` of that url to the same cached rejection, so the views' "retry
42
+ once access syncs" recovery could never reach the base repo. Rejections now
43
+ evict and the next `find` re-resolves. `findWithProgress().subscribe` also
44
+ no longer leaks its inner subscription (or fires the callback) when
45
+ unsubscribed before the resolution settles.
46
+
47
+ - Updated dependencies [bd9cd3d]
48
+ - @inkandswitch/patchwork-filesystem@0.2.1
49
+ - @inkandswitch/patchwork-plugins@1.0.1
50
+ - @inkandswitch/patchwork-providers@0.4.1
51
+
52
+ ## 0.4.2
53
+
54
+ ### Patch Changes
55
+
56
+ - 82bee46: A doc's `suggestedImportUrl` may now be an `automerge:` folder-doc URL as well
57
+ as an `http(s):` module bundle. When a view finds no built-in tool for a doc, it
58
+ loads the suggested module either way. Adds `importPackage` (which dispatches on
59
+ the URL scheme) and `isImportableSuggestedUrl` to `patchwork-filesystem`, and
60
+ `getSuggestedImportUrl` now honors automerge URLs.
61
+
62
+ The package-importing helpers are renamed from `module` to `package`, since they
63
+ resolve a `package.json` entry point: `importModuleFromFolderDocUrl` →
64
+ `importPackageFromFolderDocUrl`, `importModuleFromHttpUrl` →
65
+ `importPackageFromHttpUrl`, and the `ModuleWatcher` `importAutomergeModule` hook
66
+ (with bootloader's `importAutomergeModuleViaWorker`) → `importAutomergePackage`.
67
+
68
+ - Updated dependencies [82bee46]
69
+ - @inkandswitch/patchwork-filesystem@0.2.0
70
+ - @inkandswitch/patchwork-elements@4.0.0
71
+ - @inkandswitch/patchwork-plugins@1.0.0
72
+
3
73
  ## 0.4.1
4
74
 
5
75
  ### Patch Changes
@@ -172,11 +172,9 @@ setInterval(() => {
172
172
  const gap = now - watchdogLast;
173
173
  watchdogLast = now;
174
174
  if (gap > WATCHDOG_TICK_MS * WATCHDOG_GAP_FACTOR) {
175
- console.warn(`[lifecycle] worker resumed after ~${Math.round(gap / 1000)}s gap ` +
176
- `(timer expected every ${WATCHDOG_TICK_MS / 1000}s) likely ` +
177
- `suspended/frozen/throttled; WebSocket keepalive pongs were not sent ` +
178
- `during this window, so the sync server may have reaped us. at ` +
179
- `${new Date(now).toISOString()}`);
175
+ console.warn(`[lifecycle] ${new Date(now).toISOString()} watchdog timer gap ` +
176
+ `~${Math.round(gap / 1000)}s (expected every ` +
177
+ `${WATCHDOG_TICK_MS / 1000}s)`);
180
178
  }
181
179
  }, WATCHDOG_TICK_MS);
182
180
  // Sync server selection. Sub is the default. Build with KEYHIVE_SYNC_SERVER=true
@@ -225,9 +223,7 @@ function getSubductionEndpoints() {
225
223
  : [
226
224
  new WorkerWebSocketEndpoint(SUBDUCTION_SYNC_URL, {
227
225
  worker: subductionPortProvider.source,
228
- ...(WS_WINDOW_FRAMES
229
- ? { windowFrames: WS_WINDOW_FRAMES }
230
- : {}),
226
+ ...(WS_WINDOW_FRAMES ? { windowFrames: WS_WINDOW_FRAMES } : {}),
231
227
  }),
232
228
  ];
233
229
  }
@@ -273,7 +269,7 @@ async function connectClassicSyncNetwork(server) {
273
269
  throw err;
274
270
  }
275
271
  }
276
- const siteName = typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "tiny-patchwork";
272
+ const siteName = typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "patchwork.inkandswitch.com";
277
273
  const cacheableStatuses = [200, 203, 204];
278
274
  function log(...args) {
279
275
  if (!debugging)
@@ -288,7 +284,7 @@ function getRepoHive() {
288
284
  log("getRepo: starting");
289
285
  log("fetching wasm modules");
290
286
  const [amWasmBuf, sdnWasmBuf] = await Promise.all([
291
- fetch("/automerge.wasm?worker").then((r) => r.arrayBuffer()),
287
+ fetch("/automerge.wasm").then((r) => r.arrayBuffer()),
292
288
  fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
293
289
  ]);
294
290
  initSubductionSync(new Uint8Array(sdnWasmBuf));
@@ -612,8 +608,13 @@ function setupSyncStateBroadcast(repo, identity) {
612
608
  byStorage.set(storageId, { heads: headsCopy, timestamp });
613
609
  postHeads(documentId, storageId, headsCopy, timestamp);
614
610
  // A doc the server reported is one we hold — make sure we're advertising
615
- // our own heads for it too.
616
- scanOwnHandles();
611
+ // our own heads for it too. Track just this doc: a full scanOwnHandles()
612
+ // per event is O(all handles) and goes quadratic during sync bursts,
613
+ // starving the thread that's doing the syncing. The 3s tick still covers
614
+ // general discovery.
615
+ const handle = repo.handles[documentId];
616
+ if (handle)
617
+ trackOwnHandle(handle);
617
618
  reviewResync(documentId);
618
619
  });
619
620
  repo.on("subduction-connection", ({ connected: isConnected }) => {
@@ -717,6 +718,16 @@ async function connectPort(port, connection) {
717
718
  }
718
719
  function handleControlMessage(event, controlPort, connection) {
719
720
  const data = event.data;
721
+ // Tally of control messages received, readable from the SharedWorker console
722
+ // as `self.patchworkControl`. Not using log(): that's gated on `debugging`,
723
+ // which is only enabled by a {type:"debug"} message arriving over this same
724
+ // channel.
725
+ const stats = (self.patchworkControl ??= {
726
+ connects: 0,
727
+ byType: {},
728
+ });
729
+ stats.byType[String(data?.type ?? "<untyped>")] =
730
+ (stats.byType[String(data?.type ?? "<untyped>")] ?? 0) + 1;
720
731
  if (data?.type === "port") {
721
732
  log("received repo channel");
722
733
  const [repoPort] = event.ports;
@@ -777,6 +788,7 @@ function handleControlMessage(event, controlPort, connection) {
777
788
  self.addEventListener("connect", (event) => {
778
789
  const controlPort = event.ports[0];
779
790
  const connection = { channels: new Set() };
791
+ (self.patchworkControl ??= { connects: 0, byType: {} }).connects++;
780
792
  controlPort.addEventListener("message", (messageEvent) => {
781
793
  handleControlMessage(messageEvent, controlPort, connection);
782
794
  });
@@ -848,6 +860,13 @@ function waitForHeads(handle, hexHeads, signal) {
848
860
  check();
849
861
  });
850
862
  }
863
+ /**
864
+ * Thrown instead of returning a Response when the request should fail as a
865
+ * network error rather than resolve to something the caller can memoize.
866
+ * See {@link HandoffAbortMessage}.
867
+ */
868
+ class AbortHandoff extends Error {
869
+ }
851
870
  async function resolveAutomergeUrl(automergeURL) {
852
871
  const { repo } = await getRepoHive();
853
872
  const href = automergeURL.href;
@@ -878,7 +897,10 @@ async function resolveAutomergeUrl(automergeURL) {
878
897
  // The heads may not have synced to us yet — give them the rest of the
879
898
  // resolve window to arrive before giving up.
880
899
  if (!(await waitForHeads(baseHandle, hexHeads ?? [], signal))) {
881
- return new Response("heads not found", { status: 404 });
900
+ // Not a 404: the heads may still be on their way, and this exact URL will
901
+ // be requested again once they land. Fail it as a network error so the
902
+ // caller doesn't memoize the miss.
903
+ throw new AbortHandoff(`heads not found for ${maybeAutomergeUrl} within ${RESOLVE_TIMEOUT_MS}ms`);
882
904
  }
883
905
  const rootHandle = baseHandle.view(heads);
884
906
  const resolved = await resolvePath(repo, rootHandle, path.map(decodeURIComponent));
@@ -949,6 +971,14 @@ async function handleHandoffRequest(message) {
949
971
  ]);
950
972
  }
951
973
  catch (error) {
974
+ if (error instanceof AbortHandoff) {
975
+ handoffChannel.postMessage({
976
+ id,
977
+ type: "abort",
978
+ reason: error.message,
979
+ });
980
+ return;
981
+ }
952
982
  const body = error instanceof Error
953
983
  ? `${error.message}\n\n${error.stack}`
954
984
  : String(error);
@@ -10,7 +10,7 @@
10
10
  //
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
- import { importModuleFromFolderDocUrl } from "@inkandswitch/patchwork-filesystem";
13
+ import { importPackageFromFolderDocUrl } from "@inkandswitch/patchwork-filesystem";
14
14
  // Keep only the structured-cloneable description fields. `load` is a closure
15
15
  // and `module` is the (possibly already-loaded) implementation — neither can
16
16
  // cross the worker boundary. `import` is droppable too: the main thread
@@ -33,7 +33,7 @@ self.addEventListener("message", (event) => {
33
33
  if (!isDiscoverRequest(data))
34
34
  return;
35
35
  const { id, url } = data;
36
- importModuleFromFolderDocUrl(url)
36
+ importPackageFromFolderDocUrl(url)
37
37
  .then((mod) => {
38
38
  const plugins = Array.isArray(mod?.plugins) ? mod.plugins : [];
39
39
  const descriptors = plugins.map(toDescriptor);
@@ -3,11 +3,11 @@ type Descriptor = Record<string, unknown> & {
3
3
  type?: string;
4
4
  };
5
5
  /**
6
- * ModuleWatcher `importAutomergeModule` hook: discover descriptors in the
6
+ * ModuleWatcher `importAutomergePackage` hook: discover descriptors in the
7
7
  * worker, then return the `{ plugins }` shape with a main-thread `load()` per
8
8
  * plugin that imports the package at heads and calls its real loader.
9
9
  */
10
- export declare function importAutomergeModuleViaWorker(urlAtHeads: string): Promise<{
10
+ export declare function importAutomergePackageViaWorker(urlAtHeads: string): Promise<{
11
11
  plugins: Descriptor[];
12
12
  }>;
13
13
  export {};
@@ -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
@@ -50,11 +50,11 @@ function discoverDescriptors(urlAtHeads) {
50
50
  });
51
51
  }
52
52
  /**
53
- * ModuleWatcher `importAutomergeModule` hook: discover descriptors in the
53
+ * ModuleWatcher `importAutomergePackage` hook: discover descriptors in the
54
54
  * worker, then return the `{ plugins }` shape with a main-thread `load()` per
55
55
  * plugin that imports the package at heads and calls its real loader.
56
56
  */
57
- export async function importAutomergeModuleViaWorker(urlAtHeads) {
57
+ export async function importAutomergePackageViaWorker(urlAtHeads) {
58
58
  const url = urlAtHeads;
59
59
  const descriptors = await discoverDescriptors(url);
60
60
  const plugins = descriptors.map((descriptor) => {
@@ -135,6 +135,9 @@ handoffChannel.addEventListener("message", (event) => {
135
135
  }
136
136
  }
137
137
  });
138
+ /** Signals that respondWith should reject; see {@link HandoffAbortMessage}. */
139
+ class HandoffAborted extends Error {
140
+ }
138
141
  function handoff(request, handoffURL) {
139
142
  const id = crypto.randomUUID();
140
143
  const resolvers = Promise.withResolvers();
@@ -190,19 +193,34 @@ function rootRequestFor(request) {
190
193
  }
191
194
  async function cachePage(cache, request, response) {
192
195
  const indexRequest = indexRequestFor(request);
193
- if (indexRequest)
194
- await cache.put(indexRequest, response.clone());
195
196
  const rootRequest = rootRequestFor(request);
196
- if (rootRequest)
197
- await cache.put(rootRequest, response.clone());
198
- await cache.put(request, response);
197
+ await Promise.all([
198
+ indexRequest && cache.put(indexRequest, response.clone()),
199
+ rootRequest && cache.put(rootRequest, response.clone()),
200
+ cache.put(request, response),
201
+ ]);
202
+ }
203
+ // Write to the cache without blocking the response: cache.put only resolves
204
+ // once the whole body has been consumed and persisted, so awaiting it before
205
+ // returning would turn time-to-first-byte into time-to-last-byte-plus-disk
206
+ // for every proxied asset. waitUntil keeps the worker alive for the write.
207
+ function cacheInBackground(fetchEvent, cache, request, response) {
208
+ fetchEvent.waitUntil((request.mode === "navigate" || request.destination === "document"
209
+ ? cachePage(cache, request, response)
210
+ : cache.put(request, response)).catch((error) => {
211
+ // Always loud (not gated on debugging): a QuotaExceededError here is
212
+ // the first sign the origin is under storage pressure.
213
+ console.warn(`error caching ${request.url} in ${cachename}`, error);
214
+ }));
199
215
  }
200
216
  // ── Fetch handler ──────────────────────────────────────────────────────
201
217
  self.addEventListener("fetch", (fetchEvent) => {
202
218
  log("fetch event", fetchEvent.request.url);
203
219
  const request = fetchEvent.request;
220
+ // Not calling respondWith at all lets the browser handle non-GETs natively
221
+ // instead of proxying their bodies through this worker.
204
222
  if (request.method !== "GET")
205
- return fetchEvent.respondWith(fetch(request));
223
+ return;
206
224
  const url = new URL(fetchEvent.request.url);
207
225
  let handoffURL;
208
226
  if (url.hostname == self.location.hostname &&
@@ -227,6 +245,13 @@ self.addEventListener("fetch", (fetchEvent) => {
227
245
  const replyPromise = handoff(request, handoffURL);
228
246
  fetchEvent.waitUntil(replyPromise.catch(() => { }));
229
247
  const reply = await replyPromise;
248
+ if (reply.type === "abort") {
249
+ // Rejecting respondWith gives the caller a network error rather
250
+ // than a response it can memoize. Rethrown past the catch below,
251
+ // which would otherwise turn this into a 556.
252
+ log(`aborting ${handoffURL}: ${reply.reason}`);
253
+ throw new HandoffAborted(reply.reason);
254
+ }
230
255
  if (reply.type === "response") {
231
256
  // errors, redirects and other things that shouldn't be cached
232
257
  log(`serving handed-off response for ${handoffURL}`, reply);
@@ -258,13 +283,7 @@ self.addEventListener("fetch", (fetchEvent) => {
258
283
  if ((response.status === 0 ||
259
284
  cacheableStatuses.includes(response.status)) &&
260
285
  /^https?:/.test(request.url)) {
261
- const cachedResponse = response.clone();
262
- await (request.mode === "navigate" ||
263
- request.destination === "document"
264
- ? cachePage(cache, request, cachedResponse)
265
- : cache.put(request, cachedResponse)).catch((error) => {
266
- log(`error caching ${request.url} in ${cachename}`, error);
267
- });
286
+ cacheInBackground(fetchEvent, cache, request, response.clone());
268
287
  }
269
288
  else {
270
289
  log(`skipping uncacheable response code from cache: ${response.status} for ${request.url}`);
@@ -277,6 +296,9 @@ self.addEventListener("fetch", (fetchEvent) => {
277
296
  }
278
297
  }
279
298
  catch (error) {
299
+ // Deliberate: fail the request as a network error, no response.
300
+ if (error instanceof HandoffAborted)
301
+ throw error;
280
302
  const message = error instanceof Error
281
303
  ? `${error.message}\n\n${error.stack}`
282
304
  : String(error);