@vdoninja/ninja-p2p 0.1.4 → 0.2.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.
Files changed (46) hide show
  1. package/.agents/skills/ninja-p2p/SKILL.md +102 -2
  2. package/.claude/skills/ninja-p2p/SKILL.md +209 -130
  3. package/.codex/skills/ninja-p2p/SKILL.md +102 -2
  4. package/CHANGELOG.md +113 -0
  5. package/README.md +1077 -775
  6. package/dashboard.html +370 -50
  7. package/dist/agent-state.js +9 -0
  8. package/dist/cli-lib.d.ts +40 -0
  9. package/dist/cli-lib.js +165 -2
  10. package/dist/cli.d.ts +2 -0
  11. package/dist/cli.js +501 -10
  12. package/dist/demo.d.ts +37 -0
  13. package/dist/demo.js +186 -0
  14. package/dist/doctor.d.ts +52 -0
  15. package/dist/doctor.js +219 -0
  16. package/dist/file-transfer.d.ts +15 -2
  17. package/dist/file-transfer.js +249 -15
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/message-bus.d.ts +15 -0
  21. package/dist/message-bus.js +32 -3
  22. package/dist/peer-registry.js +7 -0
  23. package/dist/protocol.d.ts +78 -1
  24. package/dist/protocol.js +42 -6
  25. package/dist/shared-folders.js +20 -1
  26. package/dist/social-stream.d.ts +100 -0
  27. package/dist/social-stream.js +254 -0
  28. package/dist/swarm-manager.d.ts +164 -0
  29. package/dist/swarm-manager.js +957 -0
  30. package/dist/swarm-session.d.ts +197 -0
  31. package/dist/swarm-session.js +465 -0
  32. package/dist/swarm-wire.d.ts +48 -0
  33. package/dist/swarm-wire.js +86 -0
  34. package/dist/swarm.d.ts +258 -0
  35. package/dist/swarm.js +694 -0
  36. package/dist/vdo-bridge.d.ts +62 -1
  37. package/dist/vdo-bridge.js +273 -23
  38. package/dist/vdoninja-sdk-types.d.ts +75 -0
  39. package/dist/vdoninja-sdk-types.js +10 -0
  40. package/dist/wake.d.ts +83 -0
  41. package/dist/wake.js +206 -0
  42. package/docs/protocol-and-reliability.md +231 -38
  43. package/docs/sdk-wishlist.md +236 -0
  44. package/docs/security.md +197 -0
  45. package/docs/social-stream-bridge.md +390 -0
  46. package/package.json +125 -116
