mockrtc 0.1.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/.github/workflows/ci.yml +29 -0
- package/LICENSE +201 -0
- package/README.md +290 -0
- package/dist/admin-bin.d.ts +2 -0
- package/dist/admin-bin.js +67 -0
- package/dist/admin-bin.js.map +1 -0
- package/dist/client/mockrtc-client.d.ts +12 -0
- package/dist/client/mockrtc-client.js +67 -0
- package/dist/client/mockrtc-client.js.map +1 -0
- package/dist/client/mockrtc-remote-peer.d.ts +15 -0
- package/dist/client/mockrtc-remote-peer.js +246 -0
- package/dist/client/mockrtc-remote-peer.js.map +1 -0
- package/dist/control-channel.d.ts +8 -0
- package/dist/control-channel.js +11 -0
- package/dist/control-channel.js.map +1 -0
- package/dist/handling/handler-builder.d.ts +138 -0
- package/dist/handling/handler-builder.js +164 -0
- package/dist/handling/handler-builder.js.map +1 -0
- package/dist/handling/handler-step-definitions.d.ts +63 -0
- package/dist/handling/handler-step-definitions.js +123 -0
- package/dist/handling/handler-step-definitions.js.map +1 -0
- package/dist/handling/handler-steps.d.ts +48 -0
- package/dist/handling/handler-steps.js +218 -0
- package/dist/handling/handler-steps.js.map +1 -0
- package/dist/main-browser.d.ts +9 -0
- package/dist/main-browser.js +26 -0
- package/dist/main-browser.js.map +1 -0
- package/dist/main.d.ts +58 -0
- package/dist/main.js +67 -0
- package/dist/main.js.map +1 -0
- package/dist/mockrtc-admin-plugin.d.ts +56 -0
- package/dist/mockrtc-admin-plugin.js +151 -0
- package/dist/mockrtc-admin-plugin.js.map +1 -0
- package/dist/mockrtc-admin-server.d.ts +7 -0
- package/dist/mockrtc-admin-server.js +18 -0
- package/dist/mockrtc-admin-server.js.map +1 -0
- package/dist/mockrtc-client.d.ts +12 -0
- package/dist/mockrtc-client.js +64 -0
- package/dist/mockrtc-client.js.map +1 -0
- package/dist/mockrtc-handler-builder.d.ts +15 -0
- package/dist/mockrtc-handler-builder.js +24 -0
- package/dist/mockrtc-handler-builder.js.map +1 -0
- package/dist/mockrtc-peer.d.ts +147 -0
- package/dist/mockrtc-peer.js +7 -0
- package/dist/mockrtc-peer.js.map +1 -0
- package/dist/mockrtc-remote-peer.d.ts +15 -0
- package/dist/mockrtc-remote-peer.js +234 -0
- package/dist/mockrtc-remote-peer.js.map +1 -0
- package/dist/mockrtc-server-peer.d.ts +29 -0
- package/dist/mockrtc-server-peer.js +145 -0
- package/dist/mockrtc-server-peer.js.map +1 -0
- package/dist/mockrtc-server.d.ts +14 -0
- package/dist/mockrtc-server.js +53 -0
- package/dist/mockrtc-server.js.map +1 -0
- package/dist/mockrtc.d.ts +25 -0
- package/dist/mockrtc.js +7 -0
- package/dist/mockrtc.js.map +1 -0
- package/dist/package.json +52 -0
- package/dist/server/mockrtc-admin-plugin.d.ts +17 -0
- package/dist/server/mockrtc-admin-plugin.js +163 -0
- package/dist/server/mockrtc-admin-plugin.js.map +1 -0
- package/dist/server/mockrtc-admin-server.d.ts +7 -0
- package/dist/server/mockrtc-admin-server.js +18 -0
- package/dist/server/mockrtc-admin-server.js.map +1 -0
- package/dist/server/mockrtc-server-peer.d.ts +24 -0
- package/dist/server/mockrtc-server-peer.js +141 -0
- package/dist/server/mockrtc-server-peer.js.map +1 -0
- package/dist/server/mockrtc-server.d.ts +14 -0
- package/dist/server/mockrtc-server.js +53 -0
- package/dist/server/mockrtc-server.js.map +1 -0
- package/dist/src/main.d.ts +1 -0
- package/dist/src/main.js +24 -0
- package/dist/src/main.js.map +1 -0
- package/dist/src/mockrtc-peer.d.ts +0 -0
- package/dist/src/mockrtc-peer.js +2 -0
- package/dist/src/mockrtc-peer.js.map +1 -0
- package/dist/src/mockrtc.d.ts +0 -0
- package/dist/src/mockrtc.js +65 -0
- package/dist/src/mockrtc.js.map +1 -0
- package/dist/webrtc/control-channel.d.ts +8 -0
- package/dist/webrtc/control-channel.js +11 -0
- package/dist/webrtc/control-channel.js.map +1 -0
- package/dist/webrtc/datachannel-stream.d.ts +25 -0
- package/dist/webrtc/datachannel-stream.js +86 -0
- package/dist/webrtc/datachannel-stream.js.map +1 -0
- package/dist/webrtc/mediatrack-stream.d.ts +29 -0
- package/dist/webrtc/mediatrack-stream.js +109 -0
- package/dist/webrtc/mediatrack-stream.js.map +1 -0
- package/dist/webrtc/mockrtc-connection.d.ts +14 -0
- package/dist/webrtc/mockrtc-connection.js +147 -0
- package/dist/webrtc/mockrtc-connection.js.map +1 -0
- package/dist/webrtc/peer-connection.d.ts +16 -0
- package/dist/webrtc/peer-connection.js +81 -0
- package/dist/webrtc/peer-connection.js.map +1 -0
- package/dist/webrtc/rtc-connection.d.ts +47 -0
- package/dist/webrtc/rtc-connection.js +370 -0
- package/dist/webrtc/rtc-connection.js.map +1 -0
- package/dist/webrtc-hooks.d.ts +30 -0
- package/dist/webrtc-hooks.js +224 -0
- package/dist/webrtc-hooks.js.map +1 -0
- package/karma.conf.ts +89 -0
- package/ngi-eu-footer.png +0 -0
- package/package.json +86 -0
- package/src/admin-bin.ts +57 -0
- package/src/client/mockrtc-client.ts +79 -0
- package/src/client/mockrtc-remote-peer.ts +286 -0
- package/src/handling/handler-builder.ts +215 -0
- package/src/handling/handler-step-definitions.ts +142 -0
- package/src/handling/handler-steps.ts +254 -0
- package/src/main-browser.ts +44 -0
- package/src/main.ts +109 -0
- package/src/mockrtc-peer.ts +176 -0
- package/src/mockrtc.ts +36 -0
- package/src/server/mockrtc-admin-plugin.ts +196 -0
- package/src/server/mockrtc-admin-server.ts +17 -0
- package/src/server/mockrtc-server-peer.ts +159 -0
- package/src/server/mockrtc-server.ts +53 -0
- package/src/webrtc/control-channel.ts +13 -0
- package/src/webrtc/datachannel-stream.ts +102 -0
- package/src/webrtc/mediatrack-stream.ts +135 -0
- package/src/webrtc/mockrtc-connection.ts +164 -0
- package/src/webrtc/rtc-connection.ts +420 -0
- package/src/webrtc-hooks.ts +245 -0
- package/test/integration/close-steps.spec.ts +39 -0
- package/test/integration/connection-setup.spec.ts +230 -0
- package/test/integration/echo-steps.spec.ts +88 -0
- package/test/integration/proxy.spec.ts +526 -0
- package/test/integration/send-steps.spec.ts +76 -0
- package/test/integration/smoke-test.spec.ts +100 -0
- package/test/integration/wait-steps.spec.ts +225 -0
- package/test/start-test-admin-server.ts +12 -0
- package/test/test-setup.ts +136 -0
- package/test/tsconfig.json +11 -0
- package/tsconfig.json +14 -0
- package/typedoc.json +19 -0
- package/wallaby.js +41 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-FileCopyrightText: 2022 Tim Perry <tim@httptoolkit.tech>
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { randomUUID } from 'crypto';
|
|
7
|
+
import { EventEmitter } from 'events';
|
|
8
|
+
import * as SDP from 'sdp-transform';
|
|
9
|
+
import * as NodeDataChannel from 'node-datachannel';
|
|
10
|
+
|
|
11
|
+
import { AnswerOptions, MockRTCSession, OfferOptions } from '../mockrtc-peer';
|
|
12
|
+
|
|
13
|
+
import { DataChannelStream } from './datachannel-stream';
|
|
14
|
+
import { MediaTrackStream } from './mediatrack-stream';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* An RTC connection is a single connection. This base class defines the raw connection management and
|
|
18
|
+
* tracking logic for a generic connection. The MockRTCConnection subclass extends this and adds
|
|
19
|
+
* logic to support control channels, proxying and other MockRTC-specific additions.
|
|
20
|
+
*/
|
|
21
|
+
export class RTCConnection extends EventEmitter {
|
|
22
|
+
|
|
23
|
+
readonly id = randomUUID();
|
|
24
|
+
|
|
25
|
+
// Set to null when the connection is closed, as otherwise calling any method (including checking
|
|
26
|
+
// the connection state) will segfault the process.
|
|
27
|
+
private rawConn: NodeDataChannel.PeerConnection | null
|
|
28
|
+
= new NodeDataChannel.PeerConnection("MockRTCConnection", { iceServers: [] });
|
|
29
|
+
|
|
30
|
+
private remoteDescription: RTCSessionDescriptionInit | undefined;
|
|
31
|
+
|
|
32
|
+
private readonly trackedChannels: Array<{ stream: DataChannelStream, isLocal: boolean }> = [];
|
|
33
|
+
|
|
34
|
+
get channels(): ReadonlyArray<DataChannelStream> {
|
|
35
|
+
return this.trackedChannels
|
|
36
|
+
.map(channel => channel.stream);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get localChannels(): ReadonlyArray<DataChannelStream> {
|
|
40
|
+
return this.trackedChannels
|
|
41
|
+
.filter(channel => channel.isLocal)
|
|
42
|
+
.map(channel => channel.stream);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get remoteChannels(): ReadonlyArray<DataChannelStream> {
|
|
46
|
+
return this.trackedChannels
|
|
47
|
+
.filter(channel => !channel.isLocal)
|
|
48
|
+
.map(channel => channel.stream);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private readonly trackedMediaTracks: Array<{ stream: MediaTrackStream, isLocal: boolean }> = [];
|
|
52
|
+
|
|
53
|
+
get mediaTracks(): ReadonlyArray<MediaTrackStream> {
|
|
54
|
+
return this.trackedMediaTracks
|
|
55
|
+
.map(track => track.stream);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get localMediaTracks(): ReadonlyArray<MediaTrackStream> {
|
|
59
|
+
return this.trackedMediaTracks
|
|
60
|
+
.filter(track => track.isLocal)
|
|
61
|
+
.map(track => track.stream);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get remoteMediaTracks(): ReadonlyArray<MediaTrackStream> {
|
|
65
|
+
return this.trackedMediaTracks
|
|
66
|
+
.filter(track => !track.isLocal)
|
|
67
|
+
.map(track => track.stream);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
constructor() {
|
|
71
|
+
super();
|
|
72
|
+
|
|
73
|
+
this.rawConn!.onDataChannel((channel) => {
|
|
74
|
+
if (!this.rawConn) return; // https://github.com/murat-dogan/node-datachannel/issues/103
|
|
75
|
+
|
|
76
|
+
this.trackNewChannel(channel, { isLocal: false });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
this.rawConn!.onTrack((track: NodeDataChannel.Track) => {
|
|
80
|
+
if (!this.rawConn) return; // https://github.com/murat-dogan/node-datachannel/issues/103
|
|
81
|
+
|
|
82
|
+
this.trackNewMediaTrack(track, { isLocal: false });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Important to remember that only node-dc only allows one listener per event. To handle that,
|
|
86
|
+
// we reemit important events here to use normal node event methods instead:
|
|
87
|
+
this.rawConn!.onStateChange((state) => {
|
|
88
|
+
this.emit('connection-state-changed', state);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
this.on('connection-state-changed', (state) => {
|
|
92
|
+
if (state === 'closed') this.emit('connection-closed');
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
createDataChannel(label: string) {
|
|
97
|
+
if (!this.rawConn) throw new Error("Can't create data channel after connection is closed");
|
|
98
|
+
const channel = this.rawConn.createDataChannel(label);
|
|
99
|
+
return this.trackNewChannel(channel, { isLocal: true });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
protected trackNewChannel(channel: NodeDataChannel.DataChannel, options: { isLocal: boolean }) {
|
|
103
|
+
const channelStream = new DataChannelStream(channel);
|
|
104
|
+
this.trackedChannels.push({ stream: channelStream, isLocal: options.isLocal });
|
|
105
|
+
|
|
106
|
+
channelStream.on('close', () => {
|
|
107
|
+
const channelIndex = this.trackedChannels.findIndex(c => c.stream === channelStream);
|
|
108
|
+
if (channelIndex !== -1) {
|
|
109
|
+
this.trackedChannels.splice(channelIndex, 1);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
channelStream.on('error', (error) => {
|
|
114
|
+
console.error('Channel error:', error);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
this.emit('channel-open', channelStream);
|
|
118
|
+
if (options.isLocal) {
|
|
119
|
+
this.emit('local-channel-open', channelStream);
|
|
120
|
+
} else {
|
|
121
|
+
this.emit('remote-channel-open', channelStream);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return channelStream;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
protected trackNewMediaTrack(track: NodeDataChannel.Track, options: { isLocal: boolean }) {
|
|
128
|
+
const trackStream = new MediaTrackStream(track);
|
|
129
|
+
this.trackedMediaTracks.push({ stream: trackStream, isLocal: options.isLocal });
|
|
130
|
+
|
|
131
|
+
trackStream.on('close', () => {
|
|
132
|
+
const trackIndex = this.trackedMediaTracks.findIndex(c => c.stream === trackStream);
|
|
133
|
+
if (trackIndex !== -1) {
|
|
134
|
+
this.trackedChannels.splice(trackIndex, 1);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
trackStream.on('error', (error) => {
|
|
139
|
+
console.error('Media track error:', error);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
this.emit('track-open', trackStream);
|
|
143
|
+
if (options.isLocal) {
|
|
144
|
+
this.emit('local-track-open', trackStream);
|
|
145
|
+
} else {
|
|
146
|
+
this.emit('remote-track-open', trackStream);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return trackStream;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
setRemoteDescription(description: RTCSessionDescriptionInit) {
|
|
153
|
+
if (!this.rawConn) throw new Error("Can't set remote description after connection is closed");
|
|
154
|
+
|
|
155
|
+
this.remoteDescription = description;
|
|
156
|
+
const { type: offerType, sdp: offerSdp } = description;
|
|
157
|
+
if (!offerSdp) throw new Error("Cannot set MockRTC peer description without providing an SDP");
|
|
158
|
+
this.rawConn.setRemoteDescription(offerSdp, offerType[0].toUpperCase() + offerType.slice(1) as any);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Gets the local description for this connection, waiting until gathering is complete to provide a
|
|
163
|
+
* full result. Because this waits for gathering, it will not resolve if no DataChannel, other
|
|
164
|
+
* tracks or remote description have been provided beforehand.
|
|
165
|
+
*/
|
|
166
|
+
async getLocalDescription(): Promise<RTCSessionDescriptionInit> {
|
|
167
|
+
if (!this.rawConn) throw new Error("Can't get local description after connection is closed");
|
|
168
|
+
|
|
169
|
+
let setupChannel: NodeDataChannel.DataChannel | undefined;
|
|
170
|
+
if (this.rawConn.gatheringState() === 'new') {
|
|
171
|
+
// We can't create an offer until we have something to negotiate, but we don't want to
|
|
172
|
+
// negotiate ourselves when we don't really know what's being negotiated here. To work
|
|
173
|
+
// around that, we create a channel to trigger gathering & get an offer, and then we
|
|
174
|
+
// remove it before the offer is delivered, so it's never visible remotely.
|
|
175
|
+
setupChannel = this.rawConn.createDataChannel('mockrtc.setup-channel');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
await new Promise<void>((resolve) => {
|
|
179
|
+
this.rawConn!.onGatheringStateChange((state) => {
|
|
180
|
+
if (state === 'complete') resolve();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Handle race conditions where gathering has already completed
|
|
184
|
+
if (this.rawConn!.gatheringState() === 'complete') resolve();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (!this.rawConn) throw new Error("Connection was closed while building local description");
|
|
188
|
+
|
|
189
|
+
const sessionDescription = this.rawConn.localDescription() as RTCSessionDescriptionInit;
|
|
190
|
+
setupChannel?.close(); // Close the temporary setup channel, if we created one
|
|
191
|
+
return sessionDescription;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
getRemoteDescription() {
|
|
195
|
+
if (!this.rawConn) throw new Error("Can't get remote description after connection is closed");
|
|
196
|
+
return this.remoteDescription;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async getMirroredLocalOffer(
|
|
200
|
+
sdpToMirror: string,
|
|
201
|
+
options: { addDataStream?: boolean } = {}
|
|
202
|
+
): Promise<RTCSessionDescriptionInit> {
|
|
203
|
+
if (!this.rawConn) throw new Error("Can't get local description after connection is closed");
|
|
204
|
+
|
|
205
|
+
const offerToMirror = SDP.parse(sdpToMirror);
|
|
206
|
+
|
|
207
|
+
const mediaStreamsToMirror = offerToMirror.media.filter(media => media.type !== 'application');
|
|
208
|
+
const shouldMirrorDataStream = offerToMirror.media.some(media => media.type === 'application');
|
|
209
|
+
|
|
210
|
+
mediaStreamsToMirror.forEach((mediaToMirror) => {
|
|
211
|
+
// Skip media tracks that we already have
|
|
212
|
+
if (this.mediaTracks.find(({ mid }) => mid === mediaToMirror.mid!)) return;
|
|
213
|
+
|
|
214
|
+
const mid = mediaToMirror.mid!.toString();
|
|
215
|
+
const direction = sdpDirectionToNDCDirection(mediaToMirror.direction);
|
|
216
|
+
|
|
217
|
+
const media = mediaToMirror.type === 'video'
|
|
218
|
+
? new NodeDataChannel.Video(mid, direction)
|
|
219
|
+
: new NodeDataChannel.Audio(mid, direction)
|
|
220
|
+
|
|
221
|
+
// Copy SSRC data (awkward translation between per-attr and full-value structures)
|
|
222
|
+
const ssrcs = mediaToMirror.ssrcs?.reduce((ssrcs, kv) => {
|
|
223
|
+
ssrcs[kv.id] ||= {};
|
|
224
|
+
ssrcs[kv.id][kv.attribute] = kv.value;
|
|
225
|
+
return ssrcs;
|
|
226
|
+
}, {} as { [id: string]: { [attr: string]: string | undefined } }) ?? {};
|
|
227
|
+
|
|
228
|
+
Object.keys(ssrcs).forEach((id) => {
|
|
229
|
+
const ssrcAttrs = ssrcs[id];
|
|
230
|
+
const [msid, trackId] = ssrcAttrs.msid?.split(' ') ?? [];
|
|
231
|
+
if (!msid) {
|
|
232
|
+
media.addSSRC(
|
|
233
|
+
parseInt(id, 10),
|
|
234
|
+
ssrcAttrs['cname']
|
|
235
|
+
);
|
|
236
|
+
} else {
|
|
237
|
+
media.addSSRC(
|
|
238
|
+
parseInt(id, 10),
|
|
239
|
+
ssrcAttrs['cname'],
|
|
240
|
+
msid,
|
|
241
|
+
trackId
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const track = this.rawConn!.addTrack(media);
|
|
247
|
+
this.trackNewMediaTrack(track, { isLocal: true });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
let setupChannel: NodeDataChannel.DataChannel | undefined;
|
|
251
|
+
const channelRequiredForDescription = this.rawConn.gatheringState() === 'new' &&
|
|
252
|
+
!mediaStreamsToMirror.length;
|
|
253
|
+
if (shouldMirrorDataStream || channelRequiredForDescription || options.addDataStream) {
|
|
254
|
+
// See getLocalDescription() above: if we want a description and we have no media, we
|
|
255
|
+
// need to make a stub channel to allow us to negotiate _something_.
|
|
256
|
+
// In addition, we might actually have data channels to mirror. In that case, we need
|
|
257
|
+
// to create a temporary data channel to force that negotiation (which will be closed
|
|
258
|
+
// again shortly, so that it never actually gets created).
|
|
259
|
+
setupChannel = this.rawConn.createDataChannel('mockrtc.setup-channel');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
this.rawConn.setLocalDescription(NodeDataChannel.DescriptionType.Offer);
|
|
263
|
+
await new Promise<void>((resolve) => {
|
|
264
|
+
this.rawConn!.onGatheringStateChange((state) => {
|
|
265
|
+
if (state === 'complete') resolve();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// Handle race conditions where gathering has already completed
|
|
269
|
+
if (this.rawConn!.gatheringState() === 'complete') resolve();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
if (!this.rawConn) throw new Error("Connection was closed while building the local description");
|
|
273
|
+
|
|
274
|
+
const localDesc = this.rawConn.localDescription()!;
|
|
275
|
+
setupChannel?.close(); // Close the temporary setup channel, if we created one
|
|
276
|
+
|
|
277
|
+
const offerSDP = SDP.parse(localDesc.sdp);
|
|
278
|
+
mirrorMediaParams(offerToMirror, offerSDP);
|
|
279
|
+
localDesc.sdp = SDP.write(offerSDP);
|
|
280
|
+
|
|
281
|
+
return localDesc as RTCSessionDescriptionInit;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async getMirroredLocalAnswer(sdpToMirror: string): Promise<RTCSessionDescriptionInit> {
|
|
285
|
+
const localDesc = this.rawConn!.localDescription()!;
|
|
286
|
+
|
|
287
|
+
const answerToMirror = SDP.parse(sdpToMirror);
|
|
288
|
+
const answerSDP = SDP.parse(localDesc.sdp!);
|
|
289
|
+
mirrorMediaParams(answerToMirror, answerSDP);
|
|
290
|
+
|
|
291
|
+
localDesc.sdp = SDP.write(answerSDP);
|
|
292
|
+
return localDesc as RTCSessionDescriptionInit;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
waitUntilConnected() {
|
|
296
|
+
return new Promise<void>((resolve, reject) => {
|
|
297
|
+
if (!this.rawConn) throw new Error("Connection closed while/before waiting until connected");
|
|
298
|
+
|
|
299
|
+
this.on('connection-state-changed', (state) => {
|
|
300
|
+
if (state === 'connected') resolve();
|
|
301
|
+
if (state === 'failed') {
|
|
302
|
+
reject(new Error("Connection failed while waiting for connection"));
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
if (this.rawConn.state() === 'connected') resolve();
|
|
307
|
+
if (this.rawConn.state() === 'failed') {
|
|
308
|
+
reject(new Error("Connection failed while waiting for connection"));
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
readonly sessionApi: MockRTCSession = {
|
|
314
|
+
sessionId: this.id, // The session id is actually just the connection id, shhh don't tell anyone.
|
|
315
|
+
|
|
316
|
+
createOffer: async (options: OfferOptions = {}): Promise<RTCSessionDescriptionInit> => {
|
|
317
|
+
if (options.mirrorSDP) {
|
|
318
|
+
return this.getMirroredLocalOffer(options.mirrorSDP, {
|
|
319
|
+
addDataStream: !!options.addDataStream
|
|
320
|
+
});
|
|
321
|
+
} else {
|
|
322
|
+
return this.getLocalDescription();
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
completeOffer: async (answer: RTCSessionDescriptionInit): Promise<void> => {
|
|
327
|
+
this.setRemoteDescription(answer);
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
answerOffer: async (
|
|
331
|
+
offer: RTCSessionDescriptionInit,
|
|
332
|
+
options: AnswerOptions = {}
|
|
333
|
+
): Promise<RTCSessionDescriptionInit> => {
|
|
334
|
+
this.setRemoteDescription(offer);
|
|
335
|
+
|
|
336
|
+
if (options.mirrorSDP) {
|
|
337
|
+
return this.getMirroredLocalAnswer(options.mirrorSDP);
|
|
338
|
+
} else {
|
|
339
|
+
return this.getLocalDescription();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
async close() {
|
|
345
|
+
if (!this.rawConn) return; // Already closed
|
|
346
|
+
|
|
347
|
+
const { rawConn } = this;
|
|
348
|
+
this.rawConn = null; // Drop the reference, so nothing tries to use it after close
|
|
349
|
+
|
|
350
|
+
if (rawConn.state() === 'closed') return;
|
|
351
|
+
rawConn.close();
|
|
352
|
+
this.emit('connection-closed');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function sdpDirectionToNDCDirection(direction: SDP.SharedAttributes['direction']): NodeDataChannel.Direction {
|
|
358
|
+
if (direction === 'inactive') return NodeDataChannel.Direction.Inactive;
|
|
359
|
+
else if (direction?.length === 8) {
|
|
360
|
+
return direction[0].toUpperCase() +
|
|
361
|
+
direction.slice(1, 4) +
|
|
362
|
+
direction[4].toUpperCase() +
|
|
363
|
+
direction.slice(5) as NodeDataChannel.Direction;
|
|
364
|
+
} else {
|
|
365
|
+
return NodeDataChannel.Direction.Unknown;
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Takes two parsed descriptions (typically a real description we want to mock, and our own current
|
|
371
|
+
* self-generated description) and modifies the target description sure that the media params for
|
|
372
|
+
* each stream in the source description match.
|
|
373
|
+
*
|
|
374
|
+
* In theory, this should guarantee that RTP packets generated by the source and forwarded through
|
|
375
|
+
* the target's connection can be interpreted by somebody connected to the target.
|
|
376
|
+
*/
|
|
377
|
+
function mirrorMediaParams(source: SDP.SessionDescription, target: SDP.SessionDescription) {
|
|
378
|
+
target.msidSemantic = source.msidSemantic;
|
|
379
|
+
|
|
380
|
+
const sourceMediaStreams = source.media.filter(m => m.type !== 'application');
|
|
381
|
+
sourceMediaStreams.forEach((sourceMedia) => {
|
|
382
|
+
const targetMedia = target.media
|
|
383
|
+
.find((targetMedia) => targetMedia.mid === sourceMedia.mid);
|
|
384
|
+
if (!targetMedia) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
`Missing mid ${sourceMedia.mid} in target when mirroring media params`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (sourceMedia.type !== targetMedia.type) {
|
|
391
|
+
throw new Error(
|
|
392
|
+
`Unexpected media type (${
|
|
393
|
+
targetMedia.type
|
|
394
|
+
}) for mid ${
|
|
395
|
+
targetMedia.mid
|
|
396
|
+
} when mirroring media params`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Copy all the semantic parameters of the RTP & RTCP streams themselves, so that RTP packets
|
|
401
|
+
// can be forwarded correctly, but without copying the fingerprint or similar, so we can still
|
|
402
|
+
// act as a MitM to intercept the packets:
|
|
403
|
+
targetMedia.msid = sourceMedia.msid;
|
|
404
|
+
targetMedia.protocol = sourceMedia.protocol;
|
|
405
|
+
targetMedia.ext = sourceMedia.ext;
|
|
406
|
+
targetMedia.payloads = sourceMedia.payloads;
|
|
407
|
+
targetMedia.rtp = sourceMedia.rtp;
|
|
408
|
+
targetMedia.fmtp = sourceMedia.fmtp;
|
|
409
|
+
targetMedia.rtcp = sourceMedia.rtcp;
|
|
410
|
+
targetMedia.rtcpFb = sourceMedia.rtcpFb;
|
|
411
|
+
targetMedia.ssrcGroups = sourceMedia.ssrcGroups;
|
|
412
|
+
|
|
413
|
+
// SSRC info is especially important here: this is used to map RTP SSRCs to track mids, so if
|
|
414
|
+
// this is incorrect, the recipient track will not receive the data we're sending.
|
|
415
|
+
// Although in some cases we do already have some SSRC info here, for offers where we've already
|
|
416
|
+
// defined the tracks ourselves, libdatachannel doesn't support all params and it's best to copy
|
|
417
|
+
// the full definition itself directly to make sure they match:
|
|
418
|
+
targetMedia.ssrcs = sourceMedia.ssrcs;
|
|
419
|
+
});
|
|
420
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-FileCopyrightText: 2022 Tim Perry <tim@httptoolkit.tech>
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
MockRTCExternalAnswerParams,
|
|
8
|
+
MockRTCExternalOfferParams,
|
|
9
|
+
MockRTCOfferParams
|
|
10
|
+
} from "./mockrtc-peer";
|
|
11
|
+
import type { MockRTCPeer } from "./mockrtc-peer";
|
|
12
|
+
|
|
13
|
+
import { MOCKRTC_CONTROL_CHANNEL } from "./webrtc/control-channel";
|
|
14
|
+
|
|
15
|
+
type OfferPairParams = MockRTCExternalOfferParams & { realOffer: RTCSessionDescriptionInit };
|
|
16
|
+
type AnswerPairParams = MockRTCExternalAnswerParams & { realAnswer: RTCSessionDescriptionInit };
|
|
17
|
+
|
|
18
|
+
/*
|
|
19
|
+
* In this file, we define hooks which can automatically wrap an RTCPeerConnection so that the
|
|
20
|
+
* normal calls to initialize a connection instead proxy the connection through MockRTC.
|
|
21
|
+
*
|
|
22
|
+
* This is quite complicated and confusing! There's four connection endpoints to be aware of:
|
|
23
|
+
* - The original RTCPeerConnection that's being hooked here to connect to a mock ('internal')
|
|
24
|
+
* - A MockRTC connection with an associated MockRTCPeer that it will actually connect to ('mock')
|
|
25
|
+
* - The original remote peer that we're connecting to ('remote')
|
|
26
|
+
* - A MockRTC external connection that will connect to the remote peer ('external')
|
|
27
|
+
*
|
|
28
|
+
* The connection structure works like so:
|
|
29
|
+
* INTERNAL <--> MOCK <-?-> EXTERNAL <--> REMOTE
|
|
30
|
+
*
|
|
31
|
+
* Internal+Mock and External+Remote are connected via real WebRTC connections. Mock+External are
|
|
32
|
+
* connected within MockRTC once mockConnection.proxyTrafficTo(externalConnection) is called,
|
|
33
|
+
* which happens if/when a proxy step is reached (i.e. this depends on the configuration of the
|
|
34
|
+
* mock peer).
|
|
35
|
+
*
|
|
36
|
+
* Note that in extra complicated cases, both peers might be hooked, in which case REMOTE is
|
|
37
|
+
* actually the EXTERNAL for a second mirrored structure. We can mostly ignore this as it's
|
|
38
|
+
* handled implicitly.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Hooks a given RTCPeerConnection so that all connections it creates are automatically proxied
|
|
43
|
+
* through the given MockRTCPeer.
|
|
44
|
+
*
|
|
45
|
+
* This allows you to capture traffic without modifying your WebRTC code: you can create
|
|
46
|
+
* offers/answers and signal them to a remote client as normal, and both the local and remote
|
|
47
|
+
* connections will connect to MockRTC instead.
|
|
48
|
+
*
|
|
49
|
+
* What happens once they connect depends on the configuration of the given peer. This mocked
|
|
50
|
+
* local connection will follow the steps defined by the peer, so may receive mocked messages
|
|
51
|
+
* injected there, or delays, or anything else. The remote peer will receive nothing until
|
|
52
|
+
* a proxy step is reached (if ever), at which point the local & remote peers will be able to
|
|
53
|
+
* talking directly, although all traffic will still be proxied through MockRTC for logging
|
|
54
|
+
* and analysis/validation elsewhere.
|
|
55
|
+
*
|
|
56
|
+
* It is possible to proxy both real peers in a connection, potentially with different mock
|
|
57
|
+
* peers so that they experience different behaviours during the connection.
|
|
58
|
+
*
|
|
59
|
+
* @category API
|
|
60
|
+
*/
|
|
61
|
+
export function hookWebRTCConnection(conn: RTCPeerConnection, mockPeer: MockRTCPeer) {
|
|
62
|
+
// Anything that creates signalling data (createOffer/createAnswer) needs to be hooked to
|
|
63
|
+
// return the params for the external connected.
|
|
64
|
+
// Anything that sets params (setLocal/RemoteDescription) needs to be hooked to send those
|
|
65
|
+
// params to the external connection, create new equivalent mock params for the mock connection
|
|
66
|
+
// and give those to the internal connection.
|
|
67
|
+
|
|
68
|
+
const _createOffer = conn.createOffer.bind(conn);
|
|
69
|
+
const _createAnswer = conn.createAnswer.bind(conn);
|
|
70
|
+
const _setLocalDescription = conn.setLocalDescription.bind(conn);
|
|
71
|
+
const _setRemoteDescription = conn.setRemoteDescription.bind(conn);
|
|
72
|
+
|
|
73
|
+
// The offers/answers we've generated, and the params needed to use them later:
|
|
74
|
+
let pendingCreatedOffers: { [sdp: string]: OfferPairParams } = {};
|
|
75
|
+
let pendingCreatedAnswers: { [sdp: string]: AnswerPairParams } = {};
|
|
76
|
+
|
|
77
|
+
// The offer/answer we generated that we're actually using, once one is selected:
|
|
78
|
+
let selectedDescription: OfferPairParams | AnswerPairParams | undefined;
|
|
79
|
+
|
|
80
|
+
// A mirrored offer from the mock conn to the internal conn, mirroring an incoming offer we
|
|
81
|
+
// received from the remote conn. This is stored so that when we pick an answer it can be
|
|
82
|
+
// completed, and so that createAnswer can wait until generation is complete before running.
|
|
83
|
+
let mockOffer: Promise<MockRTCOfferParams> | undefined;
|
|
84
|
+
|
|
85
|
+
// We create a control channel to communicate with MockRTC once the connection is set up.
|
|
86
|
+
// That's created immediately, so its in the initial SDP, to avoid later negotation.
|
|
87
|
+
const controlChannel = conn.createDataChannel(MOCKRTC_CONTROL_CHANNEL);
|
|
88
|
+
new Promise<void>((resolve) => {
|
|
89
|
+
controlChannel.onopen = () => resolve()
|
|
90
|
+
}).then(() => {
|
|
91
|
+
controlChannel.send(JSON.stringify({
|
|
92
|
+
type: 'attach-external',
|
|
93
|
+
id: selectedDescription!.id
|
|
94
|
+
}));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
conn.createOffer = (async (options: RTCOfferOptions) => {
|
|
98
|
+
const realOffer = await _createOffer(options);
|
|
99
|
+
const externalOfferParams = await mockPeer.createExternalOffer({
|
|
100
|
+
mirrorSDP: realOffer.sdp!
|
|
101
|
+
});
|
|
102
|
+
const externalOffer = externalOfferParams.offer;
|
|
103
|
+
pendingCreatedOffers[externalOffer.sdp!] = { ...externalOfferParams, realOffer };
|
|
104
|
+
return externalOffer;
|
|
105
|
+
}) as any;
|
|
106
|
+
|
|
107
|
+
conn.createAnswer = (async (options: RTCAnswerOptions) => {
|
|
108
|
+
await mockOffer; // If we have a pending offer, wait for that first - we can't answer without it.
|
|
109
|
+
|
|
110
|
+
const realAnswer = await _createAnswer(options);
|
|
111
|
+
const pendingAnswerParams = await mockPeer.answerExternalOffer(conn.pendingRemoteDescription!, {
|
|
112
|
+
mirrorSDP: realAnswer.sdp
|
|
113
|
+
});
|
|
114
|
+
const externalAnswer = pendingAnswerParams.answer;
|
|
115
|
+
pendingCreatedAnswers[externalAnswer.sdp!] = { ...pendingAnswerParams, realAnswer };
|
|
116
|
+
return externalAnswer;
|
|
117
|
+
}) as any;
|
|
118
|
+
|
|
119
|
+
// Mock all mutations of the connection description:
|
|
120
|
+
conn.setLocalDescription = (async (localDescription: RTCSessionDescriptionInit) => {
|
|
121
|
+
if (!localDescription) {
|
|
122
|
+
if (["stable", "have-local-offer", "have-remote-pranswer"].includes(conn.signalingState)) {
|
|
123
|
+
localDescription = await conn.createOffer();
|
|
124
|
+
} else {
|
|
125
|
+
localDescription = await conn.createAnswer();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// When we set an offer or answer locally, it must be the external offer/answer we've
|
|
130
|
+
// generated to send to the other peer. We swap it back for a real equivalent that will
|
|
131
|
+
// connect us to the mock peer instead:
|
|
132
|
+
if (localDescription.type === 'offer') {
|
|
133
|
+
pendingLocalDescription = localDescription;
|
|
134
|
+
selectedDescription = pendingCreatedOffers[localDescription.sdp!];
|
|
135
|
+
const { realOffer } = selectedDescription;
|
|
136
|
+
await _setLocalDescription(realOffer);
|
|
137
|
+
} else {
|
|
138
|
+
selectedDescription = pendingCreatedAnswers[localDescription.sdp!];
|
|
139
|
+
const { realAnswer } = selectedDescription;
|
|
140
|
+
await Promise.all([
|
|
141
|
+
// Complete the mock side of the internal connection:
|
|
142
|
+
(await mockOffer!).setAnswer(realAnswer),
|
|
143
|
+
// Complete the internal side of the internal connection:
|
|
144
|
+
_setLocalDescription(realAnswer)
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
currentLocalDescription = localDescription;
|
|
148
|
+
currentRemoteDescription = pendingRemoteDescription;
|
|
149
|
+
pendingLocalDescription = null;
|
|
150
|
+
pendingRemoteDescription = null;
|
|
151
|
+
}
|
|
152
|
+
}) as any;
|
|
153
|
+
|
|
154
|
+
conn.setRemoteDescription = (async (remoteDescription: RTCSessionDescriptionInit) => {
|
|
155
|
+
if (remoteDescription.type === 'offer') {
|
|
156
|
+
// We have an offer! Remember it, so we can createAnswer shortly.
|
|
157
|
+
pendingRemoteDescription = remoteDescription;
|
|
158
|
+
|
|
159
|
+
// We persist the mock offer synchronously, so we can check for it in createAnswer
|
|
160
|
+
// and avoid race conditions where we fail to create an answer before this method
|
|
161
|
+
// hasn't yet completed.
|
|
162
|
+
mockOffer = mockPeer.createOffer({
|
|
163
|
+
mirrorSDP: remoteDescription.sdp,
|
|
164
|
+
addDataStream: true
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await _setRemoteDescription((await mockOffer).offer);
|
|
168
|
+
} else {
|
|
169
|
+
// We have an answer - we must've sent an offer, complete & use that:
|
|
170
|
+
const { setAnswer, realOffer } = selectedDescription as OfferPairParams;
|
|
171
|
+
await Promise.all([
|
|
172
|
+
// Complete the external <-> remote connection:
|
|
173
|
+
setAnswer(remoteDescription),
|
|
174
|
+
// Complete the internal <-> mock connection:
|
|
175
|
+
mockPeer.answerOffer(realOffer, {
|
|
176
|
+
mirrorSDP: remoteDescription.sdp
|
|
177
|
+
}).then(({ answer }) => _setRemoteDescription(answer))
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
currentLocalDescription = pendingLocalDescription;
|
|
181
|
+
currentRemoteDescription = remoteDescription;
|
|
182
|
+
pendingLocalDescription = null;
|
|
183
|
+
pendingRemoteDescription = null;
|
|
184
|
+
}
|
|
185
|
+
}) as any;
|
|
186
|
+
|
|
187
|
+
// Mock various props that expose the connection description:
|
|
188
|
+
let pendingLocalDescription: RTCSessionDescriptionInit | null = null;
|
|
189
|
+
Object.defineProperty(conn, 'pendingLocalDescription', {
|
|
190
|
+
get: () => pendingLocalDescription
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
let currentLocalDescription: RTCSessionDescriptionInit | null = null;
|
|
194
|
+
Object.defineProperty(conn, 'currentLocalDescription', {
|
|
195
|
+
get: () => currentLocalDescription
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
Object.defineProperty(conn, 'localDescription', {
|
|
199
|
+
get: () => conn.pendingLocalDescription ?? conn.currentLocalDescription
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
let pendingRemoteDescription: RTCSessionDescriptionInit | null = null;
|
|
203
|
+
Object.defineProperty(conn, 'pendingRemoteDescription', {
|
|
204
|
+
get: () => pendingRemoteDescription
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
let currentRemoteDescription: RTCSessionDescriptionInit | null = null;
|
|
208
|
+
Object.defineProperty(conn, 'currentRemoteDescription', {
|
|
209
|
+
get: () => currentRemoteDescription
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
Object.defineProperty(conn, 'remoteDescription', {
|
|
213
|
+
get: () => conn.pendingRemoteDescription ?? conn.currentRemoteDescription
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
Object.defineProperty(conn, 'onicecandidate', {
|
|
217
|
+
get: () => {},
|
|
218
|
+
set: () => {} // Ignore this completely - never call the callback
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// For now we ignore incoming ice candidates. They're really intended for the external connection,
|
|
222
|
+
// not us, but also they're rarely necessary since we should be using local connections and MockRTC
|
|
223
|
+
// itself always waits rather than trickling candidates.
|
|
224
|
+
conn.addIceCandidate = () => Promise.resolve();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Modifies the global RTCPeerConnection constructor to hook all WebRTC connections
|
|
229
|
+
* created after this function is called, and redirect all their traffic to the
|
|
230
|
+
* provided MockRTCPeer.
|
|
231
|
+
*
|
|
232
|
+
* @category API
|
|
233
|
+
*/
|
|
234
|
+
export function hookAllWebRTC(mockPeer: MockRTCPeer) {
|
|
235
|
+
// The original constructor
|
|
236
|
+
const _RTCPeerConnection = window.RTCPeerConnection;
|
|
237
|
+
|
|
238
|
+
window.RTCPeerConnection = function (this: RTCPeerConnection) {
|
|
239
|
+
const connection = new _RTCPeerConnection(...arguments);
|
|
240
|
+
hookWebRTCConnection(connection, mockPeer);
|
|
241
|
+
return connection;
|
|
242
|
+
} as any;
|
|
243
|
+
|
|
244
|
+
window.RTCPeerConnection.prototype = _RTCPeerConnection.prototype;
|
|
245
|
+
}
|