@inkandswitch/patchwork-bootloader 0.4.3 → 0.4.4
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 +11 -0
- package/dist/automerge-worker.js +543 -771
- package/dist/module-loader.d.ts +0 -5
- package/dist/module-loader.js +0 -6
- package/dist/service-worker.js +174 -205
- package/dist/setup.d.ts +2 -1
- package/dist/setup.js +251 -375
- package/dist/site.d.ts +17 -32
- package/dist/site.js +291 -376
- package/dist/vite/importmap-plugin.js +1 -5
- package/package.json +4 -4
- package/src/automerge-worker.ts +635 -833
- package/src/module-loader.ts +0 -6
- package/src/service-worker.ts +219 -238
- package/src/setup.ts +287 -401
- package/src/site.ts +377 -439
- package/src/vite/importmap-plugin.ts +1 -5
package/dist/automerge-worker.js
CHANGED
|
@@ -1,17 +1,11 @@
|
|
|
1
|
-
// The automerge repo for a patchwork site
|
|
2
|
-
//
|
|
3
|
-
// no keepalive pings, no idle teardown.
|
|
1
|
+
// The automerge repo for a patchwork site, in a SharedWorker: one instance
|
|
2
|
+
// serves every tab and lives as long as any tab does.
|
|
4
3
|
//
|
|
5
|
-
// The service worker holds no repo. When it misses the cache for a
|
|
6
|
-
// URL it broadcasts a HandoffRequestMessage on
|
|
7
|
-
// the automerge URL, write the response into the
|
|
8
|
-
// (keyed by a Request reconstructed to match the one
|
|
9
|
-
// reply on the same channel.
|
|
10
|
-
// Heavy imports — marked external by the service-worker vite plugin,
|
|
11
|
-
// resolved to /packages/... URLs at build time. The worker is created with
|
|
12
|
-
// type:"module" so the browser fetches these as regular network requests.
|
|
13
|
-
// Uses /slim so wasm is fetched from /automerge.wasm (emitted by the vite
|
|
14
|
-
// plugin) instead of bundling the ~3MB base64 string.
|
|
4
|
+
// The service worker holds no repo. When it misses the cache for a request that
|
|
5
|
+
// looks like a URL encoded URL, it broadcasts a HandoffRequestMessage on
|
|
6
|
+
// HANDOFF_CHANNEL; we resolve the automerge URL, write the response into the
|
|
7
|
+
// service worker's cache (keyed by a Request reconstructed to match the one
|
|
8
|
+
// it's holding), and reply on the same channel.
|
|
15
9
|
import { initializeWasm, hasHeads } from "@automerge/automerge/slim";
|
|
16
10
|
// eslint-disable-next-line
|
|
17
11
|
// @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
|
|
@@ -20,90 +14,46 @@ import { WebCryptoSigner } from "@automerge/automerge-subduction/slim";
|
|
|
20
14
|
import { makePortProvider } from "@automerge/automerge-repo/worker-port";
|
|
21
15
|
import { Repo, WorkerWebSocketEndpoint, isValidAutomergeUrl, parseAutomergeUrl, stringifyAutomergeUrl, } from "@automerge/automerge-repo/slim";
|
|
22
16
|
import { resolvePath } from "@inkandswitch/patchwork-filesystem";
|
|
23
|
-
// Small adapters — bundled directly into the worker
|
|
24
17
|
import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
|
|
25
18
|
import { MessageChannelNetworkAdapter } from "@automerge/automerge-repo-network-messagechannel";
|
|
26
19
|
import { WebSocketWorkerClientAdapter } from "@automerge/automerge-repo-network-websocket";
|
|
27
20
|
import { initializeAutomergeRepoKeyhiveRustWithRepo, initKeyhiveWasm, } from "@automerge/automerge-repo-keyhive";
|
|
21
|
+
import { DEFAULT_CLASSIC_SYNC_SERVER } from "./sync-config.js";
|
|
28
22
|
import { HANDOFF_CHANNEL, SYNCSTATE_CHANNEL, } from "./types.js";
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
23
|
+
const siteName = typeof __SITE_NAME__ !== "undefined"
|
|
24
|
+
? __SITE_NAME__
|
|
25
|
+
: "patchwork.inkandswitch.com";
|
|
26
|
+
const useKeyhive = typeof __KEYHIVE__ !== "undefined" && __KEYHIVE__;
|
|
27
|
+
const useKeyhiveSyncServer = typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
|
|
28
|
+
const SUBDUCTION_SYNC_URL = useKeyhiveSyncServer
|
|
29
|
+
? "wss://keyhive.sync.automerge.org"
|
|
30
|
+
: "wss://subduction.sync.inkandswitch.com";
|
|
31
|
+
if (useKeyhiveSyncServer) {
|
|
32
|
+
const g = globalThis;
|
|
33
|
+
g.process ??= {};
|
|
34
|
+
g.process.env = {
|
|
35
|
+
...(g.process.env ?? {}),
|
|
36
|
+
KEYHIVE_SERVER_IDENTITY: "keyhive-sync",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const RESOLVE_TIMEOUT_MS = 30_000;
|
|
40
|
+
const CACHEABLE_STATUSES = [200, 203, 204];
|
|
41
|
+
// A fresh instance means a new repo peerId and cold in-memory state, so a tab
|
|
42
|
+
// seeing a changed id knows to re-subscribe. Sent in `hello` and every `pong`.
|
|
33
43
|
const WORKER_INSTANCE_ID = Math.random().toString(36).slice(2);
|
|
34
44
|
const WORKER_BOOT_TIME = Date.now();
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// Measures how late a 1s timer fires on this thread — i.e. how late an
|
|
42
|
-
// in-thread keepalive would be under sync/wasm load. Cheap (one Date.now()
|
|
43
|
-
// per second); samples are batched to every connected tab as drift-samples
|
|
44
|
-
// messages, which setup.ts accumulates on window.__driftSamples for the
|
|
45
|
-
// Playwright bench (e2e/tests/bench-ws.spec.ts).
|
|
46
|
-
const DRIFT_INTERVAL_MS = 1_000;
|
|
47
|
-
const DRIFT_BATCH_SIZE = 5;
|
|
48
|
-
{
|
|
49
|
-
let expected = Date.now() + DRIFT_INTERVAL_MS;
|
|
50
|
-
let batch = [];
|
|
51
|
-
setInterval(() => {
|
|
52
|
-
const now = Date.now();
|
|
53
|
-
batch.push(Math.max(0, now - expected));
|
|
54
|
-
expected = now + DRIFT_INTERVAL_MS;
|
|
55
|
-
if (batch.length >= DRIFT_BATCH_SIZE) {
|
|
56
|
-
const samples = batch;
|
|
57
|
-
batch = [];
|
|
58
|
-
for (const port of controlPorts) {
|
|
59
|
-
try {
|
|
60
|
-
port.postMessage({ type: "drift-samples", samples });
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
// Port torn down mid-iteration — its close handler cleans up.
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}, DRIFT_INTERVAL_MS);
|
|
68
|
-
}
|
|
69
|
-
// ── Per-tab sync-state subscriptions ────────────────────────────────────
|
|
70
|
-
// Each tab's control port subscribes to the documents it cares about; we push
|
|
71
|
-
// only those docs' heads back down that port (addressed — tab A never sees tab
|
|
72
|
-
// B's docs), and drop a port's whole subscription set when it closes (the tab
|
|
73
|
-
// went away), so there's nothing to reference-count or time out. The global
|
|
74
|
-
// connection/whoami signals still go over SYNCSTATE_CHANNEL.
|
|
75
|
-
const syncWatchers = new Map();
|
|
76
|
-
// Installed by setupSyncStateBroadcast once the repo's snapshot exists, so a
|
|
77
|
-
// fresh `sync-sub` can be replayed the doc's current heads immediately. Null
|
|
78
|
-
// until then; subscriptions taken during boot are replayed when it installs.
|
|
79
|
-
let replaySyncForPort = null;
|
|
80
|
-
function syncSubscribe(port, documentId) {
|
|
81
|
-
let docs = syncWatchers.get(port);
|
|
82
|
-
if (!docs)
|
|
83
|
-
syncWatchers.set(port, (docs = new Set()));
|
|
84
|
-
if (docs.has(documentId))
|
|
85
|
-
return;
|
|
86
|
-
docs.add(documentId);
|
|
87
|
-
replaySyncForPort?.(documentId, port);
|
|
88
|
-
}
|
|
89
|
-
function syncUnsubscribe(port, documentId) {
|
|
90
|
-
syncWatchers.get(port)?.delete(documentId);
|
|
91
|
-
}
|
|
92
|
-
// Push one document's heads to every control port currently watching it.
|
|
93
|
-
function pushSyncState(message) {
|
|
94
|
-
for (const [port, docs] of syncWatchers) {
|
|
95
|
-
if (!docs.has(message.documentId))
|
|
96
|
-
continue;
|
|
97
|
-
try {
|
|
98
|
-
port.postMessage(message);
|
|
99
|
-
}
|
|
100
|
-
catch {
|
|
101
|
-
// Port already gone; its close handler will reap the entry.
|
|
102
|
-
}
|
|
103
|
-
}
|
|
45
|
+
// `debug` reads localStorage, which a SharedWorker doesn't have, so debugging is
|
|
46
|
+
// toggled by a control message from a tab instead.
|
|
47
|
+
let debugging = false;
|
|
48
|
+
function log(...args) {
|
|
49
|
+
if (debugging)
|
|
50
|
+
console.log("[automerge-worker]", ...args);
|
|
104
51
|
}
|
|
105
|
-
//
|
|
106
|
-
//
|
|
52
|
+
// ── Console forwarding ─────────────────────────────────────────────────
|
|
53
|
+
// The SharedWorker's own console is buried in chrome://inspect, so mirror
|
|
54
|
+
// everything over each connected tab's control port.
|
|
55
|
+
const controlPorts = new Set();
|
|
56
|
+
// Logs emitted before any tab connects (wasm boot) would otherwise be lost.
|
|
107
57
|
const preConnectBuffer = [];
|
|
108
58
|
const MAX_BUFFER = 200;
|
|
109
59
|
function serializeArg(arg) {
|
|
@@ -118,21 +68,23 @@ function serializeArg(arg) {
|
|
|
118
68
|
return String(arg);
|
|
119
69
|
}
|
|
120
70
|
}
|
|
71
|
+
function postToPort(port, message) {
|
|
72
|
+
try {
|
|
73
|
+
port.postMessage(message);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
console.warn(`sending failed`, error);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
121
79
|
function forwardToMainThread(level, rawArgs) {
|
|
122
80
|
const args = rawArgs.map(serializeArg);
|
|
123
81
|
if (!controlPorts.size) {
|
|
124
|
-
if (preConnectBuffer.length < MAX_BUFFER)
|
|
82
|
+
if (preConnectBuffer.length < MAX_BUFFER)
|
|
125
83
|
preConnectBuffer.push({ level, args });
|
|
126
|
-
}
|
|
127
84
|
return;
|
|
128
85
|
}
|
|
129
86
|
for (const port of controlPorts) {
|
|
130
|
-
|
|
131
|
-
port.postMessage({ type: "console", level, args });
|
|
132
|
-
}
|
|
133
|
-
catch {
|
|
134
|
-
// Port may be closing — ignore.
|
|
135
|
-
}
|
|
87
|
+
postToPort(port, { type: "console", level, args });
|
|
136
88
|
}
|
|
137
89
|
}
|
|
138
90
|
for (const level of ["log", "info", "warn", "error", "debug"]) {
|
|
@@ -156,689 +108,563 @@ self.addEventListener("unhandledrejection", (event) => {
|
|
|
156
108
|
reason instanceof Error ? reason.stack || reason.message : reason,
|
|
157
109
|
]);
|
|
158
110
|
});
|
|
159
|
-
|
|
160
|
-
// the worker restarted (fresh peerId + cold state).
|
|
161
|
-
console.warn(`[lifecycle] ${new Date(WORKER_BOOT_TIME).toISOString()} automerge ` +
|
|
162
|
-
`SharedWorker started (instance ${WORKER_INSTANCE_ID})`);
|
|
163
|
-
// ── Suspension watchdog ─────────────────────────────────────────────────
|
|
164
|
-
// A SharedWorker gets no lifecycle events, so infer freeze/suspend from timer
|
|
165
|
-
// drift. A large gap means keepalive pongs stalled and the server may have
|
|
166
|
-
// reaped us.
|
|
111
|
+
console.warn(`[lifecycle] automerge SharedWorker started (instance ${WORKER_INSTANCE_ID})`);
|
|
167
112
|
const WATCHDOG_TICK_MS = 5_000;
|
|
168
|
-
const WATCHDOG_GAP_FACTOR = 2;
|
|
169
113
|
let watchdogLast = Date.now();
|
|
170
114
|
setInterval(() => {
|
|
171
115
|
const now = Date.now();
|
|
172
116
|
const gap = now - watchdogLast;
|
|
173
117
|
watchdogLast = now;
|
|
174
|
-
if (gap > WATCHDOG_TICK_MS *
|
|
175
|
-
console.warn(`[lifecycle]
|
|
176
|
-
|
|
177
|
-
`${WATCHDOG_TICK_MS / 1000}s)`);
|
|
118
|
+
if (gap > WATCHDOG_TICK_MS * 2) {
|
|
119
|
+
console.warn(`[lifecycle] watchdog timer gap ~${Math.round(gap / 1000)}s ` +
|
|
120
|
+
`(expected every ${WATCHDOG_TICK_MS / 1000}s)`);
|
|
178
121
|
}
|
|
179
122
|
}, WATCHDOG_TICK_MS);
|
|
180
|
-
//
|
|
181
|
-
// to
|
|
182
|
-
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
123
|
+
// ── Per-tab sync-state subscriptions ───────────────────────────────────
|
|
124
|
+
// A tab's control port subscribes to the documents it cares about and we push
|
|
125
|
+
// only those docs' heads down that port, so tab A never sees tab B's docs. A
|
|
126
|
+
// port's whole subscription set is dropped when it closes, so there's nothing
|
|
127
|
+
// to reference-count or time out.
|
|
128
|
+
const syncWatchers = new Map();
|
|
129
|
+
// Set once the repo's snapshot exists, so a `sync-sub` arriving during boot can
|
|
130
|
+
// be replayed the doc's current heads as soon as it does.
|
|
131
|
+
let replaySyncForPort = null;
|
|
132
|
+
function syncSubscribe(port, documentId) {
|
|
133
|
+
let docs = syncWatchers.get(port);
|
|
134
|
+
if (!docs)
|
|
135
|
+
syncWatchers.set(port, (docs = new Set()));
|
|
136
|
+
if (docs.has(documentId))
|
|
137
|
+
return;
|
|
138
|
+
docs.add(documentId);
|
|
139
|
+
replaySyncForPort?.(documentId, port);
|
|
140
|
+
}
|
|
141
|
+
function syncUnsubscribe(port, documentId) {
|
|
142
|
+
syncWatchers.get(port)?.delete(documentId);
|
|
143
|
+
}
|
|
144
|
+
function pushSyncState(message) {
|
|
145
|
+
for (const [port, docs] of syncWatchers) {
|
|
146
|
+
if (docs.has(message.documentId))
|
|
147
|
+
postToPort(port, message);
|
|
148
|
+
}
|
|
190
149
|
}
|
|
191
|
-
const SUBDUCTION_SYNC_URL = useKeyhiveSyncServer
|
|
192
|
-
? "wss://keyhive.sync.automerge.org"
|
|
193
|
-
: "wss://subduction.sync.inkandswitch.com";
|
|
194
|
-
// The subduction WebSocket lives in its own worker so socket I/O (and
|
|
195
|
-
// keepalive pongs) keep flowing even when this SharedWorker's thread is busy
|
|
196
|
-
// syncing. We can't spawn that worker ourselves — Chrome doesn't expose the
|
|
197
|
-
// Worker constructor inside SharedWorkerGlobalScope — so tabs spawn the
|
|
198
|
-
// shipped SharedWorker proxy entry and donate its port to us (donatePort in
|
|
199
|
-
// setup.ts). The provider hands WorkerWebSocketEndpoint whichever port is
|
|
200
|
-
// current, healing across late arrival and proxy-worker restarts.
|
|
201
150
|
const subductionPortProvider = makePortProvider();
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
// passes the bare URL string so the socket lives on this thread — the
|
|
205
|
-
// pre-worker behaviour — as the control arm for benchmarking the
|
|
206
|
-
// worker-based endpoint. Default: "worker".
|
|
207
|
-
const WS_MODE = new URL(self.location.href).searchParams.get("ws-mode") === "inline"
|
|
208
|
-
? "inline"
|
|
209
|
-
: "worker";
|
|
210
|
-
// Optional windowFrames override (bench knob — max un-acked frames the io
|
|
211
|
-
// proxy delivers before pausing; endpoint default is 128).
|
|
212
|
-
const WS_WINDOW_FRAMES = Number(new URL(self.location.href).searchParams.get("ws-window")) ||
|
|
213
|
-
undefined;
|
|
214
|
-
// Memoized so a repo-construction retry (getRepoHive clears its promise on
|
|
215
|
-
// failure) reuses the same endpoint instead of leaking one per attempt.
|
|
151
|
+
// Memoized so a construction retry reuses the endpoint instead of leaking one
|
|
152
|
+
// per attempt.
|
|
216
153
|
let subductionEndpoints = null;
|
|
217
154
|
function getSubductionEndpoints() {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
155
|
+
return (subductionEndpoints ??= [
|
|
156
|
+
new WorkerWebSocketEndpoint(SUBDUCTION_SYNC_URL, {
|
|
157
|
+
worker: subductionPortProvider.source,
|
|
158
|
+
}),
|
|
159
|
+
]);
|
|
160
|
+
}
|
|
161
|
+
let repoHivePromise = null;
|
|
162
|
+
function getRepoHive() {
|
|
163
|
+
if (!repoHivePromise) {
|
|
164
|
+
repoHivePromise = setUpRepoHive();
|
|
165
|
+
// Don't permanently cache a rejection (e.g. the wasm fetch failed) — clear
|
|
166
|
+
// the slot so the next caller retries from scratch.
|
|
167
|
+
repoHivePromise.catch(() => {
|
|
168
|
+
repoHivePromise = null;
|
|
169
|
+
});
|
|
229
170
|
}
|
|
230
|
-
return
|
|
171
|
+
return repoHivePromise;
|
|
231
172
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
173
|
+
async function setUpRepoHive() {
|
|
174
|
+
log("fetching wasm");
|
|
175
|
+
const [automergeWasm, subductionWasm] = await Promise.all([
|
|
176
|
+
fetch("/automerge.wasm").then((r) => r.arrayBuffer()),
|
|
177
|
+
fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
|
|
178
|
+
]);
|
|
179
|
+
initSubductionSync(new Uint8Array(subductionWasm));
|
|
180
|
+
await initializeWasm(new Uint8Array(automergeWasm));
|
|
181
|
+
log("wasm initialized");
|
|
182
|
+
const built = useKeyhive
|
|
183
|
+
? await buildKeyhiveRepo()
|
|
184
|
+
: await buildPlainRepo();
|
|
185
|
+
self.repo = built.repo;
|
|
186
|
+
if (built.hive)
|
|
187
|
+
self.hive = built.hive;
|
|
188
|
+
if (built.identity)
|
|
189
|
+
self.syncIdentity = built.identity;
|
|
190
|
+
setUpSyncStateBroadcast(built.repo, built.identity);
|
|
191
|
+
// Deliberately not awaited: the network subsystem starts with only the
|
|
192
|
+
// subduction adapter, and the MessageChannel adapter is added later by
|
|
193
|
+
// connectPort, which itself awaits getRepoHive. Blocking here would deadlock
|
|
194
|
+
// that path and starve the handoff handler.
|
|
195
|
+
built.repo.networkSubsystem
|
|
196
|
+
.whenReady()
|
|
197
|
+
.then(() => log("repo network subsystem ready"));
|
|
198
|
+
return { repo: built.repo, hive: built.hive };
|
|
199
|
+
}
|
|
200
|
+
async function buildPlainRepo() {
|
|
201
|
+
const signer = await WebCryptoSigner.setup();
|
|
202
|
+
const identity = {
|
|
203
|
+
peerId: signer.peerId().toString(),
|
|
204
|
+
verifyingKey: signer.verifyingKey().toHex(),
|
|
205
|
+
};
|
|
206
|
+
const repo = new Repo({
|
|
207
|
+
storage: new IndexedDBWorkerStorageAdapter(),
|
|
208
|
+
signer,
|
|
209
|
+
peerId: `automerge-worker-${Math.random().toString(36).slice(2)}`,
|
|
210
|
+
async sharePolicy(peerId) {
|
|
211
|
+
return peerId.includes("storage-server");
|
|
212
|
+
},
|
|
213
|
+
enableRemoteHeadsGossiping: true,
|
|
214
|
+
subductionWebsocketEndpoints: getSubductionEndpoints(),
|
|
215
|
+
});
|
|
216
|
+
console.log("[patchwork] shared-worker subduction identity:", identity);
|
|
217
|
+
return { repo, identity };
|
|
218
|
+
}
|
|
219
|
+
async function buildKeyhiveRepo() {
|
|
220
|
+
initKeyhiveWasm();
|
|
221
|
+
const { hive, repo } = await initializeAutomergeRepoKeyhiveRustWithRepo({
|
|
222
|
+
createRepo: (config) => new Repo(config),
|
|
223
|
+
storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
|
|
224
|
+
peerIdSuffix: `${siteName}-worker` + Math.random().toString(36).slice(2),
|
|
225
|
+
automaticArchiveIngestion: true,
|
|
226
|
+
cachingMode: "periodic",
|
|
227
|
+
// ARK selects the relay via `syncServer`, which pairs the contact card with
|
|
228
|
+
// the matching peer id. Omitting it defaults to "subduction".
|
|
229
|
+
...(useKeyhiveSyncServer ? { syncServer: "keyhive" } : {}),
|
|
230
|
+
repo: {
|
|
231
|
+
storage: new IndexedDBWorkerStorageAdapter(),
|
|
232
|
+
subductionWebsocketEndpoints: getSubductionEndpoints(),
|
|
233
|
+
enableRemoteHeadsGossiping: true,
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
hive.networkAdapter.whenReady().then(() => {
|
|
237
|
+
hive.networkAdapter.syncKeyhive();
|
|
238
|
+
});
|
|
239
|
+
return { repo, hive };
|
|
240
|
+
}
|
|
241
|
+
// ── Classic sync ───────────────────────────────────────────────────────
|
|
241
242
|
let classicSyncServer = DEFAULT_CLASSIC_SYNC_SERVER;
|
|
242
243
|
let classicSyncAdapter = null;
|
|
243
|
-
let
|
|
244
|
-
|
|
244
|
+
let classicSyncConnect = null;
|
|
245
|
+
function connectClassicSyncNetwork(server) {
|
|
245
246
|
const url = server.trim() || DEFAULT_CLASSIC_SYNC_SERVER;
|
|
246
|
-
if (
|
|
247
|
-
return
|
|
248
|
-
}
|
|
247
|
+
if (classicSyncConnect && classicSyncServer === url)
|
|
248
|
+
return classicSyncConnect;
|
|
249
249
|
if (classicSyncAdapter && classicSyncServer !== url) {
|
|
250
250
|
classicSyncAdapter.disconnect();
|
|
251
251
|
classicSyncAdapter = null;
|
|
252
|
-
classicSyncConnectPromise = null;
|
|
253
252
|
}
|
|
254
253
|
classicSyncServer = url;
|
|
255
|
-
|
|
254
|
+
const connecting = (async () => {
|
|
256
255
|
const { repo } = await getRepoHive();
|
|
257
256
|
if (!classicSyncAdapter) {
|
|
258
257
|
classicSyncAdapter = new WebSocketWorkerClientAdapter(url);
|
|
259
258
|
repo.networkSubsystem.addNetworkAdapter(classicSyncAdapter);
|
|
260
259
|
}
|
|
261
260
|
await classicSyncAdapter.whenReady();
|
|
262
|
-
log("classic sync connected",
|
|
261
|
+
log("classic sync connected", url);
|
|
263
262
|
})();
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const cacheableStatuses = [200, 203, 204];
|
|
274
|
-
function log(...args) {
|
|
275
|
-
if (!debugging)
|
|
276
|
-
return;
|
|
277
|
-
console.log.call(console, `%cpatchwork:automergeworker%c\n`, `color: #ffaa00; font-weight: bold`, "color: inherit", ...args);
|
|
278
|
-
}
|
|
279
|
-
let repoHivePromise = null;
|
|
280
|
-
const useKeyhive = typeof __KEYHIVE__ !== "undefined" && __KEYHIVE__;
|
|
281
|
-
function getRepoHive() {
|
|
282
|
-
if (!repoHivePromise) {
|
|
283
|
-
repoHivePromise = (async () => {
|
|
284
|
-
log("getRepo: starting");
|
|
285
|
-
log("fetching wasm modules");
|
|
286
|
-
const [amWasmBuf, sdnWasmBuf] = await Promise.all([
|
|
287
|
-
fetch("/automerge.wasm").then((r) => r.arrayBuffer()),
|
|
288
|
-
fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
|
|
289
|
-
]);
|
|
290
|
-
initSubductionSync(new Uint8Array(sdnWasmBuf));
|
|
291
|
-
await initializeWasm(new Uint8Array(amWasmBuf));
|
|
292
|
-
log("wasm initialized");
|
|
293
|
-
if (!useKeyhive) {
|
|
294
|
-
const signer = await WebCryptoSigner.setup();
|
|
295
|
-
const identity = {
|
|
296
|
-
peerId: signer.peerId().toString(),
|
|
297
|
-
verifyingKey: signer.verifyingKey().toHex(),
|
|
298
|
-
};
|
|
299
|
-
const repo = new Repo({
|
|
300
|
-
storage: new IndexedDBWorkerStorageAdapter(),
|
|
301
|
-
signer,
|
|
302
|
-
peerId: ("automerge-worker-" +
|
|
303
|
-
Math.random()
|
|
304
|
-
.toString(36)
|
|
305
|
-
.slice(2)),
|
|
306
|
-
async sharePolicy(peerId) {
|
|
307
|
-
return peerId.includes("storage-server");
|
|
308
|
-
},
|
|
309
|
-
enableRemoteHeadsGossiping: true,
|
|
310
|
-
subductionWebsocketEndpoints: getSubductionEndpoints(),
|
|
311
|
-
});
|
|
312
|
-
console.log("[patchwork] shared-worker subduction identity:", identity, "networkSubsystem.adapters:", repo.networkSubsystem.adapters.length);
|
|
313
|
-
self.repo = repo;
|
|
314
|
-
self.syncIdentity = identity;
|
|
315
|
-
setupSyncStateBroadcast(repo, identity);
|
|
316
|
-
log("repo constructed (no keyhive), waiting for network subsystem");
|
|
317
|
-
repo.networkSubsystem.whenReady().then(() => {
|
|
318
|
-
log("repo network subsystem ready");
|
|
319
|
-
});
|
|
320
|
-
return { repo };
|
|
321
|
-
}
|
|
322
|
-
initKeyhiveWasm();
|
|
323
|
-
// ARK variant for talking to the keyhive-enabled subduction sync server.
|
|
324
|
-
const { hive, repo } = await initializeAutomergeRepoKeyhiveRustWithRepo({
|
|
325
|
-
createRepo: (config) => new Repo(config),
|
|
326
|
-
storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
|
|
327
|
-
peerIdSuffix: `${siteName}-worker` + Math.random().toString(36).slice(2),
|
|
328
|
-
automaticArchiveIngestion: true,
|
|
329
|
-
cachingMode: "periodic",
|
|
330
|
-
// ARK selects the relay via `syncServer` ("keyhive" | "subduction"),
|
|
331
|
-
// which pairs the contact card with the matching peer id. Omitting it
|
|
332
|
-
// defaults to "subduction".
|
|
333
|
-
...(useKeyhiveSyncServer ? { syncServer: "keyhive" } : {}),
|
|
334
|
-
repo: {
|
|
335
|
-
storage: new IndexedDBWorkerStorageAdapter(),
|
|
336
|
-
subductionWebsocketEndpoints: getSubductionEndpoints(),
|
|
337
|
-
enableRemoteHeadsGossiping: true,
|
|
338
|
-
},
|
|
339
|
-
});
|
|
340
|
-
self.repo = repo;
|
|
341
|
-
self.hive = hive;
|
|
342
|
-
setupSyncStateBroadcast(repo);
|
|
343
|
-
log("repo constructed, waiting for network subsystem");
|
|
344
|
-
// Don't block getRepoHive() on whenReady() — the network subsystem starts
|
|
345
|
-
// with only the subduction adapter, and the MessageChannel adapter is
|
|
346
|
-
// added later via connectPort (which awaits getRepoHive). Blocking here
|
|
347
|
-
// would deadlock that path and starve the handoff handler.
|
|
348
|
-
repo.networkSubsystem.whenReady().then(() => {
|
|
349
|
-
log("repo network subsystem ready");
|
|
350
|
-
});
|
|
351
|
-
hive.networkAdapter.whenReady().then(() => {
|
|
352
|
-
hive.networkAdapter.syncKeyhive();
|
|
353
|
-
});
|
|
354
|
-
return { hive, repo };
|
|
355
|
-
})();
|
|
356
|
-
// If construction fails (e.g. wasm fetch errors out), don't permanently
|
|
357
|
-
// cache the rejection — clear the slot so the next caller can retry from
|
|
358
|
-
// scratch.
|
|
359
|
-
repoHivePromise.catch(() => {
|
|
360
|
-
repoHivePromise = null;
|
|
361
|
-
});
|
|
362
|
-
}
|
|
363
|
-
return repoHivePromise;
|
|
263
|
+
// Clear the memo on failure so a later attempt can retry, and swallow the
|
|
264
|
+
// rejection on this copy so it isn't reported as unhandled — callers get it
|
|
265
|
+
// from the promise we return.
|
|
266
|
+
classicSyncConnect = connecting;
|
|
267
|
+
connecting.catch(() => {
|
|
268
|
+
if (classicSyncConnect === connecting)
|
|
269
|
+
classicSyncConnect = null;
|
|
270
|
+
});
|
|
271
|
+
return connecting;
|
|
364
272
|
}
|
|
365
273
|
// ── Sync-state broadcast ───────────────────────────────────────────────
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
|
|
373
|
-
|
|
274
|
+
// Only this worker is connected to the sync server, so it's the only place that
|
|
275
|
+
// learns the server's heads ("subduction-remote-heads", keyed by each Subduction
|
|
276
|
+
// peer's verifying-key storageId) and whether the link is up
|
|
277
|
+
// ("subduction-connection"). Global signals go out on SYNCSTATE_CHANNEL so any
|
|
278
|
+
// tab can render a sync indicator; per-document heads are addressed to
|
|
279
|
+
// subscribers instead (see pushSyncState).
|
|
280
|
+
const RESYNC_GRACE_MS = 8_000; // must be stably diverged this long first
|
|
281
|
+
const RESYNC_INITIAL_DELAY_MS = 5_000;
|
|
282
|
+
const RESYNC_MAX_DELAY_MS = 60_000;
|
|
283
|
+
const RESYNC_REVIEW_INTERVAL_MS = 5_000;
|
|
284
|
+
const OWN_HANDLE_SCAN_INTERVAL_MS = 3_000;
|
|
374
285
|
let syncStateWired = false;
|
|
375
|
-
function
|
|
286
|
+
function setUpSyncStateBroadcast(repo, identity) {
|
|
376
287
|
if (syncStateWired)
|
|
377
288
|
return;
|
|
378
289
|
syncStateWired = true;
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
return;
|
|
389
|
-
channel.postMessage({
|
|
390
|
-
type: "whoami",
|
|
391
|
-
peerId: identity.peerId,
|
|
392
|
-
verifyingKey: identity.verifyingKey,
|
|
393
|
-
});
|
|
290
|
+
const state = {
|
|
291
|
+
repo,
|
|
292
|
+
channel: new BroadcastChannel(SYNCSTATE_CHANNEL),
|
|
293
|
+
identity,
|
|
294
|
+
snapshot: new Map(),
|
|
295
|
+
connected: repo.isSubductionConnected(),
|
|
296
|
+
serverPeerIds: [],
|
|
297
|
+
tracked: new Set(),
|
|
298
|
+
resync: new Map(),
|
|
394
299
|
};
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
300
|
+
postWhoAmI(state);
|
|
301
|
+
replaySyncForPort = (documentId, port) => replayDoc(state, documentId, port);
|
|
302
|
+
for (const [port, docs] of syncWatchers) {
|
|
303
|
+
for (const documentId of docs)
|
|
304
|
+
replayDoc(state, documentId, port);
|
|
305
|
+
}
|
|
306
|
+
repo.on("subduction-remote-heads", ({ documentId, storageId, heads, timestamp }) => {
|
|
307
|
+
recordHeads(state, documentId, storageId, [...heads], timestamp);
|
|
308
|
+
// A doc the server reported is one we hold, so advertise our heads for it
|
|
309
|
+
// too. Only this doc: a full scan per event is O(all handles) and goes
|
|
310
|
+
// quadratic during sync bursts. The tick covers general discovery.
|
|
311
|
+
const handle = repo.handles[documentId];
|
|
312
|
+
if (handle)
|
|
313
|
+
trackOwnHandle(state, handle);
|
|
314
|
+
reviewResync(state, documentId);
|
|
315
|
+
});
|
|
316
|
+
repo.on("subduction-connection", ({ connected }) => {
|
|
317
|
+
state.connected = connected;
|
|
318
|
+
postConnection(state);
|
|
319
|
+
if (connected)
|
|
320
|
+
void refreshServerPeers(state);
|
|
321
|
+
});
|
|
322
|
+
// A BroadcastChannel never receives its own posts, so this only sees tabs'
|
|
323
|
+
// requests. Only the global signals are replayed; a late tab gets per-doc
|
|
324
|
+
// heads by subscribing.
|
|
325
|
+
state.channel.addEventListener("message", (event) => {
|
|
326
|
+
if (event.data?.type !== "request")
|
|
327
|
+
return;
|
|
328
|
+
postWhoAmI(state);
|
|
329
|
+
postConnection(state);
|
|
330
|
+
});
|
|
331
|
+
void refreshServerPeers(state);
|
|
332
|
+
scanOwnHandles(state);
|
|
333
|
+
if (!identity)
|
|
334
|
+
return;
|
|
335
|
+
// Subduction-pushed docs don't surface via the "document" event, so discover
|
|
336
|
+
// them by re-scanning repo.handles on a tick.
|
|
337
|
+
setInterval(() => scanOwnHandles(state), OWN_HANDLE_SCAN_INTERVAL_MS);
|
|
338
|
+
// The "stuck" case is precisely when no head events are firing, so the
|
|
339
|
+
// grace/backoff timers can only advance on a tick.
|
|
340
|
+
setInterval(() => reviewAllResync(state), RESYNC_REVIEW_INTERVAL_MS);
|
|
341
|
+
}
|
|
342
|
+
function postWhoAmI(state) {
|
|
343
|
+
if (!state.identity)
|
|
344
|
+
return;
|
|
345
|
+
state.channel.postMessage({
|
|
346
|
+
type: "whoami",
|
|
347
|
+
peerId: state.identity.peerId,
|
|
348
|
+
verifyingKey: state.identity.verifyingKey,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
function postConnection(state) {
|
|
352
|
+
state.channel.postMessage({
|
|
353
|
+
type: "connection",
|
|
354
|
+
connected: state.connected,
|
|
355
|
+
serverPeerIds: state.serverPeerIds,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
function recordHeads(state, documentId, storageId, heads, timestamp) {
|
|
359
|
+
let byStorage = state.snapshot.get(documentId);
|
|
360
|
+
if (!byStorage)
|
|
361
|
+
state.snapshot.set(documentId, (byStorage = new Map()));
|
|
362
|
+
byStorage.set(storageId, { heads, timestamp });
|
|
363
|
+
pushSyncState({
|
|
400
364
|
type: "sync-state",
|
|
401
365
|
documentId,
|
|
402
366
|
storageId,
|
|
403
367
|
heads,
|
|
404
368
|
timestamp,
|
|
405
369
|
});
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
timestamp,
|
|
420
|
-
});
|
|
421
|
-
}
|
|
422
|
-
catch {
|
|
423
|
-
// Port gone; its close handler reaps it.
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
};
|
|
427
|
-
replaySyncForPort = replayDoc;
|
|
428
|
-
// Catch up any ports that subscribed before this wiring existed.
|
|
429
|
-
for (const [port, docs] of syncWatchers) {
|
|
430
|
-
for (const documentId of docs)
|
|
431
|
-
replayDoc(documentId, port);
|
|
370
|
+
}
|
|
371
|
+
function replayDoc(state, documentId, port) {
|
|
372
|
+
const byStorage = state.snapshot.get(documentId);
|
|
373
|
+
if (!byStorage)
|
|
374
|
+
return;
|
|
375
|
+
for (const [storageId, { heads, timestamp }] of byStorage) {
|
|
376
|
+
postToPort(port, {
|
|
377
|
+
type: "sync-state",
|
|
378
|
+
documentId,
|
|
379
|
+
storageId,
|
|
380
|
+
heads,
|
|
381
|
+
timestamp,
|
|
382
|
+
});
|
|
432
383
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
});
|
|
438
|
-
// Learn (and re-announce) which connected Subduction peer is the sync server.
|
|
439
|
-
// The peer list is empty until the handshake finishes, so retry briefly.
|
|
440
|
-
const refreshServerPeers = async () => {
|
|
441
|
-
for (let attempt = 0; attempt < 6; attempt++) {
|
|
442
|
-
try {
|
|
443
|
-
const ids = await repo.connectedSubductionPeerIds();
|
|
444
|
-
if (ids.length > 0) {
|
|
445
|
-
serverPeerIds = ids;
|
|
446
|
-
postConnection();
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
catch {
|
|
451
|
-
// repo has no subduction source / not ready yet
|
|
452
|
-
}
|
|
453
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
454
|
-
}
|
|
455
|
-
};
|
|
456
|
-
// Advertise the worker's OWN heads for every doc it holds (keyed by our
|
|
457
|
-
// verifying key), so the worker hop is visible on every document.
|
|
458
|
-
//
|
|
459
|
-
// Docs pushed in by Subduction that this worker never explicitly opened don't
|
|
460
|
-
// surface via the repo's "document" event, so we discover them by re-scanning
|
|
461
|
-
// repo.handles (on a tick, and whenever the server reports a doc) and attach a
|
|
462
|
-
// heads-changed listener once per doc. No-op when there's no identity (keyhive
|
|
463
|
-
// path).
|
|
464
|
-
const ownTracked = new Set();
|
|
465
|
-
const broadcastOwnHeads = (handle) => {
|
|
466
|
-
if (!identity)
|
|
467
|
-
return;
|
|
468
|
-
const documentId = handle.documentId;
|
|
469
|
-
let heads;
|
|
470
|
-
try {
|
|
471
|
-
heads = [...handle.heads()];
|
|
472
|
-
}
|
|
473
|
-
catch {
|
|
474
|
-
return; // handle not ready yet
|
|
475
|
-
}
|
|
476
|
-
const timestamp = Date.now();
|
|
477
|
-
let byStorage = snapshot.get(documentId);
|
|
478
|
-
if (!byStorage) {
|
|
479
|
-
byStorage = new Map();
|
|
480
|
-
snapshot.set(documentId, byStorage);
|
|
481
|
-
}
|
|
482
|
-
byStorage.set(identity.peerId, { heads, timestamp });
|
|
483
|
-
postHeads(documentId, identity.peerId, heads, timestamp);
|
|
484
|
-
reviewResync(documentId);
|
|
485
|
-
};
|
|
486
|
-
const trackOwnHandle = (handle) => {
|
|
487
|
-
if (!identity || ownTracked.has(handle.documentId))
|
|
488
|
-
return;
|
|
489
|
-
ownTracked.add(handle.documentId);
|
|
490
|
-
handle.on("heads-changed", () => broadcastOwnHeads(handle));
|
|
491
|
-
broadcastOwnHeads(handle);
|
|
492
|
-
};
|
|
493
|
-
const scanOwnHandles = () => {
|
|
494
|
-
if (!identity)
|
|
495
|
-
return;
|
|
496
|
-
for (const handle of Object.values(repo.handles)) {
|
|
497
|
-
trackOwnHandle(handle);
|
|
498
|
-
}
|
|
499
|
-
};
|
|
500
|
-
// ── Backoff re-sync of stuck/diverged docs ──────────────────────────
|
|
501
|
-
//
|
|
502
|
-
// Subduction sync is event-driven and only retries syncs it observed *fail*;
|
|
503
|
-
// a doc that settles missing commits the server holds — or whose heal retries
|
|
504
|
-
// were exhausted — is otherwise never retried. When we're behind and the
|
|
505
|
-
// server's advertised heads haven't advanced for a grace window (so it's
|
|
506
|
-
// genuinely stuck, not just lagging a live edit), we re-arm its sync round
|
|
507
|
-
// with per-doc exponential backoff. Convergence clears the state.
|
|
508
|
-
const serverHeadSetsFor = (documentId) => {
|
|
509
|
-
const byStorage = snapshot.get(documentId);
|
|
510
|
-
if (!byStorage)
|
|
511
|
-
return [];
|
|
512
|
-
const sets = [];
|
|
513
|
-
for (const [storageId, { heads }] of byStorage) {
|
|
514
|
-
if (serverPeerIds.includes(storageId))
|
|
515
|
-
sets.push(heads);
|
|
516
|
-
}
|
|
517
|
-
return sets;
|
|
518
|
-
};
|
|
519
|
-
const resyncState = new Map();
|
|
520
|
-
// Inspectable from the SharedWorker console as `self.patchworkResync` to see
|
|
521
|
-
// whether/how often a doc is being re-synced and against which server heads.
|
|
522
|
-
const resyncDiag = (self.patchworkResync ??= { fires: 0, byDoc: {} });
|
|
523
|
-
const reviewResync = (documentId) => {
|
|
524
|
-
if (!identity || !connected) {
|
|
525
|
-
resyncState.delete(documentId);
|
|
526
|
-
return;
|
|
527
|
-
}
|
|
528
|
-
const handle = repo.handles[documentId];
|
|
529
|
-
if (!handle)
|
|
530
|
-
return;
|
|
531
|
-
const serverSets = serverHeadSetsFor(documentId);
|
|
532
|
-
if (serverSets.length === 0) {
|
|
533
|
-
resyncState.delete(documentId); // no server signal to compare against
|
|
534
|
-
return;
|
|
535
|
-
}
|
|
536
|
-
// The server advertises subduction *sedimentree* heads (loose-commit +
|
|
537
|
-
// fragment-boundary commit ids), which are NOT the Automerge frontier — so
|
|
538
|
-
// never compare them to handle.heads() for equality. Instead ask whether we
|
|
539
|
-
// already hold every commit the server advertises (`DocHandle.containsHeads`).
|
|
540
|
-
// If we do, the server has nothing we're missing → caught up. If not, we're
|
|
541
|
-
// genuinely behind and a re-sync can pull the rest.
|
|
542
|
-
const serverHeadsUrl = [...new Set(serverSets.flat())];
|
|
543
|
-
let haveAll;
|
|
384
|
+
}
|
|
385
|
+
/** The peer list is empty until the handshake finishes, so retry briefly. */
|
|
386
|
+
async function refreshServerPeers(state) {
|
|
387
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
544
388
|
try {
|
|
545
|
-
|
|
389
|
+
const ids = await state.repo.connectedSubductionPeerIds();
|
|
390
|
+
if (ids.length > 0) {
|
|
391
|
+
state.serverPeerIds = ids;
|
|
392
|
+
postConnection(state);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
546
395
|
}
|
|
547
396
|
catch {
|
|
548
|
-
|
|
549
|
-
}
|
|
550
|
-
if (haveAll) {
|
|
551
|
-
resyncState.delete(documentId); // we hold everything the server has
|
|
552
|
-
return;
|
|
553
|
-
}
|
|
554
|
-
// Behind. "Stuck" = the server's advertised set hasn't advanced (no
|
|
555
|
-
// progress) for a while. Key the grace timer on the server heads only, so
|
|
556
|
-
// your own edits churning don't keep resetting it.
|
|
557
|
-
const serverSig = [...serverHeadsUrl].sort().join(",");
|
|
558
|
-
const now = Date.now();
|
|
559
|
-
const prev = resyncState.get(documentId);
|
|
560
|
-
if (!prev || prev.serverSig !== serverSig) {
|
|
561
|
-
// First sighting, or the server advanced its view (progress): restart.
|
|
562
|
-
resyncState.set(documentId, {
|
|
563
|
-
serverSig,
|
|
564
|
-
since: now,
|
|
565
|
-
delay: RESYNC_INITIAL_DELAY_MS,
|
|
566
|
-
lastResyncAt: 0,
|
|
567
|
-
});
|
|
568
|
-
return;
|
|
569
|
-
}
|
|
570
|
-
if (now - prev.since < RESYNC_GRACE_MS)
|
|
571
|
-
return; // not stuck long enough yet
|
|
572
|
-
if (now - prev.lastResyncAt < prev.delay)
|
|
573
|
-
return; // within backoff cooldown
|
|
574
|
-
log("re-syncing behind doc", documentId, { serverSets });
|
|
575
|
-
resyncDiag.fires++;
|
|
576
|
-
resyncDiag.byDoc[documentId] = {
|
|
577
|
-
at: now,
|
|
578
|
-
count: (resyncDiag.byDoc[documentId]
|
|
579
|
-
?.count ?? 0) + 1,
|
|
580
|
-
serverSets,
|
|
581
|
-
};
|
|
582
|
-
try {
|
|
583
|
-
repo.resyncSubduction(documentId);
|
|
584
|
-
}
|
|
585
|
-
catch (e) {
|
|
586
|
-
log("resyncSubduction failed", e);
|
|
397
|
+
// No subduction source yet.
|
|
587
398
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
399
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// Advertise this worker's own heads for every doc it holds, so the worker hop is
|
|
403
|
+
// visible on every document. No-op on the keyhive path, which has no identity.
|
|
404
|
+
function broadcastOwnHeads(state, handle) {
|
|
405
|
+
if (!state.identity)
|
|
406
|
+
return;
|
|
407
|
+
let heads;
|
|
408
|
+
try {
|
|
409
|
+
heads = [...handle.heads()];
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
return; // handle not ready
|
|
413
|
+
}
|
|
414
|
+
recordHeads(state, handle.documentId, state.identity.peerId, heads, Date.now());
|
|
415
|
+
reviewResync(state, handle.documentId);
|
|
416
|
+
}
|
|
417
|
+
function trackOwnHandle(state, handle) {
|
|
418
|
+
if (!state.identity || state.tracked.has(handle.documentId))
|
|
419
|
+
return;
|
|
420
|
+
state.tracked.add(handle.documentId);
|
|
421
|
+
handle.on("heads-changed", () => broadcastOwnHeads(state, handle));
|
|
422
|
+
broadcastOwnHeads(state, handle);
|
|
423
|
+
}
|
|
424
|
+
function scanOwnHandles(state) {
|
|
425
|
+
if (!state.identity)
|
|
426
|
+
return;
|
|
427
|
+
for (const handle of Object.values(state.repo.handles)) {
|
|
428
|
+
trackOwnHandle(state, handle);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function serverHeadsFor(state, documentId) {
|
|
432
|
+
const byStorage = state.snapshot.get(documentId);
|
|
433
|
+
if (!byStorage)
|
|
434
|
+
return [];
|
|
435
|
+
const heads = new Set();
|
|
436
|
+
for (const [storageId, entry] of byStorage) {
|
|
437
|
+
if (state.serverPeerIds.includes(storageId)) {
|
|
438
|
+
for (const head of entry.heads)
|
|
439
|
+
heads.add(head);
|
|
607
440
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
//
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
441
|
+
}
|
|
442
|
+
return [...heads];
|
|
443
|
+
}
|
|
444
|
+
function reviewResync(state, documentId) {
|
|
445
|
+
if (!state.identity || !state.connected) {
|
|
446
|
+
state.resync.delete(documentId);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const handle = state.repo.handles[documentId];
|
|
450
|
+
if (!handle)
|
|
451
|
+
return;
|
|
452
|
+
const serverHeads = serverHeadsFor(state, documentId);
|
|
453
|
+
if (serverHeads.length === 0) {
|
|
454
|
+
state.resync.delete(documentId); // nothing to compare against
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
// The server advertises subduction sedimentree heads (loose-commit and
|
|
458
|
+
// fragment-boundary commit ids), which are NOT the Automerge frontier, so
|
|
459
|
+
// never compare them to handle.heads() for equality. Ask instead whether we
|
|
460
|
+
// already hold every commit the server advertises.
|
|
461
|
+
let haveAll;
|
|
462
|
+
try {
|
|
463
|
+
haveAll = handle.containsHeads(serverHeads);
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
return; // doc not ready, or an undecodable head
|
|
467
|
+
}
|
|
468
|
+
if (haveAll) {
|
|
469
|
+
state.resync.delete(documentId);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
// Behind. Key the grace timer on the server heads alone, so your own edits
|
|
473
|
+
// churning don't keep resetting it.
|
|
474
|
+
const serverSig = [...serverHeads].sort().join(",");
|
|
475
|
+
const now = Date.now();
|
|
476
|
+
const prev = state.resync.get(documentId);
|
|
477
|
+
if (!prev || prev.serverSig !== serverSig) {
|
|
478
|
+
// First sighting, or the server made progress: restart the clock.
|
|
479
|
+
state.resync.set(documentId, {
|
|
480
|
+
serverSig,
|
|
481
|
+
since: now,
|
|
482
|
+
delay: RESYNC_INITIAL_DELAY_MS,
|
|
483
|
+
lastResyncAt: 0,
|
|
484
|
+
});
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (now - prev.since < RESYNC_GRACE_MS)
|
|
488
|
+
return;
|
|
489
|
+
if (now - prev.lastResyncAt < prev.delay)
|
|
490
|
+
return;
|
|
491
|
+
log("re-syncing behind doc", documentId);
|
|
492
|
+
try {
|
|
493
|
+
state.repo.resyncSubduction(documentId);
|
|
494
|
+
}
|
|
495
|
+
catch (e) {
|
|
496
|
+
log("resyncSubduction failed", e);
|
|
497
|
+
}
|
|
498
|
+
prev.lastResyncAt = now;
|
|
499
|
+
prev.delay = Math.min(prev.delay * 2, RESYNC_MAX_DELAY_MS);
|
|
500
|
+
}
|
|
501
|
+
function reviewAllResync(state) {
|
|
502
|
+
if (!state.identity)
|
|
503
|
+
return;
|
|
504
|
+
for (const documentId of state.snapshot.keys())
|
|
505
|
+
reviewResync(state, documentId);
|
|
506
|
+
for (const id of [...state.resync.keys()]) {
|
|
507
|
+
if (!state.snapshot.has(id))
|
|
508
|
+
state.resync.delete(id);
|
|
509
|
+
}
|
|
648
510
|
}
|
|
649
511
|
function dropRepoChannel(repo, channel) {
|
|
650
512
|
// removeNetworkAdapter pulls the adapter out of networkSubsystem.adapters and
|
|
651
|
-
// calls
|
|
652
|
-
//
|
|
513
|
+
// calls disconnect(), which for the MessageChannel adapter emits the
|
|
514
|
+
// close/peer-disconnected events that clear #adaptersByPeer.
|
|
653
515
|
try {
|
|
654
516
|
repo.networkSubsystem.removeNetworkAdapter(channel.adapter);
|
|
655
517
|
}
|
|
656
518
|
catch (err) {
|
|
657
519
|
console.error("removeNetworkAdapter failed", err);
|
|
658
520
|
}
|
|
659
|
-
//
|
|
660
|
-
//
|
|
521
|
+
// On the keyhive path the registered adapter is a wrapper, so make sure the
|
|
522
|
+
// underlying port is disconnected and closed too.
|
|
661
523
|
try {
|
|
662
524
|
channel.mcAdapter.disconnect();
|
|
663
525
|
}
|
|
664
|
-
catch {
|
|
665
|
-
// Already disconnected by removeNetworkAdapter above.
|
|
666
|
-
}
|
|
526
|
+
catch { }
|
|
667
527
|
try {
|
|
668
528
|
channel.port.close();
|
|
669
529
|
}
|
|
670
|
-
catch {
|
|
671
|
-
// Port already closed by the departing tab.
|
|
672
|
-
}
|
|
530
|
+
catch { }
|
|
673
531
|
}
|
|
674
532
|
async function dropConnection(connection) {
|
|
675
533
|
if (!connection.channels.size || !repoHivePromise)
|
|
676
534
|
return;
|
|
677
535
|
const { repo } = await getRepoHive();
|
|
678
536
|
log(`tab gone — removing ${connection.channels.size} network adapter(s)`);
|
|
679
|
-
for (const channel of connection.channels)
|
|
537
|
+
for (const channel of connection.channels)
|
|
680
538
|
dropRepoChannel(repo, channel);
|
|
681
|
-
}
|
|
682
539
|
connection.channels.clear();
|
|
683
540
|
}
|
|
684
|
-
// Connect client MessagePorts to the repo for sync
|
|
685
541
|
async function connectPort(port, connection) {
|
|
686
542
|
const { hive, repo } = await getRepoHive();
|
|
687
|
-
const
|
|
543
|
+
const mcAdapter = new MessageChannelNetworkAdapter(port, {
|
|
688
544
|
useWeakRef: true,
|
|
689
545
|
});
|
|
690
|
-
const track = (adapter) => {
|
|
691
|
-
connection.channels.add({ adapter, mcAdapter: networkAdapter, port });
|
|
692
|
-
};
|
|
693
546
|
if (!hive) {
|
|
694
|
-
repo.networkSubsystem.addNetworkAdapter(
|
|
695
|
-
|
|
547
|
+
repo.networkSubsystem.addNetworkAdapter(mcAdapter);
|
|
548
|
+
connection.channels.add({ adapter: mcAdapter, mcAdapter, port });
|
|
696
549
|
return;
|
|
697
550
|
}
|
|
698
551
|
const onlyShareWithHardcodedServerPeerId = false;
|
|
699
552
|
const periodicallyRequestKeyhiveSync = false;
|
|
700
|
-
const
|
|
701
|
-
|
|
702
|
-
if (
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
}
|
|
553
|
+
const adapter = hive.createKeyhiveNetworkAdapter(mcAdapter, onlyShareWithHardcodedServerPeerId, periodicallyRequestKeyhiveSync, 2000);
|
|
554
|
+
adapter.on("message", (msg) => {
|
|
555
|
+
if (msg.type !== "sync" && msg.type !== "request")
|
|
556
|
+
return;
|
|
557
|
+
if (!msg.documentId)
|
|
558
|
+
return;
|
|
559
|
+
const handle = repo.handles[msg.documentId];
|
|
560
|
+
if (handle && handle.state !== "unavailable")
|
|
561
|
+
return;
|
|
562
|
+
repo.findWithProgress(`automerge:${msg.documentId}`);
|
|
563
|
+
repo.shareConfigChanged();
|
|
710
564
|
});
|
|
711
|
-
|
|
565
|
+
adapter.on("ingest-remote", () => {
|
|
712
566
|
hive.notifySameAgentKeyhiveChange();
|
|
713
567
|
hive.networkAdapter.syncKeyhive?.();
|
|
714
568
|
repo.shareConfigChanged();
|
|
715
569
|
});
|
|
716
|
-
repo.networkSubsystem.addNetworkAdapter(
|
|
717
|
-
|
|
570
|
+
repo.networkSubsystem.addNetworkAdapter(adapter);
|
|
571
|
+
connection.channels.add({ adapter, mcAdapter, port });
|
|
718
572
|
}
|
|
719
573
|
function handleControlMessage(event, controlPort, connection) {
|
|
720
574
|
const data = event.data;
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
const [repoPort] = event.ports;
|
|
734
|
-
const id = data.id;
|
|
735
|
-
connectPort(repoPort, connection).then(() => controlPort.postMessage({ type: "port-ready", id }), (err) => {
|
|
736
|
-
console.error("connectPort failed", err);
|
|
737
|
-
// Tell the client we failed so it doesn't hang forever.
|
|
738
|
-
controlPort.postMessage({
|
|
739
|
-
type: "port-failed",
|
|
740
|
-
id,
|
|
741
|
-
error: String(err),
|
|
575
|
+
switch (data?.type) {
|
|
576
|
+
case "port": {
|
|
577
|
+
log("received repo channel");
|
|
578
|
+
const [repoPort] = event.ports;
|
|
579
|
+
connectPort(repoPort, connection).then(() => controlPort.postMessage({ type: "port-ready", id: data.id }), (err) => {
|
|
580
|
+
console.error("connectPort failed", err);
|
|
581
|
+
// Tell the tab so it doesn't hang until its timeout.
|
|
582
|
+
controlPort.postMessage({
|
|
583
|
+
type: "port-failed",
|
|
584
|
+
id: data.id,
|
|
585
|
+
error: String(err),
|
|
586
|
+
});
|
|
742
587
|
});
|
|
743
|
-
|
|
744
|
-
}
|
|
745
|
-
else if (data?.type === "sync-sub") {
|
|
746
|
-
if (typeof data.documentId === "string") {
|
|
747
|
-
syncSubscribe(controlPort, data.documentId);
|
|
588
|
+
return;
|
|
748
589
|
}
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
590
|
+
case "sync-sub":
|
|
591
|
+
if (typeof data.documentId === "string") {
|
|
592
|
+
syncSubscribe(controlPort, data.documentId);
|
|
593
|
+
}
|
|
594
|
+
return;
|
|
595
|
+
case "sync-unsub":
|
|
596
|
+
if (typeof data.documentId === "string") {
|
|
597
|
+
syncUnsubscribe(controlPort, data.documentId);
|
|
598
|
+
}
|
|
599
|
+
return;
|
|
600
|
+
case "debug":
|
|
601
|
+
debugging = data.debug;
|
|
602
|
+
log("automerge worker debugging enabled");
|
|
603
|
+
return;
|
|
604
|
+
case "connect-classic-sync": {
|
|
605
|
+
const [replyPort] = event.ports;
|
|
606
|
+
const server = typeof data.server === "string"
|
|
607
|
+
? data.server
|
|
608
|
+
: DEFAULT_CLASSIC_SYNC_SERVER;
|
|
609
|
+
connectClassicSyncNetwork(server).then(() => {
|
|
610
|
+
replyPort?.postMessage({ type: "connect-classic-sync-ready" });
|
|
611
|
+
replyPort?.close();
|
|
612
|
+
}, (err) => {
|
|
613
|
+
console.error("connectClassicSyncNetwork failed", err);
|
|
614
|
+
replyPort?.postMessage({
|
|
615
|
+
type: "connect-classic-sync-failed",
|
|
616
|
+
error: String(err),
|
|
617
|
+
});
|
|
618
|
+
replyPort?.close();
|
|
619
|
+
});
|
|
620
|
+
return;
|
|
753
621
|
}
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
else if (data?.type === "connect-classic-sync") {
|
|
760
|
-
const [replyPort] = event.ports;
|
|
761
|
-
const server = typeof data.server === "string"
|
|
762
|
-
? data.server
|
|
763
|
-
: DEFAULT_CLASSIC_SYNC_SERVER;
|
|
764
|
-
connectClassicSyncNetwork(server)
|
|
765
|
-
.then(() => {
|
|
766
|
-
replyPort?.postMessage({ type: "connect-classic-sync-ready" });
|
|
767
|
-
replyPort?.close();
|
|
768
|
-
log("classic sync connected on demand", { server });
|
|
769
|
-
})
|
|
770
|
-
.catch((err) => {
|
|
771
|
-
console.error("connectClassicSyncNetwork failed", err);
|
|
772
|
-
replyPort?.postMessage({
|
|
773
|
-
type: "connect-classic-sync-failed",
|
|
774
|
-
error: String(err),
|
|
622
|
+
case "ping":
|
|
623
|
+
controlPort.postMessage({
|
|
624
|
+
type: "pong",
|
|
625
|
+
id: data.id,
|
|
626
|
+
instanceId: WORKER_INSTANCE_ID,
|
|
775
627
|
});
|
|
776
|
-
|
|
777
|
-
});
|
|
778
|
-
}
|
|
779
|
-
else if (data?.type === "ping") {
|
|
780
|
-
// Heartbeat: reply so the tab can detect our death or restart.
|
|
781
|
-
controlPort.postMessage({
|
|
782
|
-
type: "pong",
|
|
783
|
-
id: data.id,
|
|
784
|
-
instanceId: WORKER_INSTANCE_ID,
|
|
785
|
-
});
|
|
628
|
+
return;
|
|
786
629
|
}
|
|
787
630
|
}
|
|
788
631
|
self.addEventListener("connect", (event) => {
|
|
789
632
|
const controlPort = event.ports[0];
|
|
790
633
|
const connection = { channels: new Set() };
|
|
791
|
-
(self.patchworkControl ??= { connects: 0, byType: {} }).connects++;
|
|
792
634
|
controlPort.addEventListener("message", (messageEvent) => {
|
|
793
635
|
handleControlMessage(messageEvent, controlPort, connection);
|
|
794
636
|
});
|
|
795
|
-
//
|
|
796
|
-
//
|
|
797
|
-
// coexist with our control protocol above).
|
|
637
|
+
// The tab side runs donatePort; the messages are channel-tagged so they
|
|
638
|
+
// coexist with the control protocol above.
|
|
798
639
|
subductionPortProvider.attachClient(controlPort);
|
|
799
|
-
// Fires when the owning page is destroyed. Browsers without the close
|
|
800
|
-
//
|
|
640
|
+
// Fires when the owning page is destroyed. Browsers without the close event
|
|
641
|
+
// fall back to the adapters' lazy useWeakRef cleanup.
|
|
801
642
|
controlPort.addEventListener("close", () => {
|
|
802
643
|
controlPorts.delete(controlPort);
|
|
803
|
-
// The tab is gone — drop its sync subscriptions wholesale so we stop
|
|
804
|
-
// pushing it heads (no per-doc unsub needed, no leak).
|
|
805
644
|
syncWatchers.delete(controlPort);
|
|
806
645
|
void dropConnection(connection);
|
|
807
646
|
});
|
|
808
647
|
controlPort.start();
|
|
809
|
-
// Greet the tab with our per-boot instance id so it can detect a restart
|
|
810
|
-
// (a different id than last seen) even if no port "close" fired.
|
|
811
648
|
controlPort.postMessage({
|
|
812
649
|
type: "hello",
|
|
813
650
|
instanceId: WORKER_INSTANCE_ID,
|
|
814
651
|
bootTime: WORKER_BOOT_TIME,
|
|
815
652
|
});
|
|
816
|
-
// Start forwarding console output to this tab, and flush anything buffered
|
|
817
|
-
// while no tab was connected (e.g. boot-time logs) to the first arrival.
|
|
818
653
|
controlPorts.add(controlPort);
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
try {
|
|
822
|
-
controlPort.postMessage({ type: "console", level, args });
|
|
823
|
-
}
|
|
824
|
-
catch {
|
|
825
|
-
// Port may already be gone — ignore.
|
|
826
|
-
}
|
|
827
|
-
}
|
|
654
|
+
for (const { level, args } of preConnectBuffer.splice(0)) {
|
|
655
|
+
postToPort(controlPort, { type: "console", level, args });
|
|
828
656
|
}
|
|
829
657
|
});
|
|
830
|
-
// ── Automerge URL resolution ───────────────────────────────────────────
|
|
831
|
-
/**
|
|
832
|
-
* Wait for the requested heads to appear in the handle's local history —
|
|
833
|
-
* they may still be syncing toward us when the request lands. Resolves
|
|
834
|
-
* false if the signal aborts before they arrive.
|
|
835
|
-
*/
|
|
836
658
|
function waitForHeads(handle, hexHeads, signal) {
|
|
837
659
|
if (hasHeads(handle.doc(), hexHeads))
|
|
838
660
|
return Promise.resolve(true);
|
|
839
661
|
if (signal.aborted)
|
|
840
662
|
return Promise.resolve(false);
|
|
841
663
|
return new Promise((resolve) => {
|
|
664
|
+
const cleanup = () => {
|
|
665
|
+
handle.off("heads-changed", check);
|
|
666
|
+
signal.removeEventListener("abort", onAbort);
|
|
667
|
+
};
|
|
842
668
|
const check = () => {
|
|
843
669
|
if (!hasHeads(handle.doc(), hexHeads))
|
|
844
670
|
return;
|
|
@@ -849,14 +675,9 @@ function waitForHeads(handle, hexHeads, signal) {
|
|
|
849
675
|
cleanup();
|
|
850
676
|
resolve(false);
|
|
851
677
|
};
|
|
852
|
-
const cleanup = () => {
|
|
853
|
-
handle.off("heads-changed", check);
|
|
854
|
-
signal.removeEventListener("abort", onAbort);
|
|
855
|
-
};
|
|
856
678
|
handle.on("heads-changed", check);
|
|
857
679
|
signal.addEventListener("abort", onAbort);
|
|
858
|
-
// The heads may have landed between the
|
|
859
|
-
// subscribing.
|
|
680
|
+
// The heads may have landed between the check above and subscribing.
|
|
860
681
|
check();
|
|
861
682
|
});
|
|
862
683
|
}
|
|
@@ -867,43 +688,30 @@ function waitForHeads(handle, hexHeads, signal) {
|
|
|
867
688
|
*/
|
|
868
689
|
class AbortHandoff extends Error {
|
|
869
690
|
}
|
|
870
|
-
async function resolveAutomergeUrl(automergeURL) {
|
|
691
|
+
async function resolveAutomergeUrl(automergeURL, signal) {
|
|
871
692
|
const { repo } = await getRepoHive();
|
|
872
|
-
const
|
|
873
|
-
const [maybeAutomergeUrl, ...path] = href.split("/");
|
|
693
|
+
const [maybeAutomergeUrl, ...path] = automergeURL.href.split("/");
|
|
874
694
|
if (!isValidAutomergeUrl(maybeAutomergeUrl)) {
|
|
875
695
|
return new Response("invalid automerge url", { status: 400 });
|
|
876
696
|
}
|
|
877
|
-
// Trim trailing empty path segment
|
|
878
697
|
if (path.length && !path[path.length - 1])
|
|
879
698
|
path.pop();
|
|
880
699
|
const { heads, hexHeads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
|
|
881
|
-
|
|
700
|
+
// todo, maybe a bad idea? maybe we should throw instead of es-module-caching
|
|
701
|
+
// the headless req
|
|
882
702
|
if (!heads) {
|
|
883
703
|
const folder = await repo.find(maybeAutomergeUrl, { signal });
|
|
884
|
-
const
|
|
885
|
-
const
|
|
886
|
-
let location = `/${encodeURIComponent(url)}`;
|
|
887
|
-
if (path.length)
|
|
888
|
-
location += `/${path.join("/")}`;
|
|
704
|
+
const url = stringifyAutomergeUrl({ documentId, heads: folder.heads() });
|
|
705
|
+
const location = `/${encodeURIComponent(url)}${path.length ? `/${path.join("/")}` : ""}`;
|
|
889
706
|
return Response.redirect(location, 307);
|
|
890
707
|
}
|
|
891
|
-
// Load by documentId only so we can verify the requested heads are actually
|
|
892
|
-
// in our local history. repo.find with a heads-bearing URL returns a view
|
|
893
|
-
// at those heads, which silently materializes garbage if we never synced them.
|
|
894
708
|
const baseHandle = await repo.find(stringifyAutomergeUrl({ documentId }), {
|
|
895
709
|
signal,
|
|
896
710
|
});
|
|
897
|
-
// The heads may not have synced to us yet — give them the rest of the
|
|
898
|
-
// resolve window to arrive before giving up.
|
|
899
711
|
if (!(await waitForHeads(baseHandle, hexHeads ?? [], signal))) {
|
|
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
712
|
throw new AbortHandoff(`heads not found for ${maybeAutomergeUrl} within ${RESOLVE_TIMEOUT_MS}ms`);
|
|
904
713
|
}
|
|
905
|
-
const
|
|
906
|
-
const resolved = await resolvePath(repo, rootHandle, path.map(decodeURIComponent));
|
|
714
|
+
const resolved = await resolvePath(repo, baseHandle.view(heads), path.map(decodeURIComponent));
|
|
907
715
|
if (!resolved) {
|
|
908
716
|
throw new Error(`couldn't resolve ${path.join("/")} in folder at ${maybeAutomergeUrl}`);
|
|
909
717
|
}
|
|
@@ -915,59 +723,41 @@ async function resolveAutomergeUrl(automergeURL) {
|
|
|
915
723
|
headers: { "content-type": resolved.type },
|
|
916
724
|
});
|
|
917
725
|
}
|
|
918
|
-
// ── Handoff: resolve special URLs for the service worker ──────────────
|
|
919
726
|
const handoffChannel = new BroadcastChannel(HANDOFF_CHANNEL);
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
if (request.handoffURL)
|
|
930
|
-
return new URL(request.handoffURL);
|
|
931
|
-
// TODO(backcompat): a briefly-deployed shape sent the special URL in
|
|
932
|
-
// request.url and the http URL in cacheKey.
|
|
933
|
-
if (request.cacheKey)
|
|
934
|
-
return new URL(request.url);
|
|
935
|
-
// TODO(backcompat): older service workers send only the http URL,
|
|
936
|
-
// special URL still URI-encoded in its pathname.
|
|
937
|
-
return new URL(decodeURIComponent(new URL(request.url).pathname.slice(1)));
|
|
938
|
-
}
|
|
939
|
-
catch {
|
|
940
|
-
return null;
|
|
941
|
-
}
|
|
727
|
+
function replyToHandoff(id, status, body) {
|
|
728
|
+
handoffChannel.postMessage({
|
|
729
|
+
id,
|
|
730
|
+
type: "response",
|
|
731
|
+
response: { status, body, headers: { "content-type": "text/plain" } },
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
function impatience(limit) {
|
|
735
|
+
return new Promise((_, reject) => setTimeout(() => reject(new Error(`resolve timeout after ${limit}ms`)), limit));
|
|
942
736
|
}
|
|
943
737
|
async function handleHandoffRequest(message) {
|
|
944
738
|
const { id, cachename, request } = message;
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
status: 400,
|
|
953
|
-
body: `couldn't parse a special url out of ${request.url}`,
|
|
954
|
-
headers: { "content-type": "text/plain" },
|
|
955
|
-
},
|
|
956
|
-
});
|
|
739
|
+
let handoff;
|
|
740
|
+
try {
|
|
741
|
+
handoff = new URL(request.handoffURL);
|
|
742
|
+
}
|
|
743
|
+
catch {
|
|
744
|
+
console.error("couldn't parse handoff url", request);
|
|
745
|
+
replyToHandoff(id, 400, `couldn't parse a special url out of ${request.url}`);
|
|
957
746
|
return;
|
|
958
747
|
}
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
return
|
|
748
|
+
// Other handlers may be listening on the channel for other schemes, so stay
|
|
749
|
+
// quiet rather than clobbering their reply with an error.
|
|
750
|
+
if (handoff.protocol !== "automerge:") {
|
|
751
|
+
log(`ignoring handoff ${id} for non-automerge url ${handoff}. not my circus, not my monkeys`);
|
|
752
|
+
return;
|
|
964
753
|
}
|
|
965
754
|
let response;
|
|
966
755
|
try {
|
|
967
|
-
log(`resolving handoff ${id} for ${
|
|
756
|
+
log(`resolving handoff ${id} for ${handoff}`);
|
|
757
|
+
const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
|
|
968
758
|
response = await Promise.race([
|
|
969
|
-
resolveAutomergeUrl(
|
|
970
|
-
|
|
759
|
+
resolveAutomergeUrl(handoff, signal),
|
|
760
|
+
impatience(RESOLVE_TIMEOUT_MS),
|
|
971
761
|
]);
|
|
972
762
|
}
|
|
973
763
|
catch (error) {
|
|
@@ -979,43 +769,17 @@ async function handleHandoffRequest(message) {
|
|
|
979
769
|
});
|
|
980
770
|
return;
|
|
981
771
|
}
|
|
982
|
-
|
|
772
|
+
console.error(`error resolving ${request.url}`, error);
|
|
773
|
+
replyToHandoff(id, 557, error instanceof Error
|
|
983
774
|
? `${error.message}\n\n${error.stack}`
|
|
984
|
-
: String(error);
|
|
985
|
-
console.error(`automerge worker error resolving ${request.url}`, error);
|
|
986
|
-
handoffChannel.postMessage({
|
|
987
|
-
id,
|
|
988
|
-
type: "response",
|
|
989
|
-
response: {
|
|
990
|
-
status: 557,
|
|
991
|
-
body,
|
|
992
|
-
headers: { "content-type": "text/plain" },
|
|
993
|
-
},
|
|
994
|
-
});
|
|
775
|
+
: String(error));
|
|
995
776
|
return;
|
|
996
777
|
}
|
|
997
778
|
try {
|
|
998
|
-
if (
|
|
999
|
-
//
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1002
|
-
// the SW is holding except in the briefly-deployed cacheKey shape.
|
|
1003
|
-
const cacheKey = new Request(request.cacheKey ?? request.url, {
|
|
1004
|
-
method: request.method,
|
|
1005
|
-
headers: request.headers,
|
|
1006
|
-
referrer: request.referrer,
|
|
1007
|
-
});
|
|
1008
|
-
const cache = await caches.open(cachename);
|
|
1009
|
-
await cache.put(cacheKey, response);
|
|
1010
|
-
log(`cached ${cacheKey.url} in ${cachename}`);
|
|
1011
|
-
handoffChannel.postMessage({
|
|
1012
|
-
id,
|
|
1013
|
-
type: "cached",
|
|
1014
|
-
});
|
|
1015
|
-
}
|
|
1016
|
-
else {
|
|
1017
|
-
// Errors, redirects &c — things that shouldn't be cached — go back
|
|
1018
|
-
// inline for the service worker to serve directly.
|
|
779
|
+
if (!CACHEABLE_STATUSES.includes(response.status)) {
|
|
780
|
+
// Errors, redirects and the like go back inline for the service worker to
|
|
781
|
+
// serve directly, so they aren't cached forever (still in esmodulecache,
|
|
782
|
+
// cleared after a refresh)
|
|
1019
783
|
log(`responding inline to ${request.url} with ${response.status}`);
|
|
1020
784
|
handoffChannel.postMessage({
|
|
1021
785
|
id,
|
|
@@ -1026,26 +790,34 @@ async function handleHandoffRequest(message) {
|
|
|
1026
790
|
body: response.body ? await response.text() : undefined,
|
|
1027
791
|
},
|
|
1028
792
|
});
|
|
793
|
+
return;
|
|
1029
794
|
}
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
795
|
+
// Reconstruct the request the service worker is holding so the entry matches
|
|
796
|
+
// its cache.match. `destination` isn't constructible but doesn't participate
|
|
797
|
+
// in cache matching.
|
|
798
|
+
const cacheKey = new Request(request.url, {
|
|
799
|
+
method: request.method,
|
|
800
|
+
headers: request.headers,
|
|
801
|
+
referrer: request.referrer,
|
|
802
|
+
});
|
|
803
|
+
const cache = await caches.open(cachename);
|
|
804
|
+
await cache.put(cacheKey, response);
|
|
805
|
+
log(`cached ${cacheKey.url} in ${cachename}`);
|
|
1033
806
|
handoffChannel.postMessage({
|
|
1034
807
|
id,
|
|
1035
|
-
type: "
|
|
1036
|
-
response: {
|
|
1037
|
-
status: 558,
|
|
1038
|
-
body: String(error),
|
|
1039
|
-
headers: { "content-type": "text/plain" },
|
|
1040
|
-
},
|
|
808
|
+
type: "cached",
|
|
1041
809
|
});
|
|
1042
810
|
}
|
|
811
|
+
catch (error) {
|
|
812
|
+
console.error(`failed to reply for ${request.url}`, error);
|
|
813
|
+
replyToHandoff(id, 558, String(error));
|
|
814
|
+
}
|
|
1043
815
|
}
|
|
1044
816
|
handoffChannel.addEventListener("message", (event) => {
|
|
1045
817
|
if (event.data?.type === "request") {
|
|
1046
818
|
void handleHandoffRequest(event.data);
|
|
1047
819
|
}
|
|
1048
820
|
});
|
|
1049
|
-
// Announce ourselves so the service worker can re-broadcast
|
|
1050
|
-
//
|
|
821
|
+
// Announce ourselves so the service worker can re-broadcast handoff requests
|
|
822
|
+
// sent while we were booting.
|
|
1051
823
|
handoffChannel.postMessage({ type: "online" });
|