@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.
- package/.agents/skills/ninja-p2p/SKILL.md +102 -2
- package/.claude/skills/ninja-p2p/SKILL.md +209 -130
- package/.codex/skills/ninja-p2p/SKILL.md +102 -2
- package/CHANGELOG.md +113 -0
- package/README.md +1077 -775
- package/dashboard.html +370 -50
- package/dist/agent-state.js +9 -0
- package/dist/cli-lib.d.ts +40 -0
- package/dist/cli-lib.js +165 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +501 -10
- package/dist/demo.d.ts +37 -0
- package/dist/demo.js +186 -0
- package/dist/doctor.d.ts +52 -0
- package/dist/doctor.js +219 -0
- package/dist/file-transfer.d.ts +15 -2
- package/dist/file-transfer.js +249 -15
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/message-bus.d.ts +15 -0
- package/dist/message-bus.js +32 -3
- package/dist/peer-registry.js +7 -0
- package/dist/protocol.d.ts +78 -1
- package/dist/protocol.js +42 -6
- package/dist/shared-folders.js +20 -1
- package/dist/social-stream.d.ts +100 -0
- package/dist/social-stream.js +254 -0
- package/dist/swarm-manager.d.ts +164 -0
- package/dist/swarm-manager.js +957 -0
- package/dist/swarm-session.d.ts +197 -0
- package/dist/swarm-session.js +465 -0
- package/dist/swarm-wire.d.ts +48 -0
- package/dist/swarm-wire.js +86 -0
- package/dist/swarm.d.ts +258 -0
- package/dist/swarm.js +694 -0
- package/dist/vdo-bridge.d.ts +62 -1
- package/dist/vdo-bridge.js +273 -23
- package/dist/vdoninja-sdk-types.d.ts +75 -0
- package/dist/vdoninja-sdk-types.js +10 -0
- package/dist/wake.d.ts +83 -0
- package/dist/wake.js +206 -0
- package/docs/protocol-and-reliability.md +231 -38
- package/docs/sdk-wishlist.md +236 -0
- package/docs/security.md +197 -0
- package/docs/social-stream-bridge.md +390 -0
- package/package.json +125 -116
package/dist/shared-folders.js
CHANGED
|
@@ -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(/ /gi, " ")
|
|
237
|
+
.replace(/&/gi, "&")
|
|
238
|
+
.replace(/</gi, "<")
|
|
239
|
+
.replace(/>/gi, ">")
|
|
240
|
+
.replace(/"/gi, '"')
|
|
241
|
+
.replace(/'/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 };
|