uncloud-p2p 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mathew Perkins
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1 @@
1
+ # uncloud-p2p
@@ -0,0 +1,11 @@
1
+ import { UncloudP2P } from "./index";
2
+ export interface NodeFile {
3
+ MD5: string;
4
+ MimeType: string;
5
+ Name: string;
6
+ SizeBytes: number;
7
+ }
8
+ export declare function ListFiles(p2p: UncloudP2P): Promise<NodeFile[]>;
9
+ export declare function LoadFileText(p2p: UncloudP2P, file: string): Promise<string>;
10
+ export declare function LoadFileBytes(p2p: UncloudP2P, file: string): Promise<Uint8Array<ArrayBuffer>>;
11
+ export declare function LoadFile(p2p: UncloudP2P, file: string): Promise<Blob>;
package/dist/files.js ADDED
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ // util functions that wrap the core of uncloudp2p
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.ListFiles = ListFiles;
5
+ exports.LoadFileText = LoadFileText;
6
+ exports.LoadFileBytes = LoadFileBytes;
7
+ exports.LoadFile = LoadFile;
8
+ const uncloudproto_1 = require("./uncloudproto");
9
+ async function ListFiles(p2p) {
10
+ try {
11
+ const resp = await p2p.sendCommand(uncloudproto_1.Actions.CLIENT_TO_NODE_LIST_FILES, null);
12
+ return resp;
13
+ }
14
+ catch (err) {
15
+ throw new Error(`failed to retreive file list from node: ${err}`);
16
+ }
17
+ }
18
+ async function LoadFileText(p2p, file) {
19
+ const blob = await p2p.downloadToMemory(file);
20
+ return await blob.text();
21
+ }
22
+ async function LoadFileBytes(p2p, file) {
23
+ const blob = await p2p.downloadToMemory(file);
24
+ return await blob.bytes();
25
+ }
26
+ async function LoadFile(p2p, file) {
27
+ return await p2p.downloadToMemory(file);
28
+ }
@@ -0,0 +1,51 @@
1
+ import { Action } from "./uncloudproto";
2
+ export interface IceCandidate {
3
+ candidate: string;
4
+ sdpMid: string | null;
5
+ sdpMLineIndex: number | null;
6
+ }
7
+ export interface IceExchangePayload {
8
+ sdp: string;
9
+ candidates: IceCandidate[];
10
+ }
11
+ export type ConnectionState = "idle" | "gathering" | "signaling" | "connecting" | "connected" | "disconnected" | "error";
12
+ interface P2POptions {
13
+ nodeId: string;
14
+ baseUrl?: string;
15
+ iceServers?: RTCIceServer[];
16
+ onStateChange?: (state: ConnectionState) => void;
17
+ onMessage?: (data: string) => void;
18
+ onLog?: (message: string, type?: "info" | "error" | "success" | "warn") => void;
19
+ }
20
+ export declare class UncloudP2P {
21
+ private pc;
22
+ private dc;
23
+ private localCandidates;
24
+ private options;
25
+ private pendingRequests;
26
+ private pendingDownloads;
27
+ constructor(options: P2POptions);
28
+ private log;
29
+ /**
30
+ * Initializes the PeerConnection and starts the ICE gathering process.
31
+ */
32
+ connect(): Promise<void>;
33
+ /**
34
+ * Hits the exchange endpoint to swap SDP and ICE candidates.
35
+ */
36
+ private performExchange;
37
+ sendCommand<T = any>(action: Action, body: any, timeoutMs?: number): Promise<T>;
38
+ private setupControlChannelHooks;
39
+ private setupDownloadChannelHooks;
40
+ private saveBlob;
41
+ downloadFile(fileName: string): Promise<void>;
42
+ downloadToMemory(fileName: string): Promise<Blob>;
43
+ /**
44
+ * Returns a promise that resolves when the control channel is open.
45
+ */
46
+ waitForOpen(timeoutMs?: number): Promise<void>;
47
+ private updateState;
48
+ private handleError;
49
+ disconnect(): void;
50
+ }
51
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,257 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UncloudP2P = void 0;
4
+ const uncloudproto_1 = require("./uncloudproto");
5
+ class UncloudP2P {
6
+ constructor(options) {
7
+ this.pc = null;
8
+ this.dc = null;
9
+ this.localCandidates = [];
10
+ this.pendingRequests = new Map();
11
+ this.pendingDownloads = new Map();
12
+ this.options = {
13
+ baseUrl: "https://uncloud.mpsoftware.io",
14
+ iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
15
+ ...options,
16
+ };
17
+ }
18
+ log(msg, type = "info") {
19
+ this.options.onLog?.(msg, type);
20
+ }
21
+ /**
22
+ * Initializes the PeerConnection and starts the ICE gathering process.
23
+ */
24
+ async connect() {
25
+ try {
26
+ this.updateState("gathering");
27
+ this.pc = new RTCPeerConnection({
28
+ iceServers: this.options.iceServers,
29
+ });
30
+ // Create the persistent CONTROL_STREAM immediately (Offer side)
31
+ this.dc = this.pc.createDataChannel(uncloudproto_1.StreamNames.CONTROL_STREAM, {
32
+ ordered: true,
33
+ });
34
+ this.setupControlChannelHooks(this.dc);
35
+ // ICE Gathering Logic
36
+ this.pc.onicecandidate = async (e) => {
37
+ if (!e.candidate) {
38
+ this.log("ICE gathering complete, performing exchange...", "info");
39
+ await this.performExchange();
40
+ }
41
+ else {
42
+ this.localCandidates.push({
43
+ candidate: e.candidate.candidate,
44
+ sdpMid: e.candidate.sdpMid,
45
+ sdpMLineIndex: e.candidate.sdpMLineIndex,
46
+ });
47
+ }
48
+ };
49
+ // Incoming data handler
50
+ this.pc.ondatachannel = (event) => {
51
+ const channel = event.channel;
52
+ if (channel.label === uncloudproto_1.StreamNames.NODE_TO_CLIENT_FILE_TRANSFER) {
53
+ this.log("Inbound file stream detected", "info");
54
+ this.setupDownloadChannelHooks(channel);
55
+ }
56
+ };
57
+ const offer = await this.pc.createOffer();
58
+ await this.pc.setLocalDescription(offer);
59
+ }
60
+ catch (err) {
61
+ this.handleError(err);
62
+ }
63
+ }
64
+ /**
65
+ * Hits the exchange endpoint to swap SDP and ICE candidates.
66
+ */
67
+ async performExchange() {
68
+ if (!this.pc || !this.pc.localDescription)
69
+ return;
70
+ this.updateState("signaling");
71
+ const payload = {
72
+ sdp: this.pc.localDescription.sdp,
73
+ candidates: this.localCandidates,
74
+ };
75
+ try {
76
+ const endpoint = `${this.options.baseUrl}/api/public/node/${this.options.nodeId}/exchangeCandidates`;
77
+ const response = await fetch(endpoint, {
78
+ method: "POST",
79
+ headers: { "Content-Type": "application/json" },
80
+ body: JSON.stringify(payload),
81
+ });
82
+ if (!response.ok)
83
+ throw new Error(`HTTP error! status: ${response.status}`);
84
+ const nodeResponse = await response.json();
85
+ await this.pc.setRemoteDescription(new RTCSessionDescription({ type: "answer", sdp: nodeResponse.sdp }));
86
+ for (const cand of nodeResponse.candidates) {
87
+ await this.pc.addIceCandidate(cand);
88
+ }
89
+ this.updateState("connecting");
90
+ }
91
+ catch (err) {
92
+ this.handleError(err);
93
+ }
94
+ }
95
+ async sendCommand(action, body, timeoutMs = 5000) {
96
+ if (!this.dc || this.dc.readyState !== "open") {
97
+ throw new Error("P2P Control channel is not open");
98
+ }
99
+ const message = (0, uncloudproto_1.newMessage)(action, body);
100
+ const correlationId = message.correlationId;
101
+ return new Promise((resolve, reject) => {
102
+ // 1. Set up the timeout to prevent hanging promises
103
+ const timeout = window.setTimeout(() => {
104
+ if (this.pendingRequests.has(correlationId)) {
105
+ this.pendingRequests.delete(correlationId);
106
+ reject(new Error(`Command ${action} timed out after ${timeoutMs}ms`));
107
+ }
108
+ }, timeoutMs);
109
+ // 2. Store the callbacks
110
+ this.pendingRequests.set(correlationId, { resolve, reject, timeout });
111
+ this.dc.send(message.toJSON());
112
+ });
113
+ }
114
+ setupControlChannelHooks(channel) {
115
+ channel.onopen = () => this.updateState("connected");
116
+ channel.onclose = () => this.updateState("disconnected");
117
+ channel.onmessage = (e) => {
118
+ try {
119
+ const response = JSON.parse(e.data);
120
+ // Check if this is a response to a pending command
121
+ if (response.correlationId &&
122
+ this.pendingRequests.has(response.correlationId)) {
123
+ const pending = this.pendingRequests.get(response.correlationId);
124
+ clearTimeout(pending.timeout);
125
+ this.pendingRequests.delete(response.correlationId);
126
+ if (response.error) {
127
+ pending.reject(response.error);
128
+ }
129
+ else {
130
+ pending.resolve(response.body);
131
+ }
132
+ return;
133
+ }
134
+ // Otherwise, treat as a standard broadcast message
135
+ this.options.onMessage?.(e.data);
136
+ }
137
+ catch (err) {
138
+ this.log("Failed to parse incoming P2P message", "error");
139
+ }
140
+ };
141
+ }
142
+ setupDownloadChannelHooks(channel) {
143
+ let metadata = null;
144
+ let receivedSize = 0;
145
+ const chunks = [];
146
+ channel.binaryType = "arraybuffer";
147
+ channel.onmessage = (event) => {
148
+ if (typeof event.data === "string") {
149
+ metadata = JSON.parse(event.data).body;
150
+ return;
151
+ }
152
+ if (event.data instanceof ArrayBuffer) {
153
+ chunks.push(new Uint8Array(event.data));
154
+ receivedSize += event.data.byteLength;
155
+ if (metadata && receivedSize >= metadata.size) {
156
+ const finalBlob = new Blob(chunks);
157
+ // 3. Check if this was a "To Memory" request
158
+ const pending = this.pendingDownloads.get(metadata.name);
159
+ if (pending) {
160
+ pending.resolve(finalBlob);
161
+ this.pendingDownloads.delete(metadata.name);
162
+ }
163
+ else {
164
+ // Default behavior: Trigger browser save
165
+ this.saveBlob(chunks, metadata.name);
166
+ }
167
+ channel.close();
168
+ }
169
+ }
170
+ };
171
+ channel.onerror = () => {
172
+ if (metadata)
173
+ this.pendingDownloads
174
+ .get(metadata.name)
175
+ ?.reject(new Error("Channel error"));
176
+ };
177
+ }
178
+ saveBlob(chunks, fileName) {
179
+ const blob = new Blob(chunks);
180
+ const url = URL.createObjectURL(blob);
181
+ const a = document.createElement("a");
182
+ a.href = url;
183
+ a.download = fileName;
184
+ a.click();
185
+ URL.revokeObjectURL(url);
186
+ this.log(`Download complete: ${fileName}`, "success");
187
+ }
188
+ async downloadFile(fileName) {
189
+ this.log(`Requesting download: ${fileName}...`);
190
+ // Request the file via the established Control Channel
191
+ return this.sendCommand(uncloudproto_1.Actions.NODE_TO_CLIENT_FILE_TRANSFER, {
192
+ name: fileName,
193
+ });
194
+ }
195
+ async downloadToMemory(fileName) {
196
+ this.log(`Downloading ${fileName} to memory...`);
197
+ return new Promise(async (resolve, reject) => {
198
+ // 1. Store the promise callbacks indexed by filename
199
+ // Note: If you expect multiple concurrent downloads of the same name,
200
+ // you'd need a more unique ID here.
201
+ this.pendingDownloads.set(fileName, { resolve, reject });
202
+ try {
203
+ // 2. Trigger the request
204
+ await this.sendCommand(uncloudproto_1.Actions.NODE_TO_CLIENT_FILE_TRANSFER, {
205
+ name: fileName,
206
+ });
207
+ }
208
+ catch (err) {
209
+ this.pendingDownloads.delete(fileName);
210
+ reject(err);
211
+ }
212
+ });
213
+ }
214
+ /**
215
+ * Returns a promise that resolves when the control channel is open.
216
+ */
217
+ async waitForOpen(timeoutMs = 10000) {
218
+ if (this.dc && this.dc.readyState === "open")
219
+ return;
220
+ return new Promise((resolve, reject) => {
221
+ const timer = setTimeout(() => {
222
+ reject(new Error("Timeout waiting for P2P channel to open"));
223
+ }, timeoutMs);
224
+ // Check every 100ms or use the existing onopen hook
225
+ const checkInterval = setInterval(() => {
226
+ if (this.dc && this.dc.readyState === "open") {
227
+ clearInterval(checkInterval);
228
+ clearTimeout(timer);
229
+ resolve();
230
+ }
231
+ // If we hit an error state, stop checking
232
+ if (this.pc?.iceConnectionState === "failed" ||
233
+ this.pc?.iceConnectionState === "closed") {
234
+ clearInterval(checkInterval);
235
+ clearTimeout(timer);
236
+ reject(new Error("P2P Connection failed before channel opened"));
237
+ }
238
+ }, 100);
239
+ });
240
+ }
241
+ updateState(state) {
242
+ this.options.onStateChange?.(state);
243
+ }
244
+ handleError(err) {
245
+ this.log(`P2P Error: ${err.message || err}`, "error");
246
+ this.updateState("error");
247
+ }
248
+ disconnect() {
249
+ this.dc?.close();
250
+ this.pc?.close();
251
+ this.pc = null;
252
+ this.dc = null;
253
+ this.localCandidates = [];
254
+ this.updateState("idle");
255
+ }
256
+ }
257
+ exports.UncloudP2P = UncloudP2P;
@@ -0,0 +1,34 @@
1
+ type Version = number;
2
+ export declare const WEBRTC_CONTROL_PROTO_VERSION: Version;
3
+ export declare class ControlMessage<T = unknown> {
4
+ version: Version;
5
+ action: Action;
6
+ correlationId: string;
7
+ body?: T | undefined;
8
+ constructor(version: Version, action: Action, correlationId: string, body?: T | undefined);
9
+ toJSON(): string;
10
+ }
11
+ export interface FileMetadata {
12
+ name: string;
13
+ size: number;
14
+ }
15
+ export declare function newMessage<T>(action: Action, body?: T): ControlMessage<T>;
16
+ export declare function newMessageJSON<T>(action: Action, body?: T): string;
17
+ export declare const StreamNames: {
18
+ readonly CONTROL_STREAM: "control_stream";
19
+ readonly CLIENT_TO_NODE_FILE_TRANSFER: "client_to_node_file_transfer";
20
+ readonly NODE_TO_CLIENT_FILE_TRANSFER: "node_to_client_file_transfer";
21
+ };
22
+ export type StreamName = (typeof StreamNames)[keyof typeof StreamNames];
23
+ export declare const Actions: {
24
+ CLIENT_TO_NODE_FILE_TRANSFER: string;
25
+ NODE_TO_CLIENT_FILE_TRANSFER: string;
26
+ CLIENT_TO_NODE_LIST_FILES: string;
27
+ };
28
+ export type Action = (typeof Actions)[keyof typeof Actions];
29
+ export type PendingRequest = {
30
+ resolve: (value: any) => void;
31
+ reject: (reason: any) => void;
32
+ timeout: number;
33
+ };
34
+ export {};
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Actions = exports.StreamNames = exports.ControlMessage = exports.WEBRTC_CONTROL_PROTO_VERSION = void 0;
4
+ exports.newMessage = newMessage;
5
+ exports.newMessageJSON = newMessageJSON;
6
+ exports.WEBRTC_CONTROL_PROTO_VERSION = 1;
7
+ class ControlMessage {
8
+ constructor(version, action, correlationId, body) {
9
+ this.version = version;
10
+ this.action = action;
11
+ this.correlationId = correlationId;
12
+ this.body = body;
13
+ }
14
+ toJSON() {
15
+ return JSON.stringify({
16
+ version: this.version,
17
+ action: this.action,
18
+ correlationId: this.correlationId,
19
+ body: this.body,
20
+ });
21
+ }
22
+ }
23
+ exports.ControlMessage = ControlMessage;
24
+ function newMessage(action, body) {
25
+ const correlationId = crypto.randomUUID();
26
+ return new ControlMessage(exports.WEBRTC_CONTROL_PROTO_VERSION, action, correlationId, body);
27
+ }
28
+ function newMessageJSON(action, body) {
29
+ return JSON.stringify(newMessage(action, body));
30
+ }
31
+ // COPY ME FROM SERVER SIDE
32
+ // THEY MUST MATCH
33
+ exports.StreamNames = {
34
+ CONTROL_STREAM: "control_stream",
35
+ CLIENT_TO_NODE_FILE_TRANSFER: "client_to_node_file_transfer",
36
+ NODE_TO_CLIENT_FILE_TRANSFER: "node_to_client_file_transfer",
37
+ };
38
+ // COPY ME FROM SERVER SIDE
39
+ // THEY MUST MATCH
40
+ exports.Actions = {
41
+ CLIENT_TO_NODE_FILE_TRANSFER: "client_to_node_file_transfer",
42
+ NODE_TO_CLIENT_FILE_TRANSFER: "node_to_client_file_transfer",
43
+ CLIENT_TO_NODE_LIST_FILES: "client_to_node_list_files",
44
+ };
@@ -0,0 +1,59 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Uncloud P2P Demo</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ </head>
9
+ <body class="bg-zinc-950 text-zinc-100 font-sans min-h-screen p-12">
10
+ <div
11
+ class="max-w-2xl mx-auto border border-zinc-800 bg-zinc-900 p-8 rounded-xl"
12
+ >
13
+ <h1 class="text-2xl font-black uppercase tracking-widest mb-6">
14
+ P2P Node Connection
15
+ </h1>
16
+
17
+ <div class="flex gap-2 mb-8">
18
+ <input
19
+ id="nodeId"
20
+ type="text"
21
+ placeholder="Enter Node ID"
22
+ class="bg-black border border-zinc-800 px-4 py-2 rounded flex-1 font-mono text-sm focus:border-yellow-400 outline-none"
23
+ />
24
+ <button
25
+ id="connectBtn"
26
+ class="bg-zinc-100 text-black px-6 py-2 rounded font-bold uppercase text-xs hover:bg-yellow-400 transition-colors"
27
+ >
28
+ Connect
29
+ </button>
30
+ </div>
31
+
32
+ <div class="space-y-4">
33
+ <div
34
+ class="flex justify-between items-center border-b border-zinc-800 pb-2"
35
+ >
36
+ <span class="text-[10px] font-mono text-zinc-500 uppercase"
37
+ >Status</span
38
+ >
39
+ <span
40
+ id="status"
41
+ class="text-xs font-black uppercase tracking-tighter text-zinc-600"
42
+ >Idle</span
43
+ >
44
+ </div>
45
+
46
+ <div
47
+ id="logs"
48
+ class="bg-black p-4 h-48 overflow-y-auto font-mono text-[11px] text-zinc-400 space-y-1 rounded border border-zinc-800"
49
+ >
50
+ <div class="text-zinc-600 italic">
51
+ // Connection logs will appear here...
52
+ </div>
53
+ </div>
54
+ </div>
55
+ </div>
56
+
57
+ <script type="module" src="./main.ts"></script>
58
+ </body>
59
+ </html>
@@ -0,0 +1,64 @@
1
+ import { UncloudP2P, ConnectionState } from "../src/index";
2
+ import { Actions } from "../src/uncloudproto";
3
+ import { ListFiles, LoadFileText } from "../src/files";
4
+
5
+ const statusEl = document.getElementById("status")!;
6
+ const logsEl = document.getElementById("logs")!;
7
+ const connectBtn = document.getElementById("connectBtn") as HTMLButtonElement;
8
+ const nodeIdInput = document.getElementById("nodeId") as HTMLInputElement;
9
+
10
+ function log(msg: string, type: string = "info") {
11
+ const div = document.createElement("div");
12
+ const colors = {
13
+ info: "text-zinc-400",
14
+ success: "text-emerald-400",
15
+ error: "text-red-500",
16
+ warn: "text-yellow-400",
17
+ };
18
+ div.className = colors[type as keyof typeof colors];
19
+ div.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
20
+ logsEl.appendChild(div);
21
+ logsEl.scrollTop = logsEl.scrollHeight;
22
+ }
23
+
24
+ const updateStatusUI = (state: ConnectionState) => {
25
+ statusEl.textContent = state;
26
+ const colors: Record<string, string> = {
27
+ connected: "text-emerald-500",
28
+ error: "text-red-500",
29
+ connecting: "text-yellow-400",
30
+ idle: "text-zinc-600",
31
+ };
32
+ statusEl.className = `text-xs font-black uppercase tracking-tighter ${colors[state] || "text-zinc-400"}`;
33
+ };
34
+
35
+ connectBtn.onclick = async () => {
36
+ const nodeId = nodeIdInput.value.trim();
37
+ if (!nodeId) return alert("Please enter a Node ID");
38
+
39
+ const p2p = new UncloudP2P({
40
+ baseUrl: "http://localhost:3001",
41
+ nodeId: nodeId,
42
+ onStateChange: updateStatusUI,
43
+ onLog: (msg, type) => log(msg, type),
44
+ onMessage: (data) => log(`Message: ${data}`, "success"),
45
+ });
46
+
47
+ log(`Initializing connection to ${nodeId}...`);
48
+ await p2p.connect();
49
+ await p2p.waitForOpen();
50
+
51
+ try {
52
+ const files = await ListFiles(p2p);
53
+
54
+ files.forEach((file) => {
55
+ log(`${file.Name} (${(file.SizeBytes / 1024).toFixed(2)} KB)`, "info");
56
+ });
57
+ } catch (err) {
58
+ console.error("Command failed: ", err);
59
+ }
60
+
61
+ const text = await LoadFileText(p2p, "test/dst/file.txt");
62
+
63
+ log(text, "info");
64
+ };
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "uncloud-p2p",
3
+ "version": "1.0.0",
4
+ "description": "Public Peer to Peer File Reader",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "prepare": "npm run build",
10
+ "dev": "vite example"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/mperkins808/uncloud-p2p.git"
15
+ },
16
+ "author": "Mathew Perkins",
17
+ "license": "MIT",
18
+ "devDependencies": {
19
+ "typescript": "^5.0.0",
20
+ "vite": "^8.0.11"
21
+ }
22
+ }
package/src/files.ts ADDED
@@ -0,0 +1,44 @@
1
+ // util functions that wrap the core of uncloudp2p
2
+
3
+ import { Actions, FileMetadata } from "./uncloudproto";
4
+ import { UncloudP2P } from "./index";
5
+
6
+ export interface NodeFile {
7
+ MD5: string;
8
+ MimeType: string;
9
+ Name: string;
10
+ SizeBytes: number;
11
+ }
12
+
13
+ export async function ListFiles(p2p: UncloudP2P): Promise<NodeFile[]> {
14
+ try {
15
+ const resp = await p2p.sendCommand<NodeFile[]>(
16
+ Actions.CLIENT_TO_NODE_LIST_FILES,
17
+ null,
18
+ );
19
+
20
+ return resp;
21
+ } catch (err) {
22
+ throw new Error(`failed to retreive file list from node: ${err}`);
23
+ }
24
+ }
25
+
26
+ export async function LoadFileText(
27
+ p2p: UncloudP2P,
28
+ file: string,
29
+ ): Promise<string> {
30
+ const blob = await p2p.downloadToMemory(file);
31
+ return await blob.text();
32
+ }
33
+
34
+ export async function LoadFileBytes(
35
+ p2p: UncloudP2P,
36
+ file: string,
37
+ ): Promise<Uint8Array<ArrayBuffer>> {
38
+ const blob = await p2p.downloadToMemory(file);
39
+ return await blob.bytes();
40
+ }
41
+
42
+ export async function LoadFile(p2p: UncloudP2P, file: string): Promise<Blob> {
43
+ return await p2p.downloadToMemory(file);
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,351 @@
1
+ import {
2
+ StreamNames,
3
+ newMessageJSON,
4
+ Actions,
5
+ PendingRequest,
6
+ Action,
7
+ newMessage,
8
+ } from "./uncloudproto";
9
+
10
+ export interface IceCandidate {
11
+ candidate: string;
12
+ sdpMid: string | null;
13
+ sdpMLineIndex: number | null;
14
+ }
15
+
16
+ export interface IceExchangePayload {
17
+ sdp: string;
18
+ candidates: IceCandidate[];
19
+ }
20
+
21
+ export type ConnectionState =
22
+ | "idle"
23
+ | "gathering"
24
+ | "signaling"
25
+ | "connecting"
26
+ | "connected"
27
+ | "disconnected"
28
+ | "error";
29
+
30
+ interface P2POptions {
31
+ nodeId: string;
32
+ baseUrl?: string; // Allow setting the API base URL
33
+ iceServers?: RTCIceServer[];
34
+ onStateChange?: (state: ConnectionState) => void;
35
+ onMessage?: (data: string) => void;
36
+ onLog?: (
37
+ message: string,
38
+ type?: "info" | "error" | "success" | "warn",
39
+ ) => void;
40
+ }
41
+
42
+ export class UncloudP2P {
43
+ private pc: RTCPeerConnection | null = null;
44
+ private dc: RTCDataChannel | null = null;
45
+ private localCandidates: IceCandidate[] = [];
46
+ private options: P2POptions;
47
+ private pendingRequests: Map<string, PendingRequest> = new Map();
48
+ private pendingDownloads: Map<
49
+ string,
50
+ { resolve: (blob: Blob) => void; reject: (err: any) => void }
51
+ > = new Map();
52
+
53
+ constructor(options: P2POptions) {
54
+ this.options = {
55
+ baseUrl: "https://uncloud.mpsoftware.io",
56
+ iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
57
+ ...options,
58
+ };
59
+ }
60
+
61
+ private log(
62
+ msg: string,
63
+ type: "info" | "error" | "success" | "warn" = "info",
64
+ ) {
65
+ this.options.onLog?.(msg, type);
66
+ }
67
+
68
+ /**
69
+ * Initializes the PeerConnection and starts the ICE gathering process.
70
+ */
71
+ public async connect(): Promise<void> {
72
+ try {
73
+ this.updateState("gathering");
74
+
75
+ this.pc = new RTCPeerConnection({
76
+ iceServers: this.options.iceServers,
77
+ });
78
+
79
+ // Create the persistent CONTROL_STREAM immediately (Offer side)
80
+ this.dc = this.pc.createDataChannel(StreamNames.CONTROL_STREAM, {
81
+ ordered: true,
82
+ });
83
+ this.setupControlChannelHooks(this.dc);
84
+
85
+ // ICE Gathering Logic
86
+ this.pc.onicecandidate = async (e) => {
87
+ if (!e.candidate) {
88
+ this.log("ICE gathering complete, performing exchange...", "info");
89
+ await this.performExchange();
90
+ } else {
91
+ this.localCandidates.push({
92
+ candidate: e.candidate.candidate,
93
+ sdpMid: e.candidate.sdpMid,
94
+ sdpMLineIndex: e.candidate.sdpMLineIndex,
95
+ });
96
+ }
97
+ };
98
+
99
+ // Incoming data handler
100
+ this.pc.ondatachannel = (event) => {
101
+ const channel = event.channel;
102
+ if (channel.label === StreamNames.NODE_TO_CLIENT_FILE_TRANSFER) {
103
+ this.log("Inbound file stream detected", "info");
104
+ this.setupDownloadChannelHooks(channel);
105
+ }
106
+ };
107
+
108
+ const offer = await this.pc.createOffer();
109
+ await this.pc.setLocalDescription(offer);
110
+ } catch (err) {
111
+ this.handleError(err);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Hits the exchange endpoint to swap SDP and ICE candidates.
117
+ */
118
+ private async performExchange() {
119
+ if (!this.pc || !this.pc.localDescription) return;
120
+
121
+ this.updateState("signaling");
122
+
123
+ const payload: IceExchangePayload = {
124
+ sdp: this.pc.localDescription.sdp,
125
+ candidates: this.localCandidates,
126
+ };
127
+
128
+ try {
129
+ const endpoint = `${this.options.baseUrl}/api/public/node/${this.options.nodeId}/exchangeCandidates`;
130
+
131
+ const response = await fetch(endpoint, {
132
+ method: "POST",
133
+ headers: { "Content-Type": "application/json" },
134
+ body: JSON.stringify(payload),
135
+ });
136
+
137
+ if (!response.ok)
138
+ throw new Error(`HTTP error! status: ${response.status}`);
139
+
140
+ const nodeResponse: IceExchangePayload = await response.json();
141
+
142
+ await this.pc.setRemoteDescription(
143
+ new RTCSessionDescription({ type: "answer", sdp: nodeResponse.sdp }),
144
+ );
145
+
146
+ for (const cand of nodeResponse.candidates) {
147
+ await this.pc.addIceCandidate(cand);
148
+ }
149
+
150
+ this.updateState("connecting");
151
+ } catch (err) {
152
+ this.handleError(err);
153
+ }
154
+ }
155
+
156
+ public async sendCommand<T = any>(
157
+ action: Action,
158
+ body: any,
159
+ timeoutMs: number = 5000,
160
+ ): Promise<T> {
161
+ if (!this.dc || this.dc.readyState !== "open") {
162
+ throw new Error("P2P Control channel is not open");
163
+ }
164
+
165
+ const message = newMessage(action, body);
166
+ const correlationId = message.correlationId;
167
+
168
+ return new Promise((resolve, reject) => {
169
+ // 1. Set up the timeout to prevent hanging promises
170
+ const timeout = window.setTimeout(() => {
171
+ if (this.pendingRequests.has(correlationId)) {
172
+ this.pendingRequests.delete(correlationId);
173
+ reject(new Error(`Command ${action} timed out after ${timeoutMs}ms`));
174
+ }
175
+ }, timeoutMs);
176
+
177
+ // 2. Store the callbacks
178
+ this.pendingRequests.set(correlationId, { resolve, reject, timeout });
179
+
180
+ this.dc!.send(message.toJSON());
181
+ });
182
+ }
183
+
184
+ private setupControlChannelHooks(channel: RTCDataChannel) {
185
+ channel.onopen = () => this.updateState("connected");
186
+ channel.onclose = () => this.updateState("disconnected");
187
+
188
+ channel.onmessage = (e) => {
189
+ try {
190
+ const response = JSON.parse(e.data);
191
+
192
+ // Check if this is a response to a pending command
193
+ if (
194
+ response.correlationId &&
195
+ this.pendingRequests.has(response.correlationId)
196
+ ) {
197
+ const pending = this.pendingRequests.get(response.correlationId)!;
198
+
199
+ clearTimeout(pending.timeout);
200
+ this.pendingRequests.delete(response.correlationId);
201
+
202
+ if (response.error) {
203
+ pending.reject(response.error);
204
+ } else {
205
+ pending.resolve(response.body);
206
+ }
207
+ return;
208
+ }
209
+
210
+ // Otherwise, treat as a standard broadcast message
211
+ this.options.onMessage?.(e.data);
212
+ } catch (err) {
213
+ this.log("Failed to parse incoming P2P message", "error");
214
+ }
215
+ };
216
+ }
217
+
218
+ private setupDownloadChannelHooks(channel: RTCDataChannel) {
219
+ let metadata: { name: string; size: number } | null = null;
220
+ let receivedSize = 0;
221
+ const chunks: Uint8Array[] = [];
222
+
223
+ channel.binaryType = "arraybuffer";
224
+
225
+ channel.onmessage = (event) => {
226
+ if (typeof event.data === "string") {
227
+ metadata = JSON.parse(event.data).body;
228
+ return;
229
+ }
230
+
231
+ if (event.data instanceof ArrayBuffer) {
232
+ chunks.push(new Uint8Array(event.data));
233
+ receivedSize += event.data.byteLength;
234
+
235
+ if (metadata && receivedSize >= metadata.size) {
236
+ const finalBlob = new Blob(chunks as any);
237
+
238
+ // 3. Check if this was a "To Memory" request
239
+ const pending = this.pendingDownloads.get(metadata.name);
240
+ if (pending) {
241
+ pending.resolve(finalBlob);
242
+ this.pendingDownloads.delete(metadata.name);
243
+ } else {
244
+ // Default behavior: Trigger browser save
245
+ this.saveBlob(chunks, metadata.name);
246
+ }
247
+
248
+ channel.close();
249
+ }
250
+ }
251
+ };
252
+
253
+ channel.onerror = () => {
254
+ if (metadata)
255
+ this.pendingDownloads
256
+ .get(metadata.name)
257
+ ?.reject(new Error("Channel error"));
258
+ };
259
+ }
260
+
261
+ private saveBlob(chunks: Uint8Array[], fileName: string) {
262
+ const blob = new Blob(chunks as any);
263
+ const url = URL.createObjectURL(blob);
264
+ const a = document.createElement("a");
265
+ a.href = url;
266
+ a.download = fileName;
267
+ a.click();
268
+ URL.revokeObjectURL(url);
269
+ this.log(`Download complete: ${fileName}`, "success");
270
+ }
271
+
272
+ public async downloadFile(fileName: string): Promise<void> {
273
+ this.log(`Requesting download: ${fileName}...`);
274
+
275
+ // Request the file via the established Control Channel
276
+ return this.sendCommand(Actions.NODE_TO_CLIENT_FILE_TRANSFER, {
277
+ name: fileName,
278
+ });
279
+ }
280
+
281
+ public async downloadToMemory(fileName: string): Promise<Blob> {
282
+ this.log(`Downloading ${fileName} to memory...`);
283
+
284
+ return new Promise(async (resolve, reject) => {
285
+ // 1. Store the promise callbacks indexed by filename
286
+ // Note: If you expect multiple concurrent downloads of the same name,
287
+ // you'd need a more unique ID here.
288
+ this.pendingDownloads.set(fileName, { resolve, reject });
289
+
290
+ try {
291
+ // 2. Trigger the request
292
+ await this.sendCommand(Actions.NODE_TO_CLIENT_FILE_TRANSFER, {
293
+ name: fileName,
294
+ });
295
+ } catch (err) {
296
+ this.pendingDownloads.delete(fileName);
297
+ reject(err);
298
+ }
299
+ });
300
+ }
301
+
302
+ /**
303
+ * Returns a promise that resolves when the control channel is open.
304
+ */
305
+ public async waitForOpen(timeoutMs: number = 10000): Promise<void> {
306
+ if (this.dc && this.dc.readyState === "open") return;
307
+
308
+ return new Promise((resolve, reject) => {
309
+ const timer = setTimeout(() => {
310
+ reject(new Error("Timeout waiting for P2P channel to open"));
311
+ }, timeoutMs);
312
+
313
+ // Check every 100ms or use the existing onopen hook
314
+ const checkInterval = setInterval(() => {
315
+ if (this.dc && this.dc.readyState === "open") {
316
+ clearInterval(checkInterval);
317
+ clearTimeout(timer);
318
+ resolve();
319
+ }
320
+
321
+ // If we hit an error state, stop checking
322
+ if (
323
+ this.pc?.iceConnectionState === "failed" ||
324
+ this.pc?.iceConnectionState === "closed"
325
+ ) {
326
+ clearInterval(checkInterval);
327
+ clearTimeout(timer);
328
+ reject(new Error("P2P Connection failed before channel opened"));
329
+ }
330
+ }, 100);
331
+ });
332
+ }
333
+
334
+ private updateState(state: ConnectionState) {
335
+ this.options.onStateChange?.(state);
336
+ }
337
+
338
+ private handleError(err: any) {
339
+ this.log(`P2P Error: ${err.message || err}`, "error");
340
+ this.updateState("error");
341
+ }
342
+
343
+ public disconnect() {
344
+ this.dc?.close();
345
+ this.pc?.close();
346
+ this.pc = null;
347
+ this.dc = null;
348
+ this.localCandidates = [];
349
+ this.updateState("idle");
350
+ }
351
+ }
@@ -0,0 +1,69 @@
1
+ type Version = number;
2
+
3
+ export const WEBRTC_CONTROL_PROTO_VERSION: Version = 1;
4
+
5
+ export class ControlMessage<T = unknown> {
6
+ constructor(
7
+ public version: Version,
8
+ public action: Action,
9
+ public correlationId: string,
10
+ public body?: T,
11
+ ) {}
12
+
13
+ toJSON(): string {
14
+ return JSON.stringify({
15
+ version: this.version,
16
+ action: this.action,
17
+ correlationId: this.correlationId,
18
+ body: this.body,
19
+ });
20
+ }
21
+ }
22
+
23
+ export interface FileMetadata {
24
+ name: string;
25
+ size: number;
26
+ }
27
+
28
+ export function newMessage<T>(action: Action, body?: T): ControlMessage<T> {
29
+ const correlationId = crypto.randomUUID();
30
+
31
+ return new ControlMessage(
32
+ WEBRTC_CONTROL_PROTO_VERSION,
33
+ action,
34
+ correlationId,
35
+ body,
36
+ );
37
+ }
38
+
39
+ export function newMessageJSON<T>(action: Action, body?: T): string {
40
+ return JSON.stringify(newMessage<T>(action, body));
41
+ }
42
+
43
+ // COPY ME FROM SERVER SIDE
44
+ // THEY MUST MATCH
45
+
46
+ export const StreamNames = {
47
+ CONTROL_STREAM: "control_stream",
48
+ CLIENT_TO_NODE_FILE_TRANSFER: "client_to_node_file_transfer",
49
+ NODE_TO_CLIENT_FILE_TRANSFER: "node_to_client_file_transfer",
50
+ } as const;
51
+
52
+ export type StreamName = (typeof StreamNames)[keyof typeof StreamNames];
53
+
54
+ // COPY ME FROM SERVER SIDE
55
+ // THEY MUST MATCH
56
+
57
+ export const Actions = {
58
+ CLIENT_TO_NODE_FILE_TRANSFER: "client_to_node_file_transfer",
59
+ NODE_TO_CLIENT_FILE_TRANSFER: "node_to_client_file_transfer",
60
+ CLIENT_TO_NODE_LIST_FILES: "client_to_node_list_files",
61
+ };
62
+
63
+ export type Action = (typeof Actions)[keyof typeof Actions];
64
+
65
+ export type PendingRequest = {
66
+ resolve: (value: any) => void;
67
+ reject: (reason: any) => void;
68
+ timeout: number;
69
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "declaration": true,
7
+ "outDir": "./dist",
8
+ "rootDir": "./src",
9
+ "strict": true,
10
+ "lib": ["DOM", "ES2020"],
11
+ "esModuleInterop": true,
12
+ "skipLibCheck": true,
13
+ "forceConsistentCasingInFileNames": true,
14
+ },
15
+ "include": ["src/**/*"],
16
+ }