@inkandswitch/patchwork-bootloader 0.4.3 → 0.5.0
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 +29 -0
- package/dist/automerge-worker.js +533 -771
- package/dist/externals-list.d.ts +5 -0
- package/dist/externals-list.js +41 -0
- package/dist/externals.d.ts +14 -3
- package/dist/externals.js +50 -38
- package/dist/global.css +30 -0
- package/dist/module-loader.d.ts +0 -5
- package/dist/module-loader.js +0 -6
- package/dist/service-worker.js +178 -205
- package/dist/setup.d.ts +2 -1
- package/dist/setup.js +251 -375
- package/dist/sync-config.d.ts +1 -1
- package/dist/sync-config.js +3 -1
- package/package.json +15 -14
- package/src/automerge-worker.ts +634 -839
- package/src/externals-list.ts +43 -0
- package/src/externals.ts +64 -38
- package/src/global.css +30 -0
- package/src/module-loader.ts +0 -6
- package/src/service-worker.ts +224 -238
- package/src/setup.ts +287 -401
- package/src/sync-config.ts +6 -1
- package/dist/site.d.ts +0 -111
- package/dist/site.js +0 -602
- package/dist/vite/importmap-plugin.d.ts +0 -4
- package/dist/vite/importmap-plugin.js +0 -105
- package/dist/vite/patchwork-plugin.d.ts +0 -14
- package/dist/vite/patchwork-plugin.js +0 -8
- package/dist/vite/service-worker-plugin.d.ts +0 -2
- package/dist/vite/service-worker-plugin.js +0 -42
- package/src/site.ts +0 -859
- package/src/vite/importmap-plugin.ts +0 -133
- package/src/vite/patchwork-plugin.ts +0 -20
- package/src/vite/service-worker-plugin.ts +0 -46
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,36 @@ 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 syncServer = typeof __SYNC_SERVER__ !== "undefined"
|
|
27
|
+
? __SYNC_SERVER__
|
|
28
|
+
: { url: "wss://subduction.sync.inkandswitch.com" };
|
|
29
|
+
const RESOLVE_TIMEOUT_MS = 30_000;
|
|
30
|
+
const CACHEABLE_STATUSES = [200, 203, 204];
|
|
31
|
+
// A fresh instance means a new repo peerId and cold in-memory state, so a tab
|
|
32
|
+
// seeing a changed id knows to re-subscribe. Sent in `hello` and every `pong`.
|
|
33
33
|
const WORKER_INSTANCE_ID = Math.random().toString(36).slice(2);
|
|
34
34
|
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
|
-
}
|
|
35
|
+
// `debug` reads localStorage, which a SharedWorker doesn't have, so debugging is
|
|
36
|
+
// toggled by a control message from a tab instead.
|
|
37
|
+
let debugging = false;
|
|
38
|
+
function log(...args) {
|
|
39
|
+
if (debugging)
|
|
40
|
+
console.log("[automerge-worker]", ...args);
|
|
104
41
|
}
|
|
105
|
-
//
|
|
106
|
-
//
|
|
42
|
+
// ── Console forwarding ─────────────────────────────────────────────────
|
|
43
|
+
// The SharedWorker's own console is buried in chrome://inspect, so mirror
|
|
44
|
+
// everything over each connected tab's control port.
|
|
45
|
+
const controlPorts = new Set();
|
|
46
|
+
// Logs emitted before any tab connects (wasm boot) would otherwise be lost.
|
|
107
47
|
const preConnectBuffer = [];
|
|
108
48
|
const MAX_BUFFER = 200;
|
|
109
49
|
function serializeArg(arg) {
|
|
@@ -118,21 +58,23 @@ function serializeArg(arg) {
|
|
|
118
58
|
return String(arg);
|
|
119
59
|
}
|
|
120
60
|
}
|
|
61
|
+
function postToPort(port, message) {
|
|
62
|
+
try {
|
|
63
|
+
port.postMessage(message);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
console.warn(`sending failed`, error);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
121
69
|
function forwardToMainThread(level, rawArgs) {
|
|
122
70
|
const args = rawArgs.map(serializeArg);
|
|
123
71
|
if (!controlPorts.size) {
|
|
124
|
-
if (preConnectBuffer.length < MAX_BUFFER)
|
|
72
|
+
if (preConnectBuffer.length < MAX_BUFFER)
|
|
125
73
|
preConnectBuffer.push({ level, args });
|
|
126
|
-
}
|
|
127
74
|
return;
|
|
128
75
|
}
|
|
129
76
|
for (const port of controlPorts) {
|
|
130
|
-
|
|
131
|
-
port.postMessage({ type: "console", level, args });
|
|
132
|
-
}
|
|
133
|
-
catch {
|
|
134
|
-
// Port may be closing — ignore.
|
|
135
|
-
}
|
|
77
|
+
postToPort(port, { type: "console", level, args });
|
|
136
78
|
}
|
|
137
79
|
}
|
|
138
80
|
for (const level of ["log", "info", "warn", "error", "debug"]) {
|
|
@@ -156,689 +98,563 @@ self.addEventListener("unhandledrejection", (event) => {
|
|
|
156
98
|
reason instanceof Error ? reason.stack || reason.message : reason,
|
|
157
99
|
]);
|
|
158
100
|
});
|
|
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.
|
|
101
|
+
console.warn(`[lifecycle] automerge SharedWorker started (instance ${WORKER_INSTANCE_ID})`);
|
|
167
102
|
const WATCHDOG_TICK_MS = 5_000;
|
|
168
|
-
const WATCHDOG_GAP_FACTOR = 2;
|
|
169
103
|
let watchdogLast = Date.now();
|
|
170
104
|
setInterval(() => {
|
|
171
105
|
const now = Date.now();
|
|
172
106
|
const gap = now - watchdogLast;
|
|
173
107
|
watchdogLast = now;
|
|
174
|
-
if (gap > WATCHDOG_TICK_MS *
|
|
175
|
-
console.warn(`[lifecycle]
|
|
176
|
-
|
|
177
|
-
`${WATCHDOG_TICK_MS / 1000}s)`);
|
|
108
|
+
if (gap > WATCHDOG_TICK_MS * 2) {
|
|
109
|
+
console.warn(`[lifecycle] watchdog timer gap ~${Math.round(gap / 1000)}s ` +
|
|
110
|
+
`(expected every ${WATCHDOG_TICK_MS / 1000}s)`);
|
|
178
111
|
}
|
|
179
112
|
}, WATCHDOG_TICK_MS);
|
|
180
|
-
//
|
|
181
|
-
// to
|
|
182
|
-
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
113
|
+
// ── Per-tab sync-state subscriptions ───────────────────────────────────
|
|
114
|
+
// A tab's control port subscribes to the documents it cares about and we push
|
|
115
|
+
// only those docs' heads down that port, so tab A never sees tab B's docs. A
|
|
116
|
+
// port's whole subscription set is dropped when it closes, so there's nothing
|
|
117
|
+
// to reference-count or time out.
|
|
118
|
+
const syncWatchers = new Map();
|
|
119
|
+
// Set once the repo's snapshot exists, so a `sync-sub` arriving during boot can
|
|
120
|
+
// be replayed the doc's current heads as soon as it does.
|
|
121
|
+
let replaySyncForPort = null;
|
|
122
|
+
function syncSubscribe(port, documentId) {
|
|
123
|
+
let docs = syncWatchers.get(port);
|
|
124
|
+
if (!docs)
|
|
125
|
+
syncWatchers.set(port, (docs = new Set()));
|
|
126
|
+
if (docs.has(documentId))
|
|
127
|
+
return;
|
|
128
|
+
docs.add(documentId);
|
|
129
|
+
replaySyncForPort?.(documentId, port);
|
|
130
|
+
}
|
|
131
|
+
function syncUnsubscribe(port, documentId) {
|
|
132
|
+
syncWatchers.get(port)?.delete(documentId);
|
|
133
|
+
}
|
|
134
|
+
function pushSyncState(message) {
|
|
135
|
+
for (const [port, docs] of syncWatchers) {
|
|
136
|
+
if (docs.has(message.documentId))
|
|
137
|
+
postToPort(port, message);
|
|
138
|
+
}
|
|
190
139
|
}
|
|
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
140
|
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.
|
|
141
|
+
// Memoized so a construction retry reuses the endpoint instead of leaking one
|
|
142
|
+
// per attempt.
|
|
216
143
|
let subductionEndpoints = null;
|
|
217
144
|
function getSubductionEndpoints() {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
145
|
+
return (subductionEndpoints ??= [
|
|
146
|
+
new WorkerWebSocketEndpoint(syncServer.url, {
|
|
147
|
+
worker: subductionPortProvider.source,
|
|
148
|
+
}),
|
|
149
|
+
]);
|
|
150
|
+
}
|
|
151
|
+
let repoHivePromise = null;
|
|
152
|
+
function getRepoHive() {
|
|
153
|
+
if (!repoHivePromise) {
|
|
154
|
+
repoHivePromise = setUpRepoHive();
|
|
155
|
+
// Don't permanently cache a rejection (e.g. the wasm fetch failed) — clear
|
|
156
|
+
// the slot so the next caller retries from scratch.
|
|
157
|
+
repoHivePromise.catch(() => {
|
|
158
|
+
repoHivePromise = null;
|
|
159
|
+
});
|
|
229
160
|
}
|
|
230
|
-
return
|
|
161
|
+
return repoHivePromise;
|
|
231
162
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
163
|
+
async function setUpRepoHive() {
|
|
164
|
+
log("fetching wasm");
|
|
165
|
+
const [automergeWasm, subductionWasm] = await Promise.all([
|
|
166
|
+
fetch("/automerge.wasm").then((r) => r.arrayBuffer()),
|
|
167
|
+
fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
|
|
168
|
+
]);
|
|
169
|
+
initSubductionSync(new Uint8Array(subductionWasm));
|
|
170
|
+
await initializeWasm(new Uint8Array(automergeWasm));
|
|
171
|
+
log("wasm initialized");
|
|
172
|
+
const built = syncServer.keyhive
|
|
173
|
+
? await buildKeyhiveRepo(syncServer.keyhive)
|
|
174
|
+
: await buildPlainRepo();
|
|
175
|
+
self.repo = built.repo;
|
|
176
|
+
if (built.hive)
|
|
177
|
+
self.hive = built.hive;
|
|
178
|
+
if (built.identity)
|
|
179
|
+
self.syncIdentity = built.identity;
|
|
180
|
+
setUpSyncStateBroadcast(built.repo, built.identity);
|
|
181
|
+
// Deliberately not awaited: the network subsystem starts with only the
|
|
182
|
+
// subduction adapter, and the MessageChannel adapter is added later by
|
|
183
|
+
// connectPort, which itself awaits getRepoHive. Blocking here would deadlock
|
|
184
|
+
// that path and starve the handoff handler.
|
|
185
|
+
built.repo.networkSubsystem
|
|
186
|
+
.whenReady()
|
|
187
|
+
.then(() => log("repo network subsystem ready"));
|
|
188
|
+
return { repo: built.repo, hive: built.hive };
|
|
189
|
+
}
|
|
190
|
+
async function buildPlainRepo() {
|
|
191
|
+
const signer = await WebCryptoSigner.setup();
|
|
192
|
+
const identity = {
|
|
193
|
+
peerId: signer.peerId().toString(),
|
|
194
|
+
verifyingKey: signer.verifyingKey().toHex(),
|
|
195
|
+
};
|
|
196
|
+
const repo = new Repo({
|
|
197
|
+
storage: new IndexedDBWorkerStorageAdapter(),
|
|
198
|
+
signer,
|
|
199
|
+
peerId: `automerge-worker-${Math.random().toString(36).slice(2)}`,
|
|
200
|
+
async sharePolicy(peerId) {
|
|
201
|
+
return peerId.includes("storage-server");
|
|
202
|
+
},
|
|
203
|
+
enableRemoteHeadsGossiping: true,
|
|
204
|
+
subductionWebsocketEndpoints: getSubductionEndpoints(),
|
|
205
|
+
});
|
|
206
|
+
console.log("[patchwork] shared-worker subduction identity:", identity);
|
|
207
|
+
return { repo, identity };
|
|
208
|
+
}
|
|
209
|
+
async function buildKeyhiveRepo(keyhiveSyncServer) {
|
|
210
|
+
initKeyhiveWasm();
|
|
211
|
+
const { hive, repo } = await initializeAutomergeRepoKeyhiveRustWithRepo({
|
|
212
|
+
createRepo: (config) => new Repo(config),
|
|
213
|
+
storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
|
|
214
|
+
peerIdSuffix: `${siteName}-worker` + Math.random().toString(36).slice(2),
|
|
215
|
+
automaticArchiveIngestion: true,
|
|
216
|
+
cachingMode: "periodic",
|
|
217
|
+
// ARK selects the relay via `syncServer`, which pairs the contact card with
|
|
218
|
+
// the matching peer id. Omitting it defaults to "subduction".
|
|
219
|
+
syncServer: keyhiveSyncServer,
|
|
220
|
+
repo: {
|
|
221
|
+
storage: new IndexedDBWorkerStorageAdapter(),
|
|
222
|
+
subductionWebsocketEndpoints: getSubductionEndpoints(),
|
|
223
|
+
enableRemoteHeadsGossiping: true,
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
hive.networkAdapter.whenReady().then(() => {
|
|
227
|
+
hive.networkAdapter.syncKeyhive();
|
|
228
|
+
});
|
|
229
|
+
return { repo, hive };
|
|
230
|
+
}
|
|
231
|
+
// ── Classic sync ───────────────────────────────────────────────────────
|
|
241
232
|
let classicSyncServer = DEFAULT_CLASSIC_SYNC_SERVER;
|
|
242
233
|
let classicSyncAdapter = null;
|
|
243
|
-
let
|
|
244
|
-
|
|
234
|
+
let classicSyncConnect = null;
|
|
235
|
+
function connectClassicSyncNetwork(server) {
|
|
245
236
|
const url = server.trim() || DEFAULT_CLASSIC_SYNC_SERVER;
|
|
246
|
-
if (
|
|
247
|
-
return
|
|
248
|
-
}
|
|
237
|
+
if (classicSyncConnect && classicSyncServer === url)
|
|
238
|
+
return classicSyncConnect;
|
|
249
239
|
if (classicSyncAdapter && classicSyncServer !== url) {
|
|
250
240
|
classicSyncAdapter.disconnect();
|
|
251
241
|
classicSyncAdapter = null;
|
|
252
|
-
classicSyncConnectPromise = null;
|
|
253
242
|
}
|
|
254
243
|
classicSyncServer = url;
|
|
255
|
-
|
|
244
|
+
const connecting = (async () => {
|
|
256
245
|
const { repo } = await getRepoHive();
|
|
257
246
|
if (!classicSyncAdapter) {
|
|
258
247
|
classicSyncAdapter = new WebSocketWorkerClientAdapter(url);
|
|
259
248
|
repo.networkSubsystem.addNetworkAdapter(classicSyncAdapter);
|
|
260
249
|
}
|
|
261
250
|
await classicSyncAdapter.whenReady();
|
|
262
|
-
log("classic sync connected",
|
|
251
|
+
log("classic sync connected", url);
|
|
263
252
|
})();
|
|
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;
|
|
253
|
+
// Clear the memo on failure so a later attempt can retry, and swallow the
|
|
254
|
+
// rejection on this copy so it isn't reported as unhandled — callers get it
|
|
255
|
+
// from the promise we return.
|
|
256
|
+
classicSyncConnect = connecting;
|
|
257
|
+
connecting.catch(() => {
|
|
258
|
+
if (classicSyncConnect === connecting)
|
|
259
|
+
classicSyncConnect = null;
|
|
260
|
+
});
|
|
261
|
+
return connecting;
|
|
364
262
|
}
|
|
365
263
|
// ── Sync-state broadcast ───────────────────────────────────────────────
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
|
|
373
|
-
|
|
264
|
+
// Only this worker is connected to the sync server, so it's the only place that
|
|
265
|
+
// learns the server's heads ("subduction-remote-heads", keyed by each Subduction
|
|
266
|
+
// peer's verifying-key storageId) and whether the link is up
|
|
267
|
+
// ("subduction-connection"). Global signals go out on SYNCSTATE_CHANNEL so any
|
|
268
|
+
// tab can render a sync indicator; per-document heads are addressed to
|
|
269
|
+
// subscribers instead (see pushSyncState).
|
|
270
|
+
const RESYNC_GRACE_MS = 8_000; // must be stably diverged this long first
|
|
271
|
+
const RESYNC_INITIAL_DELAY_MS = 5_000;
|
|
272
|
+
const RESYNC_MAX_DELAY_MS = 60_000;
|
|
273
|
+
const RESYNC_REVIEW_INTERVAL_MS = 5_000;
|
|
274
|
+
const OWN_HANDLE_SCAN_INTERVAL_MS = 3_000;
|
|
374
275
|
let syncStateWired = false;
|
|
375
|
-
function
|
|
276
|
+
function setUpSyncStateBroadcast(repo, identity) {
|
|
376
277
|
if (syncStateWired)
|
|
377
278
|
return;
|
|
378
279
|
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
|
-
});
|
|
280
|
+
const state = {
|
|
281
|
+
repo,
|
|
282
|
+
channel: new BroadcastChannel(SYNCSTATE_CHANNEL),
|
|
283
|
+
identity,
|
|
284
|
+
snapshot: new Map(),
|
|
285
|
+
connected: repo.isSubductionConnected(),
|
|
286
|
+
serverPeerIds: [],
|
|
287
|
+
tracked: new Set(),
|
|
288
|
+
resync: new Map(),
|
|
394
289
|
};
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
290
|
+
postWhoAmI(state);
|
|
291
|
+
replaySyncForPort = (documentId, port) => replayDoc(state, documentId, port);
|
|
292
|
+
for (const [port, docs] of syncWatchers) {
|
|
293
|
+
for (const documentId of docs)
|
|
294
|
+
replayDoc(state, documentId, port);
|
|
295
|
+
}
|
|
296
|
+
repo.on("subduction-remote-heads", ({ documentId, storageId, heads, timestamp }) => {
|
|
297
|
+
recordHeads(state, documentId, storageId, [...heads], timestamp);
|
|
298
|
+
// A doc the server reported is one we hold, so advertise our heads for it
|
|
299
|
+
// too. Only this doc: a full scan per event is O(all handles) and goes
|
|
300
|
+
// quadratic during sync bursts. The tick covers general discovery.
|
|
301
|
+
const handle = repo.handles[documentId];
|
|
302
|
+
if (handle)
|
|
303
|
+
trackOwnHandle(state, handle);
|
|
304
|
+
reviewResync(state, documentId);
|
|
305
|
+
});
|
|
306
|
+
repo.on("subduction-connection", ({ connected }) => {
|
|
307
|
+
state.connected = connected;
|
|
308
|
+
postConnection(state);
|
|
309
|
+
if (connected)
|
|
310
|
+
void refreshServerPeers(state);
|
|
311
|
+
});
|
|
312
|
+
// A BroadcastChannel never receives its own posts, so this only sees tabs'
|
|
313
|
+
// requests. Only the global signals are replayed; a late tab gets per-doc
|
|
314
|
+
// heads by subscribing.
|
|
315
|
+
state.channel.addEventListener("message", (event) => {
|
|
316
|
+
if (event.data?.type !== "request")
|
|
317
|
+
return;
|
|
318
|
+
postWhoAmI(state);
|
|
319
|
+
postConnection(state);
|
|
320
|
+
});
|
|
321
|
+
void refreshServerPeers(state);
|
|
322
|
+
scanOwnHandles(state);
|
|
323
|
+
if (!identity)
|
|
324
|
+
return;
|
|
325
|
+
// Subduction-pushed docs don't surface via the "document" event, so discover
|
|
326
|
+
// them by re-scanning repo.handles on a tick.
|
|
327
|
+
setInterval(() => scanOwnHandles(state), OWN_HANDLE_SCAN_INTERVAL_MS);
|
|
328
|
+
// The "stuck" case is precisely when no head events are firing, so the
|
|
329
|
+
// grace/backoff timers can only advance on a tick.
|
|
330
|
+
setInterval(() => reviewAllResync(state), RESYNC_REVIEW_INTERVAL_MS);
|
|
331
|
+
}
|
|
332
|
+
function postWhoAmI(state) {
|
|
333
|
+
if (!state.identity)
|
|
334
|
+
return;
|
|
335
|
+
state.channel.postMessage({
|
|
336
|
+
type: "whoami",
|
|
337
|
+
peerId: state.identity.peerId,
|
|
338
|
+
verifyingKey: state.identity.verifyingKey,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
function postConnection(state) {
|
|
342
|
+
state.channel.postMessage({
|
|
343
|
+
type: "connection",
|
|
344
|
+
connected: state.connected,
|
|
345
|
+
serverPeerIds: state.serverPeerIds,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
function recordHeads(state, documentId, storageId, heads, timestamp) {
|
|
349
|
+
let byStorage = state.snapshot.get(documentId);
|
|
350
|
+
if (!byStorage)
|
|
351
|
+
state.snapshot.set(documentId, (byStorage = new Map()));
|
|
352
|
+
byStorage.set(storageId, { heads, timestamp });
|
|
353
|
+
pushSyncState({
|
|
400
354
|
type: "sync-state",
|
|
401
355
|
documentId,
|
|
402
356
|
storageId,
|
|
403
357
|
heads,
|
|
404
358
|
timestamp,
|
|
405
359
|
});
|
|
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);
|
|
360
|
+
}
|
|
361
|
+
function replayDoc(state, documentId, port) {
|
|
362
|
+
const byStorage = state.snapshot.get(documentId);
|
|
363
|
+
if (!byStorage)
|
|
364
|
+
return;
|
|
365
|
+
for (const [storageId, { heads, timestamp }] of byStorage) {
|
|
366
|
+
postToPort(port, {
|
|
367
|
+
type: "sync-state",
|
|
368
|
+
documentId,
|
|
369
|
+
storageId,
|
|
370
|
+
heads,
|
|
371
|
+
timestamp,
|
|
372
|
+
});
|
|
432
373
|
}
|
|
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;
|
|
374
|
+
}
|
|
375
|
+
/** The peer list is empty until the handshake finishes, so retry briefly. */
|
|
376
|
+
async function refreshServerPeers(state) {
|
|
377
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
544
378
|
try {
|
|
545
|
-
|
|
379
|
+
const ids = await state.repo.connectedSubductionPeerIds();
|
|
380
|
+
if (ids.length > 0) {
|
|
381
|
+
state.serverPeerIds = ids;
|
|
382
|
+
postConnection(state);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
546
385
|
}
|
|
547
386
|
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);
|
|
587
|
-
}
|
|
588
|
-
prev.lastResyncAt = now;
|
|
589
|
-
prev.delay = Math.min(prev.delay * 2, RESYNC_MAX_DELAY_MS);
|
|
590
|
-
};
|
|
591
|
-
const reviewAllResync = () => {
|
|
592
|
-
if (!identity)
|
|
593
|
-
return;
|
|
594
|
-
for (const documentId of snapshot.keys())
|
|
595
|
-
reviewResync(documentId);
|
|
596
|
-
for (const id of [...resyncState.keys()]) {
|
|
597
|
-
if (!snapshot.has(id))
|
|
598
|
-
resyncState.delete(id);
|
|
387
|
+
// No subduction source yet.
|
|
599
388
|
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
389
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// Advertise this worker's own heads for every doc it holds, so the worker hop is
|
|
393
|
+
// visible on every document. No-op on the keyhive path, which has no identity.
|
|
394
|
+
function broadcastOwnHeads(state, handle) {
|
|
395
|
+
if (!state.identity)
|
|
396
|
+
return;
|
|
397
|
+
let heads;
|
|
398
|
+
try {
|
|
399
|
+
heads = [...handle.heads()];
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
return; // handle not ready
|
|
403
|
+
}
|
|
404
|
+
recordHeads(state, handle.documentId, state.identity.peerId, heads, Date.now());
|
|
405
|
+
reviewResync(state, handle.documentId);
|
|
406
|
+
}
|
|
407
|
+
function trackOwnHandle(state, handle) {
|
|
408
|
+
if (!state.identity || state.tracked.has(handle.documentId))
|
|
409
|
+
return;
|
|
410
|
+
state.tracked.add(handle.documentId);
|
|
411
|
+
handle.on("heads-changed", () => broadcastOwnHeads(state, handle));
|
|
412
|
+
broadcastOwnHeads(state, handle);
|
|
413
|
+
}
|
|
414
|
+
function scanOwnHandles(state) {
|
|
415
|
+
if (!state.identity)
|
|
416
|
+
return;
|
|
417
|
+
for (const handle of Object.values(state.repo.handles)) {
|
|
418
|
+
trackOwnHandle(state, handle);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
function serverHeadsFor(state, documentId) {
|
|
422
|
+
const byStorage = state.snapshot.get(documentId);
|
|
423
|
+
if (!byStorage)
|
|
424
|
+
return [];
|
|
425
|
+
const heads = new Set();
|
|
426
|
+
for (const [storageId, entry] of byStorage) {
|
|
427
|
+
if (state.serverPeerIds.includes(storageId)) {
|
|
428
|
+
for (const head of entry.heads)
|
|
429
|
+
heads.add(head);
|
|
607
430
|
}
|
|
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
|
-
|
|
431
|
+
}
|
|
432
|
+
return [...heads];
|
|
433
|
+
}
|
|
434
|
+
function reviewResync(state, documentId) {
|
|
435
|
+
if (!state.identity || !state.connected) {
|
|
436
|
+
state.resync.delete(documentId);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const handle = state.repo.handles[documentId];
|
|
440
|
+
if (!handle)
|
|
441
|
+
return;
|
|
442
|
+
const serverHeads = serverHeadsFor(state, documentId);
|
|
443
|
+
if (serverHeads.length === 0) {
|
|
444
|
+
state.resync.delete(documentId); // nothing to compare against
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
// The server advertises subduction sedimentree heads (loose-commit and
|
|
448
|
+
// fragment-boundary commit ids), which are NOT the Automerge frontier, so
|
|
449
|
+
// never compare them to handle.heads() for equality. Ask instead whether we
|
|
450
|
+
// already hold every commit the server advertises.
|
|
451
|
+
let haveAll;
|
|
452
|
+
try {
|
|
453
|
+
haveAll = handle.containsHeads(serverHeads);
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
return; // doc not ready, or an undecodable head
|
|
457
|
+
}
|
|
458
|
+
if (haveAll) {
|
|
459
|
+
state.resync.delete(documentId);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
// Behind. Key the grace timer on the server heads alone, so your own edits
|
|
463
|
+
// churning don't keep resetting it.
|
|
464
|
+
const serverSig = [...serverHeads].sort().join(",");
|
|
465
|
+
const now = Date.now();
|
|
466
|
+
const prev = state.resync.get(documentId);
|
|
467
|
+
if (!prev || prev.serverSig !== serverSig) {
|
|
468
|
+
// First sighting, or the server made progress: restart the clock.
|
|
469
|
+
state.resync.set(documentId, {
|
|
470
|
+
serverSig,
|
|
471
|
+
since: now,
|
|
472
|
+
delay: RESYNC_INITIAL_DELAY_MS,
|
|
473
|
+
lastResyncAt: 0,
|
|
474
|
+
});
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (now - prev.since < RESYNC_GRACE_MS)
|
|
478
|
+
return;
|
|
479
|
+
if (now - prev.lastResyncAt < prev.delay)
|
|
480
|
+
return;
|
|
481
|
+
log("re-syncing behind doc", documentId);
|
|
482
|
+
try {
|
|
483
|
+
state.repo.resyncSubduction(documentId);
|
|
484
|
+
}
|
|
485
|
+
catch (e) {
|
|
486
|
+
log("resyncSubduction failed", e);
|
|
487
|
+
}
|
|
488
|
+
prev.lastResyncAt = now;
|
|
489
|
+
prev.delay = Math.min(prev.delay * 2, RESYNC_MAX_DELAY_MS);
|
|
490
|
+
}
|
|
491
|
+
function reviewAllResync(state) {
|
|
492
|
+
if (!state.identity)
|
|
493
|
+
return;
|
|
494
|
+
for (const documentId of state.snapshot.keys())
|
|
495
|
+
reviewResync(state, documentId);
|
|
496
|
+
for (const id of [...state.resync.keys()]) {
|
|
497
|
+
if (!state.snapshot.has(id))
|
|
498
|
+
state.resync.delete(id);
|
|
499
|
+
}
|
|
648
500
|
}
|
|
649
501
|
function dropRepoChannel(repo, channel) {
|
|
650
502
|
// removeNetworkAdapter pulls the adapter out of networkSubsystem.adapters and
|
|
651
|
-
// calls
|
|
652
|
-
//
|
|
503
|
+
// calls disconnect(), which for the MessageChannel adapter emits the
|
|
504
|
+
// close/peer-disconnected events that clear #adaptersByPeer.
|
|
653
505
|
try {
|
|
654
506
|
repo.networkSubsystem.removeNetworkAdapter(channel.adapter);
|
|
655
507
|
}
|
|
656
508
|
catch (err) {
|
|
657
509
|
console.error("removeNetworkAdapter failed", err);
|
|
658
510
|
}
|
|
659
|
-
//
|
|
660
|
-
//
|
|
511
|
+
// On the keyhive path the registered adapter is a wrapper, so make sure the
|
|
512
|
+
// underlying port is disconnected and closed too.
|
|
661
513
|
try {
|
|
662
514
|
channel.mcAdapter.disconnect();
|
|
663
515
|
}
|
|
664
|
-
catch {
|
|
665
|
-
// Already disconnected by removeNetworkAdapter above.
|
|
666
|
-
}
|
|
516
|
+
catch { }
|
|
667
517
|
try {
|
|
668
518
|
channel.port.close();
|
|
669
519
|
}
|
|
670
|
-
catch {
|
|
671
|
-
// Port already closed by the departing tab.
|
|
672
|
-
}
|
|
520
|
+
catch { }
|
|
673
521
|
}
|
|
674
522
|
async function dropConnection(connection) {
|
|
675
523
|
if (!connection.channels.size || !repoHivePromise)
|
|
676
524
|
return;
|
|
677
525
|
const { repo } = await getRepoHive();
|
|
678
526
|
log(`tab gone — removing ${connection.channels.size} network adapter(s)`);
|
|
679
|
-
for (const channel of connection.channels)
|
|
527
|
+
for (const channel of connection.channels)
|
|
680
528
|
dropRepoChannel(repo, channel);
|
|
681
|
-
}
|
|
682
529
|
connection.channels.clear();
|
|
683
530
|
}
|
|
684
|
-
// Connect client MessagePorts to the repo for sync
|
|
685
531
|
async function connectPort(port, connection) {
|
|
686
532
|
const { hive, repo } = await getRepoHive();
|
|
687
|
-
const
|
|
533
|
+
const mcAdapter = new MessageChannelNetworkAdapter(port, {
|
|
688
534
|
useWeakRef: true,
|
|
689
535
|
});
|
|
690
|
-
const track = (adapter) => {
|
|
691
|
-
connection.channels.add({ adapter, mcAdapter: networkAdapter, port });
|
|
692
|
-
};
|
|
693
536
|
if (!hive) {
|
|
694
|
-
repo.networkSubsystem.addNetworkAdapter(
|
|
695
|
-
|
|
537
|
+
repo.networkSubsystem.addNetworkAdapter(mcAdapter);
|
|
538
|
+
connection.channels.add({ adapter: mcAdapter, mcAdapter, port });
|
|
696
539
|
return;
|
|
697
540
|
}
|
|
698
541
|
const onlyShareWithHardcodedServerPeerId = false;
|
|
699
542
|
const periodicallyRequestKeyhiveSync = false;
|
|
700
|
-
const
|
|
701
|
-
|
|
702
|
-
if (
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
}
|
|
543
|
+
const adapter = hive.createKeyhiveNetworkAdapter(mcAdapter, onlyShareWithHardcodedServerPeerId, periodicallyRequestKeyhiveSync, 2000);
|
|
544
|
+
adapter.on("message", (msg) => {
|
|
545
|
+
if (msg.type !== "sync" && msg.type !== "request")
|
|
546
|
+
return;
|
|
547
|
+
if (!msg.documentId)
|
|
548
|
+
return;
|
|
549
|
+
const handle = repo.handles[msg.documentId];
|
|
550
|
+
if (handle && handle.state !== "unavailable")
|
|
551
|
+
return;
|
|
552
|
+
repo.findWithProgress(`automerge:${msg.documentId}`);
|
|
553
|
+
repo.shareConfigChanged();
|
|
710
554
|
});
|
|
711
|
-
|
|
555
|
+
adapter.on("ingest-remote", () => {
|
|
712
556
|
hive.notifySameAgentKeyhiveChange();
|
|
713
557
|
hive.networkAdapter.syncKeyhive?.();
|
|
714
558
|
repo.shareConfigChanged();
|
|
715
559
|
});
|
|
716
|
-
repo.networkSubsystem.addNetworkAdapter(
|
|
717
|
-
|
|
560
|
+
repo.networkSubsystem.addNetworkAdapter(adapter);
|
|
561
|
+
connection.channels.add({ adapter, mcAdapter, port });
|
|
718
562
|
}
|
|
719
563
|
function handleControlMessage(event, controlPort, connection) {
|
|
720
564
|
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),
|
|
565
|
+
switch (data?.type) {
|
|
566
|
+
case "port": {
|
|
567
|
+
log("received repo channel");
|
|
568
|
+
const [repoPort] = event.ports;
|
|
569
|
+
connectPort(repoPort, connection).then(() => controlPort.postMessage({ type: "port-ready", id: data.id }), (err) => {
|
|
570
|
+
console.error("connectPort failed", err);
|
|
571
|
+
// Tell the tab so it doesn't hang until its timeout.
|
|
572
|
+
controlPort.postMessage({
|
|
573
|
+
type: "port-failed",
|
|
574
|
+
id: data.id,
|
|
575
|
+
error: String(err),
|
|
576
|
+
});
|
|
742
577
|
});
|
|
743
|
-
|
|
744
|
-
}
|
|
745
|
-
else if (data?.type === "sync-sub") {
|
|
746
|
-
if (typeof data.documentId === "string") {
|
|
747
|
-
syncSubscribe(controlPort, data.documentId);
|
|
578
|
+
return;
|
|
748
579
|
}
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
580
|
+
case "sync-sub":
|
|
581
|
+
if (typeof data.documentId === "string") {
|
|
582
|
+
syncSubscribe(controlPort, data.documentId);
|
|
583
|
+
}
|
|
584
|
+
return;
|
|
585
|
+
case "sync-unsub":
|
|
586
|
+
if (typeof data.documentId === "string") {
|
|
587
|
+
syncUnsubscribe(controlPort, data.documentId);
|
|
588
|
+
}
|
|
589
|
+
return;
|
|
590
|
+
case "debug":
|
|
591
|
+
debugging = data.debug;
|
|
592
|
+
log("automerge worker debugging enabled");
|
|
593
|
+
return;
|
|
594
|
+
case "connect-classic-sync": {
|
|
595
|
+
const [replyPort] = event.ports;
|
|
596
|
+
const server = typeof data.server === "string"
|
|
597
|
+
? data.server
|
|
598
|
+
: DEFAULT_CLASSIC_SYNC_SERVER;
|
|
599
|
+
connectClassicSyncNetwork(server).then(() => {
|
|
600
|
+
replyPort?.postMessage({ type: "connect-classic-sync-ready" });
|
|
601
|
+
replyPort?.close();
|
|
602
|
+
}, (err) => {
|
|
603
|
+
console.error("connectClassicSyncNetwork failed", err);
|
|
604
|
+
replyPort?.postMessage({
|
|
605
|
+
type: "connect-classic-sync-failed",
|
|
606
|
+
error: String(err),
|
|
607
|
+
});
|
|
608
|
+
replyPort?.close();
|
|
609
|
+
});
|
|
610
|
+
return;
|
|
753
611
|
}
|
|
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),
|
|
612
|
+
case "ping":
|
|
613
|
+
controlPort.postMessage({
|
|
614
|
+
type: "pong",
|
|
615
|
+
id: data.id,
|
|
616
|
+
instanceId: WORKER_INSTANCE_ID,
|
|
775
617
|
});
|
|
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
|
-
});
|
|
618
|
+
return;
|
|
786
619
|
}
|
|
787
620
|
}
|
|
788
621
|
self.addEventListener("connect", (event) => {
|
|
789
622
|
const controlPort = event.ports[0];
|
|
790
623
|
const connection = { channels: new Set() };
|
|
791
|
-
(self.patchworkControl ??= { connects: 0, byType: {} }).connects++;
|
|
792
624
|
controlPort.addEventListener("message", (messageEvent) => {
|
|
793
625
|
handleControlMessage(messageEvent, controlPort, connection);
|
|
794
626
|
});
|
|
795
|
-
//
|
|
796
|
-
//
|
|
797
|
-
// coexist with our control protocol above).
|
|
627
|
+
// The tab side runs donatePort; the messages are channel-tagged so they
|
|
628
|
+
// coexist with the control protocol above.
|
|
798
629
|
subductionPortProvider.attachClient(controlPort);
|
|
799
|
-
// Fires when the owning page is destroyed. Browsers without the close
|
|
800
|
-
//
|
|
630
|
+
// Fires when the owning page is destroyed. Browsers without the close event
|
|
631
|
+
// fall back to the adapters' lazy useWeakRef cleanup.
|
|
801
632
|
controlPort.addEventListener("close", () => {
|
|
802
633
|
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
634
|
syncWatchers.delete(controlPort);
|
|
806
635
|
void dropConnection(connection);
|
|
807
636
|
});
|
|
808
637
|
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
638
|
controlPort.postMessage({
|
|
812
639
|
type: "hello",
|
|
813
640
|
instanceId: WORKER_INSTANCE_ID,
|
|
814
641
|
bootTime: WORKER_BOOT_TIME,
|
|
815
642
|
});
|
|
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
643
|
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
|
-
}
|
|
644
|
+
for (const { level, args } of preConnectBuffer.splice(0)) {
|
|
645
|
+
postToPort(controlPort, { type: "console", level, args });
|
|
828
646
|
}
|
|
829
647
|
});
|
|
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
648
|
function waitForHeads(handle, hexHeads, signal) {
|
|
837
649
|
if (hasHeads(handle.doc(), hexHeads))
|
|
838
650
|
return Promise.resolve(true);
|
|
839
651
|
if (signal.aborted)
|
|
840
652
|
return Promise.resolve(false);
|
|
841
653
|
return new Promise((resolve) => {
|
|
654
|
+
const cleanup = () => {
|
|
655
|
+
handle.off("heads-changed", check);
|
|
656
|
+
signal.removeEventListener("abort", onAbort);
|
|
657
|
+
};
|
|
842
658
|
const check = () => {
|
|
843
659
|
if (!hasHeads(handle.doc(), hexHeads))
|
|
844
660
|
return;
|
|
@@ -849,14 +665,9 @@ function waitForHeads(handle, hexHeads, signal) {
|
|
|
849
665
|
cleanup();
|
|
850
666
|
resolve(false);
|
|
851
667
|
};
|
|
852
|
-
const cleanup = () => {
|
|
853
|
-
handle.off("heads-changed", check);
|
|
854
|
-
signal.removeEventListener("abort", onAbort);
|
|
855
|
-
};
|
|
856
668
|
handle.on("heads-changed", check);
|
|
857
669
|
signal.addEventListener("abort", onAbort);
|
|
858
|
-
// The heads may have landed between the
|
|
859
|
-
// subscribing.
|
|
670
|
+
// The heads may have landed between the check above and subscribing.
|
|
860
671
|
check();
|
|
861
672
|
});
|
|
862
673
|
}
|
|
@@ -867,43 +678,30 @@ function waitForHeads(handle, hexHeads, signal) {
|
|
|
867
678
|
*/
|
|
868
679
|
class AbortHandoff extends Error {
|
|
869
680
|
}
|
|
870
|
-
async function resolveAutomergeUrl(automergeURL) {
|
|
681
|
+
async function resolveAutomergeUrl(automergeURL, signal) {
|
|
871
682
|
const { repo } = await getRepoHive();
|
|
872
|
-
const
|
|
873
|
-
const [maybeAutomergeUrl, ...path] = href.split("/");
|
|
683
|
+
const [maybeAutomergeUrl, ...path] = automergeURL.href.split("/");
|
|
874
684
|
if (!isValidAutomergeUrl(maybeAutomergeUrl)) {
|
|
875
685
|
return new Response("invalid automerge url", { status: 400 });
|
|
876
686
|
}
|
|
877
|
-
// Trim trailing empty path segment
|
|
878
687
|
if (path.length && !path[path.length - 1])
|
|
879
688
|
path.pop();
|
|
880
689
|
const { heads, hexHeads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
|
|
881
|
-
|
|
690
|
+
// todo, maybe a bad idea? maybe we should throw instead of es-module-caching
|
|
691
|
+
// the headless req
|
|
882
692
|
if (!heads) {
|
|
883
693
|
const folder = await repo.find(maybeAutomergeUrl, { signal });
|
|
884
|
-
const
|
|
885
|
-
const
|
|
886
|
-
let location = `/${encodeURIComponent(url)}`;
|
|
887
|
-
if (path.length)
|
|
888
|
-
location += `/${path.join("/")}`;
|
|
694
|
+
const url = stringifyAutomergeUrl({ documentId, heads: folder.heads() });
|
|
695
|
+
const location = `/${encodeURIComponent(url)}${path.length ? `/${path.join("/")}` : ""}`;
|
|
889
696
|
return Response.redirect(location, 307);
|
|
890
697
|
}
|
|
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
698
|
const baseHandle = await repo.find(stringifyAutomergeUrl({ documentId }), {
|
|
895
699
|
signal,
|
|
896
700
|
});
|
|
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
701
|
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
702
|
throw new AbortHandoff(`heads not found for ${maybeAutomergeUrl} within ${RESOLVE_TIMEOUT_MS}ms`);
|
|
904
703
|
}
|
|
905
|
-
const
|
|
906
|
-
const resolved = await resolvePath(repo, rootHandle, path.map(decodeURIComponent));
|
|
704
|
+
const resolved = await resolvePath(repo, baseHandle.view(heads), path.map(decodeURIComponent));
|
|
907
705
|
if (!resolved) {
|
|
908
706
|
throw new Error(`couldn't resolve ${path.join("/")} in folder at ${maybeAutomergeUrl}`);
|
|
909
707
|
}
|
|
@@ -915,59 +713,41 @@ async function resolveAutomergeUrl(automergeURL) {
|
|
|
915
713
|
headers: { "content-type": resolved.type },
|
|
916
714
|
});
|
|
917
715
|
}
|
|
918
|
-
// ── Handoff: resolve special URLs for the service worker ──────────────
|
|
919
716
|
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
|
-
}
|
|
717
|
+
function replyToHandoff(id, status, body) {
|
|
718
|
+
handoffChannel.postMessage({
|
|
719
|
+
id,
|
|
720
|
+
type: "response",
|
|
721
|
+
response: { status, body, headers: { "content-type": "text/plain" } },
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
function impatience(limit) {
|
|
725
|
+
return new Promise((_, reject) => setTimeout(() => reject(new Error(`resolve timeout after ${limit}ms`)), limit));
|
|
942
726
|
}
|
|
943
727
|
async function handleHandoffRequest(message) {
|
|
944
728
|
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
|
-
});
|
|
729
|
+
let handoff;
|
|
730
|
+
try {
|
|
731
|
+
handoff = new URL(request.handoffURL);
|
|
732
|
+
}
|
|
733
|
+
catch {
|
|
734
|
+
console.error("couldn't parse handoff url", request);
|
|
735
|
+
replyToHandoff(id, 400, `couldn't parse a special url out of ${request.url}`);
|
|
957
736
|
return;
|
|
958
737
|
}
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
return
|
|
738
|
+
// Other handlers may be listening on the channel for other schemes, so stay
|
|
739
|
+
// quiet rather than clobbering their reply with an error.
|
|
740
|
+
if (handoff.protocol !== "automerge:") {
|
|
741
|
+
log(`ignoring handoff ${id} for non-automerge url ${handoff}. not my circus, not my monkeys`);
|
|
742
|
+
return;
|
|
964
743
|
}
|
|
965
744
|
let response;
|
|
966
745
|
try {
|
|
967
|
-
log(`resolving handoff ${id} for ${
|
|
746
|
+
log(`resolving handoff ${id} for ${handoff}`);
|
|
747
|
+
const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
|
|
968
748
|
response = await Promise.race([
|
|
969
|
-
resolveAutomergeUrl(
|
|
970
|
-
|
|
749
|
+
resolveAutomergeUrl(handoff, signal),
|
|
750
|
+
impatience(RESOLVE_TIMEOUT_MS),
|
|
971
751
|
]);
|
|
972
752
|
}
|
|
973
753
|
catch (error) {
|
|
@@ -979,43 +759,17 @@ async function handleHandoffRequest(message) {
|
|
|
979
759
|
});
|
|
980
760
|
return;
|
|
981
761
|
}
|
|
982
|
-
|
|
762
|
+
console.error(`error resolving ${request.url}`, error);
|
|
763
|
+
replyToHandoff(id, 557, error instanceof Error
|
|
983
764
|
? `${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
|
-
});
|
|
765
|
+
: String(error));
|
|
995
766
|
return;
|
|
996
767
|
}
|
|
997
768
|
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.
|
|
769
|
+
if (!CACHEABLE_STATUSES.includes(response.status)) {
|
|
770
|
+
// Errors, redirects and the like go back inline for the service worker to
|
|
771
|
+
// serve directly, so they aren't cached forever (still in esmodulecache,
|
|
772
|
+
// cleared after a refresh)
|
|
1019
773
|
log(`responding inline to ${request.url} with ${response.status}`);
|
|
1020
774
|
handoffChannel.postMessage({
|
|
1021
775
|
id,
|
|
@@ -1026,26 +780,34 @@ async function handleHandoffRequest(message) {
|
|
|
1026
780
|
body: response.body ? await response.text() : undefined,
|
|
1027
781
|
},
|
|
1028
782
|
});
|
|
783
|
+
return;
|
|
1029
784
|
}
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
785
|
+
// Reconstruct the request the service worker is holding so the entry matches
|
|
786
|
+
// its cache.match. `destination` isn't constructible but doesn't participate
|
|
787
|
+
// in cache matching.
|
|
788
|
+
const cacheKey = new Request(request.url, {
|
|
789
|
+
method: request.method,
|
|
790
|
+
headers: request.headers,
|
|
791
|
+
referrer: request.referrer,
|
|
792
|
+
});
|
|
793
|
+
const cache = await caches.open(cachename);
|
|
794
|
+
await cache.put(cacheKey, response);
|
|
795
|
+
log(`cached ${cacheKey.url} in ${cachename}`);
|
|
1033
796
|
handoffChannel.postMessage({
|
|
1034
797
|
id,
|
|
1035
|
-
type: "
|
|
1036
|
-
response: {
|
|
1037
|
-
status: 558,
|
|
1038
|
-
body: String(error),
|
|
1039
|
-
headers: { "content-type": "text/plain" },
|
|
1040
|
-
},
|
|
798
|
+
type: "cached",
|
|
1041
799
|
});
|
|
1042
800
|
}
|
|
801
|
+
catch (error) {
|
|
802
|
+
console.error(`failed to reply for ${request.url}`, error);
|
|
803
|
+
replyToHandoff(id, 558, String(error));
|
|
804
|
+
}
|
|
1043
805
|
}
|
|
1044
806
|
handoffChannel.addEventListener("message", (event) => {
|
|
1045
807
|
if (event.data?.type === "request") {
|
|
1046
808
|
void handleHandoffRequest(event.data);
|
|
1047
809
|
}
|
|
1048
810
|
});
|
|
1049
|
-
// Announce ourselves so the service worker can re-broadcast
|
|
1050
|
-
//
|
|
811
|
+
// Announce ourselves so the service worker can re-broadcast handoff requests
|
|
812
|
+
// sent while we were booting.
|
|
1051
813
|
handoffChannel.postMessage({ type: "online" });
|