@tangentfeed/transport-webrtc 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sreeraj T A
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,8 @@
1
+ # @tangentfeed/transport-webrtc
2
+
3
+ WebRTC transports for tangentfeed.
4
+
5
+ - `WebRTCTransport` — cross-device mesh brokered by a signaling server
6
+ - `ManualPairTransport` — serverless pairing by QR code or copy-paste
7
+
8
+ Part of [tangentfeed](https://github.com/sreerajta/tangentfeed). MIT licensed.
@@ -0,0 +1,173 @@
1
+ import { Transport, WireMsg } from '@tangentfeed/core';
2
+
3
+ /**
4
+ * Manual pairing transport — WebRTC with zero infrastructure.
5
+ *
6
+ * Instead of a signaling server, two humans carry the signaling: device A
7
+ * displays its offer as a QR code (or copyable text), device B ingests it and
8
+ * displays an answer, A ingests that, and the DataChannel opens. Works with
9
+ * no internet at all on a shared LAN.
10
+ *
11
+ * The trick that makes one-QR-per-direction possible: NON-trickle ICE. We
12
+ * wait for candidate gathering to finish (with a timeout fallback — host
13
+ * candidates alone are enough on a LAN) so the SDP blob contains every
14
+ * candidate inline. Nothing else about the stack changes: this class speaks
15
+ * the same Transport interface, so the Replicator neither knows nor cares
16
+ * that signaling traveled by camera.
17
+ *
18
+ * Exactly two peers by design. Blobs are base64url(JSON) and single-use.
19
+ */
20
+
21
+ type ManualPairState = "idle" | "gathering" | "waiting-for-answer" | "connecting" | "connected" | "failed" | "closed";
22
+ interface ManualPairOptions {
23
+ deviceId: string;
24
+ wrtc?: {
25
+ RTCPeerConnection: typeof RTCPeerConnection;
26
+ };
27
+ rtcConfig?: RTCConfiguration;
28
+ onError?: (err: unknown) => void;
29
+ onState?: (state: ManualPairState) => void;
30
+ /** max ms to wait for full ICE gathering before shipping what we have */
31
+ gatherTimeoutMs?: number;
32
+ }
33
+ interface Blob1 {
34
+ v: 1;
35
+ kind: "offer" | "answer";
36
+ device: string;
37
+ /** pairing space id, minted by the offerer; the answerer adopts it */
38
+ space: string;
39
+ sdp: RTCSessionDescriptionInit;
40
+ }
41
+ declare class ManualPairTransport implements Transport {
42
+ private readonly opts;
43
+ private readonly RTCPC;
44
+ private pc;
45
+ private channel;
46
+ private readonly msgListeners;
47
+ private readonly peerConnectListeners;
48
+ private _state;
49
+ private _space;
50
+ private _peerDevice;
51
+ constructor(opts: ManualPairOptions);
52
+ get state(): ManualPairState;
53
+ /** The pairing space id (offerer mints it; answerer learns it from the offer). */
54
+ get space(): string | null;
55
+ get peerDevice(): string | null;
56
+ /** Role A: create the offer blob to display as a QR / copy to the peer. */
57
+ createOffer(): Promise<string>;
58
+ /** Role A: ingest the answer blob the peer displayed. */
59
+ acceptAnswer(blob: string): Promise<void>;
60
+ /** Role B: ingest an offer blob; returns the answer blob to display back. */
61
+ acceptOffer(blob: string): Promise<string>;
62
+ send(msg: WireMsg): void;
63
+ onMessage(cb: (msg: WireMsg) => void): () => void;
64
+ onPeerConnect(cb: (peerId?: string) => void): () => void;
65
+ close(): void;
66
+ private newPc;
67
+ private attachChannel;
68
+ /** Resolve when ICE gathering completes, or after the timeout with whatever
69
+ * candidates exist (host candidates suffice on a LAN). */
70
+ private gathered;
71
+ private setState;
72
+ }
73
+ declare function encodeBlob(msg: Blob1): string;
74
+ declare function decodeBlob(blob: string): Blob1;
75
+
76
+ /**
77
+ * WebRTC transport — device-to-device sync over DataChannels.
78
+ *
79
+ * Same four-member Transport interface as BroadcastChannel; everything below
80
+ * this API line is peer lifecycle:
81
+ *
82
+ * - Presence via the signaling server (join room → peers / peer-joined).
83
+ * - Deterministic roles kill offer-glare at the root: for any pair, the
84
+ * LOWER deviceId is the initiator (creates the DataChannel and offer);
85
+ * the higher answers. Only one side ever offers, so the classic
86
+ * both-offer-simultaneously race cannot occur.
87
+ * - Trickle ICE relayed as opaque blobs through the signaling server.
88
+ * - Fan-out send: WireMsg JSON to every open channel (bus semantics);
89
+ * Replicator's `to` filtering discards what isn't addressed to a peer.
90
+ * - Signaling reconnect with capped exponential backoff; peer connections
91
+ * are rebuilt on rejoin. Op idempotency upstream makes all of this safe.
92
+ *
93
+ * Environment injection: pass `wrtc` (RTCPeerConnection impl) and
94
+ * `WebSocketImpl` for Node (node-datachannel/polyfill, global WebSocket);
95
+ * browsers need neither.
96
+ */
97
+
98
+ type PeerConnectCb = (peerId?: string) => void;
99
+ type SignalingState = "connecting" | "connected" | "disconnected" | "conflict";
100
+ /**
101
+ * Public STUN, used when the caller supplies no `iceServers`.
102
+ *
103
+ * STUN only discovers your public address; it cannot relay. Two peers behind
104
+ * symmetric NAT will still fail to connect, and for those you need a TURN
105
+ * server — which relays traffic and therefore costs bandwidth, which is why
106
+ * there is no public default for it. See docs/TURN.md.
107
+ */
108
+ declare const DEFAULT_ICE_SERVERS: RTCIceServer[];
109
+ interface WebRTCTransportOptions {
110
+ space: string;
111
+ deviceId: string;
112
+ /** ws:// or wss:// URL of the signaling server */
113
+ signalingUrl: string;
114
+ /** RTCPeerConnection constructor; defaults to globalThis.RTCPeerConnection */
115
+ wrtc?: {
116
+ RTCPeerConnection: typeof RTCPeerConnection;
117
+ };
118
+ /** WebSocket constructor; defaults to globalThis.WebSocket */
119
+ WebSocketImpl?: typeof WebSocket;
120
+ /**
121
+ * Passed straight to RTCPeerConnection. Omit `iceServers` and you get
122
+ * DEFAULT_ICE_SERVERS — a public STUN server, enough for two peers behind
123
+ * ordinary home routers and not enough for symmetric NAT or strict
124
+ * corporate firewalls. Those need TURN; see docs/TURN.md.
125
+ */
126
+ rtcConfig?: RTCConfiguration;
127
+ /** called on non-fatal internal errors (a peer failing, signaling drop) */
128
+ onError?: (err: unknown, ctx: {
129
+ peer?: string;
130
+ }) => void;
131
+ /**
132
+ * Signaling connection lifecycle. "conflict" is terminal: another
133
+ * connection claimed this deviceId (the server evicted us). We do NOT
134
+ * auto-reconnect on conflict — doing so would make the two holders evict
135
+ * each other in an endless loop. Recover by reconnecting with a new id.
136
+ */
137
+ onSignalingState?: (state: SignalingState) => void;
138
+ }
139
+ declare class WebRTCTransport implements Transport {
140
+ private readonly opts;
141
+ private readonly RTCPC;
142
+ private readonly WS;
143
+ private ws;
144
+ private readonly peers;
145
+ private readonly msgListeners;
146
+ private readonly peerConnectListeners;
147
+ private closed;
148
+ private backoff;
149
+ constructor(opts: WebRTCTransportOptions);
150
+ send(msg: WireMsg): void;
151
+ onMessage(cb: (msg: WireMsg) => void): () => void;
152
+ onPeerConnect(cb: PeerConnectCb): () => void;
153
+ close(): void;
154
+ /** Currently connected (channel-open) peer deviceIds. For UI. */
155
+ get connectedPeers(): string[];
156
+ /** Per-peer diagnostics for debugging/UI: connection + ICE + channel state. */
157
+ get peerDiagnostics(): {
158
+ id: string;
159
+ connection: string;
160
+ ice: string;
161
+ channel: string;
162
+ }[];
163
+ private connectSignaling;
164
+ private sigSend;
165
+ private handleSignaling;
166
+ private isInitiatorFor;
167
+ private ensurePeer;
168
+ private handleSignal;
169
+ private attachChannel;
170
+ private dropPeer;
171
+ }
172
+
173
+ export { DEFAULT_ICE_SERVERS, type ManualPairOptions, type ManualPairState, ManualPairTransport, type SignalingState, WebRTCTransport, type WebRTCTransportOptions, decodeBlob, encodeBlob };
package/dist/index.js ADDED
@@ -0,0 +1,442 @@
1
+ // src/manual.ts
2
+ var CHANNEL_LABEL = "tangentfeed";
3
+ var ManualPairTransport = class {
4
+ opts;
5
+ RTCPC;
6
+ pc = null;
7
+ channel = null;
8
+ msgListeners = /* @__PURE__ */ new Set();
9
+ peerConnectListeners = /* @__PURE__ */ new Set();
10
+ _state = "idle";
11
+ _space = null;
12
+ _peerDevice = null;
13
+ constructor(opts) {
14
+ this.opts = opts;
15
+ const rtc = opts.wrtc?.RTCPeerConnection ?? globalThis.RTCPeerConnection;
16
+ if (!rtc) throw new Error("no RTCPeerConnection available; pass opts.wrtc");
17
+ this.RTCPC = rtc;
18
+ }
19
+ get state() {
20
+ return this._state;
21
+ }
22
+ /** The pairing space id (offerer mints it; answerer learns it from the offer). */
23
+ get space() {
24
+ return this._space;
25
+ }
26
+ get peerDevice() {
27
+ return this._peerDevice;
28
+ }
29
+ // ---------- pairing (device A) ----------
30
+ /** Role A: create the offer blob to display as a QR / copy to the peer. */
31
+ async createOffer() {
32
+ if (this.pc) throw new Error("pairing already in progress; create a new transport");
33
+ this._space = "manual-" + randHex(8);
34
+ const pc = this.newPc();
35
+ this.attachChannel(pc.createDataChannel(CHANNEL_LABEL));
36
+ this.setState("gathering");
37
+ const offer = await pc.createOffer();
38
+ await pc.setLocalDescription(offer);
39
+ await this.gathered(pc);
40
+ this.setState("waiting-for-answer");
41
+ return encodeBlob({
42
+ v: 1,
43
+ kind: "offer",
44
+ device: this.opts.deviceId,
45
+ space: this._space,
46
+ sdp: pc.localDescription ?? offer
47
+ });
48
+ }
49
+ /** Role A: ingest the answer blob the peer displayed. */
50
+ async acceptAnswer(blob) {
51
+ const msg = decodeBlob(blob);
52
+ if (msg.kind !== "answer") throw new Error("expected an ANSWER blob, got an offer \u2014 paste it on the other device");
53
+ if (!this.pc) throw new Error("no pending offer; call createOffer first");
54
+ this._peerDevice = msg.device;
55
+ this.setState("connecting");
56
+ await this.pc.setRemoteDescription(msg.sdp);
57
+ }
58
+ // ---------- pairing (device B) ----------
59
+ /** Role B: ingest an offer blob; returns the answer blob to display back. */
60
+ async acceptOffer(blob) {
61
+ if (this.pc) throw new Error("pairing already in progress; create a new transport");
62
+ const msg = decodeBlob(blob);
63
+ if (msg.kind !== "offer") throw new Error("expected an OFFER blob, got an answer");
64
+ this._space = msg.space;
65
+ this._peerDevice = msg.device;
66
+ const pc = this.newPc();
67
+ pc.ondatachannel = (ev) => this.attachChannel(ev.channel);
68
+ this.setState("gathering");
69
+ await pc.setRemoteDescription(msg.sdp);
70
+ const answer = await pc.createAnswer();
71
+ await pc.setLocalDescription(answer);
72
+ await this.gathered(pc);
73
+ this.setState("connecting");
74
+ return encodeBlob({
75
+ v: 1,
76
+ kind: "answer",
77
+ device: this.opts.deviceId,
78
+ space: msg.space,
79
+ sdp: pc.localDescription ?? answer
80
+ });
81
+ }
82
+ // ---------- Transport interface ----------
83
+ send(msg) {
84
+ if (this.channel?.readyState === "open") {
85
+ try {
86
+ this.channel.send(JSON.stringify(msg));
87
+ } catch (err) {
88
+ this.opts.onError?.(err);
89
+ }
90
+ }
91
+ }
92
+ onMessage(cb) {
93
+ this.msgListeners.add(cb);
94
+ return () => this.msgListeners.delete(cb);
95
+ }
96
+ onPeerConnect(cb) {
97
+ this.peerConnectListeners.add(cb);
98
+ return () => this.peerConnectListeners.delete(cb);
99
+ }
100
+ close() {
101
+ this.setState("closed");
102
+ try {
103
+ this.channel?.close();
104
+ } catch {
105
+ }
106
+ try {
107
+ this.pc?.close();
108
+ } catch {
109
+ }
110
+ this.channel = null;
111
+ this.pc = null;
112
+ this.msgListeners.clear();
113
+ this.peerConnectListeners.clear();
114
+ }
115
+ // ---------- internals ----------
116
+ newPc() {
117
+ const pc = new this.RTCPC(this.opts.rtcConfig ?? {});
118
+ this.pc = pc;
119
+ pc.onconnectionstatechange = () => {
120
+ if (pc.connectionState === "connected") this.setState("connected");
121
+ if (["failed", "closed"].includes(pc.connectionState)) {
122
+ if (this._state !== "closed") this.setState("failed");
123
+ }
124
+ };
125
+ return pc;
126
+ }
127
+ attachChannel(channel) {
128
+ this.channel = channel;
129
+ channel.onopen = () => {
130
+ this.setState("connected");
131
+ for (const cb of this.peerConnectListeners) cb(this._peerDevice ?? void 0);
132
+ };
133
+ channel.onmessage = (ev) => {
134
+ try {
135
+ const msg = JSON.parse(String(ev.data));
136
+ for (const cb of this.msgListeners) cb(msg);
137
+ } catch (err) {
138
+ this.opts.onError?.(err);
139
+ }
140
+ };
141
+ channel.onclose = () => {
142
+ if (this._state !== "closed") this.setState("failed");
143
+ };
144
+ }
145
+ /** Resolve when ICE gathering completes, or after the timeout with whatever
146
+ * candidates exist (host candidates suffice on a LAN). */
147
+ gathered(pc) {
148
+ if (pc.iceGatheringState === "complete") return Promise.resolve();
149
+ const timeoutMs = this.opts.gatherTimeoutMs ?? 2500;
150
+ return new Promise((resolve) => {
151
+ const done = () => {
152
+ clearTimeout(timer);
153
+ resolve();
154
+ };
155
+ const timer = setTimeout(done, timeoutMs);
156
+ const check = () => {
157
+ if (pc.iceGatheringState === "complete") done();
158
+ };
159
+ pc.addEventListener?.("icegatheringstatechange", check);
160
+ const prev = pc.onicecandidate;
161
+ pc.onicecandidate = (ev) => {
162
+ prev?.call(pc, ev);
163
+ if (!ev.candidate) done();
164
+ };
165
+ });
166
+ }
167
+ setState(s) {
168
+ this._state = s;
169
+ this.opts.onState?.(s);
170
+ }
171
+ };
172
+ function encodeBlob(msg) {
173
+ return b64urlEncode(JSON.stringify(msg));
174
+ }
175
+ function decodeBlob(blob) {
176
+ let parsed;
177
+ try {
178
+ parsed = JSON.parse(b64urlDecode(blob.trim()));
179
+ } catch {
180
+ throw new Error("not a valid pairing blob (paste the whole code, nothing else)");
181
+ }
182
+ const m = parsed;
183
+ if (m?.v !== 1 || !m.sdp || m.kind !== "offer" && m.kind !== "answer" || !m.space) {
184
+ throw new Error("unrecognized pairing blob format");
185
+ }
186
+ return m;
187
+ }
188
+ function b64urlEncode(s) {
189
+ const bytes = new TextEncoder().encode(s);
190
+ let bin = "";
191
+ for (const b of bytes) bin += String.fromCharCode(b);
192
+ const b64 = typeof btoa === "function" ? btoa(bin) : Buffer.from(bytes).toString("base64");
193
+ return b64.replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
194
+ }
195
+ function b64urlDecode(s) {
196
+ const b64 = s.replaceAll("-", "+").replaceAll("_", "/");
197
+ const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
198
+ const bin = typeof atob === "function" ? atob(padded) : Buffer.from(padded, "base64").toString("binary");
199
+ const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
200
+ return new TextDecoder().decode(bytes);
201
+ }
202
+ function randHex(n) {
203
+ const b = new Uint8Array(n / 2);
204
+ globalThis.crypto.getRandomValues(b);
205
+ return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
206
+ }
207
+
208
+ // src/index.ts
209
+ var DEFAULT_ICE_SERVERS = [
210
+ { urls: "stun:stun.l.google.com:19302" }
211
+ ];
212
+ var CHANNEL_LABEL2 = "tangentfeed";
213
+ var MAX_BACKOFF_MS = 15e3;
214
+ var WebRTCTransport = class {
215
+ opts;
216
+ RTCPC;
217
+ WS;
218
+ ws = null;
219
+ peers = /* @__PURE__ */ new Map();
220
+ msgListeners = /* @__PURE__ */ new Set();
221
+ peerConnectListeners = /* @__PURE__ */ new Set();
222
+ closed = false;
223
+ backoff = 500;
224
+ constructor(opts) {
225
+ this.opts = opts;
226
+ const rtc = opts.wrtc?.RTCPeerConnection ?? globalThis.RTCPeerConnection;
227
+ const ws = opts.WebSocketImpl ?? globalThis.WebSocket;
228
+ if (!rtc) throw new Error("no RTCPeerConnection available; pass opts.wrtc");
229
+ if (!ws) throw new Error("no WebSocket available; pass opts.WebSocketImpl");
230
+ this.RTCPC = rtc;
231
+ this.WS = ws;
232
+ this.connectSignaling();
233
+ }
234
+ // ---------- Transport interface ----------
235
+ send(msg) {
236
+ const payload = JSON.stringify(msg);
237
+ for (const peer of this.peers.values()) {
238
+ if (peer.channel?.readyState === "open") {
239
+ try {
240
+ peer.channel.send(payload);
241
+ } catch (err) {
242
+ this.opts.onError?.(err, {});
243
+ }
244
+ }
245
+ }
246
+ }
247
+ onMessage(cb) {
248
+ this.msgListeners.add(cb);
249
+ return () => this.msgListeners.delete(cb);
250
+ }
251
+ onPeerConnect(cb) {
252
+ this.peerConnectListeners.add(cb);
253
+ return () => this.peerConnectListeners.delete(cb);
254
+ }
255
+ close() {
256
+ this.closed = true;
257
+ for (const [id] of this.peers) this.dropPeer(id);
258
+ this.ws?.close();
259
+ this.ws = null;
260
+ this.msgListeners.clear();
261
+ this.peerConnectListeners.clear();
262
+ }
263
+ /** Currently connected (channel-open) peer deviceIds. For UI. */
264
+ get connectedPeers() {
265
+ return [...this.peers.entries()].filter(([, p]) => p.channel?.readyState === "open").map(([id]) => id);
266
+ }
267
+ /** Per-peer diagnostics for debugging/UI: connection + ICE + channel state. */
268
+ get peerDiagnostics() {
269
+ return [...this.peers.entries()].map(([id, p]) => ({
270
+ id,
271
+ connection: p.pc.connectionState ?? "?",
272
+ ice: p.pc.iceConnectionState ?? "?",
273
+ channel: p.channel?.readyState ?? "no channel"
274
+ }));
275
+ }
276
+ // ---------- signaling ----------
277
+ connectSignaling() {
278
+ if (this.closed) return;
279
+ this.opts.onSignalingState?.("connecting");
280
+ const ws = new this.WS(this.opts.signalingUrl);
281
+ this.ws = ws;
282
+ ws.onopen = () => {
283
+ this.backoff = 500;
284
+ this.opts.onSignalingState?.("connected");
285
+ this.sigSend({ t: "join", space: this.opts.space, device: this.opts.deviceId });
286
+ };
287
+ ws.onmessage = (ev) => {
288
+ let msg;
289
+ try {
290
+ msg = JSON.parse(String(ev.data));
291
+ } catch {
292
+ return;
293
+ }
294
+ void this.handleSignaling(msg).catch(
295
+ (err) => this.opts.onError?.(err, {})
296
+ );
297
+ };
298
+ ws.onclose = (ev) => {
299
+ if (this.closed) return;
300
+ if (ev.code === 4e3) {
301
+ this.opts.onSignalingState?.("conflict");
302
+ this.opts.onError?.(
303
+ new Error("deviceId already connected to this space (evicted); rejoin with a new deviceId"),
304
+ {}
305
+ );
306
+ return;
307
+ }
308
+ this.opts.onSignalingState?.("disconnected");
309
+ setTimeout(() => this.connectSignaling(), this.backoff);
310
+ this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
311
+ };
312
+ ws.onerror = () => {
313
+ };
314
+ }
315
+ sigSend(msg) {
316
+ if (this.ws && this.ws.readyState === 1) {
317
+ this.ws.send(JSON.stringify(msg));
318
+ }
319
+ }
320
+ async handleSignaling(msg) {
321
+ switch (msg["t"]) {
322
+ case "peers": {
323
+ for (const device of msg["devices"] ?? []) {
324
+ void this.ensurePeer(device);
325
+ }
326
+ break;
327
+ }
328
+ case "peer-joined": {
329
+ void this.ensurePeer(msg["device"]);
330
+ break;
331
+ }
332
+ case "peer-left": {
333
+ this.dropPeer(msg["device"]);
334
+ break;
335
+ }
336
+ case "signal": {
337
+ await this.handleSignal(msg["from"], msg["data"]);
338
+ break;
339
+ }
340
+ }
341
+ }
342
+ // ---------- peer lifecycle ----------
343
+ isInitiatorFor(peerId) {
344
+ return this.opts.deviceId < peerId;
345
+ }
346
+ async ensurePeer(peerId) {
347
+ if (!peerId || peerId === this.opts.deviceId || this.peers.has(peerId)) return;
348
+ const config = {
349
+ iceServers: DEFAULT_ICE_SERVERS,
350
+ ...this.opts.rtcConfig
351
+ };
352
+ const pc = new this.RTCPC(config);
353
+ const peer = { pc, pendingCandidates: [], remoteDescSet: false };
354
+ this.peers.set(peerId, peer);
355
+ pc.onicecandidate = (ev) => {
356
+ if (ev.candidate) {
357
+ this.sigSend({ t: "signal", to: peerId, data: { kind: "ice", candidate: ev.candidate } });
358
+ }
359
+ };
360
+ pc.onconnectionstatechange = () => {
361
+ if (["failed", "closed", "disconnected"].includes(pc.connectionState)) {
362
+ this.dropPeer(peerId);
363
+ }
364
+ };
365
+ if (this.isInitiatorFor(peerId)) {
366
+ this.attachChannel(peerId, pc.createDataChannel(CHANNEL_LABEL2));
367
+ try {
368
+ const offer = await pc.createOffer();
369
+ await pc.setLocalDescription(offer);
370
+ this.sigSend({ t: "signal", to: peerId, data: { kind: "sdp", description: pc.localDescription } });
371
+ } catch (err) {
372
+ this.opts.onError?.(err, { peer: peerId });
373
+ this.dropPeer(peerId);
374
+ }
375
+ } else {
376
+ pc.ondatachannel = (ev) => this.attachChannel(peerId, ev.channel);
377
+ }
378
+ }
379
+ async handleSignal(from, data) {
380
+ await this.ensurePeer(from);
381
+ const peer = this.peers.get(from);
382
+ if (!peer) return;
383
+ try {
384
+ if (data.kind === "sdp" && data.description) {
385
+ await peer.pc.setRemoteDescription(data.description);
386
+ peer.remoteDescSet = true;
387
+ for (const c of peer.pendingCandidates.splice(0)) {
388
+ await peer.pc.addIceCandidate(c);
389
+ }
390
+ if (data.description.type === "offer") {
391
+ const answer = await peer.pc.createAnswer();
392
+ await peer.pc.setLocalDescription(answer);
393
+ this.sigSend({ t: "signal", to: from, data: { kind: "sdp", description: peer.pc.localDescription } });
394
+ }
395
+ } else if (data.kind === "ice" && data.candidate) {
396
+ if (peer.remoteDescSet) await peer.pc.addIceCandidate(data.candidate);
397
+ else peer.pendingCandidates.push(data.candidate);
398
+ }
399
+ } catch (err) {
400
+ this.opts.onError?.(err, { peer: from });
401
+ }
402
+ }
403
+ attachChannel(peerId, channel) {
404
+ const peer = this.peers.get(peerId);
405
+ if (!peer) return;
406
+ peer.channel = channel;
407
+ channel.onopen = () => {
408
+ for (const cb of this.peerConnectListeners) cb(peerId);
409
+ };
410
+ channel.onmessage = (ev) => {
411
+ try {
412
+ const msg = JSON.parse(String(ev.data));
413
+ for (const cb of this.msgListeners) cb(msg);
414
+ } catch (err) {
415
+ this.opts.onError?.(err, { peer: peerId });
416
+ }
417
+ };
418
+ channel.onclose = () => {
419
+ if (this.peers.get(peerId)?.channel === channel) this.dropPeer(peerId);
420
+ };
421
+ }
422
+ dropPeer(peerId) {
423
+ const peer = this.peers.get(peerId);
424
+ if (!peer) return;
425
+ this.peers.delete(peerId);
426
+ try {
427
+ peer.channel?.close();
428
+ } catch {
429
+ }
430
+ try {
431
+ peer.pc.close();
432
+ } catch {
433
+ }
434
+ }
435
+ };
436
+ export {
437
+ DEFAULT_ICE_SERVERS,
438
+ ManualPairTransport,
439
+ WebRTCTransport,
440
+ decodeBlob,
441
+ encodeBlob
442
+ };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@tangentfeed/transport-webrtc",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "scripts": {
7
+ "test": "vitest run",
8
+ "build": "tsup src/index.ts --format esm --dts --clean --external node-datachannel",
9
+ "prepack": "npm run build"
10
+ },
11
+ "dependencies": {
12
+ "@tangentfeed/core": "0.2.0"
13
+ },
14
+ "devDependencies": {
15
+ "@tangentfeed/signaling-server": "0.2.0",
16
+ "@types/node": "^20.0.0",
17
+ "node-datachannel": "^0.26.0",
18
+ "typescript": "^5.5.0",
19
+ "vitest": "^2.0.0",
20
+ "tsup": "^8.5.0"
21
+ },
22
+ "description": "WebRTC transports for tangentfeed: signaling-server mesh and serverless QR pairing",
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/sreerajta/tangentfeed.git",
27
+ "directory": "packages/transport-webrtc"
28
+ },
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "import": "./dist/index.js"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "engines": {
42
+ "node": ">=20"
43
+ }
44
+ }