@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 +70 -0
- package/dist/automerge-worker.js +43 -13
- package/dist/module-loader-worker.js +2 -2
- package/dist/module-loader.d.ts +2 -2
- package/dist/module-loader.js +3 -3
- package/dist/service-worker.js +35 -13
- package/dist/setup.js +238 -35
- package/dist/site.d.ts +1 -1
- package/dist/site.js +75 -18
- package/dist/types.d.ts +18 -1
- package/dist/vite/importmap-plugin.js +33 -3
- package/package.json +30 -11
- package/src/automerge-worker.ts +48 -16
- package/src/module-loader-worker.ts +2 -2
- package/src/module-loader.ts +3 -3
- package/src/service-worker.ts +44 -13
- package/src/setup.ts +256 -39
- package/src/site.ts +90 -24
- package/src/types.ts +22 -1
- package/src/vite/importmap-plugin.ts +40 -3
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
|
package/dist/automerge-worker.js
CHANGED
|
@@ -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]
|
|
176
|
-
|
|
177
|
-
|
|
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__ : "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
36
|
+
importPackageFromFolderDocUrl(url)
|
|
37
37
|
.then((mod) => {
|
|
38
38
|
const plugins = Array.isArray(mod?.plugins) ? mod.plugins : [];
|
|
39
39
|
const descriptors = plugins.map(toDescriptor);
|
package/dist/module-loader.d.ts
CHANGED
|
@@ -3,11 +3,11 @@ type Descriptor = Record<string, unknown> & {
|
|
|
3
3
|
type?: string;
|
|
4
4
|
};
|
|
5
5
|
/**
|
|
6
|
-
* ModuleWatcher `
|
|
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
|
|
10
|
+
export declare function importAutomergePackageViaWorker(urlAtHeads: string): Promise<{
|
|
11
11
|
plugins: Descriptor[];
|
|
12
12
|
}>;
|
|
13
13
|
export {};
|
package/dist/module-loader.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Main-thread client for the module-loader worker (see module-loader-worker.ts).
|
|
2
2
|
//
|
|
3
|
-
// `
|
|
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 `
|
|
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
|
|
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) => {
|
package/dist/service-worker.js
CHANGED
|
@@ -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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
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
|
-
|
|
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);
|