@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
package/dist/protocol.js CHANGED
@@ -6,6 +6,31 @@
6
6
  * internals — it can be extracted and reused by any bot.
7
7
  */
8
8
  import { randomBytes, randomUUID } from "node:crypto";
9
+ /**
10
+ * Messages that are only worth delivering right now.
11
+ *
12
+ * Transfer traffic is high volume and stateful: a chunk request replayed from
13
+ * an offline queue minutes later asks for something the requester already has,
14
+ * while replaying half of a simple file transfer cannot restore its missing
15
+ * half. A history ring full of chunks also evicts every real message a peer
16
+ * asking for history actually wanted. Swarm state is re-announced; a rejected
17
+ * simple transfer fails explicitly and can be retried from the start.
18
+ */
19
+ export const TRANSIENT_MESSAGE_TYPES = new Set([
20
+ "file_offer",
21
+ "file_chunk",
22
+ "file_complete",
23
+ "swarm_offer",
24
+ "swarm_manifest_request",
25
+ "swarm_manifest_page",
26
+ "swarm_announce",
27
+ "swarm_request",
28
+ "swarm_chunk",
29
+ "swarm_have",
30
+ ]);
31
+ export function isTransientType(type) {
32
+ return TRANSIENT_MESSAGE_TYPES.has(type);
33
+ }
9
34
  // ── Helpers ──────────────────────────────────────────────────────────────────
10
35
  /** Generate a short unique message ID. */
11
36
  export function createMessageId() {
@@ -37,13 +62,24 @@ export function isValidEnvelope(data) {
37
62
  if (typeof data !== "object" || data === null)
38
63
  return false;
39
64
  const d = data;
65
+ const from = typeof d.from === "object" && d.from !== null
66
+ ? d.from
67
+ : null;
40
68
  return (d.v === 1 &&
41
- typeof d.id === "string" &&
42
- typeof d.type === "string" &&
43
- typeof d.from === "object" &&
44
- d.from !== null &&
45
- typeof d.from.streamId === "string" &&
46
- typeof d.ts === "number");
69
+ isBoundedText(d.id, 128) &&
70
+ isBoundedText(d.type, 64) &&
71
+ from !== null &&
72
+ isBoundedText(from.streamId, 128) &&
73
+ isBoundedText(from.role, 64) &&
74
+ isBoundedText(from.name, 256) &&
75
+ isBoundedText(from.instanceId, 128) &&
76
+ typeof d.ts === "number" &&
77
+ Number.isFinite(d.ts) &&
78
+ (d.to === null || d.to === undefined || isBoundedText(d.to, 128)) &&
79
+ (d.topic === null || d.topic === undefined || isBoundedText(d.topic, 256)));
80
+ }
81
+ function isBoundedText(value, maxLength) {
82
+ return typeof value === "string" && value.length > 0 && value.length <= maxLength && !value.includes("\0");
47
83
  }
48
84
  /** Parse raw data received from a data channel into an envelope. Returns null on failure. */
