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.
Files changed (136) hide show
  1. package/.github/workflows/ci.yml +29 -0
  2. package/LICENSE +201 -0
  3. package/README.md +290 -0
  4. package/dist/admin-bin.d.ts +2 -0
  5. package/dist/admin-bin.js +67 -0
  6. package/dist/admin-bin.js.map +1 -0
  7. package/dist/client/mockrtc-client.d.ts +12 -0
  8. package/dist/client/mockrtc-client.js +67 -0
  9. package/dist/client/mockrtc-client.js.map +1 -0
  10. package/dist/client/mockrtc-remote-peer.d.ts +15 -0
  11. package/dist/client/mockrtc-remote-peer.js +246 -0
  12. package/dist/client/mockrtc-remote-peer.js.map +1 -0
  13. package/dist/control-channel.d.ts +8 -0
  14. package/dist/control-channel.js +11 -0
  15. package/dist/control-channel.js.map +1 -0
  16. package/dist/handling/handler-builder.d.ts +138 -0
  17. package/dist/handling/handler-builder.js +164 -0
  18. package/dist/handling/handler-builder.js.map +1 -0
  19. package/dist/handling/handler-step-definitions.d.ts +63 -0
  20. package/dist/handling/handler-step-definitions.js +123 -0
  21. package/dist/handling/handler-step-definitions.js.map +1 -0
  22. package/dist/handling/handler-steps.d.ts +48 -0
  23. package/dist/handling/handler-steps.js +218 -0
  24. package/dist/handling/handler-steps.js.map +1 -0
  25. package/dist/main-browser.d.ts +9 -0
  26. package/dist/main-browser.js +26 -0
  27. package/dist/main-browser.js.map +1 -0
  28. package/dist/main.d.ts +58 -0
  29. package/dist/main.js +67 -0
  30. package/dist/main.js.map +1 -0
  31. package/dist/mockrtc-admin-plugin.d.ts +56 -0
  32. package/dist/mockrtc-admin-plugin.js +151 -0
  33. package/dist/mockrtc-admin-plugin.js.map +1 -0
  34. package/dist/mockrtc-admin-server.d.ts +7 -0
  35. package/dist/mockrtc-admin-server.js +18 -0
  36. package/dist/mockrtc-admin-server.js.map +1 -0
  37. package/dist/mockrtc-client.d.ts +12 -0
  38. package/dist/mockrtc-client.js +64 -0
  39. package/dist/mockrtc-client.js.map +1 -0
  40. package/dist/mockrtc-handler-builder.d.ts +15 -0
  41. package/dist/mockrtc-handler-builder.js +24 -0
  42. package/dist/mockrtc-handler-builder.js.map +1 -0
  43. package/dist/mockrtc-peer.d.ts +147 -0
  44. package/dist/mockrtc-peer.js +7 -0
  45. package/dist/mockrtc-peer.js.map +1 -0
  46. package/dist/mockrtc-remote-peer.d.ts +15 -0
  47. package/dist/mockrtc-remote-peer.js +234 -0
  48. package/dist/mockrtc-remote-peer.js.map +1 -0
  49. package/dist/mockrtc-server-peer.d.ts +29 -0
  50. package/dist/mockrtc-server-peer.js +145 -0
  51. package/dist/mockrtc-server-peer.js.map +1 -0
  52. package/dist/mockrtc-server.d.ts +14 -0
  53. package/dist/mockrtc-server.js +53 -0
  54. package/dist/mockrtc-server.js.map +1 -0
  55. package/dist/mockrtc.d.ts +25 -0
  56. package/dist/mockrtc.js +7 -0
  57. package/dist/mockrtc.js.map +1 -0
  58. package/dist/package.json +52 -0
  59. package/dist/server/mockrtc-admin-plugin.d.ts +17 -0
  60. package/dist/server/mockrtc-admin-plugin.js +163 -0
  61. package/dist/server/mockrtc-admin-plugin.js.map +1 -0
  62. package/dist/server/mockrtc-admin-server.d.ts +7 -0
  63. package/dist/server/mockrtc-admin-server.js +18 -0
  64. package/dist/server/mockrtc-admin-server.js.map +1 -0
  65. package/dist/server/mockrtc-server-peer.d.ts +24 -0
  66. package/dist/server/mockrtc-server-peer.js +141 -0
  67. package/dist/server/mockrtc-server-peer.js.map +1 -0
  68. package/dist/server/mockrtc-server.d.ts +14 -0
  69. package/dist/server/mockrtc-server.js +53 -0
  70. package/dist/server/mockrtc-server.js.map +1 -0
  71. package/dist/src/main.d.ts +1 -0
  72. package/dist/src/main.js +24 -0
  73. package/dist/src/main.js.map +1 -0
  74. package/dist/src/mockrtc-peer.d.ts +0 -0
  75. package/dist/src/mockrtc-peer.js +2 -0
  76. package/dist/src/mockrtc-peer.js.map +1 -0
  77. package/dist/src/mockrtc.d.ts +0 -0
  78. package/dist/src/mockrtc.js +65 -0
  79. package/dist/src/mockrtc.js.map +1 -0
  80. package/dist/webrtc/control-channel.d.ts +8 -0
  81. package/dist/webrtc/control-channel.js +11 -0
  82. package/dist/webrtc/control-channel.js.map +1 -0
  83. package/dist/webrtc/datachannel-stream.d.ts +25 -0
  84. package/dist/webrtc/datachannel-stream.js +86 -0
  85. package/dist/webrtc/datachannel-stream.js.map +1 -0
  86. package/dist/webrtc/mediatrack-stream.d.ts +29 -0
  87. package/dist/webrtc/mediatrack-stream.js +109 -0
  88. package/dist/webrtc/mediatrack-stream.js.map +1 -0
  89. package/dist/webrtc/mockrtc-connection.d.ts +14 -0
  90. package/dist/webrtc/mockrtc-connection.js +147 -0
  91. package/dist/webrtc/mockrtc-connection.js.map +1 -0
  92. package/dist/webrtc/peer-connection.d.ts +16 -0
  93. package/dist/webrtc/peer-connection.js +81 -0
  94. package/dist/webrtc/peer-connection.js.map +1 -0
  95. package/dist/webrtc/rtc-connection.d.ts +47 -0
  96. package/dist/webrtc/rtc-connection.js +370 -0
  97. package/dist/webrtc/rtc-connection.js.map +1 -0
  98. package/dist/webrtc-hooks.d.ts +30 -0
  99. package/dist/webrtc-hooks.js +224 -0
  100. package/dist/webrtc-hooks.js.map +1 -0
  101. package/karma.conf.ts +89 -0
  102. package/ngi-eu-footer.png +0 -0
  103. package/package.json +86 -0
  104. package/src/admin-bin.ts +57 -0
  105. package/src/client/mockrtc-client.ts +79 -0
  106. package/src/client/mockrtc-remote-peer.ts +286 -0
  107. package/src/handling/handler-builder.ts +215 -0
  108. package/src/handling/handler-step-definitions.ts +142 -0
  109. package/src/handling/handler-steps.ts +254 -0
  110. package/src/main-browser.ts +44 -0
  111. package/src/main.ts +109 -0
  112. package/src/mockrtc-peer.ts +176 -0
  113. package/src/mockrtc.ts +36 -0
  114. package/src/server/mockrtc-admin-plugin.ts +196 -0
  115. package/src/server/mockrtc-admin-server.ts +17 -0
  116. package/src/server/mockrtc-server-peer.ts +159 -0
  117. package/src/server/mockrtc-server.ts +53 -0
  118. package/src/webrtc/control-channel.ts +13 -0
  119. package/src/webrtc/datachannel-stream.ts +102 -0
  120. package/src/webrtc/mediatrack-stream.ts +135 -0
  121. package/src/webrtc/mockrtc-connection.ts +164 -0
  122. package/src/webrtc/rtc-connection.ts +420 -0
  123. package/src/webrtc-hooks.ts +245 -0
  124. package/test/integration/close-steps.spec.ts +39 -0
  125. package/test/integration/connection-setup.spec.ts +230 -0
  126. package/test/integration/echo-steps.spec.ts +88 -0
  127. package/test/integration/proxy.spec.ts +526 -0
  128. package/test/integration/send-steps.spec.ts +76 -0
  129. package/test/integration/smoke-test.spec.ts +100 -0
  130. package/test/integration/wait-steps.spec.ts +225 -0
  131. package/test/start-test-admin-server.ts +12 -0
  132. package/test/test-setup.ts +136 -0
  133. package/test/tsconfig.json +11 -0
  134. package/tsconfig.json +14 -0
  135. package/typedoc.json +19 -0
  136. 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
+ }