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