@le-space/orbitdb-storage-bridge 0.10.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/LICENSE +21 -0
- package/README.md +290 -0
- package/dist/components/StorachaAuth.svelte +801 -0
- package/dist/components/StorachaIntegration.svelte +965 -0
- package/dist/components/WebAuthnDIDProvider.js +562 -0
- package/dist/components/storacha-backup.js +325 -0
- package/dist/components/theme.js +79 -0
- package/lib/backends/aleph-pin.js +161 -0
- package/lib/backends/aleph.js +197 -0
- package/lib/backends/lighthouse.js +325 -0
- package/lib/backends/memory.js +140 -0
- package/lib/backends/pinata.js +330 -0
- package/lib/backends/resolve.js +72 -0
- package/lib/backends/storacha.js +178 -0
- package/lib/backends/types.js +187 -0
- package/lib/backup-car.js +1286 -0
- package/lib/backup-helpers.js +87 -0
- package/lib/backup-metadata.js +40 -0
- package/lib/backup.js +249 -0
- package/lib/block-bytes.js +39 -0
- package/lib/car-storage.js +342 -0
- package/lib/courier-sync.js +886 -0
- package/lib/dehydrate.js +146 -0
- package/lib/extract-blocks.js +258 -0
- package/lib/gateway-fetch.js +116 -0
- package/lib/ipns-helpers.js +324 -0
- package/lib/logger.js +128 -0
- package/lib/memory-courier.js +105 -0
- package/lib/orbitdb-storacha-bridge.js +2789 -0
- package/lib/pointer-ipns.js +234 -0
- package/lib/restore-cid.js +302 -0
- package/lib/ucan-bridge.js +921 -0
- package/lib/utils.js +316 -0
- package/package.json +172 -0
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Courier Sync — transport-neutral OrbitDB replication over any byte courier.
|
|
3
|
+
*
|
|
4
|
+
* The load-bearing fact this module builds on is the one this bridge proves
|
|
5
|
+
* with Storacha: OrbitDB replication is "obtain the blocks, join the heads".
|
|
6
|
+
* The courier is interchangeable — Storacha, a LoRa mesh, a QR relay, a file.
|
|
7
|
+
* Design thread: https://github.com/NiKrause/funkpost/issues/1
|
|
8
|
+
* Seam requirements: https://github.com/NiKrause/orbitdb-storage-bridge/issues/50
|
|
9
|
+
*
|
|
10
|
+
* The courier contract (the seam):
|
|
11
|
+
* courier.send(bytes: Uint8Array): Promise<void>
|
|
12
|
+
* May be slow on purpose — it resolves when the courier has delivered or
|
|
13
|
+
* scheduled the message within whatever budget it has (a duty-cycled radio
|
|
14
|
+
* legally may not hurry). The sync layer treats that as backpressure.
|
|
15
|
+
* courier.onPayload(cb: (bytes: Uint8Array) => void): () => void
|
|
16
|
+
* Delivery may be lossy, reordered and duplicated; the protocol tolerates
|
|
17
|
+
* all three. Returns an unsubscribe function.
|
|
18
|
+
*
|
|
19
|
+
* Wire messages (dag-cbor encoded, one per courier payload):
|
|
20
|
+
* { v, tag, p, t: "announce", heads: [hash] }
|
|
21
|
+
* { v, tag, p, t: "want", cids: [hash], have: [hash] }
|
|
22
|
+
* { v, tag, p, t: "blocks", heads: [hash], blocks: [{ hash, bytes }] }
|
|
23
|
+
* { v, tag, p, t: "hello" } is anybody keeping this database out there?
|
|
24
|
+
* { v, tag, p, t: "here" } the answer
|
|
25
|
+
* `tag` is a short hash of the database address, so couriers can be shared
|
|
26
|
+
* between databases without cross-talk while the address itself stays off
|
|
27
|
+
* the air (the mesh reads everything).
|
|
28
|
+
*
|
|
29
|
+
* `p` is a four-byte sender id, and it is what makes *presence* possible: a
|
|
30
|
+
* carrier can tell you a radio is in range, which is not the question. The
|
|
31
|
+
* question is whether another program is keeping the same database, and only
|
|
32
|
+
* that program can answer it. Every message carries the id, so ordinary
|
|
33
|
+
* traffic already answers it for free; `hello` exists for the silence in
|
|
34
|
+
* between, when nothing has been written for a while and somebody wants to
|
|
35
|
+
* know before spending airtime on a whole delta.
|
|
36
|
+
*
|
|
37
|
+
* The id is per instance and says nothing about who you are — the tag already
|
|
38
|
+
* names the conversation, and a mesh reads everything. A peer on an older
|
|
39
|
+
* version sends no id and answers no `hello`: its traffic still counts as
|
|
40
|
+
* "somebody is out there" (`lastHeardAgoMs`), it just cannot be counted as a
|
|
41
|
+
* peer. Answers go out immediately and without jitter on purpose; the radio
|
|
42
|
+
* underneath already has a MAC, and backing off twice is worse than once.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/* global CompressionStream, DecompressionStream */
|
|
46
|
+
import { CID } from "multiformats/cid";
|
|
47
|
+
import { base58btc } from "multiformats/bases/base58";
|
|
48
|
+
import { sha256 } from "multiformats/hashes/sha2";
|
|
49
|
+
import * as dagCbor from "@ipld/dag-cbor";
|
|
50
|
+
|
|
51
|
+
export const COURIER_SYNC_VERSION = 1;
|
|
52
|
+
|
|
53
|
+
const TAG_LENGTH = 8;
|
|
54
|
+
|
|
55
|
+
// Four bytes of sender id: enough that two peers in one conversation collide
|
|
56
|
+
// with probability ~1 in 4 billion, small enough to ride on every message.
|
|
57
|
+
const PEER_ID_LENGTH = 4;
|
|
58
|
+
// How long a peer stays "present" after its last word. A mesh is slow and a
|
|
59
|
+
// budget is rationed, so silence for two minutes is ordinary, not absence.
|
|
60
|
+
const PEER_TIMEOUT_MS = 120_000;
|
|
61
|
+
|
|
62
|
+
// How long to wait for a courier to say a message went out. Deliberately far
|
|
63
|
+
// beyond any honest delivery: it is not a deadline, it is the way out of a
|
|
64
|
+
// carrier that has stopped answering altogether.
|
|
65
|
+
const SEND_TIMEOUT_MS = 300_000;
|
|
66
|
+
// How many messages may wait for a carrier that cannot keep up.
|
|
67
|
+
const MAX_OUTBOX = 32;
|
|
68
|
+
|
|
69
|
+
// Wire framing: one prefix byte in front of the dag-cbor message — 0 = raw,
|
|
70
|
+
// 1 = gzip. The first-contact bootstrap (manifest + access controller +
|
|
71
|
+
// identity + entries) is a couple of kilobytes of dag-cbor full of CIDs and
|
|
72
|
+
// signatures, which deflates by roughly half; on a slow, lossy carrier like a
|
|
73
|
+
// LoRa mesh that is the difference between a bootstrap that clears the ARQ's
|
|
74
|
+
// rounds and one that does not. Small messages (announce, want) skip it.
|
|
75
|
+
const GZIP_PREFIX = 1;
|
|
76
|
+
const RAW_PREFIX = 0;
|
|
77
|
+
const GZIP_THRESHOLD = 256;
|
|
78
|
+
|
|
79
|
+
const prefixBytes = (flag, body) => {
|
|
80
|
+
const out = new Uint8Array(body.length + 1);
|
|
81
|
+
out[0] = flag;
|
|
82
|
+
out.set(body, 1);
|
|
83
|
+
return out;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
async function gzipBytes(input) {
|
|
87
|
+
const cs = new CompressionStream("gzip");
|
|
88
|
+
const writer = cs.writable.getWriter();
|
|
89
|
+
writer.write(input);
|
|
90
|
+
writer.close();
|
|
91
|
+
return new Uint8Array(await new Response(cs.readable).arrayBuffer());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function gunzipBytes(input) {
|
|
95
|
+
const ds = new DecompressionStream("gzip");
|
|
96
|
+
const writer = ds.writable.getWriter();
|
|
97
|
+
writer.write(input);
|
|
98
|
+
writer.close();
|
|
99
|
+
return new Uint8Array(await new Response(ds.readable).arrayBuffer());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** dag-cbor bytes → framed wire bytes (compressed when it helps). */
|
|
103
|
+
async function frameMessage(raw) {
|
|
104
|
+
if (typeof CompressionStream === "undefined" || raw.length < GZIP_THRESHOLD) {
|
|
105
|
+
return prefixBytes(RAW_PREFIX, raw);
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const z = await gzipBytes(raw);
|
|
109
|
+
// Only ship the compressed form if it is actually smaller.
|
|
110
|
+
if (z.length + 1 < raw.length) return prefixBytes(GZIP_PREFIX, z);
|
|
111
|
+
} catch {
|
|
112
|
+
/* fall through to raw */
|
|
113
|
+
}
|
|
114
|
+
return prefixBytes(RAW_PREFIX, raw);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** framed wire bytes → dag-cbor bytes (throws on foreign/garbage input). */
|
|
118
|
+
async function unframeMessage(framed) {
|
|
119
|
+
if (!(framed instanceof Uint8Array) || framed.length === 0) {
|
|
120
|
+
throw new Error("empty frame");
|
|
121
|
+
}
|
|
122
|
+
const body = framed.subarray(1);
|
|
123
|
+
if (framed[0] === GZIP_PREFIX) return gunzipBytes(body);
|
|
124
|
+
if (framed[0] === RAW_PREFIX) return body;
|
|
125
|
+
throw new Error("unknown frame prefix"); // not ours
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Short identifier for a database address: first bytes of its sha256.
|
|
130
|
+
* @param {string} address OrbitDB address (/orbitdb/zdpu...)
|
|
131
|
+
* @returns {Promise<Uint8Array>}
|
|
132
|
+
*/
|
|
133
|
+
export async function databaseTag(address) {
|
|
134
|
+
const digest = await sha256.digest(new TextEncoder().encode(address));
|
|
135
|
+
return digest.digest.slice(0, TAG_LENGTH);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function sameTag(a, b) {
|
|
139
|
+
if (!(a instanceof Uint8Array) || a.length !== TAG_LENGTH) return false;
|
|
140
|
+
return a.every((byte, i) => byte === b[i]);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A sender id for one sync instance: four random bytes, not an identity.
|
|
145
|
+
* @returns {Uint8Array}
|
|
146
|
+
*/
|
|
147
|
+
function randomPeerId() {
|
|
148
|
+
return globalThis.crypto.getRandomValues(new Uint8Array(PEER_ID_LENGTH));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const hex = (bytes) =>
|
|
152
|
+
[...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
153
|
+
|
|
154
|
+
function manifestCidOf(address) {
|
|
155
|
+
return address.split("/").pop();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isOplogEntry(value) {
|
|
159
|
+
return Boolean(
|
|
160
|
+
value &&
|
|
161
|
+
typeof value === "object" &&
|
|
162
|
+
value.sig &&
|
|
163
|
+
value.payload !== undefined &&
|
|
164
|
+
Array.isArray(value.next),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Compute the delta a peer with `theirHeads` is missing: entry blocks from our
|
|
170
|
+
* heads down to their heads, the identity blocks those entries reference, and
|
|
171
|
+
* — on first contact (empty `theirHeads`) — the manifest and access controller
|
|
172
|
+
* blocks a fresh peer needs before it can even open the database.
|
|
173
|
+
*
|
|
174
|
+
* Blocks are returned parents-before-children so a receiver can verify the
|
|
175
|
+
* chain without ever reaching for a network that is not there.
|
|
176
|
+
*
|
|
177
|
+
* When the logs have diverged below `theirHeads`, the delta may include blocks
|
|
178
|
+
* the peer already has; applying is idempotent, so that costs bytes, not
|
|
179
|
+
* correctness. (A frontier/bloom exchange can shrink this later.)
|
|
180
|
+
*
|
|
181
|
+
* @param {Object} params
|
|
182
|
+
* @param {Object} params.db An open OrbitDB database
|
|
183
|
+
* @param {Array<string>} [params.theirHeads] Head hashes the peer announced
|
|
184
|
+
* @returns {Promise<{heads: Array<string>, blocks: Array<{hash: string, bytes: Uint8Array}>}>}
|
|
185
|
+
*/
|
|
186
|
+
export async function createDelta({ db, theirHeads = [] }) {
|
|
187
|
+
const stop = new Set(theirHeads);
|
|
188
|
+
const heads = await db.log.heads();
|
|
189
|
+
const headHashes = heads.map((entry) => entry.hash);
|
|
190
|
+
|
|
191
|
+
const seen = new Set();
|
|
192
|
+
const identityHashes = new Set();
|
|
193
|
+
const entryBlocks = [];
|
|
194
|
+
const queue = headHashes.filter((hash) => !stop.has(hash));
|
|
195
|
+
|
|
196
|
+
while (queue.length > 0) {
|
|
197
|
+
const hash = queue.shift();
|
|
198
|
+
if (seen.has(hash) || stop.has(hash)) continue;
|
|
199
|
+
seen.add(hash);
|
|
200
|
+
|
|
201
|
+
const bytes = await db.log.storage.get(hash);
|
|
202
|
+
if (!bytes) continue;
|
|
203
|
+
entryBlocks.push({ hash, bytes });
|
|
204
|
+
|
|
205
|
+
const value = dagCbor.decode(bytes);
|
|
206
|
+
if (!isOplogEntry(value)) continue;
|
|
207
|
+
if (value.identity) identityHashes.add(value.identity);
|
|
208
|
+
for (const parent of [...value.next, ...(value.refs || [])]) {
|
|
209
|
+
if (!seen.has(parent) && !stop.has(parent)) queue.push(parent);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const staticBlocks = [];
|
|
214
|
+
|
|
215
|
+
// Identity blocks travel with the entries that reference them — a writer the
|
|
216
|
+
// peer has never seen costs one extra block, a known writer costs a
|
|
217
|
+
// duplicate put, which is free.
|
|
218
|
+
//
|
|
219
|
+
// Our own identity carries its block; the log's storage may never have held
|
|
220
|
+
// it. `Identities()` without `ipfs` — what an app with its own identity
|
|
221
|
+
// provider builds, a passkey or a DID — keeps identities in memory, and a
|
|
222
|
+
// Helia blockstore asked for one searches a network that, on the far side of
|
|
223
|
+
// a courier, is not there. The delta would then go out without the block the
|
|
224
|
+
// receiver needs to verify these very entries.
|
|
225
|
+
const ownIdentity = db.identity ?? db.log.identity;
|
|
226
|
+
for (const identityHash of identityHashes) {
|
|
227
|
+
const bytes =
|
|
228
|
+
ownIdentity?.hash === identityHash && ownIdentity.bytes
|
|
229
|
+
? ownIdentity.bytes
|
|
230
|
+
: await db.log.storage.get(identityHash);
|
|
231
|
+
if (bytes) staticBlocks.push({ hash: identityHash, bytes });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// First contact additionally needs the manifest and the access controller,
|
|
235
|
+
// or the peer cannot open the address at all.
|
|
236
|
+
if (theirHeads.length === 0) {
|
|
237
|
+
const manifestCid = manifestCidOf(db.address);
|
|
238
|
+
const manifestBytes = await db.log.storage.get(manifestCid);
|
|
239
|
+
if (manifestBytes) {
|
|
240
|
+
staticBlocks.push({ hash: manifestCid, bytes: manifestBytes });
|
|
241
|
+
const manifest = dagCbor.decode(manifestBytes);
|
|
242
|
+
if (manifest && manifest.accessController) {
|
|
243
|
+
const accessCid = manifest.accessController.replace("/ipfs/", "");
|
|
244
|
+
const accessBytes = await db.log.storage.get(accessCid);
|
|
245
|
+
if (accessBytes)
|
|
246
|
+
staticBlocks.push({ hash: accessCid, bytes: accessBytes });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Parents before children: entryBlocks were collected heads-first, so the
|
|
252
|
+
// reversed order is oldest-first; static blocks go before everything.
|
|
253
|
+
return {
|
|
254
|
+
heads: headHashes,
|
|
255
|
+
blocks: [...staticBlocks, ...entryBlocks.reverse()],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Apply a delta to a local blockstore and join its heads into the log.
|
|
261
|
+
*
|
|
262
|
+
* All blocks are put first; then, before any join, the entry chain is checked
|
|
263
|
+
* for closure — every `next`/`refs` reference must be present in the delta or
|
|
264
|
+
* already in the log. `joinEntry` would otherwise reach into block storage for
|
|
265
|
+
* the missing parent and time out against a network that is not there.
|
|
266
|
+
*
|
|
267
|
+
* @param {Object} params
|
|
268
|
+
* @param {Object} params.db An open OrbitDB database
|
|
269
|
+
* @param {{heads: Array<string>, blocks: Array<{hash: string, bytes: Uint8Array}>}} params.delta
|
|
270
|
+
* @returns {Promise<{complete: boolean, joined: number, missing: Array<string>, entries: Array<Object>}>}
|
|
271
|
+
*/
|
|
272
|
+
export async function applyDelta({ db, delta }) {
|
|
273
|
+
return applyDeltaToStores({
|
|
274
|
+
blockstore: dbBlockstore(db),
|
|
275
|
+
log: db.log,
|
|
276
|
+
events: db.events,
|
|
277
|
+
delta,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function dbBlockstore(db) {
|
|
282
|
+
// Database instances do not expose their Helia handle; the log's entry
|
|
283
|
+
// storage is Composed(LRU, IPFSBlockStorage) and writing through it lands in
|
|
284
|
+
// the same blockstore `joinEntry` reads from.
|
|
285
|
+
return {
|
|
286
|
+
put: async (hash, bytes) => {
|
|
287
|
+
await db.log.storage.put(hash, bytes);
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function applyDeltaToStores({ blockstore, log, events, delta }) {
|
|
293
|
+
const inDelta = new Map();
|
|
294
|
+
for (const block of delta.blocks || []) {
|
|
295
|
+
inDelta.set(block.hash, block.bytes);
|
|
296
|
+
await blockstore.put(block.hash, block.bytes);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Closure check before joining anything. Only the delta itself and the
|
|
300
|
+
// log's own index are consulted — never raw block storage, whose `get`
|
|
301
|
+
// waits out a 30-second network timeout on a miss, against a network that
|
|
302
|
+
// is not there. Anything received in an earlier partial delivery is still
|
|
303
|
+
// in the delta, because the caller keeps blocks parked until completeness.
|
|
304
|
+
const missing = [];
|
|
305
|
+
for (const [, bytes] of inDelta) {
|
|
306
|
+
const value = dagCbor.decode(bytes);
|
|
307
|
+
if (!isOplogEntry(value)) continue;
|
|
308
|
+
for (const parent of [...value.next, ...(value.refs || [])]) {
|
|
309
|
+
if (inDelta.has(parent)) continue;
|
|
310
|
+
if (await log.has(parent)) continue;
|
|
311
|
+
missing.push(parent);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (missing.length > 0) {
|
|
315
|
+
return { complete: false, joined: 0, missing, entries: [] };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let joined = 0;
|
|
319
|
+
const entries = [];
|
|
320
|
+
for (const hash of delta.heads || []) {
|
|
321
|
+
if (await log.has(hash)) continue;
|
|
322
|
+
const bytes = inDelta.get(hash);
|
|
323
|
+
if (!bytes) continue;
|
|
324
|
+
const value = dagCbor.decode(bytes);
|
|
325
|
+
if (!isOplogEntry(value)) continue;
|
|
326
|
+
const entry = { ...value, hash };
|
|
327
|
+
const updated = await log.joinEntry(entry);
|
|
328
|
+
if (updated) {
|
|
329
|
+
joined++;
|
|
330
|
+
entries.push(entry);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Database.applyOperation emits 'update' when the pubsub Sync delivers an
|
|
335
|
+
// entry; a courier delivery is the same event from the application's side.
|
|
336
|
+
if (events && joined > 0) {
|
|
337
|
+
for (const entry of entries) events.emit("update", entry);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return { complete: true, joined, missing: [], entries };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Attach a database to a courier and keep the two ends converged.
|
|
345
|
+
*
|
|
346
|
+
* Can start without an open database: given `orbitdb` and `address`, the first
|
|
347
|
+
* complete delta (which carries the manifest on first contact) opens the
|
|
348
|
+
* database locally with `sync: false` — replication then runs entirely over
|
|
349
|
+
* the courier, no libp2p involved. `db` stays null until that delta is joined:
|
|
350
|
+
* an application writes as soon as it has a database, and a write racing the
|
|
351
|
+
* bootstrap join can drop out of the log's heads and never be sent.
|
|
352
|
+
*
|
|
353
|
+
* @param {Object} params
|
|
354
|
+
* @param {Object} [params.db] An open database (own-writes side)
|
|
355
|
+
* @param {Object} [params.orbitdb] OrbitDB instance, required when `db` is not given
|
|
356
|
+
* @param {string} [params.address] Database address, required when `db` is not given
|
|
357
|
+
* @param {Object} params.courier The byte courier (see module docs)
|
|
358
|
+
* @param {Object} [params.dbOptions] Extra options for the lazy `orbitdb.open`
|
|
359
|
+
* @param {boolean} [params.announceOnLocalUpdate=true] Announce as soon as a
|
|
360
|
+
* local write lands. Default keeps the eager behaviour. Set false where the
|
|
361
|
+
* courier is expensive — a duty-cycled radio, say — and the application would
|
|
362
|
+
* rather batch several writes and call `announce()` once, deliberately.
|
|
363
|
+
* @param {Uint8Array} [params.peerId] Four-byte sender id. Random per instance
|
|
364
|
+
* by default, which is what presence wants: it identifies this program on
|
|
365
|
+
* this carrier for as long as it runs, and nothing beyond that.
|
|
366
|
+
* @param {number} [params.peerTimeoutMs=120000] How long a peer counts as
|
|
367
|
+
* present after its last word.
|
|
368
|
+
* @param {number} [params.sendTimeoutMs=300000] How long to wait for the
|
|
369
|
+
* courier to confirm one message. Not a delivery deadline — a duty-cycled
|
|
370
|
+
* radio is slow by law — but a way out of a carrier that neither delivers
|
|
371
|
+
* nor fails. 0 waits for ever.
|
|
372
|
+
* @param {number} [params.maxOutbox=32] How many messages may wait for a
|
|
373
|
+
* carrier that cannot keep up before the oldest is dropped.
|
|
374
|
+
* @returns {Promise<Object>} sync handle: { start, stop, announce, hello,
|
|
375
|
+
* presence, forgetPeers, db(), events }
|
|
376
|
+
*/
|
|
377
|
+
export async function createCourierSync({
|
|
378
|
+
db = null,
|
|
379
|
+
orbitdb = null,
|
|
380
|
+
address = null,
|
|
381
|
+
courier,
|
|
382
|
+
dbOptions = {},
|
|
383
|
+
rejoinIntervalMs = 15000,
|
|
384
|
+
announceOnLocalUpdate = true,
|
|
385
|
+
peerId = randomPeerId(),
|
|
386
|
+
peerTimeoutMs = PEER_TIMEOUT_MS,
|
|
387
|
+
sendTimeoutMs = SEND_TIMEOUT_MS,
|
|
388
|
+
maxOutbox = MAX_OUTBOX,
|
|
389
|
+
}) {
|
|
390
|
+
if (
|
|
391
|
+
!courier ||
|
|
392
|
+
typeof courier.send !== "function" ||
|
|
393
|
+
typeof courier.onPayload !== "function"
|
|
394
|
+
) {
|
|
395
|
+
throw new Error("A courier with send() and onPayload() is required");
|
|
396
|
+
}
|
|
397
|
+
if (!(peerId instanceof Uint8Array) || peerId.length !== PEER_ID_LENGTH) {
|
|
398
|
+
throw new Error(`peerId must be ${PEER_ID_LENGTH} bytes`);
|
|
399
|
+
}
|
|
400
|
+
const databaseAddress = address || (db && db.address);
|
|
401
|
+
if (!databaseAddress) {
|
|
402
|
+
throw new Error("Either an open db or a database address is required");
|
|
403
|
+
}
|
|
404
|
+
if (!db && !orbitdb) {
|
|
405
|
+
throw new Error(
|
|
406
|
+
"An orbitdb instance is required to open the database on first contact",
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const tag = await databaseTag(databaseAddress);
|
|
411
|
+
const pendingBlocks = new Map(); // hash -> bytes, parked until the database can open
|
|
412
|
+
const peers = new Map(); // sender id (hex) -> when we last heard it
|
|
413
|
+
let lastHeardAt = null; // any traffic for this database, identified or not
|
|
414
|
+
const listeners = { synced: [], message: [], error: [] };
|
|
415
|
+
let database = db;
|
|
416
|
+
// Opened on first contact but not handed out: the bootstrap is not in it yet.
|
|
417
|
+
// The protocol works on it all the same, so repair stays incremental.
|
|
418
|
+
let opening = null;
|
|
419
|
+
let unsubscribe = null;
|
|
420
|
+
let offUpdate = null;
|
|
421
|
+
let queue = Promise.resolve();
|
|
422
|
+
let started = false;
|
|
423
|
+
let applying = false;
|
|
424
|
+
let rejoinTimer = null;
|
|
425
|
+
|
|
426
|
+
const stopRejoin = () => {
|
|
427
|
+
if (rejoinTimer) {
|
|
428
|
+
clearInterval(rejoinTimer);
|
|
429
|
+
rejoinTimer = null;
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const emit = (event, payload) => {
|
|
434
|
+
for (const cb of listeners[event] || []) {
|
|
435
|
+
try {
|
|
436
|
+
cb(payload);
|
|
437
|
+
} catch {
|
|
438
|
+
// listeners must not break the protocol
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
/** One message, actually on its way — framed, counted, handed to the courier. */
|
|
444
|
+
const transmit = async (message) => {
|
|
445
|
+
const raw = dagCbor.encode({
|
|
446
|
+
v: COURIER_SYNC_VERSION,
|
|
447
|
+
tag,
|
|
448
|
+
p: peerId,
|
|
449
|
+
...message,
|
|
450
|
+
});
|
|
451
|
+
const framed = await frameMessage(raw);
|
|
452
|
+
// Report the wire size — what actually crosses the air and pays airtime.
|
|
453
|
+
emit("message", {
|
|
454
|
+
direction: "out",
|
|
455
|
+
type: message.t,
|
|
456
|
+
bytes: framed.length,
|
|
457
|
+
});
|
|
458
|
+
if (sendTimeoutMs <= 0) return courier.send(framed);
|
|
459
|
+
// A courier that neither delivers nor fails would hold the outbox for
|
|
460
|
+
// good. This is not a delivery deadline — a duty-cycled radio is slow by
|
|
461
|
+
// law, and the bound is far outside any honest delivery — it is the way
|
|
462
|
+
// out of a carrier that has simply stopped answering.
|
|
463
|
+
let timer;
|
|
464
|
+
try {
|
|
465
|
+
await Promise.race([
|
|
466
|
+
courier.send(framed),
|
|
467
|
+
new Promise((_, reject) => {
|
|
468
|
+
timer = setTimeout(
|
|
469
|
+
() =>
|
|
470
|
+
reject(
|
|
471
|
+
new Error(
|
|
472
|
+
`the courier neither delivered nor failed a ${message.t} within ${sendTimeoutMs} ms`,
|
|
473
|
+
),
|
|
474
|
+
),
|
|
475
|
+
sendTimeoutMs,
|
|
476
|
+
);
|
|
477
|
+
}),
|
|
478
|
+
]);
|
|
479
|
+
} finally {
|
|
480
|
+
clearTimeout(timer);
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The outbox: outgoing messages wait here, in order, and the receive path
|
|
486
|
+
* never waits with them.
|
|
487
|
+
*
|
|
488
|
+
* `courier.send` resolves on *delivery* — an end-to-end ARQ over a carrier
|
|
489
|
+
* that is slow by law. Awaiting it while handling an incoming message made
|
|
490
|
+
* one slow peer stall replication with every other peer: on hardware, a
|
|
491
|
+
* joiner asked fourteen times and got one answer, because the first reply
|
|
492
|
+
* was still in flight and every later message sat behind it in the same
|
|
493
|
+
* chain (funkpost#83). So handlers hand a message to this queue and go back
|
|
494
|
+
* to listening.
|
|
495
|
+
*
|
|
496
|
+
* A reply that has not gone out yet is *superseded* by a newer reply of the
|
|
497
|
+
* same kind to the same peer: the peer asked again, so the newer answer is
|
|
498
|
+
* the one it still needs, and the older one would spend airtime on what it
|
|
499
|
+
* already has. Peers on a version without a sender id cannot be told apart,
|
|
500
|
+
* so nothing of theirs is ever superseded.
|
|
501
|
+
*/
|
|
502
|
+
const outbox = [];
|
|
503
|
+
let pumping = null; // the run that is emptying the outbox, while there is one
|
|
504
|
+
|
|
505
|
+
const pump = () => {
|
|
506
|
+
if (pumping) return pumping;
|
|
507
|
+
pumping = (async () => {
|
|
508
|
+
try {
|
|
509
|
+
while (outbox.length > 0) {
|
|
510
|
+
const item = outbox.shift();
|
|
511
|
+
try {
|
|
512
|
+
await transmit(item.message);
|
|
513
|
+
item.resolve();
|
|
514
|
+
} catch (error) {
|
|
515
|
+
item.reject(error);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
} finally {
|
|
519
|
+
pumping = null;
|
|
520
|
+
}
|
|
521
|
+
})();
|
|
522
|
+
return pumping;
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
/** Resolves when nothing is waiting to go out and nothing is on its way. */
|
|
526
|
+
const outboxQuiet = async () => {
|
|
527
|
+
while (pumping) await pumping.catch(() => {});
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Queue a message. Resolves when it has been delivered, so the caller of
|
|
532
|
+
* `announce()` still learns when the radio is done — handlers, which must
|
|
533
|
+
* not wait, use `post()`.
|
|
534
|
+
*/
|
|
535
|
+
const send = (message, { to = null } = {}) =>
|
|
536
|
+
new Promise((resolve, reject) => {
|
|
537
|
+
if (to != null) {
|
|
538
|
+
const stale = outbox.findIndex(
|
|
539
|
+
(item) => item.to === to && item.message.t === message.t,
|
|
540
|
+
);
|
|
541
|
+
if (stale >= 0) outbox.splice(stale, 1)[0].resolve();
|
|
542
|
+
}
|
|
543
|
+
if (outbox.length >= maxOutbox) {
|
|
544
|
+
// A carrier that cannot keep up must not make us grow without limit.
|
|
545
|
+
// The oldest waiting message goes: everything here is re-derivable,
|
|
546
|
+
// and a peer that still wants it asks again.
|
|
547
|
+
emit(
|
|
548
|
+
"error",
|
|
549
|
+
new Error(`outbox full (${maxOutbox}) — dropping the oldest message`),
|
|
550
|
+
);
|
|
551
|
+
outbox.shift()?.resolve();
|
|
552
|
+
}
|
|
553
|
+
outbox.push({ message, to, resolve, reject });
|
|
554
|
+
pump();
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
/** Start a message on its way without waiting for it to arrive. */
|
|
558
|
+
const post = (message, options) => {
|
|
559
|
+
send(message, options).catch((error) => emit("error", error));
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Somebody said something for this database.
|
|
564
|
+
*
|
|
565
|
+
* A message without an id is an older peer: it still proves the air is not
|
|
566
|
+
* empty, which is why `lastHeardAt` moves either way, but it cannot be
|
|
567
|
+
* counted. Our own id comes back when a mesh repeats us, and that proves
|
|
568
|
+
* nothing at all.
|
|
569
|
+
*/
|
|
570
|
+
const noteHeard = (id) => {
|
|
571
|
+
const identified = id instanceof Uint8Array && id.length === PEER_ID_LENGTH;
|
|
572
|
+
if (identified && hex(id) === hex(peerId)) return; // a mesh repeating us
|
|
573
|
+
lastHeardAt = Date.now();
|
|
574
|
+
if (identified) peers.set(hex(id), lastHeardAt);
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
/** Who sent this, as far as the wire says — null on a peer without an id. */
|
|
578
|
+
const senderOf = (message) =>
|
|
579
|
+
message?.p instanceof Uint8Array && message.p.length === PEER_ID_LENGTH
|
|
580
|
+
? hex(message.p)
|
|
581
|
+
: null;
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Answer a peer asking whether anyone keeps this database. Answering means
|
|
585
|
+
* exactly that — listening on this tag — and nothing about having the log:
|
|
586
|
+
* a peer still bootstrapping is as present as one in step, and the asker
|
|
587
|
+
* finds out which by talking to it.
|
|
588
|
+
*/
|
|
589
|
+
const handleHello = (message) => {
|
|
590
|
+
post({ t: "here" }, { to: senderOf(message) });
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
/** The database the protocol works on — handed out or not. */
|
|
594
|
+
const local = () => database || opening;
|
|
595
|
+
|
|
596
|
+
const ourHeadHashes = async (target = local()) =>
|
|
597
|
+
target ? (await target.log.heads()).map((entry) => entry.hash) : [];
|
|
598
|
+
|
|
599
|
+
const announce = async () => {
|
|
600
|
+
if (!local()) {
|
|
601
|
+
// Nothing local yet — not even the manifest. An announce of empty heads
|
|
602
|
+
// cannot get one from a peer whose log is also empty, so first contact
|
|
603
|
+
// is an explicit bootstrap request: a want with an empty frontier makes
|
|
604
|
+
// the peer send its static blocks even when it has no entries at all.
|
|
605
|
+
await send({ t: "want", cids: [], have: [] });
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
await send({ t: "announce", heads: await ourHeadHashes() });
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Announce without waiting for delivery — for callers that sit on the
|
|
613
|
+
* receive queue, where waiting for the radio is the head-of-line block this
|
|
614
|
+
* design exists to avoid. `announce()` itself still resolves on delivery,
|
|
615
|
+
* because an application that presses "send" wants to know when it is out.
|
|
616
|
+
*/
|
|
617
|
+
const announceSoon = () => {
|
|
618
|
+
announce().catch((error) => emit("error", error));
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
/** The database to join a first-contact delta into, opened once the manifest is here. */
|
|
622
|
+
const openIfPossible = async () => {
|
|
623
|
+
if (opening || !orbitdb) return opening;
|
|
624
|
+
const manifestCid = manifestCidOf(databaseAddress);
|
|
625
|
+
if (!pendingBlocks.has(manifestCid)) return null;
|
|
626
|
+
// The blocks must be in the blockstore BEFORE the open: resolving the
|
|
627
|
+
// manifest (and later the access controller) reads through IPFS block
|
|
628
|
+
// storage, and a miss there waits out a 30-second timeout against a
|
|
629
|
+
// network that is not there.
|
|
630
|
+
for (const [hash, bytes] of pendingBlocks) {
|
|
631
|
+
await orbitdb.ipfs.blockstore.put(CID.parse(hash, base58btc), bytes);
|
|
632
|
+
}
|
|
633
|
+
opening = await orbitdb.open(databaseAddress, {
|
|
634
|
+
sync: false,
|
|
635
|
+
...dbOptions,
|
|
636
|
+
});
|
|
637
|
+
stopRejoin(); // the manifest is here — repair from now on is incremental
|
|
638
|
+
return opening;
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
const watchLocalUpdates = () => {
|
|
642
|
+
// Opted out: the application announces when it decides to, not when a write
|
|
643
|
+
// happens. Incoming announces are still answered, so a peer asking for
|
|
644
|
+
// blocks is served — going quiet must not mean going deaf.
|
|
645
|
+
if (!announceOnLocalUpdate) return;
|
|
646
|
+
if (!database || offUpdate) return;
|
|
647
|
+
const onUpdate = () => {
|
|
648
|
+
if (applying) return; // courier-applied entries already end in an announce
|
|
649
|
+
queue = queue
|
|
650
|
+
.then(() => announceSoon())
|
|
651
|
+
.catch((error) => emit("error", error));
|
|
652
|
+
};
|
|
653
|
+
database.events.on("update", onUpdate);
|
|
654
|
+
offUpdate = () => database.events.off("update", onUpdate);
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
const handleAnnounce = async (message) => {
|
|
658
|
+
const theirHeads = message.heads || [];
|
|
659
|
+
const target = local();
|
|
660
|
+
if (!target) {
|
|
661
|
+
// Nothing local yet: ask for everything below their heads.
|
|
662
|
+
if (theirHeads.length > 0)
|
|
663
|
+
post(
|
|
664
|
+
{ t: "want", cids: theirHeads, have: [] },
|
|
665
|
+
{ to: senderOf(message) },
|
|
666
|
+
);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
const ours = await ourHeadHashes();
|
|
670
|
+
const theirSet = new Set(theirHeads);
|
|
671
|
+
const theyLack = ours.filter((hash) => !theirSet.has(hash));
|
|
672
|
+
const weLack = [];
|
|
673
|
+
for (const hash of theirHeads) {
|
|
674
|
+
if (!(await target.log.has(hash))) weLack.push(hash);
|
|
675
|
+
}
|
|
676
|
+
if (theyLack.length > 0) {
|
|
677
|
+
const delta = await createDelta({ db: target, theirHeads });
|
|
678
|
+
post(
|
|
679
|
+
{ t: "blocks", heads: delta.heads, blocks: delta.blocks },
|
|
680
|
+
{ to: senderOf(message) },
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
if (weLack.length > 0) {
|
|
684
|
+
post({ t: "want", cids: weLack, have: ours }, { to: senderOf(message) });
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
const handleWant = async (message) => {
|
|
689
|
+
const target = local();
|
|
690
|
+
if (!target) return;
|
|
691
|
+
const delta = await createDelta({
|
|
692
|
+
db: target,
|
|
693
|
+
theirHeads: message.have || [],
|
|
694
|
+
});
|
|
695
|
+
// Repair mode: a peer may ask for specific blocks (missing parents) that
|
|
696
|
+
// sit below both frontiers; include them explicitly if we hold them.
|
|
697
|
+
const included = new Set(delta.blocks.map((block) => block.hash));
|
|
698
|
+
for (const hash of message.cids || []) {
|
|
699
|
+
if (included.has(hash)) continue;
|
|
700
|
+
const bytes = await target.log.storage.get(hash).catch(() => null);
|
|
701
|
+
if (bytes) delta.blocks.unshift({ hash, bytes });
|
|
702
|
+
}
|
|
703
|
+
post(
|
|
704
|
+
{ t: "blocks", heads: delta.heads, blocks: delta.blocks },
|
|
705
|
+
{ to: senderOf(message) },
|
|
706
|
+
);
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
const handleBlocks = async (message) => {
|
|
710
|
+
for (const block of message.blocks || [])
|
|
711
|
+
pendingBlocks.set(block.hash, block.bytes);
|
|
712
|
+
const target = local() || (await openIfPossible());
|
|
713
|
+
if (!target) return;
|
|
714
|
+
|
|
715
|
+
const delta = {
|
|
716
|
+
heads: message.heads || [],
|
|
717
|
+
blocks: Array.from(pendingBlocks, ([hash, bytes]) => ({ hash, bytes })),
|
|
718
|
+
};
|
|
719
|
+
applying = true;
|
|
720
|
+
let result;
|
|
721
|
+
try {
|
|
722
|
+
result = await applyDelta({ db: target, delta });
|
|
723
|
+
} finally {
|
|
724
|
+
applying = false;
|
|
725
|
+
}
|
|
726
|
+
if (!result.complete) {
|
|
727
|
+
post(
|
|
728
|
+
{
|
|
729
|
+
t: "want",
|
|
730
|
+
cids: result.missing,
|
|
731
|
+
have: await ourHeadHashes(target),
|
|
732
|
+
},
|
|
733
|
+
{ to: senderOf(message) },
|
|
734
|
+
);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
pendingBlocks.clear();
|
|
738
|
+
if (!database) {
|
|
739
|
+
// First contact is complete: only now is the database the application's.
|
|
740
|
+
// Handed out in the same synchronous step that emits "synced", so no
|
|
741
|
+
// turn of the event loop sees one without the other.
|
|
742
|
+
database = target;
|
|
743
|
+
opening = null;
|
|
744
|
+
watchLocalUpdates();
|
|
745
|
+
}
|
|
746
|
+
if (result.joined > 0) {
|
|
747
|
+
emit("synced", { joined: result.joined, entries: result.entries });
|
|
748
|
+
}
|
|
749
|
+
// Tells the peer where we now stand — their diff turns empty and the
|
|
750
|
+
// exchange goes quiet; doubles as an end-to-end acknowledgement.
|
|
751
|
+
await announce();
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
const handlePayload = (bytes) => {
|
|
755
|
+
queue = queue
|
|
756
|
+
.then(async () => {
|
|
757
|
+
let message;
|
|
758
|
+
try {
|
|
759
|
+
message = dagCbor.decode(await unframeMessage(bytes));
|
|
760
|
+
} catch {
|
|
761
|
+
return; // not ours (foreign traffic, garbage, or a bad frame)
|
|
762
|
+
}
|
|
763
|
+
if (
|
|
764
|
+
!message ||
|
|
765
|
+
message.v !== COURIER_SYNC_VERSION ||
|
|
766
|
+
!sameTag(message.tag, tag)
|
|
767
|
+
)
|
|
768
|
+
return;
|
|
769
|
+
emit("message", {
|
|
770
|
+
direction: "in",
|
|
771
|
+
type: message.t,
|
|
772
|
+
bytes: bytes.length,
|
|
773
|
+
});
|
|
774
|
+
noteHeard(message.p);
|
|
775
|
+
if (message.t === "hello") return handleHello(message);
|
|
776
|
+
if (message.t === "here") return; // the id in it was the whole message
|
|
777
|
+
if (message.t === "announce") return handleAnnounce(message);
|
|
778
|
+
if (message.t === "want") return handleWant(message);
|
|
779
|
+
if (message.t === "blocks") return handleBlocks(message);
|
|
780
|
+
})
|
|
781
|
+
.catch((error) => emit("error", error));
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
return {
|
|
785
|
+
get db() {
|
|
786
|
+
return database;
|
|
787
|
+
},
|
|
788
|
+
address: databaseAddress,
|
|
789
|
+
on(event, cb) {
|
|
790
|
+
(listeners[event] = listeners[event] || []).push(cb);
|
|
791
|
+
return () => listeners[event].splice(listeners[event].indexOf(cb), 1);
|
|
792
|
+
},
|
|
793
|
+
async start() {
|
|
794
|
+
if (started) return;
|
|
795
|
+
started = true;
|
|
796
|
+
unsubscribe = courier.onPayload(handlePayload);
|
|
797
|
+
watchLocalUpdates();
|
|
798
|
+
await announce();
|
|
799
|
+
// A joiner that has not bootstrapped keeps re-asking on its own until
|
|
800
|
+
// the database opens — so a bootstrap the lossy channel dropped heals
|
|
801
|
+
// without the user pressing "join" again. Cleared the moment the
|
|
802
|
+
// database opens (openIfPossible) or on stop().
|
|
803
|
+
if (!database && orbitdb && rejoinIntervalMs > 0) {
|
|
804
|
+
rejoinTimer = setInterval(() => {
|
|
805
|
+
if (local() || !started) {
|
|
806
|
+
stopRejoin();
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
queue = queue
|
|
810
|
+
.then(() => announceSoon())
|
|
811
|
+
.catch((error) => emit("error", error));
|
|
812
|
+
}, rejoinIntervalMs);
|
|
813
|
+
}
|
|
814
|
+
},
|
|
815
|
+
/** Re-announce — recovery poke after suspected loss. */
|
|
816
|
+
announce: () => announce(),
|
|
817
|
+
/** This instance's sender id, as it appears on the wire. */
|
|
818
|
+
peerId: hex(peerId),
|
|
819
|
+
/**
|
|
820
|
+
* Ask whether anybody out there keeps this database, and let them answer.
|
|
821
|
+
*
|
|
822
|
+
* Two small messages, and the only way to tell an app apart from a radio:
|
|
823
|
+
* a carrier reports the radios in range, which says nothing about whether
|
|
824
|
+
* a program on the other end is listening for *this* database. Call it
|
|
825
|
+
* before spending airtime on a delta nobody is waiting for; read the
|
|
826
|
+
* answer from `presence()` a moment later, since an answer has to travel.
|
|
827
|
+
*
|
|
828
|
+
* Requires `start()` — a sync that is not subscribed hears no answers.
|
|
829
|
+
*/
|
|
830
|
+
hello: () => send({ t: "hello" }),
|
|
831
|
+
/**
|
|
832
|
+
* Who has been heard lately, and when the air last carried anything at
|
|
833
|
+
* all for this database.
|
|
834
|
+
*
|
|
835
|
+
* Not a connection count: this carrier has no connections. It is the
|
|
836
|
+
* honest form of the question — these peers said something recently.
|
|
837
|
+
*
|
|
838
|
+
* @returns {{peers: Array<{id: string, agoMs: number}>, lastHeardAgoMs: number|null}}
|
|
839
|
+
*/
|
|
840
|
+
presence() {
|
|
841
|
+
const at = Date.now();
|
|
842
|
+
for (const [id, seen] of peers) {
|
|
843
|
+
if (at - seen > peerTimeoutMs) peers.delete(id);
|
|
844
|
+
}
|
|
845
|
+
return {
|
|
846
|
+
peers: [...peers.entries()].map(([id, seen]) => ({
|
|
847
|
+
id,
|
|
848
|
+
agoMs: at - seen,
|
|
849
|
+
})),
|
|
850
|
+
lastHeardAgoMs: lastHeardAt == null ? null : at - lastHeardAt,
|
|
851
|
+
};
|
|
852
|
+
},
|
|
853
|
+
/**
|
|
854
|
+
* Forget everyone heard so far.
|
|
855
|
+
*
|
|
856
|
+
* For when the carrier itself changes underneath — a radio switched to
|
|
857
|
+
* another channel reaches other people, and peers heard on the old one
|
|
858
|
+
* are not evidence about the new one.
|
|
859
|
+
*/
|
|
860
|
+
forgetPeers() {
|
|
861
|
+
peers.clear();
|
|
862
|
+
lastHeardAt = null;
|
|
863
|
+
},
|
|
864
|
+
/**
|
|
865
|
+
* Wait until in-flight message handling settles (mainly for tests).
|
|
866
|
+
*
|
|
867
|
+
* Both directions: the receive queue, and whatever it handed to the
|
|
868
|
+
* outbox. Since handlers no longer wait for the radio, a settled receive
|
|
869
|
+
* queue on its own would mean "decided", not "sent".
|
|
870
|
+
*/
|
|
871
|
+
async idle() {
|
|
872
|
+
await queue;
|
|
873
|
+
await outboxQuiet();
|
|
874
|
+
await queue;
|
|
875
|
+
},
|
|
876
|
+
async stop() {
|
|
877
|
+
started = false;
|
|
878
|
+
stopRejoin();
|
|
879
|
+
if (unsubscribe) unsubscribe();
|
|
880
|
+
if (offUpdate) offUpdate();
|
|
881
|
+
unsubscribe = null;
|
|
882
|
+
offUpdate = null;
|
|
883
|
+
await queue.catch(() => {});
|
|
884
|
+
},
|
|
885
|
+
};
|
|
886
|
+
}
|