@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/package.json
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inkandswitch/patchwork-bootloader",
|
|
3
|
-
"version": "0.4.
|
|
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-
|
|
50
|
-
"@automerge/automerge-repo-network-
|
|
51
|
-
"@automerge/automerge-repo-
|
|
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.
|
|
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": "^
|
|
61
|
-
"@inkandswitch/patchwork-filesystem": "^0.1
|
|
62
|
-
"@inkandswitch/patchwork-
|
|
63
|
-
"@inkandswitch/patchwork-
|
|
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.
|
|
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",
|
package/src/automerge-worker.ts
CHANGED
|
@@ -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]
|
|
223
|
-
|
|
224
|
-
|
|
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__ : "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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);
|
package/src/module-loader.ts
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
|
|
@@ -62,11 +62,11 @@ function discoverDescriptors(urlAtHeads: AutomergeUrl): Promise<Descriptor[]> {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
|
-
* ModuleWatcher `
|
|
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
|
|
69
|
+
export async function importAutomergePackageViaWorker(
|
|
70
70
|
urlAtHeads: string
|
|
71
71
|
): Promise<{ plugins: Descriptor[] }> {
|
|
72
72
|
const url = urlAtHeads as AutomergeUrl;
|
package/src/service-worker.ts
CHANGED
|
@@ -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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}`
|