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