49
85
  export function parseEnvelope(raw) {
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, statSync } from "node:fs";
1
+ import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  export function parseSharedFolderSpecs(rawValues, cwd = process.cwd()) {
4
4
  const shares = [];
@@ -114,11 +114,30 @@ function resolveSharedPath(share, relativePath) {
114
114
  if (!existsSync(target)) {
115
115
  throw new Error(`shared path does not exist: ${normalized || "."}`);
116
116
  }
117
+ // The check above is lexical, so a symlink sitting inside the share and
118
+ // pointing outside it would still pass. Compare the real paths too, or
119
+ // `--share docs=./docs` could hand out anything the link reaches.
120
+ assertRealPathContained(share.path, target);
117
121
  return {
118
122
  filePath: target,
119
123
  path: normalized,
120
124
  };
121
125
  }
126
+ function assertRealPathContained(shareRoot, target) {
127
+ let realRoot;
128
+ let realTarget;
129
+ try {
130
+ realRoot = realpathSync(shareRoot);
131
+ realTarget = realpathSync(target);
132
+ }
133
+ catch {
134
+ // If either path cannot be resolved, refuse rather than guess.
135
+ throw new Error("shared path escapes the declared folder");
136
+ }
137
+ if (realTarget !== realRoot && !realTarget.startsWith(`${realRoot}${path.sep}`)) {
138
+ throw new Error("shared path escapes the declared folder");
139
+ }
140
+ }
122
141
  function normalizeRelativeSharePath(relativePath) {
123
142
  const raw = relativePath.replace(/\\/g, "/").trim();
124
143
  if (!raw)
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Social Stream Ninja Bridge
3
+ *
4
+ * Pipes live chat from Twitch, YouTube, Kick and everything else Social Stream
5
+ * Ninja aggregates into a ninja-p2p room as topic events, and lets agents in
6
+ * that room talk back out to every connected platform at once.
7
+ *
8
+ * This uses SSN's documented WebSocket API and requires no changes to SSN
9
+ * itself. Channel 4 carries chat from the extension; channel 1 is where the
10
+ * extension listens for commands, so the bridge joins as `in=4, out=1`.
11
+ *
12
+ * See docs/social-stream-bridge.md for setup and for the direct WebRTC path
13
+ * that would remove the relay entirely.
14
+ */
15
+ export declare const DEFAULT_SSN_HOST = "wss://io.socialstream.ninja";
16
+ export declare const DEFAULT_SSN_IN_CHANNEL = 4;
17
+ export declare const DEFAULT_SSN_OUT_CHANNEL = 1;
18
+ export declare const DEFAULT_SSN_TOPIC = "social";
19
+ export type SocialMessage = {
20
+ id: string | null;
21
+ platform: string;
22
+ author: string;
23
+ text: string;
24
+ avatar: string | null;
25
+ donation: string | null;
26
+ membership: string | null;
27
+ event: string | null;
28
+ sourceName: string | null;
29
+ moderator: boolean;
30
+ bot: boolean;
31
+ };
32
+ /** Minimal WebSocket surface, so tests can inject a fake. */
33
+ export type SocialSocket = {
34
+ send(data: string): void;
35
+ close(): void;
36
+ onopen: ((event?: unknown) => void) | null;
37
+ onmessage: ((event: {
38
+ data: unknown;
39
+ }) => void) | null;
40
+ onerror: ((event?: unknown) => void) | null;
41
+ onclose: ((event?: unknown) => void) | null;
42
+ };
43
+ export type SocialSocketFactory = (url: string) => SocialSocket;
44
+ type SocialSocketConstructor = new (url: string) => SocialSocket;
45
+ export type SocialStreamBridgeOptions = {
46
+ session: string;
47
+ host?: string;
48
+ inChannel?: number;
49
+ outChannel?: number;
50
+ onMessage: (message: SocialMessage) => void;
51
+ log?: (message: string) => void;
52
+ socketFactory?: SocialSocketFactory;
53
+ /** Reconnect backoff steps in ms; the last value repeats. */
54
+ backoffMs?: number[];
55
+ };
56
+ export declare function buildSocialStreamUrl(session: string, host?: string, inChannel?: number, outChannel?: number): string;
57
+ /**
58
+ * Convert one raw SSN payload into a flat message, or null if it is not chat.
59
+ *
60
+ * The channel carries far more than chat — callbacks, waitlist state, poll
61
+ * updates, queue sizes. Everything SSN considers a message has `chatname`, so
62
+ * that is the gate. Anything without it is control traffic an agent should not
63
+ * be woken for.
64
+ */
65
+ export declare function normalizeSocialMessage(raw: unknown): SocialMessage | null;
66
+ /** A one-line human summary, useful for logs and chat relays. */
67
+ export declare function describeSocialMessage(message: SocialMessage): string;
68
+ export declare class SocialStreamBridge {
69
+ private readonly options;
70
+ private readonly socketFactory;
71
+ private readonly log;
72
+ private readonly backoffMs;
73
+ private socket;
74
+ private attempt;
75
+ private connected;
76
+ private closed;
77
+ private retryTimer;
78
+ constructor(options: SocialStreamBridgeOptions);
79
+ get url(): string;
80
+ isConnected(): boolean;
81
+ connect(): void;
82
+ /**
83
+ * Send a chat message out to every platform SSN is connected to.
84
+ * Returns false when the socket is not ready, so callers can report honestly
85
+ * rather than silently dropping an agent's reply.
86
+ */
87
+ sendChat(text: string): boolean;
88
+ sendAction(action: string, value?: unknown): boolean;
89
+ close(): void;
90
+ private scheduleReconnect;
91
+ }
92
+ /**
93
+ * Pick the platform WebSocket when present, with `ws` as the Node 20 fallback.
94
+ *
95
+ * Node 20 is the package's supported floor but does not expose WebSocket by
96
+ * default. The bridge used to advertise Node 20 support and then fail at
97
+ * runtime on exactly that version.
98
+ */
99
+ export declare function resolveSocialSocketConstructor(platform?: SocialSocketConstructor | null | undefined, load?: () => unknown): SocialSocketConstructor;
100
+ export {};
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Social Stream Ninja Bridge
3
+ *
4
+ * Pipes live chat from Twitch, YouTube, Kick and everything else Social Stream
5
+ * Ninja aggregates into a ninja-p2p room as topic events, and lets agents in
6
+ * that room talk back out to every connected platform at once.
7
+ *
8
+ * This uses SSN's documented WebSocket API and requires no changes to SSN
9
+ * itself. Channel 4 carries chat from the extension; channel 1 is where the
10
+ * extension listens for commands, so the bridge joins as `in=4, out=1`.
11
+ *
12
+ * See docs/social-stream-bridge.md for setup and for the direct WebRTC path
13
+ * that would remove the relay entirely.
14
+ */
15
+ import { createRequire } from "node:module";
16
+ export const DEFAULT_SSN_HOST = "wss://io.socialstream.ninja";
17
+ export const DEFAULT_SSN_IN_CHANNEL = 4;
18
+ export const DEFAULT_SSN_OUT_CHANNEL = 1;
19
+ export const DEFAULT_SSN_TOPIC = "social";
20
+ const requireFromHere = createRequire(import.meta.url);
21
+ export function buildSocialStreamUrl(session, host = DEFAULT_SSN_HOST, inChannel = DEFAULT_SSN_IN_CHANNEL, outChannel = DEFAULT_SSN_OUT_CHANNEL) {
22
+ const trimmedHost = host.replace(/\/+$/, "");
23
+ return `${trimmedHost}/join/${encodeURIComponent(session)}/${inChannel}/${outChannel}`;
24
+ }
25
+ /**
26
+ * Convert one raw SSN payload into a flat message, or null if it is not chat.
27
+ *
28
+ * The channel carries far more than chat — callbacks, waitlist state, poll
29
+ * updates, queue sizes. Everything SSN considers a message has `chatname`, so
30
+ * that is the gate. Anything without it is control traffic an agent should not
31
+ * be woken for.
32
+ */
33
+ export function normalizeSocialMessage(raw) {
34
+ if (typeof raw !== "object" || raw === null)
35
+ return null;
36
+ const data = raw;
37
+ const author = asText(data.chatname);
38
+ if (!author)
39
+ return null;
40
+ const message = asText(data.chatmessage);
41
+ // chatmessage may carry sanitized emote markup unless textonly is set.
42
+ // Agents want words, so strip tags but keep the original shape intact.
43
+ const text = data.textonly === true ? message : stripHtml(message);
44
+ return {
45
+ id: asText(data.id) || null,
46
+ platform: asText(data.type).toLowerCase() || "unknown",
47
+ author,
48
+ text,
49
+ avatar: asText(data.chatimg) || null,
50
+ donation: asText(data.hasDonation) || null,
51
+ membership: asText(data.membership) || null,
52
+ event: typeof data.event === "string" && data.event.trim() ? data.event.trim() : null,
53
+ sourceName: asText(data.sourceName) || null,
54
+ moderator: data.moderator === true,
55
+ bot: data.bot === true,
56
+ };
57
+ }
58
+ /** A one-line human summary, useful for logs and chat relays. */
59
+ export function describeSocialMessage(message) {
60
+ const parts = [`[${message.platform}] ${message.author}`];
61
+ if (message.donation)
62
+ parts.push(`(${message.donation})`);
63
+ if (message.event)
64
+ parts.push(`<${message.event}>`);
65
+ return `${parts.join(" ")}: ${message.text}`;
66
+ }
67
+ export class SocialStreamBridge {
68
+ options;
69
+ socketFactory;
70
+ log;
71
+ backoffMs;
72
+ socket = null;
73
+ attempt = 0;
74
+ connected = false;
75
+ closed = false;
76
+ retryTimer = null;
77
+ constructor(options) {
78
+ if (!options.session.trim()) {
79
+ throw new Error("social stream bridge requires a session id");
80
+ }
81
+ this.options = options;
82
+ this.socketFactory = options.socketFactory ?? defaultSocketFactory;
83
+ this.log = options.log ?? (() => { });
84
+ this.backoffMs = options.backoffMs ?? [1000, 2000, 5000, 10_000, 30_000];
85
+ }
86
+ get url() {
87
+ return buildSocialStreamUrl(this.options.session, this.options.host, this.options.inChannel, this.options.outChannel);
88
+ }
89
+ isConnected() {
90
+ return this.connected;
91
+ }
92
+ connect() {
93
+ if (this.closed || this.socket)
94
+ return;
95
+ let socket;
96
+ try {
97
+ socket = this.socketFactory(this.url);
98
+ }
99
+ catch (error) {
100
+ this.log(`[ssn] connect failed: ${errorMessage(error)}`);
101
+ this.scheduleReconnect();
102
+ return;
103
+ }
104
+ this.socket = socket;
105
+ socket.onopen = () => {
106
+ this.connected = true;
107
+ this.attempt = 0;
108
+ this.log(`[ssn] connected to session ${this.options.session}`);
109
+ };
110
+ socket.onmessage = (event) => {
111
+ const payload = parseJson(event?.data);
112
+ if (payload === undefined)
113
+ return;
114
+ const message = normalizeSocialMessage(payload);
115
+ if (!message)
116
+ return;
117
+ try {
118
+ this.options.onMessage(message);
119
+ }
120
+ catch (error) {
121
+ this.log(`[ssn] handler error: ${errorMessage(error)}`);
122
+ }
123
+ };
124
+ socket.onerror = () => {
125
+ this.log("[ssn] socket error");
126
+ };
127
+ socket.onclose = () => {
128
+ this.connected = false;
129
+ this.socket = null;
130
+ if (this.closed)
131
+ return;
132
+ this.log("[ssn] disconnected");
133
+ this.scheduleReconnect();
134
+ };
135
+ }
136
+ /**
137
+ * Send a chat message out to every platform SSN is connected to.
138
+ * Returns false when the socket is not ready, so callers can report honestly
139
+ * rather than silently dropping an agent's reply.
140
+ */
141
+ sendChat(text) {
142
+ return this.sendAction("sendChat", text);
143
+ }
144
+ sendAction(action, value) {
145
+ if (!this.socket || !this.connected)
146
+ return false;
147
+ try {
148
+ const payload = { action };
149
+ if (value !== undefined)
150
+ payload.value = value;
151
+ this.socket.send(JSON.stringify(payload));
152
+ return true;
153
+ }
154
+ catch (error) {
155
+ this.log(`[ssn] send failed: ${errorMessage(error)}`);
156
+ return false;
157
+ }
158
+ }
159
+ close() {
160
+ this.closed = true;
161
+ this.connected = false;
162
+ if (this.retryTimer) {
163
+ clearTimeout(this.retryTimer);
164
+ this.retryTimer = null;
165
+ }
166
+ const socket = this.socket;
167
+ this.socket = null;
168
+ try {
169
+ socket?.close();
170
+ }
171
+ catch { /* best effort */ }
172
+ }
173
+ scheduleReconnect() {
174
+ if (this.closed || this.retryTimer)
175
+ return;
176
+ const delay = this.backoffMs[Math.min(this.attempt, this.backoffMs.length - 1)];
177
+ this.attempt += 1;
178
+ this.log(`[ssn] reconnecting in ${delay}ms`);
179
+ this.retryTimer = setTimeout(() => {
180
+ this.retryTimer = null;
181
+ this.connect();
182
+ }, delay);
183
+ if (typeof this.retryTimer.unref === "function")
184
+ this.retryTimer.unref();
185
+ }
186
+ }
187
+ /**
188
+ * Pick the platform WebSocket when present, with `ws` as the Node 20 fallback.
189
+ *
190
+ * Node 20 is the package's supported floor but does not expose WebSocket by
191
+ * default. The bridge used to advertise Node 20 support and then fail at
192
+ * runtime on exactly that version.
193
+ */
194
+ export function resolveSocialSocketConstructor(platform = globalThis.WebSocket, load = () => requireFromHere("ws")) {
195
+ if (platform)
196
+ return platform;
197
+ let loaded;
198
+ try {
199
+ loaded = load();
200
+ }
201
+ catch (error) {
202
+ throw new Error(`no WebSocket implementation available: ${errorMessage(error)}`);
203
+ }
204
+ const module = loaded;
205
+ const implementation = typeof module === "function"
206
+ ? module
207
+ : module.WebSocket ?? module.default;
208
+ if (typeof implementation !== "function") {
209
+ throw new Error("the ws package did not export a WebSocket constructor");
210
+ }
211
+ return implementation;
212
+ }
213
+ function defaultSocketFactory(url) {
214
+ const Impl = resolveSocialSocketConstructor();
215
+ return new Impl(url);
216
+ }
217
+ function parseJson(data) {
218
+ const text = typeof data === "string"
219
+ ? data
220
+ : (data && typeof data.toString === "function")
221
+ ? String(data)
222
+ : null;
223
+ if (!text)
224
+ return undefined;
225
+ try {
226
+ return JSON.parse(text);
227
+ }
228
+ catch {
229
+ return undefined;
230
+ }
231
+ }
232
+ function stripHtml(value) {
233
+ return value
234
+ .replace(/<br\s*\/?>/gi, " ")
235
+ .replace(/<[^>]*>/g, "")
236
+ .replace(/&nbsp;/gi, " ")
237
+ .replace(/&amp;/gi, "&")
238
+ .replace(/&lt;/gi, "<")
239
+ .replace(/&gt;/gi, ">")
240
+ .replace(/&quot;/gi, '"')
241
+ .replace(/&#39;/gi, "'")
242
+ .replace(/\s+/g, " ")
243
+ .trim();
244
+ }
245
+ function asText(value) {
246
+ if (typeof value === "string")
247
+ return value.trim();
248
+ if (typeof value === "number" && Number.isFinite(value))
249
+ return String(value);
250
+ return "";
251
+ }
252
+ function errorMessage(error) {
253
+ return error instanceof Error ? error.message : String(error);
254
+ }
@@ -0,0 +1,164 @@
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 type { VDOBridge } from "./vdo-bridge.js";
13
+ import { ChunkMap, type SwarmManifest, type SwarmManifestSummary } from "./swarm.js";
14
+ import { SwarmSession, type SwarmProgress } from "./swarm-session.js";
15
+ export declare const DEFAULT_PUMP_INTERVAL_MS = 250;
16
+ export declare const DEFAULT_ANNOUNCE_INTERVAL_MS = 5000;
17
+ /** Floor on how often one peer can be answered about one file. */
18
+ export declare const ANNOUNCE_REPLY_MIN_INTERVAL_MS = 1000;
19
+ /** Consecutive timeouts, with nothing delivered between, before rebuilding a path. */
20
+ export declare const UNRESPONSIVE_TIMEOUT_THRESHOLD = 3;
21
+ /** Floor between rebuild attempts for one peer, so a dead peer is not hammered. */
22
+ export declare const REVIVE_MIN_INTERVAL_MS = 20000;
23
+ /** Retry a lost manifest page request without flooding the control channel. */
24
+ export declare const MANIFEST_REQUEST_RETRY_MS = 2000;
25
+ /** Bound both request messages and the burst of page responses they trigger. */
26
+ export declare const MANIFEST_PAGES_PER_REQUEST = 8;
27
+ export type SwarmCompletion = {
28
+ fileId: string;
29
+ manifest: SwarmManifest;
30
+ savedPath: string;
31
+ };
32
+ export type SwarmManagerOptions = {
33
+ bridge: VDOBridge;
34
+ /** Where finished files land. */
35
+ downloadDir: string;
36
+ /** Where in-progress `.part` files live. */
37
+ workDir: string;
38
+ pumpIntervalMs?: number;
39
+ announceIntervalMs?: number;
40
+ /** Outstanding chunk requests allowed per peer. */
41
+ maxInFlightPerPeer?: number;
42
+ /** Outstanding chunk requests allowed across all peers, as a memory bound. */
43
+ maxInFlightTotal?: number;
44
+ log?: (message: string) => void;
45
+ onComplete?: (completion: SwarmCompletion) => void;
46
+ onProgress?: (progress: SwarmProgress) => void;
47
+ onError?: (message: string, manifest?: SwarmManifestSummary) => void;
48
+ };
49
+ export type SwarmOfferInfo = SwarmManifestSummary & {
50
+ manifestReady: boolean;
51
+ };
52
+ export declare class SwarmManager {
53
+ private readonly bridge;
54
+ private readonly downloadDir;
55
+ private readonly workDir;
56
+ private readonly pumpIntervalMs;
57
+ private readonly announceIntervalMs;
58
+ private readonly maxInFlightPerPeer?;
59
+ private readonly maxInFlightTotal?;
60
+ private readonly log;
61
+ private readonly onComplete?;
62
+ private readonly onProgress?;
63
+ private readonly onError?;
64
+ private readonly sessions;
65
+ private readonly offers;
66
+ /** Files asked for before their offer arrived. */
67
+ private readonly wanted;
68
+ private readonly seeding;
69
+ /** `fileId:peerId` -> when we last answered that peer's announce. */
70
+ private readonly lastAnnounceReply;
71
+ /** peerId -> when its connection was last rebuilt. */
72
+ private readonly lastRevive;
73
+ private pumpTimer;
74
+ private announceTimer;
75
+ private coalesceTimer;
76
+ private started;
77
+ constructor(options: SwarmManagerOptions);
78
+ start(): void;
79
+ stop(): void;
80
+ /** Publish a local file to the room and start serving it. */
81
+ seed(filePath: string, chunkSize?: number): SwarmManifest;
82
+ /**
83
+ * Ask for a file by content id. If its offer has not arrived yet the request
84
+ * is remembered, so `fetch` before the seeder announces still works.
85
+ */
86
+ fetch(fileId: string): boolean;
87
+ knownOffers(): SwarmOfferInfo[];
88
+ /**
89
+ * Find an offer by full content id, an unambiguous id prefix, or exact file
90
+ * name. A 64-character hex id is not something anyone types by hand.
91
+ */
92
+ resolveOffer(query: string): SwarmOfferInfo | null;
93
+ progress(): SwarmProgress[];
94
+ sessionFor(fileId: string): SwarmSession | undefined;
95
+ private pump;
96
+ /**
97
+ * Rebuild the connection to a peer that keeps timing out without delivering.
98
+ *
99
+ * The case this exists for is a path that every layer believes is healthy: a
100
+ * signalling blip leaves the peer connection reporting `open`, the sender's
101
+ * `send()` succeeds, and the bytes are simply dropped. Waiting it out took
102
+ * around a minute; the swarm has the evidence to act much sooner, because a
103
+ * run of timeouts with nothing delivered in between says so.
104
+ *
105
+ * Deliberately conservative: a peer must miss several requests in a row, and
106
+ * rebuilding is rate-limited, because a genuinely slow peer must not be
107
+ * disconnected for being slow.
108
+ */
109
+ private reviveUnresponsive;
110
+ /**
111
+ * Re-plan as soon as a request slot frees, instead of waiting for the timer.
112
+ *
113
+ * The interval alone caps throughput hard: with four requests in flight and a
114
+ * 250ms tick, a slot that frees 10ms after a pump sits idle for the other
115
+ * 240ms. Measured against a real 5 MB transfer that ceiling was 16 chunks/s
116
+ * and we were getting 9.6. Pumping on arrival removes the ceiling and leaves
117
+ * round-trip time as the governor.
118
+ *
119
+ * The short coalescing delay matters too: chunks arrive in bursts, and
120
+ * planning is O(chunks x peers), so replanning once per burst beats
121
+ * replanning once per chunk.
122
+ */
123
+ private schedulePump;
124
+ private announceAll;
125
+ /**
126
+ * Answer a peer's announce with our own bitfield when we can serve it.
127
+ *
128
+ * Without this a fresh downloader knows a file exists but not who holds any
129
+ * of it, so it sits idle until the next periodic announce comes round.
130
+ * Measured on a 5 MB transfer that stall was 2.6 s of a 4.0 s total — the
131
+ * transfer itself was never the slow part. Replying on demand takes the same
132
+ * transfer to 1.0 s without making the room any chattier at rest.
133
+ *
134
+ * Three things keep this from becoming an announce storm: we only reply if we
135
+ * actually hold something the peer lacks, the reply is unicast rather than
136
+ * broadcast, and each peer gets at most one reply per file per second.
137
+ */
138
+ private answerAnnounce;
139
+ /** Tell one peer everything we can offer, then what we currently hold. */
140
+ private greetPeer;
141
+ private broadcastOffer;
142
+ private handleOffer;
143
+ private handleManifestRequest;
144
+ private handleManifestPage;
145
+ private handleAnnounce;
146
+ private handleHave;
147
+ private handleRequest;
148
+ private requestManifestPages;
149
+ private guard;
150
+ private reportError;
151
+ private handleChunk;
152
+ private startDownload;
153
+ private completeSession;
154
+ private sendFor;
155
+ private sendChunkAsBase64;
156
+ /**
157
+ * Route a frame off the shared binary lane.
158
+ *
159
+ * Anything that is not one of our chunk frames is left alone: the lane is the
160
+ * application's, and another feature may well be using it too.
161
+ */
162
+ private handleBinary;
163
+ }
164
+ export { ChunkMap };