@inkandswitch/patchwork-bootloader 0.3.2 → 0.4.1
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 +18 -0
- package/dist/automerge-worker.js +91 -15
- package/dist/externals.js +6 -0
- package/dist/service-worker.js +76 -29
- package/dist/setup.js +66 -11
- package/dist/site.js +27 -52
- package/package.json +14 -14
- package/src/automerge-worker.ts +104 -16
- package/src/externals.ts +6 -0
- package/src/service-worker.ts +92 -32
- package/src/setup.ts +70 -10
- package/src/site.ts +35 -59
package/dist/site.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* site's `main.ts`. Non-UI consumers should import the package default (which
|
|
12
12
|
* only does SW registration and the automerge-worker handoff).
|
|
13
13
|
*/
|
|
14
|
-
import { initializeWasm, isValidAutomergeUrl, isValidDocumentId, MessageChannelNetworkAdapter,
|
|
14
|
+
import { initializeWasm, isValidAutomergeUrl, isValidDocumentId, MessageChannelNetworkAdapter, Repo, stringifyAutomergeUrl, } from "@automerge/vanillajs/slim";
|
|
15
15
|
import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
|
|
16
16
|
import * as Automerge from "@automerge/automerge/slim";
|
|
17
17
|
import * as AutomergeRepo from "@automerge/automerge-repo/slim";
|
|
@@ -31,8 +31,11 @@ import * as plugins from "@inkandswitch/patchwork-plugins";
|
|
|
31
31
|
import setupServiceWorker, { lifecycleLoggingEnabled, } from "./setup.js";
|
|
32
32
|
import debug from "debug";
|
|
33
33
|
const log = debug("patchwork:bootloader:site");
|
|
34
|
-
// Legacy big-patchwork hash shape:
|
|
35
|
-
|
|
34
|
+
// Legacy big-patchwork hash shape: `<slug>--<documentId>[?…]`. The slug can
|
|
35
|
+
// contain characters we don't otherwise permit (e.g. `drawing-(branch-1)`), so
|
|
36
|
+
// we anchor on the `--` before the base58 document id and allow any non-query
|
|
37
|
+
// characters ahead of it rather than a strict slug charset.
|
|
38
|
+
const BIG_PATCHWORK_HASH_REGEX = /^(?<title>[^=&?/#]*)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)/;
|
|
36
39
|
const [automergeWasm, subductionWasm] = await Promise.all([
|
|
37
40
|
fetch("/automerge.wasm?main").then((r) => r.bytes()),
|
|
38
41
|
fetch("/subduction.wasm").then((r) => r.bytes()),
|
|
@@ -182,7 +185,6 @@ export async function bootPatchworkSite(config) {
|
|
|
182
185
|
rootElement,
|
|
183
186
|
repo,
|
|
184
187
|
accountDocHandle,
|
|
185
|
-
moduleWatcher,
|
|
186
188
|
titleSuffix: config.titleSuffix,
|
|
187
189
|
});
|
|
188
190
|
return { repo, moduleWatcher, accountDocHandle };
|
|
@@ -397,12 +399,12 @@ async function uncache(match) {
|
|
|
397
399
|
}
|
|
398
400
|
}
|
|
399
401
|
}
|
|
400
|
-
//
|
|
401
|
-
// heads) literal rather than percent-encoding
|
|
402
|
-
const RAW_HASH_KEYS = new Set(["doc"
|
|
403
|
-
// Emit hash params in a stable order so re-serializing the same logical
|
|
404
|
-
//
|
|
405
|
-
const HASH_KEY_ORDER = ["doc", "
|
|
402
|
+
// The `doc=` value is an automerge URL — we keep its `:` (and any `#`/`|`
|
|
403
|
+
// heads) literal rather than percent-encoding it so links stay readable.
|
|
404
|
+
const RAW_HASH_KEYS = new Set(["doc"]);
|
|
405
|
+
// Emit hash params in a stable order so re-serializing the same logical params
|
|
406
|
+
// yields a byte-identical string (avoids spurious `hashchange` round-trips).
|
|
407
|
+
const HASH_KEY_ORDER = ["doc", "tool", "type", "title", "frame"];
|
|
406
408
|
function serializeHashParams(params) {
|
|
407
409
|
const emitted = new Set();
|
|
408
410
|
const parts = [];
|
|
@@ -423,14 +425,15 @@ function serializeHashParams(params) {
|
|
|
423
425
|
}
|
|
424
426
|
/**
|
|
425
427
|
* Coerce a `doc=` hash param to a full automerge URL. Accepts a full URL
|
|
426
|
-
* (`automerge:<id>[#heads]`) or a bare document id for backwards
|
|
427
|
-
*
|
|
428
|
+
* (`automerge:<id>[#heads]`) or a bare document id for backwards compatibility
|
|
429
|
+
* with older links.
|
|
428
430
|
*/
|
|
429
431
|
function docParamToUrl(docParam) {
|
|
430
432
|
if (!docParam)
|
|
431
433
|
return undefined;
|
|
432
|
-
if (isValidAutomergeUrl(docParam))
|
|
434
|
+
if (isValidAutomergeUrl(docParam)) {
|
|
433
435
|
return docParam;
|
|
436
|
+
}
|
|
434
437
|
const documentId = docParam.replace(/^automerge:/, "");
|
|
435
438
|
if (isValidDocumentId(documentId)) {
|
|
436
439
|
return stringifyAutomergeUrl({ documentId: documentId });
|
|
@@ -438,12 +441,12 @@ function docParamToUrl(docParam) {
|
|
|
438
441
|
return undefined;
|
|
439
442
|
}
|
|
440
443
|
function installHashRouting(params) {
|
|
441
|
-
const { rootElement, repo, accountDocHandle,
|
|
444
|
+
const { rootElement, repo, accountDocHandle, titleSuffix } = params;
|
|
442
445
|
rootElement.addEventListener("patchwork:open-document", async (event) => {
|
|
443
446
|
const params = new URLSearchParams(window.location.hash.slice(1));
|
|
444
447
|
const { url, toolId, type, title } = event.detail;
|
|
445
|
-
// `doc` is now the full automerge URL
|
|
446
|
-
//
|
|
448
|
+
// `doc` is now the full automerge URL — heads, if any, live inside it, so
|
|
449
|
+
// the separate `heads=` param is gone.
|
|
447
450
|
params.delete("heads");
|
|
448
451
|
params.set("doc", url);
|
|
449
452
|
if (toolId)
|
|
@@ -478,29 +481,6 @@ function installHashRouting(params) {
|
|
|
478
481
|
console.error("Failed to update document title", e);
|
|
479
482
|
}
|
|
480
483
|
});
|
|
481
|
-
// When a tool mounts we know the package actually rendering the top-level
|
|
482
|
-
// document, so record its (heads-pinned) importUrl in `package=`.
|
|
483
|
-
const recordInUsePackage = (event) => {
|
|
484
|
-
const detail = event.detail;
|
|
485
|
-
if (!detail || !("url" in detail) || !detail.importUrl)
|
|
486
|
-
return;
|
|
487
|
-
const params = new URLSearchParams(window.location.hash.slice(1));
|
|
488
|
-
const docUrl = docParamToUrl(params.get("doc"));
|
|
489
|
-
if (!docUrl)
|
|
490
|
-
return;
|
|
491
|
-
// Ignore nested branch / side-by-side views: only the top-level doc's
|
|
492
|
-
// package belongs in the hash.
|
|
493
|
-
if (parseAutomergeUrl(docUrl).documentId !==
|
|
494
|
-
parseAutomergeUrl(detail.url).documentId) {
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
if (params.get("package") === detail.importUrl)
|
|
498
|
-
return;
|
|
499
|
-
params.set("package", detail.importUrl);
|
|
500
|
-
// Replace rather than push: recording the in-use package shouldn't add a
|
|
501
|
-
// back-button entry (and replaceState avoids a `hashchange` round-trip).
|
|
502
|
-
history.replaceState(null, "", `#${serializeHashParams(params)}`);
|
|
503
|
-
};
|
|
504
484
|
let firstMount = true;
|
|
505
485
|
const reveal = () => {
|
|
506
486
|
if (!firstMount)
|
|
@@ -510,7 +490,6 @@ function installHashRouting(params) {
|
|
|
510
490
|
hideLoadingAnimation();
|
|
511
491
|
};
|
|
512
492
|
rootElement.addEventListener("patchwork:mounted", (event) => {
|
|
513
|
-
recordInUsePackage(event);
|
|
514
493
|
handleHashChange();
|
|
515
494
|
if (event.target !== rootElement)
|
|
516
495
|
return;
|
|
@@ -525,12 +504,14 @@ function installHashRouting(params) {
|
|
|
525
504
|
setTimeout(reveal, 12_000);
|
|
526
505
|
const handleHashChange = async () => {
|
|
527
506
|
const hash = window.location.hash.slice(1);
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
507
|
+
// Legacy big-patchwork link (`<slug>--<docId>?…`): if the hash carries a
|
|
508
|
+
// `--` followed by a valid document id, normalize it to the canonical
|
|
509
|
+
// `#doc=automerge:<docId>` form and let routing re-run on the hashchange.
|
|
510
|
+
const legacyDocId = BIG_PATCHWORK_HASH_REGEX.exec(hash)?.groups?.docId;
|
|
511
|
+
if (legacyDocId && isValidDocumentId(legacyDocId)) {
|
|
512
|
+
window.location.hash = serializeHashParams(new URLSearchParams({
|
|
513
|
+
doc: stringifyAutomergeUrl({ documentId: legacyDocId }),
|
|
514
|
+
}));
|
|
534
515
|
return;
|
|
535
516
|
}
|
|
536
517
|
// Bare automerge URL in hash: /#automerge:<documentId>
|
|
@@ -542,16 +523,10 @@ function installHashRouting(params) {
|
|
|
542
523
|
}
|
|
543
524
|
const params = new URLSearchParams(hash);
|
|
544
525
|
const docUrl = docParamToUrl(params.get("doc"));
|
|
545
|
-
const packageUrl = params.get("package");
|
|
546
526
|
const toolId = params.get("tool");
|
|
547
527
|
const title = params.get("title");
|
|
548
528
|
const type = params.get("type");
|
|
549
529
|
const frame = params.get("frame");
|
|
550
|
-
// Load the package that produced this document so its tool is available
|
|
551
|
-
// even when it isn't in the user's module settings.
|
|
552
|
-
if (packageUrl) {
|
|
553
|
-
void moduleWatcher.loadModules([packageUrl]);
|
|
554
|
-
}
|
|
555
530
|
if (frame) {
|
|
556
531
|
const frameDocUrl = docUrl ?? accountDocHandle.url;
|
|
557
532
|
if (rootElement.getAttribute("tool-id") !== frame ||
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inkandswitch/patchwork-bootloader",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"author": "chee",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -44,29 +44,29 @@
|
|
|
44
44
|
}
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@automerge/automerge": "3.3.
|
|
48
|
-
"@automerge/automerge-repo": "2.6.0-subduction.
|
|
49
|
-
"@automerge/automerge-repo-network-messagechannel": "2.6.0-subduction.
|
|
50
|
-
"@automerge/automerge-repo-network-websocket": "2.6.0-subduction.
|
|
51
|
-
"@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.
|
|
47
|
+
"@automerge/automerge": "3.3.2",
|
|
48
|
+
"@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",
|
|
52
52
|
"@automerge/automerge-subduction": "0.16.0",
|
|
53
|
-
"@automerge/vanillajs": "2.6.0-subduction.
|
|
53
|
+
"@automerge/vanillajs": "2.6.0-subduction.45",
|
|
54
54
|
"@keyhive/keyhive": "0.1.0-alpha.5",
|
|
55
55
|
"@types/debug": "^4.1.13",
|
|
56
56
|
"debug": "^4.4.3",
|
|
57
57
|
"resolve.exports": "^2.0.3",
|
|
58
58
|
"service-worker-types": "npm:@types/serviceworker@^0.0.153",
|
|
59
59
|
"tinyargs": "^0.1.4",
|
|
60
|
-
"@inkandswitch/patchwork-elements": "^
|
|
61
|
-
"@inkandswitch/patchwork-filesystem": "^0.1.
|
|
62
|
-
"@inkandswitch/patchwork-
|
|
63
|
-
"@inkandswitch/patchwork-
|
|
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"
|
|
64
64
|
},
|
|
65
65
|
"peerDependencies": {
|
|
66
|
-
"@automerge/automerge": "3.3.
|
|
67
|
-
"@automerge/automerge-repo": "2.6.0-subduction.
|
|
66
|
+
"@automerge/automerge": "3.3.2",
|
|
67
|
+
"@automerge/automerge-repo": "2.6.0-subduction.46",
|
|
68
68
|
"@automerge/automerge-repo-keyhive": "0.3.0-alpha.sub.8b",
|
|
69
|
-
"@automerge/vanillajs": "2.6.0-subduction.
|
|
69
|
+
"@automerge/vanillajs": "2.6.0-subduction.45"
|
|
70
70
|
},
|
|
71
71
|
"scripts": {
|
|
72
72
|
"build": "tsc",
|
package/src/automerge-worker.ts
CHANGED
|
@@ -18,9 +18,11 @@ import { initializeWasm, hasHeads } from "@automerge/automerge/slim";
|
|
|
18
18
|
// @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
|
|
19
19
|
import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
|
|
20
20
|
import { WebCryptoSigner } from "@automerge/automerge-subduction/slim";
|
|
21
|
+
import { makePortProvider } from "@automerge/automerge-repo/worker-port";
|
|
21
22
|
|
|
22
23
|
import {
|
|
23
24
|
Repo,
|
|
25
|
+
WorkerWebSocketEndpoint,
|
|
24
26
|
isValidAutomergeUrl,
|
|
25
27
|
parseAutomergeUrl,
|
|
26
28
|
stringifyAutomergeUrl,
|
|
@@ -73,6 +75,35 @@ const WORKER_BOOT_TIME = Date.now();
|
|
|
73
75
|
|
|
74
76
|
const controlPorts = new Set<MessagePort>();
|
|
75
77
|
|
|
78
|
+
// ── Keepalive-drift probe (bench instrumentation) ───────────────────────
|
|
79
|
+
// Measures how late a 1s timer fires on this thread — i.e. how late an
|
|
80
|
+
// in-thread keepalive would be under sync/wasm load. Cheap (one Date.now()
|
|
81
|
+
// per second); samples are batched to every connected tab as drift-samples
|
|
82
|
+
// messages, which setup.ts accumulates on window.__driftSamples for the
|
|
83
|
+
// Playwright bench (e2e/tests/bench-ws.spec.ts).
|
|
84
|
+
const DRIFT_INTERVAL_MS = 1_000;
|
|
85
|
+
const DRIFT_BATCH_SIZE = 5;
|
|
86
|
+
{
|
|
87
|
+
let expected = Date.now() + DRIFT_INTERVAL_MS;
|
|
88
|
+
let batch: number[] = [];
|
|
89
|
+
setInterval(() => {
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
batch.push(Math.max(0, now - expected));
|
|
92
|
+
expected = now + DRIFT_INTERVAL_MS;
|
|
93
|
+
if (batch.length >= DRIFT_BATCH_SIZE) {
|
|
94
|
+
const samples = batch;
|
|
95
|
+
batch = [];
|
|
96
|
+
for (const port of controlPorts) {
|
|
97
|
+
try {
|
|
98
|
+
port.postMessage({ type: "drift-samples", samples });
|
|
99
|
+
} catch {
|
|
100
|
+
// Port torn down mid-iteration — its close handler cleans up.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}, DRIFT_INTERVAL_MS);
|
|
105
|
+
}
|
|
106
|
+
|
|
76
107
|
// ── Per-tab sync-state subscriptions ────────────────────────────────────
|
|
77
108
|
// Each tab's control port subscribes to the documents it cares about; we push
|
|
78
109
|
// only those docs' heads back down that port (addressed — tab A never sees tab
|
|
@@ -211,11 +242,55 @@ if (useKeyhiveSyncServer) {
|
|
|
211
242
|
};
|
|
212
243
|
}
|
|
213
244
|
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
245
|
+
const SUBDUCTION_SYNC_URL = useKeyhiveSyncServer
|
|
246
|
+
? "wss://keyhive.sync.automerge.org"
|
|
247
|
+
: "wss://subduction.sync.inkandswitch.com";
|
|
248
|
+
|
|
249
|
+
// The subduction WebSocket lives in its own worker so socket I/O (and
|
|
250
|
+
// keepalive pongs) keep flowing even when this SharedWorker's thread is busy
|
|
251
|
+
// syncing. We can't spawn that worker ourselves — Chrome doesn't expose the
|
|
252
|
+
// Worker constructor inside SharedWorkerGlobalScope — so tabs spawn the
|
|
253
|
+
// shipped SharedWorker proxy entry and donate its port to us (donatePort in
|
|
254
|
+
// setup.ts). The provider hands WorkerWebSocketEndpoint whichever port is
|
|
255
|
+
// current, healing across late arrival and proxy-worker restarts.
|
|
256
|
+
const subductionPortProvider = makePortProvider();
|
|
257
|
+
|
|
258
|
+
// A/B bench toggle: the tab appends ?ws-mode=inline to our URL (SharedWorker
|
|
259
|
+
// scope has no localStorage; see getAutomergeWorker in setup.ts). "inline"
|
|
260
|
+
// passes the bare URL string so the socket lives on this thread — the
|
|
261
|
+
// pre-worker behaviour — as the control arm for benchmarking the
|
|
262
|
+
// worker-based endpoint. Default: "worker".
|
|
263
|
+
const WS_MODE =
|
|
264
|
+
new URL(self.location.href).searchParams.get("ws-mode") === "inline"
|
|
265
|
+
? "inline"
|
|
266
|
+
: "worker";
|
|
267
|
+
|
|
268
|
+
// Optional windowFrames override (bench knob — max un-acked frames the io
|
|
269
|
+
// proxy delivers before pausing; endpoint default is 128).
|
|
270
|
+
const WS_WINDOW_FRAMES =
|
|
271
|
+
Number(new URL(self.location.href).searchParams.get("ws-window")) ||
|
|
272
|
+
undefined;
|
|
273
|
+
|
|
274
|
+
// Memoized so a repo-construction retry (getRepoHive clears its promise on
|
|
275
|
+
// failure) reuses the same endpoint instead of leaking one per attempt.
|
|
276
|
+
let subductionEndpoints: (WorkerWebSocketEndpoint | string)[] | null = null;
|
|
277
|
+
function getSubductionEndpoints(): (WorkerWebSocketEndpoint | string)[] {
|
|
278
|
+
if (!subductionEndpoints) {
|
|
279
|
+
log(`subduction websocket mode: ${WS_MODE}`);
|
|
280
|
+
subductionEndpoints =
|
|
281
|
+
WS_MODE === "inline"
|
|
282
|
+
? [SUBDUCTION_SYNC_URL]
|
|
283
|
+
: [
|
|
284
|
+
new WorkerWebSocketEndpoint(SUBDUCTION_SYNC_URL, {
|
|
285
|
+
worker: subductionPortProvider.source,
|
|
286
|
+
...(WS_WINDOW_FRAMES
|
|
287
|
+
? { windowFrames: WS_WINDOW_FRAMES }
|
|
288
|
+
: {}),
|
|
289
|
+
}),
|
|
290
|
+
];
|
|
291
|
+
}
|
|
292
|
+
return subductionEndpoints;
|
|
293
|
+
}
|
|
219
294
|
const RESOLVE_TIMEOUT_MS = 30_000;
|
|
220
295
|
|
|
221
296
|
// Backoff re-sync of stuck/diverged docs. Only this worker is connected to the
|
|
@@ -310,8 +385,6 @@ function getRepoHive() {
|
|
|
310
385
|
}
|
|
311
386
|
).toHex(),
|
|
312
387
|
};
|
|
313
|
-
console.log("[patchwork] shared-worker subduction identity:", identity);
|
|
314
|
-
|
|
315
388
|
const repo = new Repo({
|
|
316
389
|
storage: new IndexedDBWorkerStorageAdapter(),
|
|
317
390
|
signer,
|
|
@@ -323,9 +396,16 @@ function getRepoHive() {
|
|
|
323
396
|
return peerId.includes("storage-server");
|
|
324
397
|
},
|
|
325
398
|
enableRemoteHeadsGossiping: true,
|
|
326
|
-
subductionWebsocketEndpoints:
|
|
399
|
+
subductionWebsocketEndpoints: getSubductionEndpoints(),
|
|
327
400
|
});
|
|
328
401
|
|
|
402
|
+
console.log(
|
|
403
|
+
"[patchwork] shared-worker subduction identity:",
|
|
404
|
+
identity,
|
|
405
|
+
"networkSubsystem.adapters:",
|
|
406
|
+
repo.networkSubsystem.adapters.length
|
|
407
|
+
);
|
|
408
|
+
|
|
329
409
|
(self as any).repo = repo;
|
|
330
410
|
(self as any).syncIdentity = identity;
|
|
331
411
|
setupSyncStateBroadcast(repo, identity);
|
|
@@ -354,7 +434,7 @@ function getRepoHive() {
|
|
|
354
434
|
...(useKeyhiveSyncServer ? { syncServer: "keyhive" as const } : {}),
|
|
355
435
|
repo: {
|
|
356
436
|
storage: new IndexedDBWorkerStorageAdapter(),
|
|
357
|
-
subductionWebsocketEndpoints:
|
|
437
|
+
subductionWebsocketEndpoints: getSubductionEndpoints(),
|
|
358
438
|
enableRemoteHeadsGossiping: true,
|
|
359
439
|
},
|
|
360
440
|
});
|
|
@@ -717,10 +797,14 @@ function dropRepoChannel(repo: Repo, channel: RepoChannel) {
|
|
|
717
797
|
// wrapper: make sure the underlying port is disconnected and closed too.
|
|
718
798
|
try {
|
|
719
799
|
channel.mcAdapter.disconnect();
|
|
720
|
-
} catch {
|
|
800
|
+
} catch {
|
|
801
|
+
// Already disconnected by removeNetworkAdapter above.
|
|
802
|
+
}
|
|
721
803
|
try {
|
|
722
804
|
channel.port.close();
|
|
723
|
-
} catch {
|
|
805
|
+
} catch {
|
|
806
|
+
// Port already closed by the departing tab.
|
|
807
|
+
}
|
|
724
808
|
}
|
|
725
809
|
|
|
726
810
|
async function dropConnection(connection: Connection) {
|
|
@@ -851,6 +935,11 @@ self.addEventListener("connect", (event) => {
|
|
|
851
935
|
handleControlMessage(messageEvent as MessageEvent, controlPort, connection);
|
|
852
936
|
});
|
|
853
937
|
|
|
938
|
+
// Let the subduction port provider negotiate over this tab's control port
|
|
939
|
+
// (the tab side runs donatePort; the messages are channel-tagged so they
|
|
940
|
+
// coexist with our control protocol above).
|
|
941
|
+
subductionPortProvider.attachClient(controlPort);
|
|
942
|
+
|
|
854
943
|
// Fires when the owning page is destroyed. Browsers without the close
|
|
855
944
|
// event fall back to the adapters' lazy useWeakRef cleanup.
|
|
856
945
|
controlPort.addEventListener("close", () => {
|
|
@@ -975,11 +1064,10 @@ async function resolveAutomergeUrl(automergeURL: URL): Promise<Response> {
|
|
|
975
1064
|
? (new Uint8Array(resolved.content) as BlobPart)
|
|
976
1065
|
: resolved.content;
|
|
977
1066
|
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
return new Response(body, { status: 200, headers });
|
|
1067
|
+
return new Response(body, {
|
|
1068
|
+
status: 200,
|
|
1069
|
+
headers: { "content-type": resolved.type },
|
|
1070
|
+
});
|
|
983
1071
|
}
|
|
984
1072
|
|
|
985
1073
|
// ── Handoff: resolve special URLs for the service worker ──────────────
|
package/src/externals.ts
CHANGED
|
@@ -6,6 +6,11 @@ const externals = [
|
|
|
6
6
|
"@automerge/automerge/slim",
|
|
7
7
|
"@automerge/automerge-repo",
|
|
8
8
|
"@automerge/automerge-repo/slim",
|
|
9
|
+
// Port-donation plumbing for WorkerWebSocketEndpoint: tabs spawn the shared
|
|
10
|
+
// proxy entry and donate its port to the automerge worker (Chrome can't
|
|
11
|
+
// spawn workers from inside a SharedWorker). See setup.ts/automerge-worker.ts.
|
|
12
|
+
"@automerge/automerge-repo/worker-port",
|
|
13
|
+
"@automerge/automerge-repo/subduction-websocket-worker-shared",
|
|
9
14
|
"@automerge/automerge-repo-network-messagechannel",
|
|
10
15
|
"@automerge/automerge-repo-network-websocket",
|
|
11
16
|
"@automerge/automerge-repo-storage-indexeddb",
|
|
@@ -24,6 +29,7 @@ const externals = [
|
|
|
24
29
|
"@codemirror/state",
|
|
25
30
|
"@codemirror/view",
|
|
26
31
|
"@codemirror/language",
|
|
32
|
+
"@codemirror/commands",
|
|
27
33
|
|
|
28
34
|
// rip
|
|
29
35
|
"solid-js",
|
package/src/service-worker.ts
CHANGED
|
@@ -13,10 +13,13 @@ import {
|
|
|
13
13
|
type HandoffRequestMessage,
|
|
14
14
|
} from "./types.js";
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
const DEFAULT_CACHE_NAME = "patchwork";
|
|
17
|
+
|
|
18
|
+
let cachename = DEFAULT_CACHE_NAME;
|
|
17
19
|
let debugging = false;
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
// 0 is an opaque response, that also needs cached
|
|
22
|
+
const cacheableStatuses = [0, 200, 203, 204];
|
|
20
23
|
|
|
21
24
|
// The automerge worker times its own resolution out after 30s and replies
|
|
22
25
|
// with an error, so this only fires when nobody is listening at all.
|
|
@@ -82,22 +85,19 @@ self.addEventListener("install", (event) => {
|
|
|
82
85
|
(event as ExtendableEvent).waitUntil(self.skipWaiting());
|
|
83
86
|
});
|
|
84
87
|
|
|
85
|
-
async function
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
|
-
await Promise.all(deletePromises);
|
|
88
|
+
async function clearOtherCaches() {
|
|
89
|
+
await Promise.all(
|
|
90
|
+
(await caches.keys()).map((cacheName) => {
|
|
91
|
+
if (cacheName !== cachename) return caches.delete(cacheName);
|
|
92
|
+
})
|
|
93
|
+
);
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
self.addEventListener("activate", (event) => {
|
|
97
97
|
lifecycle("info", "activate (claiming clients)");
|
|
98
98
|
(event as ExtendableEvent).waitUntil(
|
|
99
99
|
(async () => {
|
|
100
|
-
await
|
|
100
|
+
await clearOtherCaches();
|
|
101
101
|
await self.clients.claim();
|
|
102
102
|
// Pre-cache pages of already-open clients so they survive going offline
|
|
103
103
|
// before the next navigation.
|
|
@@ -110,7 +110,7 @@ self.addEventListener("activate", (event) => {
|
|
|
110
110
|
if (!existing) {
|
|
111
111
|
const response = await fetch(client.url);
|
|
112
112
|
if (cacheableStatuses.includes(response.status)) {
|
|
113
|
-
await cache
|
|
113
|
+
await cachePage(cache, client.url, response);
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
} catch {
|
|
@@ -128,11 +128,19 @@ self.addEventListener("message", async (event) => {
|
|
|
128
128
|
if (cachename == nextCachename) {
|
|
129
129
|
return;
|
|
130
130
|
}
|
|
131
|
-
console.info(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
131
|
+
console.info(`moving from cache ${cachename} to ${nextCachename}`);
|
|
132
|
+
if (cachename === DEFAULT_CACHE_NAME) {
|
|
133
|
+
const defaultCache = await caches.open(cachename);
|
|
134
|
+
const nextCache = await caches.open(nextCachename);
|
|
135
|
+
await Promise.all(
|
|
136
|
+
(await defaultCache.keys()).map(async (request) => {
|
|
137
|
+
const response = await defaultCache.match(request);
|
|
138
|
+
if (response) await nextCache.put(request, response);
|
|
139
|
+
})
|
|
140
|
+
);
|
|
141
|
+
}
|
|
135
142
|
cachename = nextCachename;
|
|
143
|
+
await clearOtherCaches();
|
|
136
144
|
} else if (event.data.type == "debug") {
|
|
137
145
|
debugging = event.data.debug;
|
|
138
146
|
log("serviceworker debugging enabled");
|
|
@@ -216,20 +224,47 @@ function handoff(
|
|
|
216
224
|
});
|
|
217
225
|
}
|
|
218
226
|
|
|
219
|
-
function
|
|
227
|
+
function makeResponse(response: {
|
|
220
228
|
body?: BodyInit | ReadableStream<Uint8Array> | null;
|
|
221
229
|
status?: number;
|
|
222
230
|
headers?: HeadersInit;
|
|
223
231
|
}): Response {
|
|
224
|
-
const headers = new Headers(response.headers);
|
|
225
|
-
headers.set("cross-origin-embedder-policy", "credentialless");
|
|
226
|
-
headers.set("cross-origin-resource-policy", "cross-origin");
|
|
227
232
|
return new Response(response.body ?? null, {
|
|
228
233
|
status: response.status ?? 200,
|
|
229
|
-
headers,
|
|
234
|
+
headers: response.headers,
|
|
230
235
|
});
|
|
231
236
|
}
|
|
232
237
|
|
|
238
|
+
function indexRequestFor(request: Request | string): Request | undefined {
|
|
239
|
+
const url = new URL(typeof request === "string" ? request : request.url);
|
|
240
|
+
if (url.origin !== self.location.origin) return undefined;
|
|
241
|
+
url.pathname = "/index.html";
|
|
242
|
+
url.search = "";
|
|
243
|
+
url.hash = "";
|
|
244
|
+
return new Request(url.href);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function rootRequestFor(request: Request | string): Request | undefined {
|
|
248
|
+
const url = new URL(typeof request === "string" ? request : request.url);
|
|
249
|
+
if (url.origin !== self.location.origin) return undefined;
|
|
250
|
+
url.pathname = "/";
|
|
251
|
+
url.search = "";
|
|
252
|
+
url.hash = "";
|
|
253
|
+
return new Request(url.href);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function cachePage(
|
|
257
|
+
cache: Cache,
|
|
258
|
+
request: Request | string,
|
|
259
|
+
response: Response
|
|
260
|
+
) {
|
|
261
|
+
const indexRequest = indexRequestFor(request);
|
|
262
|
+
if (indexRequest) await cache.put(indexRequest, response.clone());
|
|
263
|
+
const rootRequest = rootRequestFor(request);
|
|
264
|
+
if (rootRequest) await cache.put(rootRequest, response.clone());
|
|
265
|
+
await cache.put(request, response);
|
|
266
|
+
}
|
|
267
|
+
|
|
233
268
|
// ── Fetch handler ──────────────────────────────────────────────────────
|
|
234
269
|
|
|
235
270
|
self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
|
|
@@ -260,7 +295,7 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
|
|
|
260
295
|
if (handoffURL) {
|
|
261
296
|
if (match) {
|
|
262
297
|
log(`serving ${handoffURL} from cache ${cachename}`);
|
|
263
|
-
return
|
|
298
|
+
return match;
|
|
264
299
|
}
|
|
265
300
|
|
|
266
301
|
log(`handing ${handoffURL} off to the automerge worker`);
|
|
@@ -271,7 +306,7 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
|
|
|
271
306
|
if (reply.type === "response") {
|
|
272
307
|
// errors, redirects and other things that shouldn't be cached
|
|
273
308
|
log(`serving handed-off response for ${handoffURL}`, reply);
|
|
274
|
-
return
|
|
309
|
+
return makeResponse(reply.response);
|
|
275
310
|
}
|
|
276
311
|
|
|
277
312
|
// reply.type === "cached": the automerge worker has put the
|
|
@@ -284,26 +319,51 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
|
|
|
284
319
|
);
|
|
285
320
|
}
|
|
286
321
|
log(`serving ${handoffURL} from cache ${cachename} after handoff`);
|
|
287
|
-
return
|
|
322
|
+
return cached;
|
|
288
323
|
} else {
|
|
289
|
-
|
|
290
|
-
|
|
324
|
+
// fetch() rejects on network error / abort rather than resolving;
|
|
325
|
+
// keep the error so we can surface it in the 503 body below.
|
|
326
|
+
const result = await fetch(request).catch((error: unknown) =>
|
|
327
|
+
error instanceof Error ? error : new Error(String(error))
|
|
328
|
+
);
|
|
329
|
+
if (result instanceof Response) {
|
|
330
|
+
const response = result;
|
|
331
|
+
// Tool subresources (<link>/<script>) are requested from srcdoc
|
|
332
|
+
// frames whose origin is "null", so they come back as opaque
|
|
333
|
+
// cross-origin `no-cors` responses: status 0 and an empty url. They
|
|
334
|
+
// render fine while online but were being excluded from the cache,
|
|
335
|
+
// so e.g. a theme stylesheet vanished on an offline refresh. Opaque
|
|
336
|
+
// responses are cacheable and replay to the same no-cors consumer,
|
|
337
|
+
// so treat status 0 as cacheable and gate the scheme on request.url
|
|
338
|
+
// (an opaque response's own url is "").
|
|
291
339
|
if (
|
|
292
|
-
|
|
293
|
-
|
|
340
|
+
(response.status === 0 ||
|
|
341
|
+
cacheableStatuses.includes(response.status)) &&
|
|
342
|
+
/^https?:/.test(request.url)
|
|
294
343
|
) {
|
|
295
|
-
|
|
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) => {
|
|
296
351
|
log(`error caching ${request.url} in ${cachename}`, error);
|
|
297
352
|
});
|
|
298
353
|
} else {
|
|
299
354
|
log(
|
|
300
|
-
`skipping uncacheable response code from cache: ${response.status} for ${
|
|
355
|
+
`skipping uncacheable response code from cache: ${response.status} for ${request.url}`
|
|
301
356
|
);
|
|
302
357
|
}
|
|
303
358
|
return response;
|
|
304
359
|
}
|
|
305
360
|
if (match) return match;
|
|
306
|
-
return new Response(
|
|
361
|
+
return new Response(
|
|
362
|
+
`couldnt fetch ${request.url} and no stale copy in ${cachename}\n\n${
|
|
363
|
+
result.stack ?? result.message
|
|
364
|
+
}`,
|
|
365
|
+
{ status: 503, headers: { "content-type": "text/plain" } }
|
|
366
|
+
);
|
|
307
367
|
}
|
|
308
368
|
} catch (error) {
|
|
309
369
|
const message =
|