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