@vdoninja/ninja-p2p 0.1.2 → 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 (47) 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 -825
  6. package/dashboard.html +370 -50
  7. package/dist/agent-state.js +17 -1
  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 +17 -1
  21. package/dist/message-bus.js +55 -16
  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 +280 -30
  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/images/agent-room-dashboard.png +0 -0
  43. package/docs/protocol-and-reliability.md +231 -0
  44. package/docs/sdk-wishlist.md +236 -0
  45. package/docs/security.md +197 -0
  46. package/docs/social-stream-bridge.md +390 -0
  47. package/package.json +125 -113
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Swarm Session
3
+ *
4
+ * Holds the live state of one file transfer: what we have, what each peer has,
5
+ * what is in flight, and who has been reliable. It is transport-agnostic — it
6
+ * emits requests and chunks through a `SwarmSend` interface — so the whole
7
+ * state machine is testable without a network.
8
+ *
9
+ * The property that matters: a session serves any chunk it has verified, even
10
+ * while it is still downloading the rest. A peer that is 10% done is already a
11
+ * source for that 10%, which is what turns a one-to-one transfer into a swarm.
12
+ */
13
+ import { ChunkMap, chunkLength, type SwarmManifest, type SwarmPeerStats } from "./swarm.js";
14
+ export declare const DEFAULT_REQUEST_TIMEOUT_MS = 15000;
15
+ /**
16
+ * Floor on the wait before a request is written off.
17
+ *
18
+ * Chosen to be far longer than any healthy delivery — chunks normally land in
19
+ * well under 200ms even with several downloaders sharing a seeder — so a peer
20
+ * that is merely busy is never mistaken for one that has dropped the request.
21
+ */
22
+ export declare const MIN_REQUEST_TIMEOUT_MS = 3000;
23
+ /** Round trips of grace before a request is assumed lost. */
24
+ export declare const REQUEST_TIMEOUT_RTT_MULTIPLIER = 12;
25
+ /** How the session reaches the network. */
26
+ export type SwarmSend = {
27
+ /**
28
+ * @returns false when the transport did not take the request, so the chunk
29
+ * can be planned again straight away rather than occupying a slot until it
30
+ * times out.
31
+ */
32
+ request(peerId: string, index: number): boolean;
33
+ /**
34
+ * @param binary Whether the requester said it can take raw bytes. The session
35
+ * passes the requester's preference straight through rather than deciding —
36
+ * only the transport knows whether it can honour it.
37
+ */
38
+ chunk(peerId: string, index: number, bytes: Uint8Array, binary: boolean): void;
39
+ /**
40
+ * Tell the listed peers which chunks we now hold. Either list being empty
41
+ * means there is nothing to say, and the transport should send nothing.
42
+ */
43
+ have(indexes: number[], toPeerIds: string[]): void;
44
+ /** Broadcast our full bitfield. */
45
+ announce(chunksBase64: string): void;
46
+ /** Send our bitfield to one peer only. */
47
+ announceTo(peerId: string, chunksBase64: string): void;
48
+ };
49
+ export type SwarmSessionOptions = {
50
+ manifest: SwarmManifest;
51
+ partPath: string;
52
+ savedPath: string;
53
+ send: SwarmSend;
54
+ /** Pre-seeded bitfield. A sender passes a full map; a receiver passes nothing. */
55
+ have?: ChunkMap;
56
+ now?: () => number;
57
+ requestTimeoutMs?: number;
58
+ maxInFlightPerPeer?: number;
59
+ maxInFlightTotal?: number;
60
+ /** Tie-break source for piece selection. Injectable for deterministic tests. */
61
+ random?: () => number;
62
+ log?: (message: string) => void;
63
+ };
64
+ export type SwarmProgress = {
65
+ fileId: string;
66
+ name: string;
67
+ size: number;
68
+ totalChunks: number;
69
+ haveChunks: number;
70
+ inFlight: number;
71
+ peers: number;
72
+ complete: boolean;
73
+ percent: number;
74
+ };
75
+ export type SwarmFinishResult = {
76
+ ok: boolean;
77
+ savedPath?: string;
78
+ error?: string;
79
+ /** The part bytes cannot be resumed safely and should be discarded. */
80
+ integrityFailure?: boolean;
81
+ };
82
+ export declare class SwarmSession {
83
+ readonly manifest: SwarmManifest;
84
+ readonly savedPath: string;
85
+ private readonly file;
86
+ private readonly send;
87
+ private readonly now;
88
+ private readonly requestTimeoutMs;
89
+ private readonly maxInFlightPerPeer?;
90
+ private readonly maxInFlightTotal?;
91
+ private readonly random?;
92
+ private readonly log;
93
+ private readonly have;
94
+ private readonly peerChunks;
95
+ private readonly peerStats;
96
+ private readonly inFlight;
97
+ /** Chunks acquired since the last flush, batched to keep the room quiet. */
98
+ private pendingHave;
99
+ /** Requests to a peer that have timed out with nothing arriving in between. */
100
+ private readonly consecutiveTimeouts;
101
+ private completed;
102
+ constructor(options: SwarmSessionOptions);
103
+ setPeerChunks(peerId: string, chunks: ChunkMap): void;
104
+ onPeerAnnounce(peerId: string, chunksBase64: string): void;
105
+ onPeerHave(peerId: string, index: number): void;
106
+ removePeer(peerId: string): void;
107
+ peerCount(): number;
108
+ statsFor(peerId: string): SwarmPeerStats | undefined;
109
+ /**
110
+ * Answer a peer's request. Works mid-download: if we hold and verified the
111
+ * chunk, we can serve it, whether or not the rest has arrived.
112
+ */
113
+ onChunkRequest(peerId: string, index: number, binary?: boolean): boolean;
114
+ /**
115
+ * Accept a delivered chunk. Returns false when it fails verification, in
116
+ * which case the sending peer is charged a failure and the chunk stays
117
+ * outstanding for someone else to serve.
118
+ */
119
+ onChunkData(peerId: string, index: number, data: Uint8Array): boolean;
120
+ /**
121
+ * Give up on everything outstanding without blaming anyone.
122
+ *
123
+ * Used when our own connection was replaced: every request was addressed to a
124
+ * path that no longer exists, so waiting out their timeouts wastes the length
125
+ * of a timeout per chunk. The peers did nothing wrong, so no failure is
126
+ * charged — scoring them down for our outage would send the next requests to
127
+ * the wrong places.
128
+ */
129
+ abandonInFlight(): number;
130
+ /** Expire outstanding requests that never came back, charging the peer. */
131
+ expireTimeouts(): number;
132
+ /**
133
+ * How long to wait on this peer before assuming the request is lost.
134
+ *
135
+ * Chunks are occasionally dropped even on a healthy local link — measured on
136
+ * an idle machine, one downloader in three lost a chunk and the flat 15s
137
+ * timeout turned a 2s transfer into 17s. A peer answering in 80ms does not
138
+ * need fifteen seconds of grace to prove it has failed.
139
+ *
140
+ * Scaling by measured round-trip time, with a floor far above any healthy
141
+ * delivery, shortens that without risking the opposite mistake: a peer
142
+ * serving several downloaders is slow rather than broken, and expiring it
143
+ * early costs twice — the chunk is re-requested, and the peer is charged a
144
+ * failure it did not earn. A peer we have never timed keeps the full ceiling.
145
+ */
146
+ private timeoutFor;
147
+ /**
148
+ * Peers that have gone quiet: several requests timed out with nothing
149
+ * arriving in between. One timeout is ordinary; a run of them with no
150
+ * deliveries is the signature of a path that is open but not carrying.
151
+ */
152
+ unresponsivePeers(threshold: number): string[];
153
+ /** Forget the quiet streak for a peer whose connection is being rebuilt. */
154
+ clearUnresponsive(peerId: string): void;
155
+ /** Plan and issue the next batch of chunk requests. Returns how many went out. */
156
+ pump(): number;
157
+ announce(): void;
158
+ /**
159
+ * Send any queued "I now hold these" news.
160
+ *
161
+ * A peer that already holds the whole file gains nothing from it, so only
162
+ * peers still missing something are told at all.
163
+ */
164
+ flushHave(): void;
165
+ /** Peers that still want something, so progress is only told to them. */
166
+ private peersMissingAnything;
167
+ /** Send our bitfield to one peer, without telling the whole room. */
168
+ announceTo(peerId: string): void;
169
+ /**
170
+ * Whether we hold a chunk this peer does not.
171
+ *
172
+ * Used to answer a peer's announce with our own, which is what lets a fresh
173
+ * downloader start immediately instead of waiting out the announce interval.
174
+ * Gating on "can I actually help" is what stops that reply turning into a
175
+ * broadcast storm: a peer we cannot help gets nothing back, so the exchange
176
+ * terminates after one round.
177
+ */
178
+ canHelp(peerId: string): boolean;
179
+ isComplete(): boolean;
180
+ chunkMap(): ChunkMap;
181
+ progress(): SwarmProgress;
182
+ /**
183
+ * Verify the assembled file and move it into place.
184
+ *
185
+ * Every chunk was already hash-checked on arrival, so this is belt and
186
+ * braces — but it is cheap next to the download and it is the only thing that
187
+ * catches a wrong manifest or a damaged part file.
188
+ */
189
+ finish(): SwarmFinishResult;
190
+ close(): void;
191
+ /** Drop the partial file. Used when a transfer is abandoned. */
192
+ discard(): void;
193
+ private inFlightForPeer;
194
+ }
195
+ /** Open a session over a file we already hold in full, ready to seed. */
196
+ export declare function createSeedSession(manifest: SwarmManifest, filePath: string, send: SwarmSend, options?: Partial<SwarmSessionOptions>): SwarmSession;
197
+ export { chunkLength };
@@ -0,0 +1,465 @@
1
+ /**
2
+ * Swarm Session
3
+ *
4
+ * Holds the live state of one file transfer: what we have, what each peer has,
5
+ * what is in flight, and who has been reliable. It is transport-agnostic — it
6
+ * emits requests and chunks through a `SwarmSend` interface — so the whole
7
+ * state machine is testable without a network.
8
+ *
9
+ * The property that matters: a session serves any chunk it has verified, even
10
+ * while it is still downloading the rest. A peer that is 10% done is already a
11
+ * source for that 10%, which is what turns a one-to-one transfer into a swarm.
12
+ */
13
+ import { constants, copyFileSync, existsSync, linkSync, rmSync } from "node:fs";
14
+ import path from "node:path";
15
+ import { ChunkFile, ChunkMap, chunkLength, createPeerStats, planChunkRequests, recordPeerRtt, sha256FileHex, } from "./swarm.js";
16
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
17
+ /**
18
+ * Floor on the wait before a request is written off.
19
+ *
20
+ * Chosen to be far longer than any healthy delivery — chunks normally land in
21
+ * well under 200ms even with several downloaders sharing a seeder — so a peer
22
+ * that is merely busy is never mistaken for one that has dropped the request.
23
+ */
24
+ export const MIN_REQUEST_TIMEOUT_MS = 3_000;
25
+ /** Round trips of grace before a request is assumed lost. */
26
+ export const REQUEST_TIMEOUT_RTT_MULTIPLIER = 12;
27
+ export class SwarmSession {
28
+ manifest;
29
+ savedPath;
30
+ file;
31
+ send;
32
+ now;
33
+ requestTimeoutMs;
34
+ maxInFlightPerPeer;
35
+ maxInFlightTotal;
36
+ random;
37
+ log;
38
+ have;
39
+ peerChunks = new Map();
40
+ peerStats = new Map();
41
+ inFlight = new Map();
42
+ /** Chunks acquired since the last flush, batched to keep the room quiet. */
43
+ pendingHave = [];
44
+ /** Requests to a peer that have timed out with nothing arriving in between. */
45
+ consecutiveTimeouts = new Map();
46
+ completed = false;
47
+ constructor(options) {
48
+ this.manifest = options.manifest;
49
+ this.savedPath = options.savedPath;
50
+ this.send = options.send;
51
+ this.now = options.now ?? (() => Date.now());
52
+ this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
53
+ this.maxInFlightPerPeer = options.maxInFlightPerPeer;
54
+ this.maxInFlightTotal = options.maxInFlightTotal;
55
+ this.random = options.random;
56
+ this.log = options.log ?? (() => { });
57
+ // A seed session is handed a pre-filled bitfield and points at the user's
58
+ // own file; a download owns its part file and must not share it.
59
+ this.file = new ChunkFile(options.partPath, options.manifest, options.have === undefined);
60
+ // A download credits whatever a previous, interrupted run already verified.
61
+ this.have = options.have ?? this.file.scanVerified();
62
+ }
63
+ // ── Peer tracking ──────────────────────────────────────────────────────────
64
+ setPeerChunks(peerId, chunks) {
65
+ this.peerChunks.set(peerId, chunks);
66
+ if (!this.peerStats.has(peerId))
67
+ this.peerStats.set(peerId, createPeerStats());
68
+ }
69
+ onPeerAnnounce(peerId, chunksBase64) {
70
+ this.setPeerChunks(peerId, ChunkMap.fromBase64(chunksBase64, this.manifest.totalChunks));
71
+ }
72
+ onPeerHave(peerId, index) {
73
+ const existing = this.peerChunks.get(peerId) ?? new ChunkMap(this.manifest.totalChunks);
74
+ existing.set(index);
75
+ this.setPeerChunks(peerId, existing);
76
+ }
77
+ removePeer(peerId) {
78
+ this.peerChunks.delete(peerId);
79
+ this.peerStats.delete(peerId);
80
+ this.consecutiveTimeouts.delete(peerId);
81
+ // Anything outstanding with that peer is never arriving; free it to re-plan.
82
+ for (const [index, request] of [...this.inFlight]) {
83
+ if (request.peerId === peerId)
84
+ this.inFlight.delete(index);
85
+ }
86
+ }
87
+ peerCount() {
88
+ return this.peerChunks.size;
89
+ }
90
+ statsFor(peerId) {
91
+ return this.peerStats.get(peerId);
92
+ }
93
+ // ── Serving ────────────────────────────────────────────────────────────────
94
+ /**
95
+ * Answer a peer's request. Works mid-download: if we hold and verified the
96
+ * chunk, we can serve it, whether or not the rest has arrived.
97
+ */
98
+ onChunkRequest(peerId, index, binary = false) {
99
+ if (!this.have.has(index))
100
+ return false;
101
+ try {
102
+ const bytes = this.file.readChunk(index);
103
+ if (!bytes)
104
+ return false;
105
+ this.send.chunk(peerId, index, bytes, binary);
106
+ return true;
107
+ }
108
+ catch (error) {
109
+ this.log(`[swarm] could not serve chunk ${index} to ${peerId}: ${errorMessage(error)}`);
110
+ return false;
111
+ }
112
+ }
113
+ // ── Receiving ──────────────────────────────────────────────────────────────
114
+ /**
115
+ * Accept a delivered chunk. Returns false when it fails verification, in
116
+ * which case the sending peer is charged a failure and the chunk stays
117
+ * outstanding for someone else to serve.
118
+ */
119
+ onChunkData(peerId, index, data) {
120
+ const request = this.inFlight.get(index);
121
+ if (this.have.has(index)) {
122
+ // A duplicate from a slower peer. Harmless, and not a failure.
123
+ return true;
124
+ }
125
+ const stats = this.peerStats.get(peerId) ?? createPeerStats();
126
+ this.peerStats.set(peerId, stats);
127
+ let written = false;
128
+ try {
129
+ written = this.file.writeChunk(index, data);
130
+ }
131
+ catch (error) {
132
+ this.log(`[swarm] could not store chunk ${index}: ${errorMessage(error)}`);
133
+ // Only release a request when the failing delivery came from the peer we
134
+ // asked. An unsolicited bad chunk must not cancel a good peer's work.
135
+ if (request?.peerId === peerId)
136
+ this.inFlight.delete(index);
137
+ return false;
138
+ }
139
+ if (!written) {
140
+ if (request?.peerId === peerId)
141
+ this.inFlight.delete(index);
142
+ stats.failures += 1;
143
+ this.log(`[swarm] bad chunk ${index} from ${peerId} (${stats.failures} failure(s))`);
144
+ return false;
145
+ }
146
+ // The chunk is now satisfied regardless of which peer delivered it.
147
+ if (request)
148
+ this.inFlight.delete(index);
149
+ // Anything arriving proves the path works, whatever came before.
150
+ this.consecutiveTimeouts.delete(peerId);
151
+ // Only time requests we actually issued to this peer.
152
+ if (request && request.peerId === peerId) {
153
+ recordPeerRtt(stats, this.now() - request.sentAt);
154
+ }
155
+ stats.delivered += 1;
156
+ this.have.set(index);
157
+ // Queued rather than sent: one message per chunk per interested peer costs
158
+ // chunks x peers messages, all on the same control channel the chunk
159
+ // requests use. Measured with five downloaders of a 164-chunk file, that
160
+ // flood cost an order of magnitude of throughput. The pump flushes this as
161
+ // a batch, so a burst of arrivals becomes one message.
162
+ this.pendingHave.push(index);
163
+ return true;
164
+ }
165
+ // ── Driving ────────────────────────────────────────────────────────────────
166
+ /**
167
+ * Give up on everything outstanding without blaming anyone.
168
+ *
169
+ * Used when our own connection was replaced: every request was addressed to a
170
+ * path that no longer exists, so waiting out their timeouts wastes the length
171
+ * of a timeout per chunk. The peers did nothing wrong, so no failure is
172
+ * charged — scoring them down for our outage would send the next requests to
173
+ * the wrong places.
174
+ */
175
+ abandonInFlight() {
176
+ const abandoned = this.inFlight.size;
177
+ this.inFlight.clear();
178
+ return abandoned;
179
+ }
180
+ /** Expire outstanding requests that never came back, charging the peer. */
181
+ expireTimeouts() {
182
+ const now = this.now();
183
+ let expired = 0;
184
+ for (const [index, request] of [...this.inFlight]) {
185
+ if (now - request.sentAt < this.timeoutFor(request.peerId))
186
+ continue;
187
+ this.inFlight.delete(index);
188
+ const stats = this.peerStats.get(request.peerId) ?? createPeerStats();
189
+ stats.failures += 1;
190
+ this.peerStats.set(request.peerId, stats);
191
+ this.consecutiveTimeouts.set(request.peerId, (this.consecutiveTimeouts.get(request.peerId) ?? 0) + 1);
192
+ expired += 1;
193
+ this.log(`[swarm] request for chunk ${index} to ${request.peerId} timed out`);
194
+ }
195
+ return expired;
196
+ }
197
+ /**
198
+ * How long to wait on this peer before assuming the request is lost.
199
+ *
200
+ * Chunks are occasionally dropped even on a healthy local link — measured on
201
+ * an idle machine, one downloader in three lost a chunk and the flat 15s
202
+ * timeout turned a 2s transfer into 17s. A peer answering in 80ms does not
203
+ * need fifteen seconds of grace to prove it has failed.
204
+ *
205
+ * Scaling by measured round-trip time, with a floor far above any healthy
206
+ * delivery, shortens that without risking the opposite mistake: a peer
207
+ * serving several downloaders is slow rather than broken, and expiring it
208
+ * early costs twice — the chunk is re-requested, and the peer is charged a
209
+ * failure it did not earn. A peer we have never timed keeps the full ceiling.
210
+ */
211
+ timeoutFor(peerId) {
212
+ const rtt = this.peerStats.get(peerId)?.rttMs;
213
+ if (rtt === null || rtt === undefined)
214
+ return this.requestTimeoutMs;
215
+ return Math.min(this.requestTimeoutMs, Math.max(MIN_REQUEST_TIMEOUT_MS, rtt * REQUEST_TIMEOUT_RTT_MULTIPLIER));
216
+ }
217
+ /**
218
+ * Peers that have gone quiet: several requests timed out with nothing
219
+ * arriving in between. One timeout is ordinary; a run of them with no
220
+ * deliveries is the signature of a path that is open but not carrying.
221
+ */
222
+ unresponsivePeers(threshold) {
223
+ const out = [];
224
+ for (const [peerId, count] of this.consecutiveTimeouts) {
225
+ if (count >= threshold)
226
+ out.push(peerId);
227
+ }
228
+ return out;
229
+ }
230
+ /** Forget the quiet streak for a peer whose connection is being rebuilt. */
231
+ clearUnresponsive(peerId) {
232
+ this.consecutiveTimeouts.delete(peerId);
233
+ }
234
+ /** Plan and issue the next batch of chunk requests. Returns how many went out. */
235
+ pump() {
236
+ // Before the completion check: the batch containing the final chunk still
237
+ // has to reach the peers still downloading.
238
+ this.flushHave();
239
+ if (this.isComplete())
240
+ return 0;
241
+ this.expireTimeouts();
242
+ // Keep the part-file lock warm, so a slow but healthy transfer is never
243
+ // mistaken for one abandoned by a killed process.
244
+ this.file.touchLock();
245
+ const peers = [];
246
+ for (const [peerId, chunks] of this.peerChunks) {
247
+ peers.push({
248
+ peerId,
249
+ chunks,
250
+ inFlight: this.inFlightForPeer(peerId),
251
+ stats: this.peerStats.get(peerId),
252
+ });
253
+ }
254
+ const plans = planChunkRequests(this.have, peers, new Set(this.inFlight.keys()), {
255
+ maxInFlightPerPeer: this.maxInFlightPerPeer,
256
+ maxInFlightTotal: this.maxInFlightTotal,
257
+ random: this.random,
258
+ });
259
+ let issued = 0;
260
+ for (const plan of plans) {
261
+ // Only occupy a slot if the request actually left. Assuming it did meant
262
+ // a request lost to a momentary transport hiccup held its chunk hostage
263
+ // for the full request timeout — measured as a 60s stall after a
264
+ // signalling blip that the data channel itself sailed through.
265
+ if (!this.send.request(plan.peerId, plan.index))
266
+ continue;
267
+ this.inFlight.set(plan.index, {
268
+ peerId: plan.peerId,
269
+ index: plan.index,
270
+ sentAt: this.now(),
271
+ });
272
+ issued += 1;
273
+ }
274
+ return issued;
275
+ }
276
+ announce() {
277
+ this.send.announce(this.have.toBase64());
278
+ }
279
+ /**
280
+ * Send any queued "I now hold these" news.
281
+ *
282
+ * A peer that already holds the whole file gains nothing from it, so only
283
+ * peers still missing something are told at all.
284
+ */
285
+ flushHave() {
286
+ if (this.pendingHave.length === 0)
287
+ return;
288
+ const indexes = this.pendingHave;
289
+ this.pendingHave = [];
290
+ const interested = this.peersMissingAnything();
291
+ if (interested.length === 0)
292
+ return;
293
+ this.send.have(indexes, interested);
294
+ }
295
+ /** Peers that still want something, so progress is only told to them. */
296
+ peersMissingAnything() {
297
+ const interested = [];
298
+ for (const [peerId, chunks] of this.peerChunks) {
299
+ if (!chunks.isComplete())
300
+ interested.push(peerId);
301
+ }
302
+ return interested;
303
+ }
304
+ /** Send our bitfield to one peer, without telling the whole room. */
305
+ announceTo(peerId) {
306
+ this.send.announceTo(peerId, this.have.toBase64());
307
+ }
308
+ /**
309
+ * Whether we hold a chunk this peer does not.
310
+ *
311
+ * Used to answer a peer's announce with our own, which is what lets a fresh
312
+ * downloader start immediately instead of waiting out the announce interval.
313
+ * Gating on "can I actually help" is what stops that reply turning into a
314
+ * broadcast storm: a peer we cannot help gets nothing back, so the exchange
315
+ * terminates after one round.
316
+ */
317
+ canHelp(peerId) {
318
+ const theirs = this.peerChunks.get(peerId);
319
+ if (!theirs)
320
+ return false;
321
+ for (let index = 0; index < this.manifest.totalChunks; index += 1) {
322
+ if (this.have.has(index) && !theirs.has(index))
323
+ return true;
324
+ }
325
+ return false;
326
+ }
327
+ // ── State ──────────────────────────────────────────────────────────────────
328
+ isComplete() {
329
+ return this.have.isComplete();
330
+ }
331
+ chunkMap() {
332
+ return this.have;
333
+ }
334
+ progress() {
335
+ const haveChunks = this.have.count();
336
+ return {
337
+ fileId: this.manifest.fileId,
338
+ name: this.manifest.name,
339
+ size: this.manifest.size,
340
+ totalChunks: this.manifest.totalChunks,
341
+ haveChunks,
342
+ inFlight: this.inFlight.size,
343
+ peers: this.peerChunks.size,
344
+ complete: this.have.isComplete(),
345
+ percent: this.manifest.totalChunks === 0
346
+ ? 100
347
+ : Math.floor((haveChunks / this.manifest.totalChunks) * 100),
348
+ };
349
+ }
350
+ /**
351
+ * Verify the assembled file and move it into place.
352
+ *
353
+ * Every chunk was already hash-checked on arrival, so this is belt and
354
+ * braces — but it is cheap next to the download and it is the only thing that
355
+ * catches a wrong manifest or a damaged part file.
356
+ */
357
+ finish() {
358
+ if (!this.isComplete()) {
359
+ return { ok: false, error: `incomplete: ${this.have.count()}/${this.manifest.totalChunks} chunks` };
360
+ }
361
+ if (this.completed) {
362
+ return { ok: true, savedPath: this.savedPath };
363
+ }
364
+ // Seed sessions point at the final file already. `finish()` is harmless on
365
+ // them rather than trying to move a file onto itself and reporting EEXIST.
366
+ if (path.resolve(this.file.filePath) === path.resolve(this.savedPath)) {
367
+ this.completed = true;
368
+ return { ok: true, savedPath: this.savedPath };
369
+ }
370
+ // Close the data handle but keep the destination lock until verification
371
+ // and the final move both finish. Releasing it first lets a second process
372
+ // claim and mutate the same part file during a long final hash.
373
+ this.file.closeHandle();
374
+ try {
375
+ const assembled = sha256FileHex(this.file.filePath);
376
+ if (assembled.size !== this.manifest.size) {
377
+ return {
378
+ ok: false,
379
+ error: `size mismatch: ${assembled.size} != ${this.manifest.size}`,
380
+ integrityFailure: true,
381
+ };
382
+ }
383
+ if (assembled.sha256 !== this.manifest.fileId) {
384
+ return { ok: false, error: "sha256 mismatch on the assembled file", integrityFailure: true };
385
+ }
386
+ moveFileExclusive(this.file.filePath, this.savedPath);
387
+ this.completed = true;
388
+ return { ok: true, savedPath: this.savedPath };
389
+ }
390
+ catch (error) {
391
+ return { ok: false, error: errorMessage(error) };
392
+ }
393
+ finally {
394
+ this.file.releaseExclusiveLock();
395
+ }
396
+ }
397
+ close() {
398
+ this.file.close();
399
+ }
400
+ /** Drop the partial file. Used when a transfer is abandoned. */
401
+ discard() {
402
+ this.file.close();
403
+ if (existsSync(this.file.filePath))
404
+ rmSync(this.file.filePath, { force: true });
405
+ }
406
+ inFlightForPeer(peerId) {
407
+ let count = 0;
408
+ for (const request of this.inFlight.values()) {
409
+ if (request.peerId === peerId)
410
+ count += 1;
411
+ }
412
+ return count;
413
+ }
414
+ }
415
+ /** Open a session over a file we already hold in full, ready to seed. */
416
+ export function createSeedSession(manifest, filePath, send, options = {}) {
417
+ return new SwarmSession({
418
+ ...options,
419
+ manifest,
420
+ partPath: filePath,
421
+ savedPath: filePath,
422
+ have: ChunkMap.full(manifest.totalChunks),
423
+ send,
424
+ });
425
+ }
426
+ export { chunkLength };
427
+ /**
428
+ * Move without overwriting an existing destination and without assuming the
429
+ * temp directory shares a filesystem with the user's download directory.
430
+ */
431
+ function moveFileExclusive(source, destination) {
432
+ let linked = false;
433
+ try {
434
+ linkSync(source, destination);
435
+ linked = true;
436
+ }
437
+ catch (error) {
438
+ const code = error.code;
439
+ if (code === "EEXIST")
440
+ throw error;
441
+ if (!["EXDEV", "EPERM", "EACCES", "ENOTSUP", "UNKNOWN"].includes(code ?? "")) {
442
+ throw error;
443
+ }
444
+ }
445
+ if (linked) {
446
+ try {
447
+ rmSync(source, { force: true });
448
+ }
449
+ catch {
450
+ // Destination is already complete and immutable from our perspective.
451
+ }
452
+ return;
453
+ }
454
+ copyFileSync(source, destination, constants.COPYFILE_EXCL);
455
+ try {
456
+ rmSync(source, { force: true });
457
+ }
458
+ catch {
459
+ // The completed destination is authoritative; a stale part can be cleaned
460
+ // on the next run without turning a successful transfer into a failure.
461
+ }
462
+ }
463
+ function errorMessage(error) {
464
+ return error instanceof Error ? error.message : String(error);
465
+ }