@inkandswitch/patchwork-bootloader 0.4.2 → 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 +60 -0
- package/dist/automerge-worker.js +560 -758
- package/dist/module-loader.d.ts +0 -5
- package/dist/module-loader.js +0 -6
- package/dist/service-worker.js +183 -192
- package/dist/setup.d.ts +2 -1
- package/dist/setup.js +350 -271
- package/dist/site.d.ts +17 -32
- package/dist/site.js +309 -340
- package/dist/types.d.ts +18 -1
- package/dist/vite/importmap-plugin.js +32 -6
- package/package.json +30 -11
- package/src/automerge-worker.ts +654 -820
- package/src/module-loader.ts +0 -6
- package/src/service-worker.ts +237 -225
- package/src/setup.ts +384 -281
- package/src/site.ts +404 -404
- package/src/types.ts +22 -1
- package/src/vite/importmap-plugin.ts +39 -6
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,622 +108,524 @@ 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
|
-
`suspended/frozen/throttled; WebSocket keepalive pongs were not sent ` +
|
|
178
|
-
`during this window, so the sync server may have reaped us. at ` +
|
|
179
|
-
`${new Date(now).toISOString()}`);
|
|
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)`);
|
|
180
121
|
}
|
|
181
122
|
}, WATCHDOG_TICK_MS);
|
|
182
|
-
//
|
|
183
|
-
// to
|
|
184
|
-
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
+
}
|
|
192
149
|
}
|
|
193
|
-
const SUBDUCTION_SYNC_URL = useKeyhiveSyncServer
|
|
194
|
-
? "wss://keyhive.sync.automerge.org"
|
|
195
|
-
: "wss://subduction.sync.inkandswitch.com";
|
|
196
|
-
// The subduction WebSocket lives in its own worker so socket I/O (and
|
|
197
|
-
// keepalive pongs) keep flowing even when this SharedWorker's thread is busy
|
|
198
|
-
// syncing. We can't spawn that worker ourselves — Chrome doesn't expose the
|
|
199
|
-
// Worker constructor inside SharedWorkerGlobalScope — so tabs spawn the
|
|
200
|
-
// shipped SharedWorker proxy entry and donate its port to us (donatePort in
|
|
201
|
-
// setup.ts). The provider hands WorkerWebSocketEndpoint whichever port is
|
|
202
|
-
// current, healing across late arrival and proxy-worker restarts.
|
|
203
150
|
const subductionPortProvider = makePortProvider();
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
// passes the bare URL string so the socket lives on this thread — the
|
|
207
|
-
// pre-worker behaviour — as the control arm for benchmarking the
|
|
208
|
-
// worker-based endpoint. Default: "worker".
|
|
209
|
-
const WS_MODE = new URL(self.location.href).searchParams.get("ws-mode") === "inline"
|
|
210
|
-
? "inline"
|
|
211
|
-
: "worker";
|
|
212
|
-
// Optional windowFrames override (bench knob — max un-acked frames the io
|
|
213
|
-
// proxy delivers before pausing; endpoint default is 128).
|
|
214
|
-
const WS_WINDOW_FRAMES = Number(new URL(self.location.href).searchParams.get("ws-window")) ||
|
|
215
|
-
undefined;
|
|
216
|
-
// Memoized so a repo-construction retry (getRepoHive clears its promise on
|
|
217
|
-
// 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.
|
|
218
153
|
let subductionEndpoints = null;
|
|
219
154
|
function getSubductionEndpoints() {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
: [
|
|
226
|
-
new WorkerWebSocketEndpoint(SUBDUCTION_SYNC_URL, {
|
|
227
|
-
worker: subductionPortProvider.source,
|
|
228
|
-
...(WS_WINDOW_FRAMES
|
|
229
|
-
? { windowFrames: WS_WINDOW_FRAMES }
|
|
230
|
-
: {}),
|
|
231
|
-
}),
|
|
232
|
-
];
|
|
233
|
-
}
|
|
234
|
-
return subductionEndpoints;
|
|
155
|
+
return (subductionEndpoints ??= [
|
|
156
|
+
new WorkerWebSocketEndpoint(SUBDUCTION_SYNC_URL, {
|
|
157
|
+
worker: subductionPortProvider.source,
|
|
158
|
+
}),
|
|
159
|
+
]);
|
|
235
160
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return repoHivePromise;
|
|
172
|
+
}
|
|
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 ───────────────────────────────────────────────────────
|
|
245
242
|
let classicSyncServer = DEFAULT_CLASSIC_SYNC_SERVER;
|
|
246
243
|
let classicSyncAdapter = null;
|
|
247
|
-
let
|
|
248
|
-
|
|
244
|
+
let classicSyncConnect = null;
|
|
245
|
+
function connectClassicSyncNetwork(server) {
|
|
249
246
|
const url = server.trim() || DEFAULT_CLASSIC_SYNC_SERVER;
|
|
250
|
-
if (
|
|
251
|
-
return
|
|
252
|
-
}
|
|
247
|
+
if (classicSyncConnect && classicSyncServer === url)
|
|
248
|
+
return classicSyncConnect;
|
|
253
249
|
if (classicSyncAdapter && classicSyncServer !== url) {
|
|
254
250
|
classicSyncAdapter.disconnect();
|
|
255
251
|
classicSyncAdapter = null;
|
|
256
|
-
classicSyncConnectPromise = null;
|
|
257
252
|
}
|
|
258
253
|
classicSyncServer = url;
|
|
259
|
-
|
|
254
|
+
const connecting = (async () => {
|
|
260
255
|
const { repo } = await getRepoHive();
|
|
261
256
|
if (!classicSyncAdapter) {
|
|
262
257
|
classicSyncAdapter = new WebSocketWorkerClientAdapter(url);
|
|
263
258
|
repo.networkSubsystem.addNetworkAdapter(classicSyncAdapter);
|
|
264
259
|
}
|
|
265
260
|
await classicSyncAdapter.whenReady();
|
|
266
|
-
log("classic sync connected",
|
|
261
|
+
log("classic sync connected", url);
|
|
267
262
|
})();
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
const cacheableStatuses = [200, 203, 204];
|
|
278
|
-
function log(...args) {
|
|
279
|
-
if (!debugging)
|
|
280
|
-
return;
|
|
281
|
-
console.log.call(console, `%cpatchwork:automergeworker%c\n`, `color: #ffaa00; font-weight: bold`, "color: inherit", ...args);
|
|
282
|
-
}
|
|
283
|
-
let repoHivePromise = null;
|
|
284
|
-
const useKeyhive = typeof __KEYHIVE__ !== "undefined" && __KEYHIVE__;
|
|
285
|
-
function getRepoHive() {
|
|
286
|
-
if (!repoHivePromise) {
|
|
287
|
-
repoHivePromise = (async () => {
|
|
288
|
-
log("getRepo: starting");
|
|
289
|
-
log("fetching wasm modules");
|
|
290
|
-
const [amWasmBuf, sdnWasmBuf] = await Promise.all([
|
|
291
|
-
fetch("/automerge.wasm?worker").then((r) => r.arrayBuffer()),
|
|
292
|
-
fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
|
|
293
|
-
]);
|
|
294
|
-
initSubductionSync(new Uint8Array(sdnWasmBuf));
|
|
295
|
-
await initializeWasm(new Uint8Array(amWasmBuf));
|
|
296
|
-
log("wasm initialized");
|
|
297
|
-
if (!useKeyhive) {
|
|
298
|
-
const signer = await WebCryptoSigner.setup();
|
|
299
|
-
const identity = {
|
|
300
|
-
peerId: signer.peerId().toString(),
|
|
301
|
-
verifyingKey: signer.verifyingKey().toHex(),
|
|
302
|
-
};
|
|
303
|
-
const repo = new Repo({
|
|
304
|
-
storage: new IndexedDBWorkerStorageAdapter(),
|
|
305
|
-
signer,
|
|
306
|
-
peerId: ("automerge-worker-" +
|
|
307
|
-
Math.random()
|
|
308
|
-
.toString(36)
|
|
309
|
-
.slice(2)),
|
|
310
|
-
async sharePolicy(peerId) {
|
|
311
|
-
return peerId.includes("storage-server");
|
|
312
|
-
},
|
|
313
|
-
enableRemoteHeadsGossiping: true,
|
|
314
|
-
subductionWebsocketEndpoints: getSubductionEndpoints(),
|
|
315
|
-
});
|
|
316
|
-
console.log("[patchwork] shared-worker subduction identity:", identity, "networkSubsystem.adapters:", repo.networkSubsystem.adapters.length);
|
|
317
|
-
self.repo = repo;
|
|
318
|
-
self.syncIdentity = identity;
|
|
319
|
-
setupSyncStateBroadcast(repo, identity);
|
|
320
|
-
log("repo constructed (no keyhive), waiting for network subsystem");
|
|
321
|
-
repo.networkSubsystem.whenReady().then(() => {
|
|
322
|
-
log("repo network subsystem ready");
|
|
323
|
-
});
|
|
324
|
-
return { repo };
|
|
325
|
-
}
|
|
326
|
-
initKeyhiveWasm();
|
|
327
|
-
// ARK variant for talking to the keyhive-enabled subduction sync server.
|
|
328
|
-
const { hive, repo } = await initializeAutomergeRepoKeyhiveRustWithRepo({
|
|
329
|
-
createRepo: (config) => new Repo(config),
|
|
330
|
-
storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
|
|
331
|
-
peerIdSuffix: `${siteName}-worker` + Math.random().toString(36).slice(2),
|
|
332
|
-
automaticArchiveIngestion: true,
|
|
333
|
-
cachingMode: "periodic",
|
|
334
|
-
// ARK selects the relay via `syncServer` ("keyhive" | "subduction"),
|
|
335
|
-
// which pairs the contact card with the matching peer id. Omitting it
|
|
336
|
-
// defaults to "subduction".
|
|
337
|
-
...(useKeyhiveSyncServer ? { syncServer: "keyhive" } : {}),
|
|
338
|
-
repo: {
|
|
339
|
-
storage: new IndexedDBWorkerStorageAdapter(),
|
|
340
|
-
subductionWebsocketEndpoints: getSubductionEndpoints(),
|
|
341
|
-
enableRemoteHeadsGossiping: true,
|
|
342
|
-
},
|
|
343
|
-
});
|
|
344
|
-
self.repo = repo;
|
|
345
|
-
self.hive = hive;
|
|
346
|
-
setupSyncStateBroadcast(repo);
|
|
347
|
-
log("repo constructed, waiting for network subsystem");
|
|
348
|
-
// Don't block getRepoHive() on whenReady() — the network subsystem starts
|
|
349
|
-
// with only the subduction adapter, and the MessageChannel adapter is
|
|
350
|
-
// added later via connectPort (which awaits getRepoHive). Blocking here
|
|
351
|
-
// would deadlock that path and starve the handoff handler.
|
|
352
|
-
repo.networkSubsystem.whenReady().then(() => {
|
|
353
|
-
log("repo network subsystem ready");
|
|
354
|
-
});
|
|
355
|
-
hive.networkAdapter.whenReady().then(() => {
|
|
356
|
-
hive.networkAdapter.syncKeyhive();
|
|
357
|
-
});
|
|
358
|
-
return { hive, repo };
|
|
359
|
-
})();
|
|
360
|
-
// If construction fails (e.g. wasm fetch errors out), don't permanently
|
|
361
|
-
// cache the rejection — clear the slot so the next caller can retry from
|
|
362
|
-
// scratch.
|
|
363
|
-
repoHivePromise.catch(() => {
|
|
364
|
-
repoHivePromise = null;
|
|
365
|
-
});
|
|
366
|
-
}
|
|
367
|
-
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;
|
|
368
272
|
}
|
|
369
273
|
// ── Sync-state broadcast ───────────────────────────────────────────────
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
|
|
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;
|
|
378
285
|
let syncStateWired = false;
|
|
379
|
-
function
|
|
286
|
+
function setUpSyncStateBroadcast(repo, identity) {
|
|
380
287
|
if (syncStateWired)
|
|
381
288
|
return;
|
|
382
289
|
syncStateWired = true;
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
return;
|
|
393
|
-
channel.postMessage({
|
|
394
|
-
type: "whoami",
|
|
395
|
-
peerId: identity.peerId,
|
|
396
|
-
verifyingKey: identity.verifyingKey,
|
|
397
|
-
});
|
|
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(),
|
|
398
299
|
};
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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({
|
|
404
364
|
type: "sync-state",
|
|
405
365
|
documentId,
|
|
406
366
|
storageId,
|
|
407
367
|
heads,
|
|
408
368
|
timestamp,
|
|
409
369
|
});
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
timestamp,
|
|
424
|
-
});
|
|
425
|
-
}
|
|
426
|
-
catch {
|
|
427
|
-
// Port gone; its close handler reaps it.
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
};
|
|
431
|
-
replaySyncForPort = replayDoc;
|
|
432
|
-
// Catch up any ports that subscribed before this wiring existed.
|
|
433
|
-
for (const [port, docs] of syncWatchers) {
|
|
434
|
-
for (const documentId of docs)
|
|
435
|
-
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
|
+
});
|
|
436
383
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
});
|
|
442
|
-
// Learn (and re-announce) which connected Subduction peer is the sync server.
|
|
443
|
-
// The peer list is empty until the handshake finishes, so retry briefly.
|
|
444
|
-
const refreshServerPeers = async () => {
|
|
445
|
-
for (let attempt = 0; attempt < 6; attempt++) {
|
|
446
|
-
try {
|
|
447
|
-
const ids = await repo.connectedSubductionPeerIds();
|
|
448
|
-
if (ids.length > 0) {
|
|
449
|
-
serverPeerIds = ids;
|
|
450
|
-
postConnection();
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
catch {
|
|
455
|
-
// repo has no subduction source / not ready yet
|
|
456
|
-
}
|
|
457
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
458
|
-
}
|
|
459
|
-
};
|
|
460
|
-
// Advertise the worker's OWN heads for every doc it holds (keyed by our
|
|
461
|
-
// verifying key), so the worker hop is visible on every document.
|
|
462
|
-
//
|
|
463
|
-
// Docs pushed in by Subduction that this worker never explicitly opened don't
|
|
464
|
-
// surface via the repo's "document" event, so we discover them by re-scanning
|
|
465
|
-
// repo.handles (on a tick, and whenever the server reports a doc) and attach a
|
|
466
|
-
// heads-changed listener once per doc. No-op when there's no identity (keyhive
|
|
467
|
-
// path).
|
|
468
|
-
const ownTracked = new Set();
|
|
469
|
-
const broadcastOwnHeads = (handle) => {
|
|
470
|
-
if (!identity)
|
|
471
|
-
return;
|
|
472
|
-
const documentId = handle.documentId;
|
|
473
|
-
let heads;
|
|
474
|
-
try {
|
|
475
|
-
heads = [...handle.heads()];
|
|
476
|
-
}
|
|
477
|
-
catch {
|
|
478
|
-
return; // handle not ready yet
|
|
479
|
-
}
|
|
480
|
-
const timestamp = Date.now();
|
|
481
|
-
let byStorage = snapshot.get(documentId);
|
|
482
|
-
if (!byStorage) {
|
|
483
|
-
byStorage = new Map();
|
|
484
|
-
snapshot.set(documentId, byStorage);
|
|
485
|
-
}
|
|
486
|
-
byStorage.set(identity.peerId, { heads, timestamp });
|
|
487
|
-
postHeads(documentId, identity.peerId, heads, timestamp);
|
|
488
|
-
reviewResync(documentId);
|
|
489
|
-
};
|
|
490
|
-
const trackOwnHandle = (handle) => {
|
|
491
|
-
if (!identity || ownTracked.has(handle.documentId))
|
|
492
|
-
return;
|
|
493
|
-
ownTracked.add(handle.documentId);
|
|
494
|
-
handle.on("heads-changed", () => broadcastOwnHeads(handle));
|
|
495
|
-
broadcastOwnHeads(handle);
|
|
496
|
-
};
|
|
497
|
-
const scanOwnHandles = () => {
|
|
498
|
-
if (!identity)
|
|
499
|
-
return;
|
|
500
|
-
for (const handle of Object.values(repo.handles)) {
|
|
501
|
-
trackOwnHandle(handle);
|
|
502
|
-
}
|
|
503
|
-
};
|
|
504
|
-
// ── Backoff re-sync of stuck/diverged docs ──────────────────────────
|
|
505
|
-
//
|
|
506
|
-
// Subduction sync is event-driven and only retries syncs it observed *fail*;
|
|
507
|
-
// a doc that settles missing commits the server holds — or whose heal retries
|
|
508
|
-
// were exhausted — is otherwise never retried. When we're behind and the
|
|
509
|
-
// server's advertised heads haven't advanced for a grace window (so it's
|
|
510
|
-
// genuinely stuck, not just lagging a live edit), we re-arm its sync round
|
|
511
|
-
// with per-doc exponential backoff. Convergence clears the state.
|
|
512
|
-
const serverHeadSetsFor = (documentId) => {
|
|
513
|
-
const byStorage = snapshot.get(documentId);
|
|
514
|
-
if (!byStorage)
|
|
515
|
-
return [];
|
|
516
|
-
const sets = [];
|
|
517
|
-
for (const [storageId, { heads }] of byStorage) {
|
|
518
|
-
if (serverPeerIds.includes(storageId))
|
|
519
|
-
sets.push(heads);
|
|
520
|
-
}
|
|
521
|
-
return sets;
|
|
522
|
-
};
|
|
523
|
-
const resyncState = new Map();
|
|
524
|
-
// Inspectable from the SharedWorker console as `self.patchworkResync` to see
|
|
525
|
-
// whether/how often a doc is being re-synced and against which server heads.
|
|
526
|
-
const resyncDiag = (self.patchworkResync ??= { fires: 0, byDoc: {} });
|
|
527
|
-
const reviewResync = (documentId) => {
|
|
528
|
-
if (!identity || !connected) {
|
|
529
|
-
resyncState.delete(documentId);
|
|
530
|
-
return;
|
|
531
|
-
}
|
|
532
|
-
const handle = repo.handles[documentId];
|
|
533
|
-
if (!handle)
|
|
534
|
-
return;
|
|
535
|
-
const serverSets = serverHeadSetsFor(documentId);
|
|
536
|
-
if (serverSets.length === 0) {
|
|
537
|
-
resyncState.delete(documentId); // no server signal to compare against
|
|
538
|
-
return;
|
|
539
|
-
}
|
|
540
|
-
// The server advertises subduction *sedimentree* heads (loose-commit +
|
|
541
|
-
// fragment-boundary commit ids), which are NOT the Automerge frontier — so
|
|
542
|
-
// never compare them to handle.heads() for equality. Instead ask whether we
|
|
543
|
-
// already hold every commit the server advertises (`DocHandle.containsHeads`).
|
|
544
|
-
// If we do, the server has nothing we're missing → caught up. If not, we're
|
|
545
|
-
// genuinely behind and a re-sync can pull the rest.
|
|
546
|
-
const serverHeadsUrl = [...new Set(serverSets.flat())];
|
|
547
|
-
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++) {
|
|
548
388
|
try {
|
|
549
|
-
|
|
389
|
+
const ids = await state.repo.connectedSubductionPeerIds();
|
|
390
|
+
if (ids.length > 0) {
|
|
391
|
+
state.serverPeerIds = ids;
|
|
392
|
+
postConnection(state);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
550
395
|
}
|
|
551
396
|
catch {
|
|
552
|
-
|
|
553
|
-
}
|
|
554
|
-
if (haveAll) {
|
|
555
|
-
resyncState.delete(documentId); // we hold everything the server has
|
|
556
|
-
return;
|
|
557
|
-
}
|
|
558
|
-
// Behind. "Stuck" = the server's advertised set hasn't advanced (no
|
|
559
|
-
// progress) for a while. Key the grace timer on the server heads only, so
|
|
560
|
-
// your own edits churning don't keep resetting it.
|
|
561
|
-
const serverSig = [...serverHeadsUrl].sort().join(",");
|
|
562
|
-
const now = Date.now();
|
|
563
|
-
const prev = resyncState.get(documentId);
|
|
564
|
-
if (!prev || prev.serverSig !== serverSig) {
|
|
565
|
-
// First sighting, or the server advanced its view (progress): restart.
|
|
566
|
-
resyncState.set(documentId, {
|
|
567
|
-
serverSig,
|
|
568
|
-
since: now,
|
|
569
|
-
delay: RESYNC_INITIAL_DELAY_MS,
|
|
570
|
-
lastResyncAt: 0,
|
|
571
|
-
});
|
|
572
|
-
return;
|
|
573
|
-
}
|
|
574
|
-
if (now - prev.since < RESYNC_GRACE_MS)
|
|
575
|
-
return; // not stuck long enough yet
|
|
576
|
-
if (now - prev.lastResyncAt < prev.delay)
|
|
577
|
-
return; // within backoff cooldown
|
|
578
|
-
log("re-syncing behind doc", documentId, { serverSets });
|
|
579
|
-
resyncDiag.fires++;
|
|
580
|
-
resyncDiag.byDoc[documentId] = {
|
|
581
|
-
at: now,
|
|
582
|
-
count: (resyncDiag.byDoc[documentId]
|
|
583
|
-
?.count ?? 0) + 1,
|
|
584
|
-
serverSets,
|
|
585
|
-
};
|
|
586
|
-
try {
|
|
587
|
-
repo.resyncSubduction(documentId);
|
|
588
|
-
}
|
|
589
|
-
catch (e) {
|
|
590
|
-
log("resyncSubduction failed", e);
|
|
591
|
-
}
|
|
592
|
-
prev.lastResyncAt = now;
|
|
593
|
-
prev.delay = Math.min(prev.delay * 2, RESYNC_MAX_DELAY_MS);
|
|
594
|
-
};
|
|
595
|
-
const reviewAllResync = () => {
|
|
596
|
-
if (!identity)
|
|
597
|
-
return;
|
|
598
|
-
for (const documentId of snapshot.keys())
|
|
599
|
-
reviewResync(documentId);
|
|
600
|
-
for (const id of [...resyncState.keys()]) {
|
|
601
|
-
if (!snapshot.has(id))
|
|
602
|
-
resyncState.delete(id);
|
|
397
|
+
// No subduction source yet.
|
|
603
398
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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);
|
|
611
440
|
}
|
|
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
|
-
|
|
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
|
+
}
|
|
647
510
|
}
|
|
648
511
|
function dropRepoChannel(repo, channel) {
|
|
649
512
|
// removeNetworkAdapter pulls the adapter out of networkSubsystem.adapters and
|
|
650
|
-
// calls
|
|
651
|
-
//
|
|
513
|
+
// calls disconnect(), which for the MessageChannel adapter emits the
|
|
514
|
+
// close/peer-disconnected events that clear #adaptersByPeer.
|
|
652
515
|
try {
|
|
653
516
|
repo.networkSubsystem.removeNetworkAdapter(channel.adapter);
|
|
654
517
|
}
|
|
655
518
|
catch (err) {
|
|
656
519
|
console.error("removeNetworkAdapter failed", err);
|
|
657
520
|
}
|
|
658
|
-
//
|
|
659
|
-
//
|
|
521
|
+
// On the keyhive path the registered adapter is a wrapper, so make sure the
|
|
522
|
+
// underlying port is disconnected and closed too.
|
|
660
523
|
try {
|
|
661
524
|
channel.mcAdapter.disconnect();
|
|
662
525
|
}
|
|
663
|
-
catch {
|
|
664
|
-
// Already disconnected by removeNetworkAdapter above.
|
|
665
|
-
}
|
|
526
|
+
catch { }
|
|
666
527
|
try {
|
|
667
528
|
channel.port.close();
|
|
668
529
|
}
|
|
669
|
-
catch {
|
|
670
|
-
// Port already closed by the departing tab.
|
|
671
|
-
}
|
|
530
|
+
catch { }
|
|
672
531
|
}
|
|
673
532
|
async function dropConnection(connection) {
|
|
674
533
|
if (!connection.channels.size || !repoHivePromise)
|
|
675
534
|
return;
|
|
676
535
|
const { repo } = await getRepoHive();
|
|
677
536
|
log(`tab gone — removing ${connection.channels.size} network adapter(s)`);
|
|
678
|
-
for (const channel of connection.channels)
|
|
537
|
+
for (const channel of connection.channels)
|
|
679
538
|
dropRepoChannel(repo, channel);
|
|
680
|
-
}
|
|
681
539
|
connection.channels.clear();
|
|
682
540
|
}
|
|
683
|
-
// Connect client MessagePorts to the repo for sync
|
|
684
541
|
async function connectPort(port, connection) {
|
|
685
542
|
const { hive, repo } = await getRepoHive();
|
|
686
|
-
const
|
|
543
|
+
const mcAdapter = new MessageChannelNetworkAdapter(port, {
|
|
687
544
|
useWeakRef: true,
|
|
688
545
|
});
|
|
689
|
-
const track = (adapter) => {
|
|
690
|
-
connection.channels.add({ adapter, mcAdapter: networkAdapter, port });
|
|
691
|
-
};
|
|
692
546
|
if (!hive) {
|
|
693
|
-
repo.networkSubsystem.addNetworkAdapter(
|
|
694
|
-
|
|
547
|
+
repo.networkSubsystem.addNetworkAdapter(mcAdapter);
|
|
548
|
+
connection.channels.add({ adapter: mcAdapter, mcAdapter, port });
|
|
695
549
|
return;
|
|
696
550
|
}
|
|
697
551
|
const onlyShareWithHardcodedServerPeerId = false;
|
|
698
552
|
const periodicallyRequestKeyhiveSync = false;
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
if (
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
}
|
|
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();
|
|
709
564
|
});
|
|
710
|
-
|
|
565
|
+
adapter.on("ingest-remote", () => {
|
|
711
566
|
hive.notifySameAgentKeyhiveChange();
|
|
712
567
|
hive.networkAdapter.syncKeyhive?.();
|
|
713
568
|
repo.shareConfigChanged();
|
|
714
569
|
});
|
|
715
|
-
repo.networkSubsystem.addNetworkAdapter(
|
|
716
|
-
|
|
570
|
+
repo.networkSubsystem.addNetworkAdapter(adapter);
|
|
571
|
+
connection.channels.add({ adapter, mcAdapter, port });
|
|
717
572
|
}
|
|
718
573
|
function handleControlMessage(event, controlPort, connection) {
|
|
719
574
|
const data = event.data;
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
+
});
|
|
731
587
|
});
|
|
732
|
-
|
|
733
|
-
}
|
|
734
|
-
else if (data?.type === "sync-sub") {
|
|
735
|
-
if (typeof data.documentId === "string") {
|
|
736
|
-
syncSubscribe(controlPort, data.documentId);
|
|
588
|
+
return;
|
|
737
589
|
}
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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;
|
|
742
621
|
}
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
else if (data?.type === "connect-classic-sync") {
|
|
749
|
-
const [replyPort] = event.ports;
|
|
750
|
-
const server = typeof data.server === "string"
|
|
751
|
-
? data.server
|
|
752
|
-
: DEFAULT_CLASSIC_SYNC_SERVER;
|
|
753
|
-
connectClassicSyncNetwork(server)
|
|
754
|
-
.then(() => {
|
|
755
|
-
replyPort?.postMessage({ type: "connect-classic-sync-ready" });
|
|
756
|
-
replyPort?.close();
|
|
757
|
-
log("classic sync connected on demand", { server });
|
|
758
|
-
})
|
|
759
|
-
.catch((err) => {
|
|
760
|
-
console.error("connectClassicSyncNetwork failed", err);
|
|
761
|
-
replyPort?.postMessage({
|
|
762
|
-
type: "connect-classic-sync-failed",
|
|
763
|
-
error: String(err),
|
|
622
|
+
case "ping":
|
|
623
|
+
controlPort.postMessage({
|
|
624
|
+
type: "pong",
|
|
625
|
+
id: data.id,
|
|
626
|
+
instanceId: WORKER_INSTANCE_ID,
|
|
764
627
|
});
|
|
765
|
-
|
|
766
|
-
});
|
|
767
|
-
}
|
|
768
|
-
else if (data?.type === "ping") {
|
|
769
|
-
// Heartbeat: reply so the tab can detect our death or restart.
|
|
770
|
-
controlPort.postMessage({
|
|
771
|
-
type: "pong",
|
|
772
|
-
id: data.id,
|
|
773
|
-
instanceId: WORKER_INSTANCE_ID,
|
|
774
|
-
});
|
|
628
|
+
return;
|
|
775
629
|
}
|
|
776
630
|
}
|
|
777
631
|
self.addEventListener("connect", (event) => {
|
|
@@ -780,53 +634,37 @@ self.addEventListener("connect", (event) => {
|
|
|
780
634
|
controlPort.addEventListener("message", (messageEvent) => {
|
|
781
635
|
handleControlMessage(messageEvent, controlPort, connection);
|
|
782
636
|
});
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
// 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.
|
|
786
639
|
subductionPortProvider.attachClient(controlPort);
|
|
787
|
-
// Fires when the owning page is destroyed. Browsers without the close
|
|
788
|
-
//
|
|
640
|
+
// Fires when the owning page is destroyed. Browsers without the close event
|
|
641
|
+
// fall back to the adapters' lazy useWeakRef cleanup.
|
|
789
642
|
controlPort.addEventListener("close", () => {
|
|
790
643
|
controlPorts.delete(controlPort);
|
|
791
|
-
// The tab is gone — drop its sync subscriptions wholesale so we stop
|
|
792
|
-
// pushing it heads (no per-doc unsub needed, no leak).
|
|
793
644
|
syncWatchers.delete(controlPort);
|
|
794
645
|
void dropConnection(connection);
|
|
795
646
|
});
|
|
796
647
|
controlPort.start();
|
|
797
|
-
// Greet the tab with our per-boot instance id so it can detect a restart
|
|
798
|
-
// (a different id than last seen) even if no port "close" fired.
|
|
799
648
|
controlPort.postMessage({
|
|
800
649
|
type: "hello",
|
|
801
650
|
instanceId: WORKER_INSTANCE_ID,
|
|
802
651
|
bootTime: WORKER_BOOT_TIME,
|
|
803
652
|
});
|
|
804
|
-
// Start forwarding console output to this tab, and flush anything buffered
|
|
805
|
-
// while no tab was connected (e.g. boot-time logs) to the first arrival.
|
|
806
653
|
controlPorts.add(controlPort);
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
try {
|
|
810
|
-
controlPort.postMessage({ type: "console", level, args });
|
|
811
|
-
}
|
|
812
|
-
catch {
|
|
813
|
-
// Port may already be gone — ignore.
|
|
814
|
-
}
|
|
815
|
-
}
|
|
654
|
+
for (const { level, args } of preConnectBuffer.splice(0)) {
|
|
655
|
+
postToPort(controlPort, { type: "console", level, args });
|
|
816
656
|
}
|
|
817
657
|
});
|
|
818
|
-
// ── Automerge URL resolution ───────────────────────────────────────────
|
|
819
|
-
/**
|
|
820
|
-
* Wait for the requested heads to appear in the handle's local history —
|
|
821
|
-
* they may still be syncing toward us when the request lands. Resolves
|
|
822
|
-
* false if the signal aborts before they arrive.
|
|
823
|
-
*/
|
|
824
658
|
function waitForHeads(handle, hexHeads, signal) {
|
|
825
659
|
if (hasHeads(handle.doc(), hexHeads))
|
|
826
660
|
return Promise.resolve(true);
|
|
827
661
|
if (signal.aborted)
|
|
828
662
|
return Promise.resolve(false);
|
|
829
663
|
return new Promise((resolve) => {
|
|
664
|
+
const cleanup = () => {
|
|
665
|
+
handle.off("heads-changed", check);
|
|
666
|
+
signal.removeEventListener("abort", onAbort);
|
|
667
|
+
};
|
|
830
668
|
const check = () => {
|
|
831
669
|
if (!hasHeads(handle.doc(), hexHeads))
|
|
832
670
|
return;
|
|
@@ -837,51 +675,43 @@ function waitForHeads(handle, hexHeads, signal) {
|
|
|
837
675
|
cleanup();
|
|
838
676
|
resolve(false);
|
|
839
677
|
};
|
|
840
|
-
const cleanup = () => {
|
|
841
|
-
handle.off("heads-changed", check);
|
|
842
|
-
signal.removeEventListener("abort", onAbort);
|
|
843
|
-
};
|
|
844
678
|
handle.on("heads-changed", check);
|
|
845
679
|
signal.addEventListener("abort", onAbort);
|
|
846
|
-
// The heads may have landed between the
|
|
847
|
-
// subscribing.
|
|
680
|
+
// The heads may have landed between the check above and subscribing.
|
|
848
681
|
check();
|
|
849
682
|
});
|
|
850
683
|
}
|
|
851
|
-
|
|
684
|
+
/**
|
|
685
|
+
* Thrown instead of returning a Response when the request should fail as a
|
|
686
|
+
* network error rather than resolve to something the caller can memoize.
|
|
687
|
+
* See {@link HandoffAbortMessage}.
|
|
688
|
+
*/
|
|
689
|
+
class AbortHandoff extends Error {
|
|
690
|
+
}
|
|
691
|
+
async function resolveAutomergeUrl(automergeURL, signal) {
|
|
852
692
|
const { repo } = await getRepoHive();
|
|
853
|
-
const
|
|
854
|
-
const [maybeAutomergeUrl, ...path] = href.split("/");
|
|
693
|
+
const [maybeAutomergeUrl, ...path] = automergeURL.href.split("/");
|
|
855
694
|
if (!isValidAutomergeUrl(maybeAutomergeUrl)) {
|
|
856
695
|
return new Response("invalid automerge url", { status: 400 });
|
|
857
696
|
}
|
|
858
|
-
// Trim trailing empty path segment
|
|
859
697
|
if (path.length && !path[path.length - 1])
|
|
860
698
|
path.pop();
|
|
861
699
|
const { heads, hexHeads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
|
|
862
|
-
|
|
700
|
+
// todo, maybe a bad idea? maybe we should throw instead of es-module-caching
|
|
701
|
+
// the headless req
|
|
863
702
|
if (!heads) {
|
|
864
703
|
const folder = await repo.find(maybeAutomergeUrl, { signal });
|
|
865
|
-
const
|
|
866
|
-
const
|
|
867
|
-
let location = `/${encodeURIComponent(url)}`;
|
|
868
|
-
if (path.length)
|
|
869
|
-
location += `/${path.join("/")}`;
|
|
704
|
+
const url = stringifyAutomergeUrl({ documentId, heads: folder.heads() });
|
|
705
|
+
const location = `/${encodeURIComponent(url)}${path.length ? `/${path.join("/")}` : ""}`;
|
|
870
706
|
return Response.redirect(location, 307);
|
|
871
707
|
}
|
|
872
|
-
// Load by documentId only so we can verify the requested heads are actually
|
|
873
|
-
// in our local history. repo.find with a heads-bearing URL returns a view
|
|
874
|
-
// at those heads, which silently materializes garbage if we never synced them.
|
|
875
708
|
const baseHandle = await repo.find(stringifyAutomergeUrl({ documentId }), {
|
|
876
709
|
signal,
|
|
877
710
|
});
|
|
878
|
-
// The heads may not have synced to us yet — give them the rest of the
|
|
879
|
-
// resolve window to arrive before giving up.
|
|
880
711
|
if (!(await waitForHeads(baseHandle, hexHeads ?? [], signal))) {
|
|
881
|
-
|
|
712
|
+
throw new AbortHandoff(`heads not found for ${maybeAutomergeUrl} within ${RESOLVE_TIMEOUT_MS}ms`);
|
|
882
713
|
}
|
|
883
|
-
const
|
|
884
|
-
const resolved = await resolvePath(repo, rootHandle, path.map(decodeURIComponent));
|
|
714
|
+
const resolved = await resolvePath(repo, baseHandle.view(heads), path.map(decodeURIComponent));
|
|
885
715
|
if (!resolved) {
|
|
886
716
|
throw new Error(`couldn't resolve ${path.join("/")} in folder at ${maybeAutomergeUrl}`);
|
|
887
717
|
}
|
|
@@ -893,99 +723,63 @@ async function resolveAutomergeUrl(automergeURL) {
|
|
|
893
723
|
headers: { "content-type": resolved.type },
|
|
894
724
|
});
|
|
895
725
|
}
|
|
896
|
-
// ── Handoff: resolve special URLs for the service worker ──────────────
|
|
897
726
|
const handoffChannel = new BroadcastChannel(HANDOFF_CHANNEL);
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
if (request.handoffURL)
|
|
908
|
-
return new URL(request.handoffURL);
|
|
909
|
-
// TODO(backcompat): a briefly-deployed shape sent the special URL in
|
|
910
|
-
// request.url and the http URL in cacheKey.
|
|
911
|
-
if (request.cacheKey)
|
|
912
|
-
return new URL(request.url);
|
|
913
|
-
// TODO(backcompat): older service workers send only the http URL,
|
|
914
|
-
// special URL still URI-encoded in its pathname.
|
|
915
|
-
return new URL(decodeURIComponent(new URL(request.url).pathname.slice(1)));
|
|
916
|
-
}
|
|
917
|
-
catch {
|
|
918
|
-
return null;
|
|
919
|
-
}
|
|
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));
|
|
920
736
|
}
|
|
921
737
|
async function handleHandoffRequest(message) {
|
|
922
738
|
const { id, cachename, request } = message;
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
status: 400,
|
|
931
|
-
body: `couldn't parse a special url out of ${request.url}`,
|
|
932
|
-
headers: { "content-type": "text/plain" },
|
|
933
|
-
},
|
|
934
|
-
});
|
|
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}`);
|
|
935
746
|
return;
|
|
936
747
|
}
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
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;
|
|
942
753
|
}
|
|
943
754
|
let response;
|
|
944
755
|
try {
|
|
945
|
-
log(`resolving handoff ${id} for ${
|
|
756
|
+
log(`resolving handoff ${id} for ${handoff}`);
|
|
757
|
+
const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
|
|
946
758
|
response = await Promise.race([
|
|
947
|
-
resolveAutomergeUrl(
|
|
948
|
-
|
|
759
|
+
resolveAutomergeUrl(handoff, signal),
|
|
760
|
+
impatience(RESOLVE_TIMEOUT_MS),
|
|
949
761
|
]);
|
|
950
762
|
}
|
|
951
763
|
catch (error) {
|
|
952
|
-
|
|
953
|
-
? `${error.message}\n\n${error.stack}`
|
|
954
|
-
: String(error);
|
|
955
|
-
console.error(`automerge worker error resolving ${request.url}`, error);
|
|
956
|
-
handoffChannel.postMessage({
|
|
957
|
-
id,
|
|
958
|
-
type: "response",
|
|
959
|
-
response: {
|
|
960
|
-
status: 557,
|
|
961
|
-
body,
|
|
962
|
-
headers: { "content-type": "text/plain" },
|
|
963
|
-
},
|
|
964
|
-
});
|
|
965
|
-
return;
|
|
966
|
-
}
|
|
967
|
-
try {
|
|
968
|
-
if (cacheableStatuses.includes(response.status)) {
|
|
969
|
-
// Reconstruct the request the service worker is holding so the entry
|
|
970
|
-
// matches on its cache.match. (destination isn't constructible, but it
|
|
971
|
-
// doesn't participate in cache matching.) request.url is the http URL
|
|
972
|
-
// the SW is holding except in the briefly-deployed cacheKey shape.
|
|
973
|
-
const cacheKey = new Request(request.cacheKey ?? request.url, {
|
|
974
|
-
method: request.method,
|
|
975
|
-
headers: request.headers,
|
|
976
|
-
referrer: request.referrer,
|
|
977
|
-
});
|
|
978
|
-
const cache = await caches.open(cachename);
|
|
979
|
-
await cache.put(cacheKey, response);
|
|
980
|
-
log(`cached ${cacheKey.url} in ${cachename}`);
|
|
764
|
+
if (error instanceof AbortHandoff) {
|
|
981
765
|
handoffChannel.postMessage({
|
|
982
766
|
id,
|
|
983
|
-
type: "
|
|
767
|
+
type: "abort",
|
|
768
|
+
reason: error.message,
|
|
984
769
|
});
|
|
770
|
+
return;
|
|
985
771
|
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
772
|
+
console.error(`error resolving ${request.url}`, error);
|
|
773
|
+
replyToHandoff(id, 557, error instanceof Error
|
|
774
|
+
? `${error.message}\n\n${error.stack}`
|
|
775
|
+
: String(error));
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
try {
|
|
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)
|
|
989
783
|
log(`responding inline to ${request.url} with ${response.status}`);
|
|
990
784
|
handoffChannel.postMessage({
|
|
991
785
|
id,
|
|
@@ -996,26 +790,34 @@ async function handleHandoffRequest(message) {
|
|
|
996
790
|
body: response.body ? await response.text() : undefined,
|
|
997
791
|
},
|
|
998
792
|
});
|
|
793
|
+
return;
|
|
999
794
|
}
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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}`);
|
|
1003
806
|
handoffChannel.postMessage({
|
|
1004
807
|
id,
|
|
1005
|
-
type: "
|
|
1006
|
-
response: {
|
|
1007
|
-
status: 558,
|
|
1008
|
-
body: String(error),
|
|
1009
|
-
headers: { "content-type": "text/plain" },
|
|
1010
|
-
},
|
|
808
|
+
type: "cached",
|
|
1011
809
|
});
|
|
1012
810
|
}
|
|
811
|
+
catch (error) {
|
|
812
|
+
console.error(`failed to reply for ${request.url}`, error);
|
|
813
|
+
replyToHandoff(id, 558, String(error));
|
|
814
|
+
}
|
|
1013
815
|
}
|
|
1014
816
|
handoffChannel.addEventListener("message", (event) => {
|
|
1015
817
|
if (event.data?.type === "request") {
|
|
1016
818
|
void handleHandoffRequest(event.data);
|
|
1017
819
|
}
|
|
1018
820
|
});
|
|
1019
|
-
// Announce ourselves so the service worker can re-broadcast
|
|
1020
|
-
//
|
|
821
|
+
// Announce ourselves so the service worker can re-broadcast handoff requests
|
|
822
|
+
// sent while we were booting.
|
|
1021
823
|
handoffChannel.postMessage({ type: "online" });
|