pluto-rtc 0.0.2

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.
@@ -0,0 +1,242 @@
1
+ import { MediaTransport } from './MediaTransport';
2
+ // Tiny SDP helpers to make the ticket look like an SDP so generic parsers don't choke immediately
3
+ // although we usually just pass the string. The demo code uses desc.sdp.
4
+ function toSDP(ticket) {
5
+ return `v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=pluto-ticket:${ticket}\r\n`;
6
+ }
7
+ function fromSDP(sdp) {
8
+ const match = sdp.match(/a=pluto-ticket:(.+)(\r\n|$)/);
9
+ return match ? match[1] : null;
10
+ }
11
+ export class PlutoPeerConnection extends EventTarget {
12
+ static setDefaultClient(client) {
13
+ this.defaultClient = client;
14
+ }
15
+ constructor(configuration, clientInstance) {
16
+ super();
17
+ this.onicecandidate = null;
18
+ this.oniceconnectionstatechange = null;
19
+ this.ontrack = null;
20
+ this.onsignalingstatechange = null;
21
+ this.connectionState = 'new';
22
+ this.iceConnectionState = 'new';
23
+ this.signalingState = 'stable';
24
+ this.localDescription = null;
25
+ this.remoteDescription = null;
26
+ this.connection = null;
27
+ /**
28
+ * Manually provide an existing connection (e.g. from an incoming connection listener)
29
+ */
30
+ // Simplified PlutoPeerConnection
31
+ // Implementation: Delegates to Core Connection
32
+ this.bufferTracks = [];
33
+ this.receivers = {};
34
+ this.mediaSenders = {};
35
+ // Try to resolve client: constructor arg -> config arg (custom) -> static default
36
+ const client = clientInstance || configuration?.client || PlutoPeerConnection.defaultClient;
37
+ if (!client) {
38
+ throw new Error("PlutoPeerConnection requires a 'Client' instance. Pass it in constructor or use PlutoPeerConnection.setDefaultClient().");
39
+ }
40
+ this.client = client;
41
+ }
42
+ async createOffer(options) {
43
+ // Get our ticket
44
+ let ticket;
45
+ try {
46
+ ticket = await this.client.getTicket();
47
+ }
48
+ catch (e) {
49
+ console.error("Failed to get ticket for offer:", e);
50
+ ticket = "error-no-ticket";
51
+ }
52
+ const sdp = toSDP(ticket);
53
+ return { type: 'offer', sdp };
54
+ }
55
+ async createAnswer(options) {
56
+ // Return dummy answer.
57
+ // In "half-signaling", the connection is arguably already established by the offerer (if they connected)
58
+ // OR the answerer (if they connected upon receiving offer).
59
+ // Strategy: The SIDE THAT RECEIVES THE TICKET connects.
60
+ // Usually, 'offer' contains ticket. 'Remote' receives offer -> Remote connects to Local.
61
+ return { type: 'answer', sdp: toSDP('answer-dummy') };
62
+ }
63
+ async setLocalDescription(desc) {
64
+ this.localDescription = desc;
65
+ this.signalingState = (desc.type === 'offer') ? 'have-local-offer' : 'stable';
66
+ // If we just set a local offer, we should simulate gathering candidates (immediate finish)
67
+ if (desc.type === 'offer') {
68
+ setTimeout(() => {
69
+ // Emit a null candidate to signal end of candidates
70
+ const event = new Event('icecandidate');
71
+ event.candidate = null;
72
+ if (this.onicecandidate)
73
+ this.onicecandidate(event);
74
+ this.dispatchEvent(event);
75
+ }, 10);
76
+ }
77
+ }
78
+ async setRemoteDescription(desc) {
79
+ this.remoteDescription = desc;
80
+ this.signalingState = (desc.type === 'offer') ? 'have-remote-offer' : 'stable';
81
+ // Check if description has a ticket
82
+ const ticket = fromSDP(desc.sdp || '');
83
+ if (ticket && ticket !== 'answer-dummy' && ticket !== 'error-no-ticket') {
84
+ // We received a ticket! Connect to it.
85
+ this.connect(ticket);
86
+ }
87
+ else {
88
+ // Maybe it's an answer? If so, we assume the OTHER side connected to US.
89
+ // We just wait for the connection to come in via client.onConnection?
90
+ // Actually, if we are the Offerer, we are passive.
91
+ }
92
+ }
93
+ async addIceCandidate(candidate) {
94
+ // No-op for Iroh/Pluto
95
+ return;
96
+ }
97
+ async handleIncomingConnection(connection) {
98
+ this.connection = connection;
99
+ this.setupHooks();
100
+ // Flush buffer
101
+ this.bufferTracks.forEach(t => this.addTrack(t.track));
102
+ this.bufferTracks = [];
103
+ }
104
+ setupHooks() {
105
+ if (!this.connection)
106
+ return;
107
+ this.connection.onDisconnect(() => this.close());
108
+ this.connection.onTrack((ev) => {
109
+ if (this.ontrack)
110
+ this.ontrack(ev);
111
+ // Clone event to avoid "event is already being dispatched" error
112
+ const newEvent = new RTCTrackEvent('track', {
113
+ track: ev.track,
114
+ streams: [...ev.streams],
115
+ receiver: ev.receiver,
116
+ transceiver: ev.transceiver
117
+ });
118
+ this.dispatchEvent(newEvent);
119
+ });
120
+ // Listen for Iroh media (Fallback/Legacy)
121
+ this.connection.onMedia((type, data) => {
122
+ let track = this.receivers[type];
123
+ if (!track) {
124
+ // New track received!
125
+ track = MediaTransport.receiveTrack(type, () => {
126
+ this.connection?.send({ type: 'PLI', kind: type }).catch(console.error);
127
+ });
128
+ if (track) {
129
+ this.receivers[type] = track;
130
+ // Helper to fire Event
131
+ const event = new Event('track');
132
+ event.track = track;
133
+ event.streams = [new MediaStream([track])];
134
+ event.receiver = { track };
135
+ event.transceiver = { receiver: event.receiver, sender: {}, direction: 'receiveonly' };
136
+ if (this.ontrack)
137
+ this.ontrack(event);
138
+ this.dispatchEvent(event);
139
+ }
140
+ }
141
+ if (track)
142
+ (track.feed(data));
143
+ });
144
+ }
145
+ addTrack(track, ...streams) {
146
+ const sender = {
147
+ track,
148
+ replaceTrack: async () => { },
149
+ getStats: async () => new Map()
150
+ };
151
+ if (this.connection) {
152
+ // Use Native if available, fallback to Iroh is handled inside Connection or we do dual?
153
+ // Connection now has addTransceiver/addTrack which ADDS to the native PC.
154
+ // But we ALSO need to start the Iroh stream for fallback (Graceful).
155
+ // 1. Add to Native
156
+ this.connection.addTransceiver(track, { direction: 'sendrecv' });
157
+ // 2. Start Iroh Stream (Legacy)
158
+ this.startIrohStream(track);
159
+ }
160
+ else {
161
+ this.bufferTracks.push({ track });
162
+ }
163
+ return sender;
164
+ }
165
+ async startIrohStream(track) {
166
+ if (!this.connection)
167
+ return;
168
+ const conn = this.connection;
169
+ try {
170
+ const sender = await MediaTransport.sendTrack(track, async (data) => {
171
+ // Stop if connection closed
172
+ if (!this.connection || this.connection.isClosed) {
173
+ // We can throw here to stop MediaTransport
174
+ throw new Error("Connection closed");
175
+ }
176
+ try {
177
+ await conn.sendMedia(track.kind, data);
178
+ }
179
+ catch (e) {
180
+ if (e.message && e.message.includes('Connection is closed')) {
181
+ // Expected race condition on close
182
+ return;
183
+ }
184
+ console.error("Iroh send failed", e);
185
+ }
186
+ });
187
+ if (sender) {
188
+ this.mediaSenders[track.kind] = sender;
189
+ }
190
+ }
191
+ catch (e) {
192
+ console.error("Failed to start Iroh stream", e);
193
+ }
194
+ }
195
+ // ... createDataChannel delegates ...
196
+ createDataChannel(label) {
197
+ // We return a proxy that calls connection.send
198
+ return new PlutoDataChannel(label, this);
199
+ }
200
+ sendData(data) {
201
+ this.connection?.send(data);
202
+ }
203
+ // ... connect ...
204
+ async connect(ticket) {
205
+ this.connection = await this.client.connect(ticket);
206
+ this.setupHooks();
207
+ }
208
+ // ... close ...
209
+ close() {
210
+ this.connection?.disconnect();
211
+ // Stop all legacy senders
212
+ Object.values(this.mediaSenders).forEach(sender => {
213
+ try {
214
+ sender.stop();
215
+ }
216
+ catch (e) {
217
+ console.warn("Error stopping sender", e);
218
+ }
219
+ });
220
+ this.mediaSenders = {};
221
+ this.updateState('closed');
222
+ }
223
+ updateState(state) {
224
+ this.connectionState = state;
225
+ this.iceConnectionState = (state === 'closed') ? 'closed' : 'connected'; // aprox
226
+ this.dispatchEvent(new Event('connectionstatechange'));
227
+ this.dispatchEvent(new Event('iceconnectionstatechange'));
228
+ }
229
+ }
230
+ PlutoPeerConnection.defaultClient = null;
231
+ // ... Simple Data Channel Proxy ...
232
+ class PlutoDataChannel extends EventTarget {
233
+ constructor(label, pc) {
234
+ super();
235
+ this.label = label;
236
+ this.pc = pc;
237
+ setTimeout(() => this.emitOpen(), 0);
238
+ }
239
+ send(data) { this.pc.sendData(data); }
240
+ emitOpen() { this.dispatchEvent(new Event('open')); if (this.onopen)
241
+ this.onopen(new Event('open')); }
242
+ }
@@ -0,0 +1,24 @@
1
+ import { Client } from '../core/Client';
2
+ export declare class PlutoWebSocket extends EventTarget {
3
+ static readonly CONNECTING = 0;
4
+ static readonly OPEN = 1;
5
+ static readonly CLOSING = 2;
6
+ static readonly CLOSED = 3;
7
+ readonly url: string;
8
+ readyState: number;
9
+ bufferedAmount: number;
10
+ extensions: string;
11
+ protocol: string;
12
+ binaryType: "blob" | "arraybuffer";
13
+ onopen: ((this: PlutoWebSocket, ev: Event) => any) | null;
14
+ onmessage: ((this: PlutoWebSocket, ev: MessageEvent) => any) | null;
15
+ onclose: ((this: PlutoWebSocket, ev: CloseEvent) => any) | null;
16
+ onerror: ((this: PlutoWebSocket, ev: Event) => any) | null;
17
+ private client;
18
+ private connection;
19
+ constructor(url: string, protocols?: string | string[], clientInstance?: Client);
20
+ private connect;
21
+ send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void;
22
+ close(code?: number, reason?: string): void;
23
+ private handleClose;
24
+ }
@@ -0,0 +1,112 @@
1
+ export class PlutoWebSocket extends EventTarget {
2
+ constructor(url, protocols, clientInstance) {
3
+ super();
4
+ this.readyState = PlutoWebSocket.CONNECTING;
5
+ this.bufferedAmount = 0;
6
+ this.extensions = "";
7
+ this.protocol = "";
8
+ this.binaryType = "blob";
9
+ this.onopen = null;
10
+ this.onmessage = null;
11
+ this.onclose = null;
12
+ this.onerror = null;
13
+ this.connection = null;
14
+ this.url = url;
15
+ // We need a Client instance. If not provided, we have to throw or use a singleton?
16
+ // Since Pluto requires setup (tag, auth), it's hard to make a 100% drop-in without configuration.
17
+ // We will assume the user has a Client initialized or we provide one via a static method,
18
+ // BUT for a true new WebSocket replacement, we might rely on a global or strictly require a passed client if not configuring here.
19
+ // However, the standard WebSocket(url) doesn't take a client.
20
+ // To make it easy, we will support passing the client in the 2nd arg if protocols is ignored/abused,
21
+ // or just expect the developer to instantiate this class with the client.
22
+ if (clientInstance) {
23
+ this.client = clientInstance;
24
+ }
25
+ else {
26
+ // Fallback: This is tricky. We'll throw for now.
27
+ throw new Error("PlutoWebSocket requires a 'Client' instance to be passed in constructor currently.");
28
+ }
29
+ this.connect();
30
+ }
31
+ async connect() {
32
+ try {
33
+ // URL format: pluto://<ticket>
34
+ let ticket = this.url;
35
+ if (ticket.startsWith('pluto://')) {
36
+ ticket = ticket.replace('pluto://', '');
37
+ }
38
+ else if (ticket.startsWith('wss://') || ticket.startsWith('ws://')) {
39
+ // Ignore standard WS urls or treat as ticket?
40
+ // Assume ticket only for now.
41
+ }
42
+ this.connection = await this.client.connect(ticket);
43
+ this.readyState = PlutoWebSocket.OPEN;
44
+ const event = new Event('open');
45
+ if (this.onopen)
46
+ this.onopen(event);
47
+ this.dispatchEvent(event);
48
+ this.connection.onMessage((msg) => {
49
+ let data = msg;
50
+ // If msg is buffer/uint8array, respect binaryType
51
+ if (this.binaryType === 'arraybuffer' && (msg instanceof Uint8Array)) {
52
+ data = msg.buffer;
53
+ }
54
+ const msgEvent = new MessageEvent('message', {
55
+ data,
56
+ origin: this.url,
57
+ });
58
+ if (this.onmessage)
59
+ this.onmessage(msgEvent);
60
+ this.dispatchEvent(msgEvent);
61
+ });
62
+ this.connection.onDisconnect(() => {
63
+ this.handleClose();
64
+ });
65
+ }
66
+ catch (e) {
67
+ console.error("WebSocket connection failure:", e);
68
+ const event = new Event('error');
69
+ if (this.onerror)
70
+ this.onerror(event);
71
+ this.dispatchEvent(event);
72
+ this.handleClose(1006, "Connection failed");
73
+ }
74
+ }
75
+ send(data) {
76
+ if (this.readyState !== PlutoWebSocket.OPEN || !this.connection) {
77
+ throw new Error("WebSocket is not open");
78
+ }
79
+ if (data instanceof Blob) {
80
+ data.arrayBuffer().then(buf => {
81
+ this.connection?.send(buf);
82
+ });
83
+ }
84
+ else {
85
+ // @ts-ignore - Connection.send handles these
86
+ this.connection.send(data);
87
+ }
88
+ }
89
+ close(code, reason) {
90
+ if (this.readyState === PlutoWebSocket.CLOSED)
91
+ return;
92
+ this.readyState = PlutoWebSocket.CLOSING;
93
+ if (this.connection) {
94
+ this.connection.disconnect();
95
+ }
96
+ // Force close if it takes too long?
97
+ this.handleClose(code, reason);
98
+ }
99
+ handleClose(code = 1000, reason = "") {
100
+ if (this.readyState === PlutoWebSocket.CLOSED)
101
+ return;
102
+ this.readyState = PlutoWebSocket.CLOSED;
103
+ const event = new CloseEvent('close', { code, reason, wasClean: true });
104
+ if (this.onclose)
105
+ this.onclose(event);
106
+ this.dispatchEvent(event);
107
+ }
108
+ }
109
+ PlutoWebSocket.CONNECTING = 0;
110
+ PlutoWebSocket.OPEN = 1;
111
+ PlutoWebSocket.CLOSING = 2;
112
+ PlutoWebSocket.CLOSED = 3;
@@ -0,0 +1,28 @@
1
+ import { Client } from '../core/Client';
2
+ export declare class PlutoWebTransport {
3
+ readonly ready: Promise<void>;
4
+ closed: Promise<void>;
5
+ datagrams: any;
6
+ incomingBidirectionalStreams: ReadableStream<WebTransportBidirectionalStream>;
7
+ incomingUnidirectionalStreams: ReadableStream<ReadableStream<Uint8Array>>;
8
+ private client;
9
+ private connection;
10
+ private url;
11
+ private _readyResolve;
12
+ private _readyReject;
13
+ private _closedResolve;
14
+ private _closedReject;
15
+ private incomingBiController;
16
+ private incomingUniController;
17
+ private remoteNodeId;
18
+ constructor(url: string, options?: any, clientInstance?: Client);
19
+ private setupListeners;
20
+ private connect;
21
+ createBidirectionalStream(): Promise<WebTransportBidirectionalStream>;
22
+ createUnidirectionalStream(): Promise<WritableStream<Uint8Array>>;
23
+ close(): void;
24
+ }
25
+ export interface WebTransportBidirectionalStream {
26
+ readable: ReadableStream<Uint8Array>;
27
+ writable: WritableStream<Uint8Array>;
28
+ }
@@ -0,0 +1,88 @@
1
+ export class PlutoWebTransport {
2
+ constructor(url, options, clientInstance) {
3
+ this.connection = null;
4
+ this.remoteNodeId = null;
5
+ this.url = url;
6
+ if (clientInstance) {
7
+ this.client = clientInstance;
8
+ }
9
+ else {
10
+ throw new Error("PlutoWebTransport requires a 'Client' instance to be passed in constructor currently.");
11
+ }
12
+ this.ready = new Promise((resolve, reject) => {
13
+ this._readyResolve = resolve;
14
+ this._readyReject = reject;
15
+ });
16
+ this.closed = new Promise((resolve, reject) => {
17
+ this._closedResolve = resolve;
18
+ this._closedReject = reject;
19
+ });
20
+ this.datagrams = {
21
+ readable: new ReadableStream(),
22
+ writable: new WritableStream()
23
+ };
24
+ this.incomingBidirectionalStreams = new ReadableStream({
25
+ start: (controller) => { this.incomingBiController = controller; }
26
+ });
27
+ this.incomingUnidirectionalStreams = new ReadableStream({
28
+ start: (controller) => { this.incomingUniController = controller; }
29
+ });
30
+ this.setupListeners();
31
+ this.connect();
32
+ }
33
+ setupListeners() {
34
+ this.client.onIncomingStream((incoming) => {
35
+ // Filter by remote Node ID if we are connected
36
+ if (!this.remoteNodeId || incoming.remoteNodeId !== this.remoteNodeId) {
37
+ return;
38
+ }
39
+ if (incoming.type === 'bi') {
40
+ const stream = incoming.stream; // BiStream
41
+ this.incomingBiController.enqueue({
42
+ readable: stream.recv,
43
+ writable: stream.send
44
+ });
45
+ }
46
+ else if (incoming.type === 'uni') {
47
+ const stream = incoming.stream; // ReadableStream
48
+ this.incomingUniController.enqueue(stream);
49
+ }
50
+ });
51
+ }
52
+ async connect() {
53
+ try {
54
+ let ticket = this.url;
55
+ if (ticket.startsWith('pluto://')) {
56
+ ticket = ticket.replace('pluto://', '');
57
+ }
58
+ else if (ticket.startsWith('https://')) {
59
+ // WebTransport uses https, but we treat it as ticket for now or need a way to resolve
60
+ }
61
+ this.connection = await this.client.connect(ticket);
62
+ this.remoteNodeId = this.connection.deviceId; // Get peer ID from connection
63
+ this._readyResolve();
64
+ this.connection.onDisconnect(() => {
65
+ this._closedResolve();
66
+ });
67
+ }
68
+ catch (e) {
69
+ this._readyReject(e);
70
+ this._closedReject(e);
71
+ }
72
+ }
73
+ async createBidirectionalStream() {
74
+ await this.ready;
75
+ if (!this.connection || !this.remoteNodeId)
76
+ throw new Error("Connection not established");
77
+ return await this.client.openBi(this.remoteNodeId);
78
+ }
79
+ async createUnidirectionalStream() {
80
+ await this.ready;
81
+ if (!this.connection || !this.remoteNodeId)
82
+ throw new Error("Connection not established");
83
+ return await this.client.openUni(this.remoteNodeId);
84
+ }
85
+ close() {
86
+ this.connection?.disconnect();
87
+ }
88
+ }
@@ -0,0 +1,70 @@
1
+ import { Connection } from './Connection';
2
+ import { Signaling } from './Signaling';
3
+ import { RoomManager, JoinRequest } from './Room';
4
+ export interface ClientOptions {
5
+ tag: string;
6
+ storagePrefix?: string;
7
+ deviceTTL?: number;
8
+ nodeIdPersistence?: 'persistent' | 'ephemeral';
9
+ secretKey?: string;
10
+ experimental?: {
11
+ nativeWebRTC?: RTCConfiguration;
12
+ };
13
+ }
14
+ export declare class Client {
15
+ signaling: Signaling;
16
+ rooms: RoomManager;
17
+ private wasmLoaded;
18
+ private wasmPkg;
19
+ private node;
20
+ private connections;
21
+ private connectionListeners;
22
+ private disconnectionListeners;
23
+ private messageListeners;
24
+ private roomRequestListeners;
25
+ private streamListeners;
26
+ private localNodeId;
27
+ private listening;
28
+ private options;
29
+ private pendingConnection;
30
+ private presenceInterval;
31
+ constructor(options: ClientOptions);
32
+ get currentUser(): import("@firebase/auth").User | null;
33
+ onAuthChange(callback: (user: any) => void): void;
34
+ signInAnonymously(): Promise<void>;
35
+ signInWithPluto(): void;
36
+ signOut(): Promise<void>;
37
+ searchDevices(): Promise<{
38
+ deviceId: string;
39
+ deviceName: string;
40
+ online: boolean;
41
+ ticket: string;
42
+ }[]>;
43
+ onDevicesChange(callback: any): () => void;
44
+ init(): Promise<void>;
45
+ connect(ticket: string): Promise<Connection>;
46
+ openBi(nodeId: string): Promise<{
47
+ readable: ReadableStream<Uint8Array>;
48
+ writable: WritableStream<Uint8Array>;
49
+ }>;
50
+ openUni(nodeId: string): Promise<WritableStream<Uint8Array>>;
51
+ getConnections(): Connection[];
52
+ getTicket(): Promise<string>;
53
+ getNodeId(): Promise<string>;
54
+ startListening(): Promise<void>;
55
+ stopListening(): void;
56
+ private updatePresence;
57
+ private listenLoop;
58
+ private handleIncomingStream;
59
+ private registerConnection;
60
+ onConnection(callback: (conn: Connection) => void): void;
61
+ onDisconnection(callback: (conn: Connection) => void): void;
62
+ onMessage(callback: (conn: Connection, msg: any) => void): void;
63
+ onIncomingStream(callback: (stream: {
64
+ type: 'bi' | 'uni';
65
+ stream: any;
66
+ remoteNodeId: string;
67
+ }) => void): void;
68
+ onRoomJoinRequest(callback: (request: JoinRequest) => void): void;
69
+ private handleMessage;
70
+ }