@yz-social/webrtc 0.0.1

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/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { WebRTC } from './lib/webrtc.js';
2
+ export { PromiseWebRTC } from './lib/promisewebrtc.js';
3
+ export { TrickleWebRTC } from './lib/tricklewebrtc.js';
@@ -0,0 +1,147 @@
1
+ import { WebRTCPeerEvents } from './peerevents.js';
2
+
3
+ // Data Channels:
4
+ // -
5
+ // - Send/receive, including the break-up of long messages and re-assembly.
6
+
7
+ export class WebRTCDataChannels extends WebRTCPeerEvents {
8
+
9
+ // Negotiated channels use specific integers on both sides, starting with this number.
10
+ // We do not start at zero because the non-negotiated channels (as used on server relays) generate their
11
+ // own ids starting with 0, and we don't want to conflict.
12
+ // The spec says these can go to 65,534, but I find that starting greater than the value here gives errors.
13
+ // As of 7/6/25, current evergreen browsers work with 1000 base, but Firefox fails in our case (10 negotatiated channels)
14
+ // if any ids are 256 or higher.
15
+ static BASE_CHANNEL_ID = 125;
16
+ channelId = this.constructor.BASE_CHANNEL_ID;
17
+ get hasStartedConnecting() {
18
+ return this.channelId > this.constructor.BASE_CHANNEL_ID;
19
+ }
20
+ close(removeConnection = true) {
21
+ this.channelId = this.constructor.BASE_CHANNEL_ID;
22
+ super.close();
23
+ if (removeConnection) this.constructor.connections.delete(this.serviceLabel);
24
+ }
25
+ async ensureDataChannel(channelName, channelOptions = {}, signals = null) { // Return a promise for an open data channel on this connection.
26
+ // Much of this machinery is for ealing with the first data channel on either side in the case that the connection as a whole
27
+ // has not yet been made.
28
+
29
+ const hasStartedConnecting = this.hasStartedConnecting; // True for first channel only. Must ask before incrementing id.
30
+ const id = this.channelId++; // This and everything leading up to it must be synchronous on this instance, so that id assignment is deterministic.
31
+ const negotiated = this.multiplex && hasStartedConnecting;
32
+ const allowOtherSideToCreate = !hasStartedConnecting /*!negotiated*/ && !!signals; // Only the 0th with signals waits passively.
33
+ // signals can be nullish, in which case the real signals will have to be assigned later. This allows the data channel to be started (and to consume
34
+ // a channelId) synchronously, but the promise won't resolve until the real signals are supplied later. This is
35
+ // useful in multiplexing an ordered series of data channels on an ANSWER connection, where the data channels must
36
+ // match up with an OFFER connection on a peer. This works because of the wonderful happenstance that answer connections
37
+ // getDataChannelPromise (which doesn't require the connection to yet be open) rather than createDataChannel (which would
38
+ // require the connection to already be open).
39
+ const useSignals = !hasStartedConnecting && signals?.length;
40
+ const options = negotiated ? {id, negotiated, ...channelOptions} : channelOptions;
41
+ this.log({label: this.label, channelName, multiplex: this.multiplex, hasStartedConnecting, id, negotiated, allowOtherSideToCreate, useSignals, options});
42
+ if (hasStartedConnecting) {
43
+ await this.connected; // Before creating promise.
44
+ // I sometimes encounter a bug in Safari in which ONE of the channels created soon after connection gets stuck in
45
+ // the connecting readyState and never opens. Experimentally, this seems to be robust.
46
+ //
47
+ // Note to self: If it should turn out that we still have problems, try serializing the calls to peer.createDataChannel
48
+ // so that there isn't more than one channel opening at a time.
49
+ await new Promise(resolve => setTimeout(resolve, 100));
50
+ } else if (useSignals) {
51
+ this.signals = signals;
52
+ }
53
+ const promise = allowOtherSideToCreate ?
54
+ this.getDataChannelPromise(channelName) :
55
+ this.createDataChannel(channelName, options);
56
+ return await promise;
57
+ }
58
+
59
+ createDataChannel(label = "data", channelOptions = {}) { // Promise resolves when the channel is open (which will be after any needed negotiation).
60
+ return new Promise(resolve => {
61
+ this.log('create data-channel', label, channelOptions);
62
+ let channel = this.peer.createDataChannel(label, channelOptions);
63
+ this.noteChannel(channel, 'explicit'); // Noted even before opened.
64
+ // The channel may have already been opened on the other side. In this case, all browsers fire the open event anyway,
65
+ // but wrtc (i.e., on nodeJS) does not. So we have to explicitly check.
66
+ switch (channel.readyState) {
67
+ case 'open':
68
+ setTimeout(() => resolve(channel), 10);
69
+ break;
70
+ case 'connecting':
71
+ channel.onopen = _ => resolve(channel);
72
+ break;
73
+ default:
74
+ throw new Error(`Unexpected readyState ${channel.readyState} for data channel ${label}.`);
75
+ }
76
+ });
77
+ }
78
+ waitingChannels = {}; // channelName => resolvers
79
+ getDataChannelPromise(label = "data") { // Resolves to an open data channel.
80
+ return new Promise(resolve => {
81
+ this.log('promise data-channel', label);
82
+ this.waitingChannels[label] = resolve;
83
+ });
84
+ }
85
+ resetPeer() { // Reset a 'connected' property that promised to resolve when opened, and track incoming datachannels.
86
+ super.resetPeer();
87
+ this.connected = new Promise(resolve => { // this.connected is a promise that resolves when we are.
88
+ this.peer.addEventListener('connectionstatechange', event => {
89
+ if (this.peer.connectionState === 'connected') {
90
+ resolve(true);
91
+ }
92
+ });
93
+ });
94
+ this.peer.addEventListener('datachannel', event => { // Resolve promise made with getDataChannelPromise().
95
+ const channel = event.channel;
96
+ const label = channel.label;
97
+ const waiting = this.waitingChannels[label];
98
+ this.noteChannel(channel, 'datachannel event', waiting); // Regardless of whether we are waiting.
99
+ if (!waiting) return; // Might not be explicitly waiting. E.g., routers.
100
+ delete this.waitingChannels[label];
101
+ waiting(channel);
102
+ });
103
+ }
104
+
105
+ // TODO: (Optionaly?) Set up generic jsonrpc on each channel, along with serialization and fragmentation/assembly for long messages. (See collections/serializer)
106
+
107
+
108
+ // We need to know if there are open data channels. There is a proposal and even an accepted PR for RTCPeerConnection.getDataChannels(),
109
+ // https://github.com/w3c/webrtc-extensions/issues/110
110
+ // but it hasn't been deployed everywhere yet. So we'll need to keep our own count.
111
+ // Alas, a count isn't enough, because we can open stuff, and the other side can open stuff, but if it happens to be
112
+ // the same "negotiated" id, it isn't really a different channel. (https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/datachannel_event
113
+ dataChannels = new Map(); // channelName => Open channel objects.
114
+ reportChannels() { // Return a report string useful for debugging.
115
+ const entries = Array.from(this.dataChannels.entries());
116
+ const kv = entries.map(([k, v]) => `${k}:${v.id}`);
117
+ return `${this.dataChannels.size}/${kv.join(', ')}`;
118
+ }
119
+ noteChannel(channel, source, waiting) { // Bookkeep open channel and return it.
120
+ // Emperically, with multiplex false: // 18 occurrences, with id=null|0|1 as for eventchannel or createDataChannel
121
+ // Apparently, without negotiation, id is initially null (regardless of options.id), and then assigned to a free value during opening
122
+ const key = channel.label; //fixme channel.id === null ? 1 : channel.id;
123
+ const existing = this.dataChannels.get(key);
124
+ this.log('got data-channel', source, key, channel.readyState, 'existing:', existing, 'waiting:', waiting);
125
+ this.dataChannels.set(key, channel);
126
+ channel.addEventListener('close', event => { // Close whole connection when no more data channels or streams.
127
+ this.dataChannels.delete(key);
128
+ // If there's nothing open, close the connection.
129
+ if (this.dataChannels.size) return;
130
+ if (this.peer.getSenders().length) return;
131
+ this.close();
132
+ });
133
+ return channel;
134
+ }
135
+ close() {
136
+ super.close();
137
+ // If the webrtc implementation closes the data channels before the peer itself, then this.dataChannels will be empty.
138
+ // But if not (e.g., status 'failed' or 'disconnected' on Safari), then let us explicitly close them so that Synchronizers know to clean up.
139
+ for (const channel of this.dataChannels.values()) {
140
+ if (channel.readyState !== 'open') continue; // Keep debugging sanity.
141
+ // It appears that in Safari (18.5) for a call to channel.close() with the connection already internally closed, Safari
142
+ // will set channel.readyState to 'closing', but NOT fire the closed or closing event. So we have to dispatch it ourselves.
143
+ //channel.close();
144
+ channel.dispatchEvent(new Event('close'));
145
+ }
146
+ }
147
+ }
@@ -0,0 +1,56 @@
1
+ import { WebRTCUtilities } from './utilities.js';
2
+
3
+ // Extendible methods for events on the RTCPeerConnection.
4
+ export class WebRTCPeerEvents extends WebRTCUtilities {
5
+ resetPeer() {
6
+ const peer = super.resetPeer();
7
+
8
+ peer.onnegotiationneeded = event => this.negotiationneeded(event);
9
+ peer.onicecandidate = event => this.onLocalIceCandidate(event);
10
+ // I don't think anyone actually signals this. Instead, they reject from addIceCandidate, which we handle the same.
11
+ peer.onicecandidateerror = error => this.icecandidateError(error);
12
+ // I think this is redundant because no implementation fires this event any significant time ahead of emitting icecandidate with an empty event.candidate.
13
+ peer.onicegatheringstatechange = event => (peer.iceGatheringState === 'complete') && this.onLocalEndIce;
14
+ peer.onconnectionstatechange = event => this.connectionStateChange(this.peer.connectionState);
15
+
16
+ return peer;
17
+ }
18
+ negotiationneeded() { // Something has changed locally (new stream, or network change), such that we have to start negotiation.
19
+ this.log('negotiationnneeded');
20
+ this.peer.createOffer()
21
+ .then(offer => {
22
+ this.peer.setLocalDescription(offer); // promise does not resolve to offer
23
+ return offer;
24
+ })
25
+ .then(offer => this.signal('offer', offer))
26
+ .catch(error => this.negotiationneededError(error));
27
+ }
28
+ negotiationneededError(eventOrException) {
29
+ this.logError('negotiationneeded', eventOrException);
30
+ }
31
+ icecandidateError(eventOrException) { // For errors on this peer during gathering.
32
+ // Can be overridden or extended by applications.
33
+
34
+ // STUN errors are in the range 300-699. See RFC 5389, section 15.6
35
+ // for a list of codes. TURN adds a few more error codes; see
36
+ // RFC 5766, section 15 for details.
37
+ // Server could not be reached are in the range 700-799.
38
+ const code = eventOrException.code || eventOrException.errorCode || eventOrException.status;
39
+ // Chrome gives 701 errors for some turn servers that it does not give for other turn servers.
40
+ // This isn't good, but it's way too noisy to slog through such errors, and I don't know how to fix our turn configuration.
41
+ if (code === 701) return;
42
+ this.logError('ice', eventOrException);
43
+ }
44
+ onLocalIceCandidate(event) {
45
+ // The spec says that a null candidate should not be sent, but that an empty string candidate should. Safari (used to?) get errors either way.
46
+ if (!event.candidate || !event.candidate.candidate) this.onLocalEndIce();
47
+ else this.signal('icecandidate', event.candidate);
48
+ }
49
+ onLocalEndIce() { // Triggered on our side by any/all of onicecandidate with no event.candidate, iceGatheringState === 'complete'.
50
+ // I.e., can happen multiple times. Subclasses might do something.
51
+ }
52
+ connectionStateChange(state) {
53
+ this.log('state change:', state);
54
+ if (['disconnected', 'failed', 'closed'].includes(state)) this.close(); // Other behavior are reasonable, tolo.
55
+ }
56
+ }
@@ -0,0 +1,58 @@
1
+ import { WebRTC } from './webrtc.js';
2
+
3
+ export class PromiseWebRTC extends WebRTC {
4
+ // Extends WebRTC.signal() such that:
5
+ // - instance.signals answers a promise that will resolve with an array of signal messages.
6
+ // - instance.signals = [...signalMessages] will dispatch those messages.
7
+ //
8
+ // For example, suppose peer1 and peer2 are instances of this.
9
+ // 0. Something triggers negotiation on peer1 (such as calling peer1.createDataChannel()).
10
+ // 1. peer1.signals resolves with <signal1>, a POJO to be conveyed to peer2.
11
+ // 2. Set peer2.signals = <signal1>.
12
+ // 3. peer2.signals resolves with <signal2>, a POJO to be conveyed to peer1.
13
+ // 4. Set peer1.signals = <signal2>.
14
+ // 5. Data flows, but each side whould grab a new signals promise and be prepared to act if it resolves.
15
+ //
16
+ constructor({iceTimeout = 2e3, ...properties}) {
17
+ super(properties);
18
+ this.iceTimeout = iceTimeout;
19
+ }
20
+ get signals() { // Returns a promise that resolve to the signal messaging when ice candidate gathering is complete.
21
+ return this._signalPromise ||= new Promise((resolve, reject) => this._signalReady = {resolve, reject});
22
+ }
23
+ set signals(data) { // Set with the signals received from the other end.
24
+ data.forEach(([type, message]) => this[type](message));
25
+ }
26
+ onLocalIceCandidate(event) {
27
+ // Each wrtc implementation has its own ideas as to what ice candidates to try before emitting them in icecanddiate.
28
+ // Most will try things that cannot be reached, and give up when they hit the OS network timeout. Forty seconds is a long time to wait.
29
+ // If the wrtc is still waiting after our iceTimeout (2 seconds), lets just go with what we have.
30
+ this.timer ||= setTimeout(() => this.onLocalEndIce(), this.iceTimeout);
31
+ super.onLocalIceCandidate(event);
32
+ }
33
+ clearIceTimer() {
34
+ clearTimeout(this.timer);
35
+ this.timer = null;
36
+ }
37
+ async onLocalEndIce() { // Resolve the promise with what we've been gathering.
38
+ this.clearIceTimer();
39
+ if (!this._signalPromise) {
40
+ //this.logError('ice', "End of ICE without anything waiting on signals."); // Not helpful when there are three ways to receive this message.
41
+ return;
42
+ }
43
+ this._signalReady.resolve(this.sending);
44
+ this.sending = [];
45
+ }
46
+ sending = [];
47
+ signal(type, message) {
48
+ super.signal(type, message);
49
+ this.sending.push([type, message]);
50
+ }
51
+ close() {
52
+ if (this.peer.connectionState === 'failed') this._signalPromise?.reject?.();
53
+ super.close();
54
+ this.clearIceTimer();
55
+ this._signalPromise = this._signalReady = null;
56
+ this.sending = [];
57
+ }
58
+ }
@@ -0,0 +1,49 @@
1
+ import { WebRTC } from './webrtc.js';
2
+
3
+ export class TrickleWebRTC extends WebRTC {
4
+ // Same idea as PromiseWebRTC, but instead of waiting for End of Ice (or timeout), signals will resolve as soon as there
5
+ // are any signals ready to be picked up, and a new call to get signals will start collecting a new set of promises.
6
+ // Signals stops collecting new signals when we connect (even if there are more ice candidates, and even if signals is
7
+ // an empty array at that point).
8
+
9
+ get signals() { // Returns a promise that collects signals until the next time someone calls this.signals,
10
+ // but which resolves as soon as it is not empty (and continues to accumulate).
11
+ // Clients must be careful to no grab a new signals promise until the previous one has resolved.
12
+ const { xx = 0, sending } = this;
13
+ const { promise, resolve } = Promise.withResolvers();
14
+ const counter = xx;
15
+ Object.assign(promise, {resolve, counter});
16
+ this._signalPromise = promise;
17
+ this.sending = [];
18
+ this.xx = counter + 1;
19
+ //console.log(this.label, 'get signals', {xx, sending, promise, counter, promise, sending: this.sending});
20
+ return promise;
21
+ }
22
+ set signals(data) { // Set with the signals received from the other end.
23
+ data.forEach(([type, message]) => this[type](message));
24
+ }
25
+ // onLocalEndIce() {
26
+ // console.log(this.label, 'end ice');
27
+ // this.signal('icecandidate', null); // So that clients can recognize that they should not await more signals.
28
+ // }
29
+ // connectionStateChange(state) {
30
+ // //console.log(this.label, 'connection state', state);
31
+ // super.connectionStateChange(state);
32
+ // //this._signalPromise.resolve(this.sending);
33
+ // }
34
+ sending = [];
35
+ signal(type, message) {
36
+ super.signal(type, message);
37
+ this.sending.push([type, message]);
38
+ // Until another call to get signals (which replaces _signalPromise), we will continue to accumulate into
39
+ // the same sending array, and subsequent signals will re-resolve (which has no effect on the promise).
40
+ this._signalPromise.resolve(this.sending);
41
+ //console.log(this.label, 'signal', type, 'resolving', this._signalPromise);
42
+ }
43
+ // close() {
44
+ // if (this.peer.connectionState === 'failed') this._signalPromise?.reject?.();
45
+ // super.close();
46
+ // // this._signalPromise = null;
47
+ // // this.sending = [];
48
+ // }
49
+ }
@@ -0,0 +1,92 @@
1
+ import wrtc from '#wrtc';
2
+
3
+ const iceServers = [ // Some default stun and even turn servers.
4
+
5
+ { urls: 'stun:stun.l.google.com:19302'},
6
+ // https://freestun.net/ Currently 50 KBit/s. (2.5 MBit/s fors $9/month)
7
+ { urls: 'stun:freestun.net:3478' },
8
+
9
+ //{ urls: 'turn:freestun.net:3478', username: 'free', credential: 'free' },
10
+ // Presumably traffic limited. Can generate new credentials at https://speed.cloudflare.com/turn-creds
11
+ // Also https://developers.cloudflare.com/calls/ 1 TB/month, and $0.05 /GB after that.
12
+ { urls: 'turn:turn.speed.cloudflare.com:50000', username: '826226244cd6e5edb3f55749b796235f420fe5ee78895e0dd7d2baa45e1f7a8f49e9239e78691ab38b72ce016471f7746f5277dcef84ad79fc60f8020b132c73', credential: 'aba9b169546eb6dcc7bfb1cdf34544cf95b5161d602e3b5fa7c8342b2e9802fb' }
13
+
14
+ // See also:
15
+ // https://fastturn.net/ Currently 500MB/month? (25 GB/month for $9/month)
16
+ // https://xirsys.com/pricing/ 500 MB/month (50 GB/month for $33/month)
17
+ // Also https://www.npmjs.com/package/node-turn or https://meetrix.io/blog/webrtc/coturn/installation.html
18
+ ];
19
+
20
+ // Basic logging and configuration wrapper around an instance of RTCPeerConnection.
21
+ export class WebRTCUtilities {
22
+ constructor({label = '', configuration = null, debug = false, error = console.error, ...rest} = {}) {
23
+ configuration ??= {iceServers}; // If configuration can be ommitted or explicitly as null, to use our default. But if {}, leave it be.
24
+ Object.assign(this, {label, configuration, debug, error, ...rest});
25
+ this.resetPeer();
26
+ this.connectionStartTime = Date.now();
27
+ }
28
+ signal(type, message) { // Subclasses must override or extend. Default just logs.
29
+ this.log('sending', type, type.length, JSON.stringify(message).length);
30
+ }
31
+ close() {
32
+ if ((this.peer.connectionState === 'new') && (this.peer.signalingState === 'stable')) return;
33
+ this.resetPeer();
34
+ }
35
+ instanceVersion = 0;
36
+ resetPeer() { // Set up a new RTCPeerConnection.
37
+ const old = this.peer;
38
+ if (old) {
39
+ old.onnegotiationneeded = old.onicecandidate = old.onicecandidateerror = old.onconnectionstatechange = null;
40
+ // Don't close unless it's been opened, because there are likely handlers that we don't want to fire.
41
+ if (old.connectionState !== 'new') old.close();
42
+ }
43
+ const peer = this.peer = new wrtc.RTCPeerConnection(this.configuration);
44
+ peer.instanceVersion = this.instanceVersion++;
45
+
46
+ return peer;
47
+ }
48
+ async reportConnection(doLogging = false) { // Update self with latest wrtc stats (and log them if doLogging true). See Object.assign for properties.
49
+ const stats = await this.peer.getStats();
50
+ let transport;
51
+ for (const report of stats.values()) {
52
+ if (report.type === 'transport') {
53
+ transport = report;
54
+ break;
55
+ }
56
+ }
57
+ let candidatePair = transport && stats.get(transport.selectedCandidatePairId);
58
+ if (!candidatePair) { // Safari doesn't follow the standard.
59
+ for (const report of stats.values()) {
60
+ if ((report.type === 'candidate-pair') && report.selected) {
61
+ candidatePair = report;
62
+ break;
63
+ }
64
+ }
65
+ }
66
+ if (!candidatePair) {
67
+ console.warn(this.label, 'got stats without candidatePair', Array.from(stats.values()));
68
+ return;
69
+ }
70
+ const remote = stats.get(candidatePair.remoteCandidateId);
71
+ const {protocol, candidateType} = remote;
72
+ const now = Date.now();
73
+ Object.assign(this, {stats, transport, candidatePair, remote, protocol, candidateType, statsTime: now});
74
+ if (doLogging) console.info(this.label, 'connected', protocol, candidateType, ((now - this.connectionStartTime)/1e3).toFixed(1));
75
+ }
76
+ log(...rest) { // console.log(...rest) ONLY if debug is set.
77
+ if (this.debug) console.log(this.label, this.peer.instanceVersion, ...rest);
78
+ }
79
+ logError(label, eventOrException) { // Call error with the gathered platform-specific data. Returns the gatheredata.
80
+ const data = [this.label, this.peer.instanceVersion, ...this.constructor.gatherErrorData(label, eventOrException)];
81
+ this.error(...data);
82
+ return data;
83
+ }
84
+ static gatherErrorData(label, eventOrException) { // Normalize several context- or platform-specific properties.
85
+ return [
86
+ label + " error:",
87
+ eventOrException.code || eventOrException.errorCode || eventOrException.status || "", // First is deprecated, but still useful.
88
+ eventOrException.url || eventOrException.name || '',
89
+ eventOrException.message || eventOrException.errorText || eventOrException.statusText || eventOrException
90
+ ];
91
+ }
92
+ }
package/lib/webrtc.js ADDED
@@ -0,0 +1,42 @@
1
+ import { WebRTCDataChannels } from './datachannels.js';
2
+
3
+ // Basics.
4
+ export class WebRTC extends WebRTCDataChannels {
5
+ static connections = new Map();
6
+ static ensure({serviceLabel, multiplex = true, ...rest}) { // Answer the named connection, creating it if there isn't already a CONNECTED one by this name.
7
+ // The serviceLabel is used as the log label throughout.
8
+ // E.g., if a node has connections to many other nodes, but with multiple channels on each connection, then it is
9
+ // convenient to name the shared connection with the id of the other side.
10
+ //
11
+ // If running both ends in the same Javascript, be sure to give them different names!
12
+
13
+ let connection = this.connections.get(serviceLabel);
14
+ // It is possible that we were backgrounded before we had a chance to act on a closing connection and remove it.
15
+ if (connection) {
16
+ const {connectionState, signalingState} = connection.peer;
17
+ if ((connectionState === 'new') || (connectionState === 'closed') || (signalingState === 'closed')) connection = null;
18
+ }
19
+ if (!connection) {
20
+ connection = new this({label: serviceLabel, multiplex, ...rest});
21
+ if (multiplex) this.connections.set(serviceLabel, connection);
22
+ }
23
+ return connection;
24
+ }
25
+
26
+ // Handlers for signal messages from the peer.
27
+ // Note that one peer will receive offer(), and the other will receive answer(). Both receive icecandidate.
28
+ offer(offer) { // Handler for receiving an offer from the other user (who started the signaling process).
29
+ // Note that during signaling, we will receive negotiationneeded/answer, or offer, but not both, depending
30
+ // on whether we were the one that started the signaling process.
31
+ this.peer.setRemoteDescription(offer)
32
+ .then(_ => this.peer.createAnswer())
33
+ .then(answer => this.peer.setLocalDescription(answer)) // promise does not resolve to answer
34
+ .then(_ => this.signal('answer', this.peer.localDescription));
35
+ }
36
+ answer(answer) { // Handler for finishing the signaling process that we started.
37
+ this.peer.setRemoteDescription(answer);
38
+ }
39
+ icecandidate(iceCandidate) { // Handler for a new candidate received from the other end through signaling.
40
+ this.peer.addIceCandidate(iceCandidate).catch(error => this.icecandidateError(error));
41
+ }
42
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@yz-social/webrtc",
3
+ "version": "0.0.1",
4
+ "description": "Streamlined portable webrtc management of p2p and client2server.",
5
+ "keywords": [
6
+ "webrtc",
7
+ "expressjs",
8
+ "nodejs",
9
+ "browser"
10
+ ],
11
+ "type": "module",
12
+ "imports": {
13
+ "#wrtc": {
14
+ "node": "@roamhq/wrtc",
15
+ "default": "./lib/browser-wrtc.js"
16
+ }
17
+ },
18
+ "exports": {
19
+ ".": "./index.mjs",
20
+ "./router": "./lib/router.js"
21
+ },
22
+ "scripts": {
23
+ "test": "npx jasmine",
24
+ "testServer": "node testServer.js",
25
+ "stopServer": "pkill webrtcTestServer"
26
+ },
27
+ "dependencies": {
28
+ "@roamhq/wrtc": "^0.9.1"
29
+ },
30
+ "devDependencies": {
31
+ "express": "^5.2.1",
32
+ "jasmine": "^5.12.0"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/kilroy-code/flexstore.git"
37
+ },
38
+ "publishConfig": {
39
+ "registry": "https://registry.npmjs.org"
40
+ }
41
+ }
@@ -0,0 +1,61 @@
1
+ import express from 'express';
2
+ import { PromiseWebRTC, TrickleWebRTC } from '../index.js';
3
+ export const router = express.Router();
4
+
5
+ const testConnections = {}; // Is it really necessary to keep these around, against garbage collection?
6
+ router.post('/promise/echo/:tag', async (req, res, next) => { // Test endpoint for WebRTC.
7
+ // Client posts collected signal data to us.
8
+ // We create a WebRTC connection here with the given tag, and answer our response signals.
9
+ // We also create message handler on the channel that just sends back whatever comes over
10
+ // from the other side.
11
+ const {params, body} = req;
12
+ const tag = params.tag;
13
+ const signals = body;
14
+ const connection = testConnections[tag] = new PromiseWebRTC({label: tag});
15
+ const timer = setTimeout(() => connection.close(), 15e3);
16
+ const dataPromise = connection.getDataChannelPromise('echo');
17
+ dataPromise.then(dataChannel => {
18
+ dataChannel.onclose = () => {
19
+ clearTimeout(timer);
20
+ connection.close();
21
+ delete testConnections[tag];
22
+ };
23
+ dataChannel.onmessage = event => dataChannel.send(event.data); // Just echo what we are given.
24
+ });
25
+ connection.signals = signals; // Convey the posted offer+ice signals to our connection.
26
+ res.send(await connection.signals); // Send back our signalling answer+ice.
27
+ });
28
+ router.post('/trickle/echo/:tag', async (req, res, next) => { // Test endpoint for WebRTC.
29
+ // Same as above, but using trickle ice. The server "peer" is created first and given
30
+ // the initial offer as above. But then the client reposts (long polling) to give
31
+ // ice candidates and the server waits until there are any to give back (or connection).
32
+ const {params, body} = req;
33
+ const tag = params.tag;
34
+ const incomingSignals = body;
35
+ let connection = testConnections[tag];
36
+ console.log('post connection:', !!connection, 'incoming:', incomingSignals.map(s => s[0]));
37
+ if (!connection) {
38
+ connection = testConnections[tag] = TrickleWebRTC.ensure({serviceLabel: tag});
39
+ const timer = setTimeout(() => connection.close(), 15e3);
40
+ console.log('created connection and applying', incomingSignals.map(s => s[0]));
41
+ connection.next = connection.signals;
42
+ const dataPromise = connection.dataChannelPromise = connection.ensureDataChannel('echo', {}, incomingSignals);
43
+ dataPromise.then(dataChannel => {
44
+ connection.reportConnection(true);
45
+ dataChannel.onclose = () => {
46
+ clearTimeout(timer);
47
+ connection.close();
48
+ delete testConnections[tag];
49
+ };
50
+ dataChannel.onmessage = event => dataChannel.send(event.data); // Just echo what we are given.
51
+ });
52
+ } else {
53
+ console.log('applying incoming signals');
54
+ connection.signals = incomingSignals;
55
+ }
56
+ console.log('awaiting response');
57
+ const responseSignals = await Promise.race([connection.next, connection.dataChannelPromise]);
58
+ connection.next = connection.signals;
59
+ console.log('responding', responseSignals.map(s => s[0]));
60
+ res.send(responseSignals); // Send back our signalling answer+ice.
61
+ });
@@ -0,0 +1,14 @@
1
+ export default {
2
+ spec_dir: "spec",
3
+ spec_files: [
4
+ "**/*[sS]pec.?(m)js"
5
+ ],
6
+ helpers: [
7
+ "helpers/**/*.?(m)js"
8
+ ],
9
+ env: {
10
+ stopSpecOnExpectationFailure: false,
11
+ random: true,
12
+ forbidDuplicateNames: true
13
+ }
14
+ }
@@ -0,0 +1,230 @@
1
+ const { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach} = globalThis; // For linters.
2
+ import { WebRTC, PromiseWebRTC, TrickleWebRTC } from '../index.js';
3
+
4
+ class DirectSignaling extends WebRTC {
5
+ signal(type, message) { // Just invoke the method directly on the otherSide.
6
+ this.otherSide[type](message);
7
+ }
8
+ signals = [];
9
+ }
10
+ function delay(ms) {
11
+ return new Promise(resolve => setTimeout(resolve, ms));
12
+ }
13
+ async function fetchSignals(url, signalsToSend) {
14
+ const response = await fetch(url, {
15
+ method: 'POST',
16
+ headers: { 'Content-Type': 'application/json' },
17
+ body: JSON.stringify(signalsToSend)
18
+ });
19
+ return await response.json();
20
+ }
21
+ function map(signals) { return signals?.map?.(s => s[0]); }
22
+
23
+ describe("WebRTC", function () {
24
+ describe("connection to server", function () {
25
+ describe("using PromiseWebRTC", function () {
26
+ let connection, dataChannel;
27
+ beforeAll(async function () {
28
+ connection = PromiseWebRTC.ensure({serviceLabel: 'PromiseClient'});
29
+ const dataChannelPromise = connection.ensureDataChannel('echo');
30
+ connection.signals = await fetchSignals("http://localhost:3000/test/promise/echo/foo", await connection.signals);
31
+ dataChannel = await dataChannelPromise;
32
+ connection.reportConnection(true);
33
+ });
34
+ afterAll(function () {
35
+ connection.close();
36
+ });
37
+ it("sends and receives data", async function () {
38
+ const echoPromise = new Promise(resolve => dataChannel.onmessage = event => resolve(event.data));
39
+ dataChannel.send('hello');
40
+ expect(await echoPromise).toBe('hello');
41
+ });
42
+ });
43
+ describe("using trickle ICE", function () {
44
+ let connection, dataChannel;
45
+ beforeAll(async function () {
46
+ connection = TrickleWebRTC.ensure({serviceLabel: 'Client'});
47
+ const dataChannelPromise = connection.ensureDataChannel('echo');
48
+ await connection.signals;
49
+ async function exchange() {
50
+ if (connection.peer.iceGatheringState !== 'gathering') return;
51
+ const sending = connection.sending;
52
+ connection.sending = [];
53
+
54
+ console.log('client sending', map(sending));
55
+ const returning = await fetchSignals("http://localhost:3000/test/trickle/echo/foo", sending);
56
+
57
+ if (!returning?.length) return;
58
+ //if (a.connection.peer.iceGatheringState !== 'gathering') return;
59
+ console.log('client', 'received', map(returning), connection.peer.iceGatheringState);
60
+ connection.signals = returning;
61
+ exchange();
62
+ }
63
+ exchange();
64
+
65
+ dataChannel = await dataChannelPromise;
66
+ connection.reportConnection(true);
67
+ });
68
+ afterAll(function () {
69
+ connection.close();
70
+ });
71
+ it("sends and receives data", async function () {
72
+ const echoPromise = new Promise(resolve => dataChannel.onmessage = event => resolve(event.data));
73
+ dataChannel.send('hello');
74
+ expect(await echoPromise).toBe('hello');
75
+ });
76
+ });
77
+ });
78
+
79
+ describe('connection between two peers on the same computer', function () {
80
+ function test(Kind) {
81
+ describe(Kind.name, function () {
82
+ let a = {}, b = {};
83
+ const channelName = 'test';
84
+ const channelOptions = {};
85
+ beforeAll(async function () {
86
+ const debug = false;
87
+ a.connection = Kind.ensure({serviceLabel: 'A'+Kind.name, debug});
88
+ b.connection = Kind.ensure({serviceLabel: 'B'+Kind.name, debug});
89
+
90
+ // Required only for DirectSignaling signal(), above. Ignored for others.
91
+ a.connection.otherSide = b.connection;
92
+ b.connection.otherSide = a.connection;
93
+
94
+ let aPromise = a.connection.signals;
95
+ a.dataChannelPromise = a.connection.ensureDataChannel(channelName, channelOptions);
96
+ const aSignals = await aPromise;
97
+ a.connection.sending = [];
98
+ //console.log(Kind.name, 'aSignals:', map(aSignals));
99
+
100
+ // The second peer on the initial negotiation must specify a non-empty signals --
101
+ // either an empty list for trickle-ice, or a list of the actual signals from the PromiseWebRTC.
102
+
103
+ b.next = b.connection.signals;
104
+ // let bPromise = b.connection.signals;
105
+ // aPromise = a.connection.signals;
106
+ b.dataChannelPromise = b.connection.ensureDataChannel(channelName, channelOptions, aSignals);
107
+ // const bSignals = await bPromise;
108
+ // console.log(Kind.name, 'bSignals:', map(bSignals));
109
+
110
+ // bPromise = b.connection.signals;
111
+ // a.connection.signals = bSignals;
112
+
113
+ async function exchange() {
114
+ if (a.connection.peer.iceGatheringState !== 'gathering') return;
115
+ const sending = a.connection.sending;
116
+ a.connection.sending = [];
117
+
118
+ console.log(Kind.name, 'sending', map(sending), b.connection.peer.iceGatheringState);
119
+ b.connection.signals = sending;
120
+ const returning = await Promise.race([b.next, b.dataChannelPromise]);
121
+ b.next = b.connection.signals;
122
+
123
+ if (!returning?.length) return;
124
+ //if (a.connection.peer.iceGatheringState !== 'gathering') return;
125
+ console.log(Kind.name, 'received', map(returning), a.connection.peer.iceGatheringState);
126
+ a.connection.signals = returning;
127
+ exchange();
128
+ }
129
+ exchange();
130
+
131
+ // Only needed in the trickle ice case. Harmless otherwise.
132
+ async function conveySignals(from, to, promise) {
133
+ const state = from.peer.iceGatheringState;
134
+ if (state !== 'gathering') {
135
+ console.log(Kind.name, from.label, state, 'skipping');
136
+ return;
137
+ }
138
+ console.log(Kind.name, from.label, state, from.peer.iceConnectionState, from.peer.connectionState, from.peer.signalingState, 'waiting');
139
+ const signals = await Promise.race([promise,
140
+ from === a.connection ? a.dataChannelPromise : b.dataChannelPromise,
141
+ ]); // 7 | f
142
+ console.log(Kind.name, from.label, 'resolved:', map(signals));
143
+ const next = from.signals; // 8 | g
144
+ if (!signals?.length) return;
145
+ to.signals = signals; // d | h
146
+ conveySignals(from, to, next);
147
+ }
148
+ // conveySignals(a.connection, b.connection, aNext); //6.5
149
+ // conveySignals(b.connection, a.connection, bNext);
150
+
151
+ async function push() { // Await signals in a and send them to b.
152
+ if (a.connection.peer.iceGatheringState !== 'gathering') return;
153
+ const signals = await Promise.race([aPromise, a.dataChannelPromise]);
154
+ aPromise = a.connection.signals;
155
+ if (!signals.length) return;
156
+ console.log(Kind.name, 'pushing', map(signals));
157
+ b.connection.signals = signals;
158
+ push();
159
+ }
160
+ async function pull(promise) { // Request signals from b, setting whatever we get.
161
+ if (a.connection.peer.iceGatheringState !== 'gathering') return;
162
+ const signals = await Promise.race([bPromise, b.dataChannelPromise]);
163
+ bPromise = b.connection.signals;
164
+ if (!signals.length) return;
165
+ console.log(Kind.name, 'pulling', map(signals));
166
+ a.connection.signals = signals;
167
+ pull();
168
+ }
169
+ //push();
170
+ //pull();
171
+
172
+ a.testChannel = await a.dataChannelPromise;
173
+ b.testChannel = await b.dataChannelPromise;
174
+
175
+ await a.connection.reportConnection(true);
176
+ await b.connection.reportConnection(true);
177
+ });
178
+ afterAll(async function () {
179
+ a.connection.close();
180
+ expect(a.connection.peer.connectionState).toBe('new');
181
+ await delay(10); // Yield to allow the other side to close.
182
+ expect(b.connection.peer.connectionState).toBe('new');
183
+ });
184
+ it("changes state appropriately.", async function () {
185
+ expect(await a.dataChannelPromise).toBeTruthy();
186
+ expect(await b.dataChannelPromise).toBeTruthy();
187
+ expect(a.connection.peer.connectionState).toBe('connected');
188
+ expect(b.connection.peer.connectionState).toBe('connected');
189
+ expect(a.connection.protocol).toBe(b.connection.protocol);
190
+ expect(a.connection.protocol).toBe('udp');
191
+ // In this case, since both are on the same machine:
192
+ expect(a.connection.candidateType).toBe(b.connection.candidateType);
193
+ expect(a.connection.candidateType).toBe('host');
194
+
195
+ });
196
+ it("sends data over raw channel.", async function () {
197
+ const aReceived = new Promise(resolve => a.testChannel.onmessage = event => resolve(event.data));
198
+ const bReceived = new Promise(resolve => b.testChannel.onmessage = event => resolve(event.data));
199
+ a.testChannel.send("forty-two");
200
+ b.testChannel.send("17");
201
+ expect(await aReceived).toBe("17");
202
+ expect(await bReceived).toBe("forty-two");
203
+ });
204
+ describe("second channel", function () {
205
+ beforeAll(async function () {
206
+ const name2 = 'channel2';
207
+ a.promise2 = a.connection.ensureDataChannel(name2, channelOptions);
208
+ b.promise2 = b.connection.ensureDataChannel(name2, channelOptions);
209
+ a.channel2 = await a.promise2;
210
+ b.channel2 = await b.promise2;
211
+ });
212
+ afterAll(async function () {
213
+ a.channel2.close();
214
+ });
215
+ it("handles data.", async function () {
216
+ const aReceived = new Promise(resolve => a.channel2.onmessage = event => resolve(event.data));
217
+ const bReceived = new Promise(resolve => b.channel2.onmessage = event => resolve(event.data));
218
+ a.channel2.send("red");
219
+ b.channel2.send("blue");
220
+ expect(await aReceived).toBe("blue");
221
+ expect(await bReceived).toBe("red");
222
+ }, 10e3);
223
+ });
224
+ });
225
+ }
226
+ test(DirectSignaling);
227
+ test(PromiseWebRTC);
228
+ test(TrickleWebRTC);
229
+ });
230
+ });
package/testServer.js ADDED
@@ -0,0 +1,14 @@
1
+ import express from 'express';
2
+ import http from 'http';
3
+ import { router } from './routes/index.js';
4
+
5
+ process.title = 'webrtcTestServer';
6
+ export const app = express();
7
+ const port = 3000;
8
+ app.set('port', port);
9
+
10
+ app.use(express.json());
11
+ app.use('/test', router);
12
+
13
+ app.listen(port);
14
+