@@ -0,0 +1,957 @@
1
+ /**
2
+ * Swarm Manager
3
+ *
4
+ * Binds swarm sessions to a live room. It routes swarm messages to the right
5
+ * file, drives the request pump, and keeps peers informed about what it holds.
6
+ *
7
+ * One behaviour worth calling out: when a download completes, the manager
8
+ * immediately reopens the finished file as a seed session. A peer that just
9
+ * finished is the swarm's newest full source, and dropping out at that moment
10
+ * is exactly what starves a swarm.
11
+ */
12
+ import { existsSync, mkdirSync, statSync } from "node:fs";
13
+ import path from "node:path";
14
+ import { buildManifestFromFile, ChunkMap, chunkLength, DEFAULT_SWARM_CHUNK_SIZE, hashChunkHashes, isSha256Hex, MAX_SWARM_TOTAL_CHUNKS, manifestsAgree, maxSafeChunkSize, partPathFor, SWARM_INLINE_MANIFEST_HASHES, SWARM_MANIFEST_PAGE_HASHES, toManifestSummary, validateManifestSummary, validateSwarmManifest, } from "./swarm.js";
15
+ import { createSeedSession, SwarmSession, } from "./swarm-session.js";
16
+ import { decodeChunkFrame, encodeChunkFrame, isBinaryFileId } from "./swarm-wire.js";
17
+ export const DEFAULT_PUMP_INTERVAL_MS = 250;
18
+ export const DEFAULT_ANNOUNCE_INTERVAL_MS = 5_000;
19
+ /** Floor on how often one peer can be answered about one file. */
20
+ export const ANNOUNCE_REPLY_MIN_INTERVAL_MS = 1_000;
21
+ /** Consecutive timeouts, with nothing delivered between, before rebuilding a path. */
22
+ export const UNRESPONSIVE_TIMEOUT_THRESHOLD = 3;
23
+ /** Floor between rebuild attempts for one peer, so a dead peer is not hammered. */
24
+ export const REVIVE_MIN_INTERVAL_MS = 20_000;
25
+ /** Retry a lost manifest page request without flooding the control channel. */
26
+ export const MANIFEST_REQUEST_RETRY_MS = 2_000;
27
+ /** Bound both request messages and the burst of page responses they trigger. */
28
+ export const MANIFEST_PAGES_PER_REQUEST = 8;
29
+ export class SwarmManager {
30
+ bridge;
31
+ downloadDir;
32
+ workDir;
33
+ pumpIntervalMs;
34
+ announceIntervalMs;
35
+ maxInFlightPerPeer;
36
+ maxInFlightTotal;
37
+ log;
38
+ onComplete;
39
+ onProgress;
40
+ onError;
41
+ sessions = new Map();
42
+ offers = new Map();
43
+ /** Files asked for before their offer arrived. */
44
+ wanted = new Set();
45
+ seeding = new Set();
46
+ /** `fileId:peerId` -> when we last answered that peer's announce. */
47
+ lastAnnounceReply = new Map();
48
+ /** peerId -> when its connection was last rebuilt. */
49
+ lastRevive = new Map();
50
+ pumpTimer = null;
51
+ announceTimer = null;
52
+ coalesceTimer = null;
53
+ started = false;
54
+ constructor(options) {
55
+ this.bridge = options.bridge;
56
+ this.downloadDir = options.downloadDir;
57
+ this.workDir = options.workDir;
58
+ this.pumpIntervalMs = options.pumpIntervalMs ?? DEFAULT_PUMP_INTERVAL_MS;
59
+ this.announceIntervalMs = options.announceIntervalMs ?? DEFAULT_ANNOUNCE_INTERVAL_MS;
60
+ this.maxInFlightPerPeer = options.maxInFlightPerPeer;
61
+ this.maxInFlightTotal = options.maxInFlightTotal;
62
+ this.log = options.log ?? (() => { });
63
+ this.onComplete = options.onComplete;
64
+ this.onProgress = options.onProgress;
65
+ this.onError = options.onError;
66
+ }
67
+ // ── Lifecycle ──────────────────────────────────────────────────────────────
68
+ start() {
69
+ if (this.started)
70
+ return;
71
+ this.started = true;
72
+ mkdirSync(this.downloadDir, { recursive: true });
73
+ mkdirSync(this.workDir, { recursive: true });
74
+ this.bridge.bus.on("message:swarm_offer", (envelope) => {
75
+ this.guard("offer", envelope, () => this.handleOffer(envelope));
76
+ });
77
+ this.bridge.bus.on("message:swarm_manifest_request", (envelope) => {
78
+ this.guard("manifest request", envelope, () => this.handleManifestRequest(envelope));
79
+ });
80
+ this.bridge.bus.on("message:swarm_manifest_page", (envelope) => {
81
+ this.guard("manifest page", envelope, () => this.handleManifestPage(envelope));
82
+ });
83
+ this.bridge.bus.on("message:swarm_announce", (envelope) => {
84
+ this.guard("announce", envelope, () => this.handleAnnounce(envelope));
85
+ });
86
+ this.bridge.bus.on("message:swarm_have", (envelope) => {
87
+ this.guard("have", envelope, () => this.handleHave(envelope));
88
+ });
89
+ this.bridge.bus.on("message:swarm_request", (envelope) => {
90
+ this.guard("chunk request", envelope, () => this.handleRequest(envelope));
91
+ });
92
+ this.bridge.bus.on("message:swarm_chunk", (envelope) => {
93
+ this.guard("chunk", envelope, () => this.handleChunk(envelope));
94
+ });
95
+ this.bridge.on("binary", ({ streamId, bytes }) => {
96
+ try {
97
+ this.handleBinary(streamId, bytes);
98
+ }
99
+ catch (error) {
100
+ this.log(`[swarm] rejected binary frame from ${streamId}: ${errorMessage(error)}`);
101
+ }
102
+ });
103
+ this.bridge.on("peer:disconnected", ({ streamId }) => {
104
+ for (const session of this.sessions.values())
105
+ session.removePeer(streamId);
106
+ for (const offer of this.offers.values()) {
107
+ offer.sources.delete(streamId);
108
+ offer.requestedAt.clear();
109
+ }
110
+ for (const key of [...this.lastAnnounceReply.keys()]) {
111
+ if (key.endsWith(`:${streamId}`))
112
+ this.lastAnnounceReply.delete(key);
113
+ }
114
+ this.lastRevive.delete(streamId);
115
+ });
116
+ // A peer that just appeared missed every offer we already broadcast, so it
117
+ // has no way to learn a file exists. Send it our catalogue directly rather
118
+ // than re-broadcasting: a manifest carries one hash per chunk, which is
119
+ // megabytes for a large file and would hit every peer on every join.
120
+ this.bridge.on("peer:announce", ({ streamId }) => {
121
+ this.greetPeer(streamId);
122
+ });
123
+ // Our socket came back on new peer connections. Anything outstanding was
124
+ // addressed to the old ones, so re-plan now rather than waiting out a
125
+ // timeout per chunk, and re-state what we hold so peers can place us again.
126
+ this.bridge.on("ws:reconnected", () => {
127
+ let abandoned = 0;
128
+ for (const session of this.sessions.values())
129
+ abandoned += session.abandonInFlight();
130
+ if (abandoned > 0)
131
+ this.log(`[swarm] reconnected; re-planning ${abandoned} outstanding request(s)`);
132
+ this.announceAll();
133
+ this.schedulePump();
134
+ });
135
+ this.pumpTimer = setInterval(() => this.pump(), this.pumpIntervalMs);
136
+ if (this.pumpTimer.unref)
137
+ this.pumpTimer.unref();
138
+ this.announceTimer = setInterval(() => this.announceAll(), this.announceIntervalMs);
139
+ if (this.announceTimer.unref)
140
+ this.announceTimer.unref();
141
+ }
142
+ stop() {
143
+ if (this.pumpTimer)
144
+ clearInterval(this.pumpTimer);
145
+ if (this.announceTimer)
146
+ clearInterval(this.announceTimer);
147
+ if (this.coalesceTimer)
148
+ clearTimeout(this.coalesceTimer);
149
+ this.pumpTimer = null;
150
+ this.announceTimer = null;
151
+ this.coalesceTimer = null;
152
+ for (const session of this.sessions.values())
153
+ session.close();
154
+ this.started = false;
155
+ }
156
+ // ── Public API ─────────────────────────────────────────────────────────────
157
+ /** Publish a local file to the room and start serving it. */
158
+ seed(filePath, chunkSize = DEFAULT_SWARM_CHUNK_SIZE) {
159
+ const resolved = path.resolve(filePath);
160
+ if (!existsSync(resolved) || !statSync(resolved).isFile()) {
161
+ throw new Error(`not a file: ${resolved}`);
162
+ }
163
+ // A chunk is fixed for the life of the swarm, so this is the only chance to
164
+ // check it against what the transport will actually carry. Peers that join
165
+ // later may negotiate a smaller limit than anyone present now — nothing can
166
+ // be done about that here, but binary chunks stay under the 65536 floor
167
+ // regardless, so only the base64 fallback is exposed.
168
+ const limit = this.bridge.smallestMaxMessageSize();
169
+ if (limit !== null) {
170
+ const safe = maxSafeChunkSize(limit);
171
+ if (chunkSize > safe) {
172
+ this.log(`[swarm] chunk size ${chunkSize} exceeds what a ${limit}-byte message limit can carry ` +
173
+ `once base64-encoded; using ${safe}`);
174
+ chunkSize = safe;
175
+ }
176
+ }
177
+ const manifest = buildManifestFromFile(resolved, path.basename(resolved), guessMimeType(resolved), chunkSize);
178
+ this.offers.set(manifest.fileId, createOfferRecord(manifest));
179
+ this.seeding.add(manifest.fileId);
180
+ const session = createSeedSession(manifest, resolved, this.sendFor(manifest), {
181
+ log: this.log,
182
+ });
183
+ this.sessions.set(manifest.fileId, session);
184
+ this.broadcastOffer(manifest);
185
+ session.announce();
186
+ this.log(`[swarm] seeding ${manifest.name} (${manifest.totalChunks} chunks, ${manifest.fileId.slice(0, 12)})`);
187
+ return manifest;
188
+ }
189
+ /**
190
+ * Ask for a file by content id. If its offer has not arrived yet the request
191
+ * is remembered, so `fetch` before the seeder announces still works.
192
+ */
193
+ fetch(fileId) {
194
+ if (this.sessions.has(fileId))
195
+ return true;
196
+ if (!isSha256Hex(fileId)) {
197
+ this.reportError("refusing an invalid swarm file id");
198
+ return false;
199
+ }
200
+ const offer = this.offers.get(fileId);
201
+ if (!offer) {
202
+ this.wanted.add(fileId);
203
+ this.log(`[swarm] waiting for an offer of ${fileId.slice(0, 12)}`);
204
+ return false;
205
+ }
206
+ this.wanted.add(fileId);
207
+ if (offer.manifest)
208
+ return this.startDownload(offer.manifest);
209
+ this.requestManifestPages(offer);
210
+ this.log(`[swarm] requesting manifest for ${offer.summary.name}`);
211
+ return true;
212
+ }
213
+ knownOffers() {
214
+ return [...this.offers.values()].map(toOfferInfo);
215
+ }
216
+ /**
217
+ * Find an offer by full content id, an unambiguous id prefix, or exact file
218
+ * name. A 64-character hex id is not something anyone types by hand.
219
+ */
220
+ resolveOffer(query) {
221
+ const trimmed = query.trim();
222
+ if (!trimmed)
223
+ return null;
224
+ const exact = this.offers.get(trimmed);
225
+ if (exact)
226
+ return toOfferInfo(exact);
227
+ const byName = [...this.offers.values()].filter((record) => record.summary.name === trimmed);
228
+ if (byName.length === 1)
229
+ return toOfferInfo(byName[0]);
230
+ const lowered = trimmed.toLowerCase();
231
+ const byPrefix = [...this.offers.values()].filter((record) => record.summary.fileId.startsWith(lowered));
232
+ if (byPrefix.length === 1)
233
+ return toOfferInfo(byPrefix[0]);
234
+ // Ambiguous matches resolve to nothing rather than guessing wrong.
235
+ return null;
236
+ }
237
+ progress() {
238
+ return [...this.sessions.values()].map((session) => session.progress());
239
+ }
240
+ sessionFor(fileId) {
241
+ return this.sessions.get(fileId);
242
+ }
243
+ // ── Internals ──────────────────────────────────────────────────────────────
244
+ pump() {
245
+ for (const fileId of this.wanted) {
246
+ const offer = this.offers.get(fileId);
247
+ if (offer && !offer.manifest)
248
+ this.requestManifestPages(offer);
249
+ }
250
+ for (const session of this.sessions.values()) {
251
+ if (session.isComplete())
252
+ continue;
253
+ session.pump();
254
+ this.reviveUnresponsive(session);
255
+ }
256
+ }
257
+ /**
258
+ * Rebuild the connection to a peer that keeps timing out without delivering.
259
+ *
260
+ * The case this exists for is a path that every layer believes is healthy: a
261
+ * signalling blip leaves the peer connection reporting `open`, the sender's
262
+ * `send()` succeeds, and the bytes are simply dropped. Waiting it out took
263
+ * around a minute; the swarm has the evidence to act much sooner, because a
264
+ * run of timeouts with nothing delivered in between says so.
265
+ *
266
+ * Deliberately conservative: a peer must miss several requests in a row, and
267
+ * rebuilding is rate-limited, because a genuinely slow peer must not be
268
+ * disconnected for being slow.
269
+ */
270
+ reviveUnresponsive(session) {
271
+ const now = Date.now();
272
+ for (const peerId of session.unresponsivePeers(UNRESPONSIVE_TIMEOUT_THRESHOLD)) {
273
+ if (now - (this.lastRevive.get(peerId) ?? 0) < REVIVE_MIN_INTERVAL_MS)
274
+ continue;
275
+ this.lastRevive.set(peerId, now);
276
+ session.clearUnresponsive(peerId);
277
+ this.log(`[swarm] ${peerId} stopped delivering; rebuilding the connection`);
278
+ this.bridge.revivePeer(peerId);
279
+ }
280
+ }
281
+ /**
282
+ * Re-plan as soon as a request slot frees, instead of waiting for the timer.
283
+ *
284
+ * The interval alone caps throughput hard: with four requests in flight and a
285
+ * 250ms tick, a slot that frees 10ms after a pump sits idle for the other
286
+ * 240ms. Measured against a real 5 MB transfer that ceiling was 16 chunks/s
287
+ * and we were getting 9.6. Pumping on arrival removes the ceiling and leaves
288
+ * round-trip time as the governor.
289
+ *
290
+ * The short coalescing delay matters too: chunks arrive in bursts, and
291
+ * planning is O(chunks x peers), so replanning once per burst beats
292
+ * replanning once per chunk.
293
+ */
294
+ schedulePump() {
295
+ if (!this.started || this.coalesceTimer)
296
+ return;
297
+ this.coalesceTimer = setTimeout(() => {
298
+ this.coalesceTimer = null;
299
+ this.pump();
300
+ }, 5);
301
+ if (this.coalesceTimer.unref)
302
+ this.coalesceTimer.unref();
303
+ }
304
+ announceAll() {
305
+ for (const session of this.sessions.values())
306
+ session.announce();
307
+ }
308
+ /**
309
+ * Answer a peer's announce with our own bitfield when we can serve it.
310
+ *
311
+ * Without this a fresh downloader knows a file exists but not who holds any
312
+ * of it, so it sits idle until the next periodic announce comes round.
313
+ * Measured on a 5 MB transfer that stall was 2.6 s of a 4.0 s total — the
314
+ * transfer itself was never the slow part. Replying on demand takes the same
315
+ * transfer to 1.0 s without making the room any chattier at rest.
316
+ *
317
+ * Three things keep this from becoming an announce storm: we only reply if we
318
+ * actually hold something the peer lacks, the reply is unicast rather than
319
+ * broadcast, and each peer gets at most one reply per file per second.
320
+ */
321
+ answerAnnounce(session, peerId) {
322
+ if (!session.canHelp(peerId))
323
+ return;
324
+ const key = `${session.manifest.fileId}:${peerId}`;
325
+ const last = this.lastAnnounceReply.get(key) ?? 0;
326
+ const now = Date.now();
327
+ if (now - last < ANNOUNCE_REPLY_MIN_INTERVAL_MS)
328
+ return;
329
+ this.lastAnnounceReply.set(key, now);
330
+ session.announceTo(peerId);
331
+ }
332
+ /** Tell one peer everything we can offer, then what we currently hold. */
333
+ greetPeer(streamId) {
334
+ for (const session of this.sessions.values()) {
335
+ // Only advertise files we can actually serve some of.
336
+ if (session.chunkMap().count() === 0)
337
+ continue;
338
+ this.bridge.bus.trySend(streamId, "swarm_offer", toOfferPayload(session.manifest));
339
+ session.announceTo(streamId);
340
+ }
341
+ }
342
+ broadcastOffer(manifest) {
343
+ if (!this.bridge.bus.tryBroadcast("swarm_offer", toOfferPayload(manifest))) {
344
+ this.log(`[swarm] transport refused offer for ${manifest.name}; it will be retried when peers join`);
345
+ }
346
+ }
347
+ handleOffer(envelope) {
348
+ const parsed = parseOfferPayload(envelope.payload);
349
+ if (!parsed.ok) {
350
+ this.log(`[swarm] rejecting invalid offer from ${envelope.from.streamId}: ${parsed.error}`);
351
+ return;
352
+ }
353
+ const { summary, manifest, pageSize, totalPages } = parsed;
354
+ const known = this.offers.get(summary.fileId);
355
+ if (known) {
356
+ // Same content id must mean the same bytes. Disagreement means the peer
357
+ // is broken or lying, and trading chunks with it would corrupt the file.
358
+ if (!summariesAgree(known.summary, summary)) {
359
+ this.log(`[swarm] rejecting conflicting offer for ${summary.fileId.slice(0, 12)} from ${envelope.from.streamId}`);
360
+ return;
361
+ }
362
+ if (manifest && known.manifest && !manifestsAgree(known.manifest, manifest)) {
363
+ this.log(`[swarm] rejecting conflicting inline manifest for ${summary.fileId.slice(0, 12)} from ${envelope.from.streamId}`);
364
+ return;
365
+ }
366
+ known.sources.add(envelope.from.streamId);
367
+ if (!known.manifest && manifest)
368
+ known.manifest = manifest;
369
+ if (this.wanted.has(summary.fileId)) {
370
+ if (known.manifest) {
371
+ this.startDownload(known.manifest);
372
+ }
373
+ else {
374
+ this.requestManifestPages(known);
375
+ }
376
+ }
377
+ return;
378
+ }
379
+ const record = {
380
+ summary,
381
+ manifest,
382
+ pageSize,
383
+ totalPages,
384
+ sources: new Set([envelope.from.streamId]),
385
+ pages: new Map(),
386
+ requestedAt: new Map(),
387
+ sourceCursor: 0,
388
+ };
389
+ this.offers.set(summary.fileId, record);
390
+ this.log(`[swarm] offer: ${summary.name} (${summary.fileId.slice(0, 12)}) from ${envelope.from.streamId}`);
391
+ if (this.wanted.has(summary.fileId)) {
392
+ if (manifest) {
393
+ this.startDownload(manifest);
394
+ }
395
+ else {
396
+ this.requestManifestPages(record);
397
+ }
398
+ }
399
+ }
400
+ handleManifestRequest(envelope) {
401
+ const payload = asRecord(envelope.payload);
402
+ if (!payload || !isSha256Hex(payload.fileId))
403
+ return;
404
+ const pageSize = payload.pageSize;
405
+ const pages = payload.pages;
406
+ if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > SWARM_MANIFEST_PAGE_HASHES)
407
+ return;
408
+ if (!Array.isArray(pages) || pages.length < 1 || pages.length > MANIFEST_PAGES_PER_REQUEST)
409
+ return;
410
+ const manifest = this.sessions.get(payload.fileId)?.manifest ?? this.offers.get(payload.fileId)?.manifest;
411
+ if (!manifest)
412
+ return;
413
+ const totalPages = Math.ceil(manifest.totalChunks / pageSize);
414
+ const uniquePages = [...new Set(pages)];
415
+ for (const page of uniquePages) {
416
+ if (!Number.isInteger(page) || page < 0 || page >= totalPages)
417
+ continue;
418
+ const start = page * pageSize;
419
+ this.bridge.bus.trySend(envelope.from.streamId, "swarm_manifest_page", {
420
+ fileId: manifest.fileId,
421
+ page,
422
+ totalPages,
423
+ pageSize,
424
+ chunkHashesHash: manifest.chunkHashesHash,
425
+ hashes: manifest.chunkHashes.slice(start, start + pageSize),
426
+ });
427
+ }
428
+ }
429
+ handleManifestPage(envelope) {
430
+ const payload = parseManifestPage(envelope.payload);
431
+ if (!payload)
432
+ return;
433
+ const offer = this.offers.get(payload.fileId);
434
+ if (!offer || offer.manifest)
435
+ return;
436
+ if (!offer.sources.has(envelope.from.streamId))
437
+ return;
438
+ if (payload.pageSize !== offer.pageSize ||
439
+ payload.totalPages !== offer.totalPages ||
440
+ payload.chunkHashesHash !== offer.summary.chunkHashesHash) {
441
+ this.log(`[swarm] rejecting conflicting manifest page from ${envelope.from.streamId}`);
442
+ return;
443
+ }
444
+ const expected = expectedHashesOnPage(offer.summary.totalChunks, offer.pageSize, payload.page);
445
+ if (payload.hashes.length !== expected)
446
+ return;
447
+ offer.pages.set(payload.page, payload.hashes);
448
+ offer.requestedAt.delete(payload.page);
449
+ if (offer.pages.size !== offer.totalPages) {
450
+ this.requestManifestPages(offer);
451
+ return;
452
+ }
453
+ const chunkHashes = [];
454
+ for (let page = 0; page < offer.totalPages; page += 1) {
455
+ const hashes = offer.pages.get(page);
456
+ if (!hashes)
457
+ return;
458
+ chunkHashes.push(...hashes);
459
+ }
460
+ if (chunkHashes.length !== offer.summary.totalChunks ||
461
+ hashChunkHashes(chunkHashes) !== offer.summary.chunkHashesHash) {
462
+ this.log(`[swarm] rejecting assembled manifest for ${offer.summary.fileId.slice(0, 12)}`);
463
+ offer.pages.clear();
464
+ offer.requestedAt.clear();
465
+ return;
466
+ }
467
+ const manifest = { ...offer.summary, chunkHashes };
468
+ const validationError = validateSwarmManifest(manifest);
469
+ if (validationError) {
470
+ this.log(`[swarm] rejecting assembled manifest: ${validationError}`);
471
+ return;
472
+ }
473
+ offer.manifest = manifest;
474
+ offer.pages.clear();
475
+ offer.requestedAt.clear();
476
+ this.log(`[swarm] manifest ready for ${manifest.name} (${manifest.totalChunks} chunk hashes)`);
477
+ if (this.wanted.has(manifest.fileId))
478
+ this.startDownload(manifest);
479
+ }
480
+ handleAnnounce(envelope) {
481
+ const payload = asRecord(envelope.payload);
482
+ if (!payload || !isSha256Hex(payload.fileId) || typeof payload.chunks !== "string")
483
+ return;
484
+ const session = this.sessions.get(payload.fileId);
485
+ if (!session || payload.totalChunks !== session.manifest.totalChunks)
486
+ return;
487
+ if (!isValidChunkMapBase64(payload.chunks, session.manifest.totalChunks))
488
+ return;
489
+ session.onPeerAnnounce(envelope.from.streamId, payload.chunks);
490
+ this.answerAnnounce(session, envelope.from.streamId);
491
+ this.schedulePump();
492
+ }
493
+ handleHave(envelope) {
494
+ const payload = asRecord(envelope.payload);
495
+ if (!payload || !isSha256Hex(payload.fileId))
496
+ return;
497
+ const session = this.sessions.get(payload.fileId);
498
+ if (!session)
499
+ return;
500
+ const rawIndexes = Array.isArray(payload.indexes)
501
+ ? payload.indexes
502
+ : (typeof payload.index === "number" ? [payload.index] : []);
503
+ if (rawIndexes.length > 4_096)
504
+ return;
505
+ const indexes = rawIndexes.filter((index) => Number.isInteger(index) && index >= 0 && index < session.manifest.totalChunks);
506
+ for (const index of indexes)
507
+ session.onPeerHave(envelope.from.streamId, index);
508
+ if (indexes.length > 0)
509
+ this.schedulePump();
510
+ }
511
+ handleRequest(envelope) {
512
+ const payload = asRecord(envelope.payload);
513
+ if (!payload || !isSha256Hex(payload.fileId) || !Number.isInteger(payload.index))
514
+ return;
515
+ const session = this.sessions.get(payload.fileId);
516
+ if (!session || payload.index < 0 || payload.index >= session.manifest.totalChunks)
517
+ return;
518
+ session.onChunkRequest(envelope.from.streamId, payload.index, payload.bin === 1);
519
+ }
520
+ requestManifestPages(offer) {
521
+ if (offer.manifest || offer.totalPages === 0)
522
+ return;
523
+ const now = Date.now();
524
+ const pages = [];
525
+ for (let page = 0; page < offer.totalPages && pages.length < MANIFEST_PAGES_PER_REQUEST; page += 1) {
526
+ if (offer.pages.has(page))
527
+ continue;
528
+ const requestedAt = offer.requestedAt.get(page) ?? 0;
529
+ if (now - requestedAt < MANIFEST_REQUEST_RETRY_MS)
530
+ continue;
531
+ pages.push(page);
532
+ }
533
+ if (pages.length === 0)
534
+ return;
535
+ const sources = [...offer.sources];
536
+ if (sources.length === 0)
537
+ return;
538
+ for (let attempt = 0; attempt < sources.length; attempt += 1) {
539
+ const index = (offer.sourceCursor + attempt) % sources.length;
540
+ const source = sources[index];
541
+ const sent = this.bridge.bus.trySend(source, "swarm_manifest_request", {
542
+ fileId: offer.summary.fileId,
543
+ pageSize: offer.pageSize,
544
+ pages,
545
+ });
546
+ if (!sent)
547
+ continue;
548
+ offer.sourceCursor = (index + 1) % sources.length;
549
+ for (const page of pages)
550
+ offer.requestedAt.set(page, now);
551
+ return;
552
+ }
553
+ }
554
+ guard(label, envelope, action) {
555
+ try {
556
+ action();
557
+ }
558
+ catch (error) {
559
+ this.log(`[swarm] rejected ${label} from ${envelope.from.streamId}: ${errorMessage(error)}`);
560
+ }
561
+ }
562
+ reportError(message, manifest) {
563
+ this.log(`[swarm] ${message}`);
564
+ this.onError?.(message, manifest);
565
+ }
566
+ handleChunk(envelope) {
567
+ const payload = asRecord(envelope.payload);
568
+ if (!payload ||
569
+ !isSha256Hex(payload.fileId) ||
570
+ !Number.isInteger(payload.index) ||
571
+ typeof payload.data !== "string")
572
+ return;
573
+ const session = this.sessions.get(payload.fileId);
574
+ if (!session || session.isComplete())
575
+ return;
576
+ if (payload.index < 0 || payload.index >= session.manifest.totalChunks)
577
+ return;
578
+ const expectedLength = chunkLength(payload.index, session.manifest);
579
+ const maxBase64Length = Math.ceil(expectedLength / 3) * 4;
580
+ if (payload.data.length > maxBase64Length + 4 || !BASE64_PATTERN.test(payload.data))
581
+ return;
582
+ const bytes = new Uint8Array(Buffer.from(payload.data, "base64"));
583
+ session.onChunkData(envelope.from.streamId, payload.index, bytes);
584
+ if (!session.isComplete()) {
585
+ // That request slot is free now; refill it rather than waiting a tick.
586
+ this.schedulePump();
587
+ this.onProgress?.(session.progress());
588
+ return;
589
+ }
590
+ this.completeSession(payload.fileId, session);
591
+ }
592
+ startDownload(manifest) {
593
+ if (this.sessions.has(manifest.fileId))
594
+ return true;
595
+ const validationError = validateSwarmManifest(manifest);
596
+ if (validationError) {
597
+ this.reportError(`cannot start ${manifest.name}: ${validationError}`, toManifestSummary(manifest));
598
+ return false;
599
+ }
600
+ let session;
601
+ try {
602
+ const savedPath = chooseSavedPath(this.downloadDir, manifest.name, manifest.fileId);
603
+ const partPath = partPathFor(this.workDir, manifest.fileId, savedPath);
604
+ session = new SwarmSession({
605
+ manifest,
606
+ partPath,
607
+ savedPath,
608
+ send: this.sendFor(manifest),
609
+ maxInFlightPerPeer: this.maxInFlightPerPeer,
610
+ maxInFlightTotal: this.maxInFlightTotal,
611
+ log: this.log,
612
+ });
613
+ }
614
+ catch (err) {
615
+ // Constructing a download claims the part file, so this is where a
616
+ // conflicting download surfaces. It reaches us from inside a bus event
617
+ // handler, where an unhandled throw would be swallowed or take the
618
+ // process down — say what happened and decline the file instead.
619
+ this.reportError(`cannot start ${manifest.name}: ${errorMessage(err)}`, toManifestSummary(manifest));
620
+ return false;
621
+ }
622
+ this.sessions.set(manifest.fileId, session);
623
+ this.wanted.delete(manifest.fileId);
624
+ const resumed = session.chunkMap().count();
625
+ if (resumed > 0) {
626
+ this.log(`[swarm] resuming ${manifest.name}: ${resumed}/${manifest.totalChunks} chunks already verified`);
627
+ }
628
+ // A part file left by a run that was interrupted after the last chunk but
629
+ // before the rename is already whole. Nothing would ever drive it to
630
+ // completion — the pump exits early on a complete session — so finish it
631
+ // here rather than waiting for a chunk that is never coming.
632
+ if (session.isComplete()) {
633
+ this.completeSession(manifest.fileId, session);
634
+ return true;
635
+ }
636
+ // Tell the room what we hold, which for a fresh download is nothing —
637
+ // that is still useful, because it identifies us as a participant.
638
+ session.announce();
639
+ this.log(`[swarm] fetching ${manifest.name} (${manifest.totalChunks} chunks)`);
640
+ return true;
641
+ }
642
+ completeSession(fileId, session) {
643
+ let result;
644
+ try {
645
+ result = session.finish();
646
+ }
647
+ catch (error) {
648
+ result = { ok: false, error: errorMessage(error) };
649
+ }
650
+ if (!result.ok) {
651
+ if (result.integrityFailure) {
652
+ // Keeping a complete-but-invalid part file makes every retry scan it as
653
+ // complete against the same bad chunk manifest and fail forever.
654
+ session.discard();
655
+ this.offers.delete(fileId);
656
+ this.seeding.delete(fileId);
657
+ this.wanted.add(fileId);
658
+ }
659
+ else {
660
+ session.close();
661
+ }
662
+ this.sessions.delete(fileId);
663
+ this.reportError(`finish failed for ${fileId.slice(0, 12)}: ${result.error ?? "unknown error"}` +
664
+ (result.integrityFailure ? " (invalid part discarded)" : ""), toManifestSummary(session.manifest));
665
+ return;
666
+ }
667
+ const manifest = session.manifest;
668
+ this.log(`[swarm] complete: ${manifest.name} -> ${result.savedPath}`);
669
+ // Reopen the finished file as a seed. The part file was renamed away, so
670
+ // without this the session can no longer serve and the swarm loses its
671
+ // newest full source at the worst possible moment.
672
+ const seed = createSeedSession(manifest, result.savedPath, this.sendFor(manifest), { log: this.log });
673
+ this.sessions.set(fileId, seed);
674
+ this.seeding.add(fileId);
675
+ this.offers.set(fileId, createOfferRecord(manifest));
676
+ seed.announce();
677
+ this.onComplete?.({ fileId, manifest, savedPath: result.savedPath });
678
+ }
679
+ sendFor(manifest) {
680
+ const fileId = manifest.fileId;
681
+ // Only ask for binary replies if we could actually decode one. A hex content
682
+ // id is required by the frame header; ids we generate always qualify, but an
683
+ // offer from somewhere else might not, and that peer should still work.
684
+ const canReceiveBinary = this.bridge.supportsBinary() && isBinaryFileId(fileId);
685
+ return {
686
+ request: (peerId, index) => {
687
+ const payload = { fileId, index };
688
+ if (canReceiveBinary)
689
+ payload.bin = 1;
690
+ return this.bridge.bus.trySend(peerId, "swarm_request", payload);
691
+ },
692
+ chunk: (peerId, index, bytes, binary) => {
693
+ if (binary && this.bridge.supportsBinary() && isBinaryFileId(fileId)) {
694
+ const frame = encodeChunkFrame(fileId, index, bytes);
695
+ // Fire and forget, but fall back if the lane refuses the bytes —
696
+ // a peer that asked for binary and then got nothing would stall
697
+ // until its request timed out, once per chunk.
698
+ void this.bridge
699
+ .sendBinaryTo(peerId, frame)
700
+ .then((sent) => {
701
+ if (!sent)
702
+ this.sendChunkAsBase64(peerId, fileId, index, bytes);
703
+ })
704
+ .catch(() => this.sendChunkAsBase64(peerId, fileId, index, bytes));
705
+ return;
706
+ }
707
+ this.sendChunkAsBase64(peerId, fileId, index, bytes);
708
+ },
709
+ have: (indexes, toPeerIds) => {
710
+ // Nobody in the room is still downloading this, so stay quiet.
711
+ if (toPeerIds.length === 0 || indexes.length === 0)
712
+ return;
713
+ const payload = { fileId, indexes };
714
+ for (const peerId of toPeerIds)
715
+ this.bridge.bus.trySend(peerId, "swarm_have", payload);
716
+ },
717
+ announce: (chunks) => {
718
+ this.bridge.bus.tryBroadcast("swarm_announce", {
719
+ fileId,
720
+ totalChunks: manifest.totalChunks,
721
+ chunks,
722
+ });
723
+ },
724
+ announceTo: (peerId, chunks) => {
725
+ this.bridge.bus.trySend(peerId, "swarm_announce", {
726
+ fileId,
727
+ totalChunks: manifest.totalChunks,
728
+ chunks,
729
+ });
730
+ },
731
+ };
732
+ }
733
+ sendChunkAsBase64(peerId, fileId, index, bytes) {
734
+ this.bridge.bus.trySend(peerId, "swarm_chunk", {
735
+ fileId,
736
+ index,
737
+ data: Buffer.from(bytes).toString("base64"),
738
+ });
739
+ }
740
+ /**
741
+ * Route a frame off the shared binary lane.
742
+ *
743
+ * Anything that is not one of our chunk frames is left alone: the lane is the
744
+ * application's, and another feature may well be using it too.
745
+ */
746
+ handleBinary(streamId, bytes) {
747
+ const frame = decodeChunkFrame(bytes);
748
+ if (!frame)
749
+ return;
750
+ const session = this.sessions.get(frame.fileId);
751
+ if (!session || session.isComplete())
752
+ return;
753
+ session.onChunkData(streamId, frame.index, frame.data);
754
+ if (!session.isComplete()) {
755
+ this.schedulePump();
756
+ this.onProgress?.(session.progress());
757
+ return;
758
+ }
759
+ this.completeSession(frame.fileId, session);
760
+ }
761
+ }
762
+ const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
763
+ function parseOfferPayload(value) {
764
+ const data = asRecord(value);
765
+ if (!data)
766
+ return { ok: false, error: "payload is not an object" };
767
+ if (!isSha256Hex(data.fileId) ||
768
+ typeof data.name !== "string" ||
769
+ typeof data.mimeType !== "string" ||
770
+ !Number.isSafeInteger(data.size) ||
771
+ !Number.isInteger(data.chunkSize) ||
772
+ !Number.isInteger(data.totalChunks)) {
773
+ return { ok: false, error: "missing or invalid summary fields" };
774
+ }
775
+ let inlineHashes = null;
776
+ if (data.chunkHashes !== undefined) {
777
+ if (!Array.isArray(data.chunkHashes) || data.chunkHashes.length > MAX_SWARM_TOTAL_CHUNKS) {
778
+ return { ok: false, error: "chunkHashes is invalid" };
779
+ }
780
+ if (!data.chunkHashes.every(isSha256Hex)) {
781
+ return { ok: false, error: "chunkHashes contains an invalid digest" };
782
+ }
783
+ inlineHashes = [...data.chunkHashes];
784
+ }
785
+ let chunkHashesHash = isSha256Hex(data.chunkHashesHash) ? data.chunkHashesHash : null;
786
+ if (!chunkHashesHash && inlineHashes) {
787
+ // Compatibility with pre-paging builds that sent the full list inline.
788
+ chunkHashesHash = hashChunkHashes(inlineHashes);
789
+ }
790
+ if (!chunkHashesHash)
791
+ return { ok: false, error: "chunkHashesHash is missing or invalid" };
792
+ const summary = {
793
+ fileId: data.fileId,
794
+ name: data.name,
795
+ mimeType: data.mimeType,
796
+ size: data.size,
797
+ chunkSize: data.chunkSize,
798
+ totalChunks: data.totalChunks,
799
+ chunkHashesHash,
800
+ };
801
+ const summaryError = validateManifestSummary(summary);
802
+ if (summaryError)
803
+ return { ok: false, error: summaryError };
804
+ if (inlineHashes) {
805
+ const manifest = { ...summary, chunkHashes: inlineHashes };
806
+ const manifestError = validateSwarmManifest(manifest);
807
+ if (manifestError)
808
+ return { ok: false, error: manifestError };
809
+ return {
810
+ ok: true,
811
+ summary,
812
+ manifest,
813
+ pageSize: SWARM_MANIFEST_PAGE_HASHES,
814
+ totalPages: Math.ceil(summary.totalChunks / SWARM_MANIFEST_PAGE_HASHES),
815
+ };
816
+ }
817
+ if (!Number.isInteger(data.manifestPageSize) ||
818
+ data.manifestPageSize < 1 ||
819
+ data.manifestPageSize > SWARM_MANIFEST_PAGE_HASHES ||
820
+ !Number.isInteger(data.manifestPages)) {
821
+ return { ok: false, error: "paged manifest metadata is invalid" };
822
+ }
823
+ const expectedPages = Math.ceil(summary.totalChunks / data.manifestPageSize);
824
+ if (summary.totalChunks === 0 || data.manifestPages !== expectedPages) {
825
+ return { ok: false, error: "manifest page count does not match totalChunks" };
826
+ }
827
+ return {
828
+ ok: true,
829
+ summary,
830
+ manifest: null,
831
+ pageSize: data.manifestPageSize,
832
+ totalPages: data.manifestPages,
833
+ };
834
+ }
835
+ function parseManifestPage(value) {
836
+ const data = asRecord(value);
837
+ if (!data ||
838
+ !isSha256Hex(data.fileId) ||
839
+ !isSha256Hex(data.chunkHashesHash) ||
840
+ !Number.isInteger(data.page) ||
841
+ !Number.isInteger(data.totalPages) ||
842
+ !Number.isInteger(data.pageSize) ||
843
+ data.pageSize < 1 ||
844
+ data.pageSize > SWARM_MANIFEST_PAGE_HASHES ||
845
+ data.totalPages < 1 ||
846
+ data.totalPages > MAX_SWARM_TOTAL_CHUNKS ||
847
+ data.page < 0 ||
848
+ data.page >= data.totalPages ||
849
+ !Array.isArray(data.hashes) ||
850
+ data.hashes.length > data.pageSize ||
851
+ !data.hashes.every(isSha256Hex)) {
852
+ return null;
853
+ }
854
+ return {
855
+ fileId: data.fileId,
856
+ page: data.page,
857
+ totalPages: data.totalPages,
858
+ pageSize: data.pageSize,
859
+ chunkHashesHash: data.chunkHashesHash,
860
+ hashes: [...data.hashes],
861
+ };
862
+ }
863
+ function toOfferPayload(manifest) {
864
+ const summary = toManifestSummary(manifest);
865
+ if (manifest.totalChunks <= SWARM_INLINE_MANIFEST_HASHES) {
866
+ return { ...summary, chunkHashes: [...manifest.chunkHashes] };
867
+ }
868
+ return {
869
+ ...summary,
870
+ manifestPageSize: SWARM_MANIFEST_PAGE_HASHES,
871
+ manifestPages: Math.ceil(manifest.totalChunks / SWARM_MANIFEST_PAGE_HASHES),
872
+ };
873
+ }
874
+ function createOfferRecord(manifest) {
875
+ return {
876
+ summary: toManifestSummary(manifest),
877
+ manifest,
878
+ pageSize: SWARM_MANIFEST_PAGE_HASHES,
879
+ totalPages: Math.ceil(manifest.totalChunks / SWARM_MANIFEST_PAGE_HASHES),
880
+ sources: new Set(),
881
+ pages: new Map(),
882
+ requestedAt: new Map(),
883
+ sourceCursor: 0,
884
+ };
885
+ }
886
+ function toOfferInfo(record) {
887
+ return { ...record.summary, manifestReady: record.manifest !== null };
888
+ }
889
+ function summariesAgree(a, b) {
890
+ return (a.fileId === b.fileId &&
891
+ a.size === b.size &&
892
+ a.chunkSize === b.chunkSize &&
893
+ a.totalChunks === b.totalChunks &&
894
+ a.chunkHashesHash === b.chunkHashesHash);
895
+ }
896
+ function expectedHashesOnPage(totalChunks, pageSize, page) {
897
+ return Math.max(0, Math.min(pageSize, totalChunks - page * pageSize));
898
+ }
899
+ function isValidChunkMapBase64(value, totalChunks) {
900
+ const expectedBytes = Math.ceil(totalChunks / 8);
901
+ const expectedEncoded = Math.ceil(expectedBytes / 3) * 4;
902
+ if (value.length !== expectedEncoded || !BASE64_PATTERN.test(value))
903
+ return false;
904
+ try {
905
+ return Buffer.from(value, "base64").byteLength === expectedBytes;
906
+ }
907
+ catch {
908
+ return false;
909
+ }
910
+ }
911
+ function asRecord(value) {
912
+ return typeof value === "object" && value !== null ? value : null;
913
+ }
914
+ function chooseSavedPath(downloadDir, name, fileId) {
915
+ let safe = path.basename(name || "download")
916
+ .replace(/[<>:"/\\|?*\x00-\x1F]/g, "_")
917
+ .replace(/[. ]+$/g, "")
918
+ .slice(0, 180) || "download";
919
+ if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(safe))
920
+ safe = `_${safe}`;
921
+ const direct = path.join(downloadDir, safe);
922
+ if (!existsSync(direct))
923
+ return direct;
924
+ const parsed = path.parse(safe);
925
+ const tagged = path.join(downloadDir, `${parsed.name}_${fileId.slice(0, 8)}${parsed.ext}`);
926
+ if (!existsSync(tagged))
927
+ return tagged;
928
+ for (let suffix = 2; suffix < 100_000; suffix += 1) {
929
+ const candidate = path.join(downloadDir, `${parsed.name}_${fileId.slice(0, 8)}_${suffix}${parsed.ext}`);
930
+ if (!existsSync(candidate))
931
+ return candidate;
932
+ }
933
+ throw new Error(`could not choose an unused destination for ${safe}`);
934
+ }
935
+ function guessMimeType(filePath) {
936
+ const known = {
937
+ ".png": "image/png",
938
+ ".jpg": "image/jpeg",
939
+ ".jpeg": "image/jpeg",
940
+ ".gif": "image/gif",
941
+ ".webp": "image/webp",
942
+ ".txt": "text/plain",
943
+ ".md": "text/markdown",
944
+ ".json": "application/json",
945
+ ".pdf": "application/pdf",
946
+ ".zip": "application/zip",
947
+ ".gz": "application/gzip",
948
+ ".tar": "application/x-tar",
949
+ ".mp4": "video/mp4",
950
+ ".mp3": "audio/mpeg",
951
+ };
952
+ return known[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
953
+ }
954
+ function errorMessage(error) {
955
+ return error instanceof Error ? error.message : String(error);
956
+ }
957
+ export { ChunkMap };