arnacon-webrtc-service 0.2.3 → 0.2.5
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/package.json +3 -1
- package/webRTCservice/modules/calls/poly/__tests__/WebRtcNegotiation.test.js +135 -0
- package/webRTCservice/modules/calls/poly/__tests__/WebRtcNegotiation.werift.integration.test.js +198 -0
- package/webRTCservice/modules/calls/poly/__tests__/helpers/weriftHarness.js +125 -0
- package/webRTCservice/modules/calls/poly/negotiation/WebRtcNegotiation.js +62 -8
- package/webRTCservice/modules/media/negotiation/SdpUtils.js +16 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arnacon-webrtc-service",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Arnacon WebRTC core runtime and service modules",
|
|
5
5
|
"main": "./webRTCservice/core.js",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
"scripts": {
|
|
24
24
|
"test": "npm run test:poly",
|
|
25
25
|
"test:poly": "node --test webRTCservice/modules/calls/poly/__tests__/*.test.js",
|
|
26
|
+
"test:werift": "node --test --test-concurrency=1 webRTCservice/modules/calls/poly/__tests__/WebRtcNegotiation.werift.integration.test.js",
|
|
27
|
+
"test:poly:all": "npm run test:poly && npm run test:werift",
|
|
26
28
|
"test:scenarios": "node --test webRTCservice/modules/calls/poly/__tests__/Scenarios.test.js",
|
|
27
29
|
"build": "node webRTCservice/build.js",
|
|
28
30
|
"clean": "node webRTCservice/clean.js",
|
|
@@ -4,6 +4,7 @@ const assert = require("node:assert/strict");
|
|
|
4
4
|
const { WebRtcNegotiation } = require("../negotiation/WebRtcNegotiation");
|
|
5
5
|
const { FakeSignaling, FakePeerConnection, fakePrimitives, silentLogger } = require("./fakes");
|
|
6
6
|
const { CallSdpUseCases } = require("../../useCases/CallSdpUseCases");
|
|
7
|
+
const { addIceCandidates: addIceCandidatesUtil } = require("../../../media/negotiation/SdpUtils");
|
|
7
8
|
|
|
8
9
|
function build({ offerSdp, answerSdp } = {}) {
|
|
9
10
|
const signaling = new FakeSignaling();
|
|
@@ -234,6 +235,56 @@ class MidMapLagPeerConnection extends FakePeerConnection {
|
|
|
234
235
|
}
|
|
235
236
|
}
|
|
236
237
|
|
|
238
|
+
class CandidateMLineStrictPeerConnection extends FakePeerConnection {
|
|
239
|
+
constructor(opts = {}) {
|
|
240
|
+
super(opts);
|
|
241
|
+
this._remoteMLineCount = 0;
|
|
242
|
+
this.appliedCandidates = [];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async setRemoteDescription(d) {
|
|
246
|
+
this.remoteDescription = d;
|
|
247
|
+
const sdp = String(d?.sdp || "");
|
|
248
|
+
this._remoteMLineCount = (sdp.match(/^m=/gm) || []).length;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async addIceCandidate(candidate) {
|
|
252
|
+
const idx = Number(candidate?.sdpMLineIndex);
|
|
253
|
+
if (Number.isFinite(idx) && (idx < 0 || idx >= this._remoteMLineCount)) {
|
|
254
|
+
throw new Error("m line not found");
|
|
255
|
+
}
|
|
256
|
+
this.appliedCandidates.push(candidate);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
class ParserMLineRecoveryPeerConnection extends FakePeerConnection {
|
|
261
|
+
constructor(opts = {}) {
|
|
262
|
+
super(opts);
|
|
263
|
+
this.transceivers = [
|
|
264
|
+
{ kind: "application", mid: "0", setDirection() {}, sender: { replaceTrack: async () => {}, track: null } },
|
|
265
|
+
{ kind: "audio", mid: "1", setDirection() {}, sender: { replaceTrack: async () => {}, track: null } },
|
|
266
|
+
];
|
|
267
|
+
this.failOnce = true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
addTrack(track) {
|
|
271
|
+
const audio = this.transceivers.find((t) => t.kind === "audio");
|
|
272
|
+
if (audio?.sender) audio.sender.track = track;
|
|
273
|
+
return { kind: track?.kind || "audio" };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async setRemoteDescription(d) {
|
|
277
|
+
const sdp = String(d?.sdp || "");
|
|
278
|
+
const hasAudio = /^m=audio\b/m.test(sdp);
|
|
279
|
+
const audio = this.transceivers.find((t) => t.kind === "audio");
|
|
280
|
+
if (!hasAudio && this.failOnce && audio?.sender?.track) {
|
|
281
|
+
this.failOnce = false;
|
|
282
|
+
throw new Error("Cannot read properties of undefined (reading 'iceParams')");
|
|
283
|
+
}
|
|
284
|
+
this.remoteDescription = d;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
237
288
|
function deepLifecyclePrimitives() {
|
|
238
289
|
const base = fakePrimitives();
|
|
239
290
|
const callSdpUseCases = new CallSdpUseCases({
|
|
@@ -250,6 +301,12 @@ function deepLifecyclePrimitives() {
|
|
|
250
301
|
...base,
|
|
251
302
|
ensureLocalAudioTrack: (...args) => callSdpUseCases.ensureLocalAudioTrack(...args),
|
|
252
303
|
createAnswerSdp: (...args) => callSdpUseCases.createAnswerSdp(...args),
|
|
304
|
+
addIceCandidates: (...args) =>
|
|
305
|
+
addIceCandidatesUtil(...args, class RTCIceCandidate {
|
|
306
|
+
constructor(init = {}) {
|
|
307
|
+
Object.assign(this, init);
|
|
308
|
+
}
|
|
309
|
+
}),
|
|
253
310
|
};
|
|
254
311
|
}
|
|
255
312
|
|
|
@@ -828,3 +885,81 @@ test("DEEP BUG REPRO: ice-restart after reuse survives stale mid map", async ()
|
|
|
828
885
|
"ice-restart path should recover from stale reused mid mappings",
|
|
829
886
|
);
|
|
830
887
|
});
|
|
888
|
+
|
|
889
|
+
test("DEEP BUG REPRO: applyOffer ignores stale ICE candidate m-line mismatch", async () => {
|
|
890
|
+
const signaling = new FakeSignaling();
|
|
891
|
+
const pc = new CandidateMLineStrictPeerConnection({
|
|
892
|
+
answerSdp: "v=0\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\na=mid:0\r\n",
|
|
893
|
+
});
|
|
894
|
+
const session = {
|
|
895
|
+
sessionId: "alice|bob|mline",
|
|
896
|
+
callerEns: "alice.secnum.global",
|
|
897
|
+
toIdentity: "bob.secnum.global",
|
|
898
|
+
peerConnection: pc,
|
|
899
|
+
localAudioTrack: { kind: "audio" },
|
|
900
|
+
};
|
|
901
|
+
const neg = new WebRtcNegotiation({
|
|
902
|
+
id: "alice",
|
|
903
|
+
endpoint: "alice.secnum.global",
|
|
904
|
+
session,
|
|
905
|
+
signaling,
|
|
906
|
+
primitives: deepLifecyclePrimitives(),
|
|
907
|
+
logger: silentLogger,
|
|
908
|
+
});
|
|
909
|
+
const dataOnlyOffer =
|
|
910
|
+
"v=0\r\n" +
|
|
911
|
+
"a=group:BUNDLE 0\r\n" +
|
|
912
|
+
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
|
|
913
|
+
"a=mid:0\r\n";
|
|
914
|
+
await assert.doesNotReject(
|
|
915
|
+
() =>
|
|
916
|
+
neg.applyOffer({
|
|
917
|
+
mode: "ring",
|
|
918
|
+
payload: {
|
|
919
|
+
sdp: dataOnlyOffer,
|
|
920
|
+
candidates: [
|
|
921
|
+
{ candidate: "candidate:1 1 udp 1 1.1.1.1 11111 typ host", sdpMid: "0", sdpMLineIndex: 0 },
|
|
922
|
+
{ candidate: "candidate:2 1 udp 1 2.2.2.2 22222 typ host", sdpMid: "1", sdpMLineIndex: 1 },
|
|
923
|
+
],
|
|
924
|
+
},
|
|
925
|
+
}),
|
|
926
|
+
"stale candidate with missing m-line must be ignored instead of failing offer ingress",
|
|
927
|
+
);
|
|
928
|
+
assert.equal(pc.appliedCandidates.length, 1, "valid candidate should still be applied");
|
|
929
|
+
assert.equal(pc.appliedCandidates[0].sdpMLineIndex, 0);
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
test("DEEP BUG REPRO: parser m-line failure realigns on data-only offer", async () => {
|
|
933
|
+
const signaling = new FakeSignaling();
|
|
934
|
+
const localTrack = { kind: "audio" };
|
|
935
|
+
const pc = new ParserMLineRecoveryPeerConnection({
|
|
936
|
+
answerSdp: "v=0\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\na=mid:0\r\n",
|
|
937
|
+
});
|
|
938
|
+
pc.transceivers.find((t) => t.kind === "audio").sender.track = localTrack;
|
|
939
|
+
const session = {
|
|
940
|
+
sessionId: "alice|bob|parser-mline",
|
|
941
|
+
callerEns: "alice.secnum.global",
|
|
942
|
+
toIdentity: "bob.secnum.global",
|
|
943
|
+
peerConnection: pc,
|
|
944
|
+
localAudioTrack: localTrack,
|
|
945
|
+
};
|
|
946
|
+
const neg = new WebRtcNegotiation({
|
|
947
|
+
id: "alice",
|
|
948
|
+
endpoint: "alice.secnum.global",
|
|
949
|
+
session,
|
|
950
|
+
signaling,
|
|
951
|
+
primitives: deepLifecyclePrimitives(),
|
|
952
|
+
logger: silentLogger,
|
|
953
|
+
});
|
|
954
|
+
const dataOnlyOffer =
|
|
955
|
+
"v=0\r\n" +
|
|
956
|
+
"a=group:BUNDLE 0\r\n" +
|
|
957
|
+
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
|
|
958
|
+
"a=mid:0\r\n";
|
|
959
|
+
|
|
960
|
+
await assert.doesNotReject(
|
|
961
|
+
() => neg.applyOffer({ mode: "ring", payload: { sdp: dataOnlyOffer } }),
|
|
962
|
+
"parser-style m-line failures should realign and recover on data-only offers",
|
|
963
|
+
);
|
|
964
|
+
assert.equal(pc.transceivers.find((t) => t.kind === "audio")?.sender?.track, null);
|
|
965
|
+
});
|
package/webRTCservice/modules/calls/poly/__tests__/WebRtcNegotiation.werift.integration.test.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
const { test } = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
|
|
4
|
+
const { buildWeriftNegotiationHarness } = require("./helpers/weriftHarness");
|
|
5
|
+
|
|
6
|
+
const dataOnlyOfferSdp =
|
|
7
|
+
"v=0\r\n" +
|
|
8
|
+
"o=- 5832931903811073529 2 IN IP4 127.0.0.1\r\n" +
|
|
9
|
+
"s=-\r\n" +
|
|
10
|
+
"t=0 0\r\n" +
|
|
11
|
+
"a=group:BUNDLE 0\r\n" +
|
|
12
|
+
"a=extmap-allow-mixed\r\n" +
|
|
13
|
+
"a=msid-semantic: WMS\r\n" +
|
|
14
|
+
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
|
|
15
|
+
"c=IN IP4 0.0.0.0\r\n" +
|
|
16
|
+
"a=ice-ufrag:57EQ\r\n" +
|
|
17
|
+
"a=ice-pwd:JQGLYFDLhxBWo6r5hauNiYkM\r\n" +
|
|
18
|
+
"a=ice-options:trickle renomination\r\n" +
|
|
19
|
+
"a=fingerprint:sha-256 D9:5A:99:6F:F1:81:12:49:04:97:BC:76:96:D4:EE:C9:B5:27:4B:D6:8C:A4:68:8E:CF:27:D1:DC:90:96:3A:42\r\n" +
|
|
20
|
+
"a=setup:actpass\r\n" +
|
|
21
|
+
"a=mid:0\r\n" +
|
|
22
|
+
"a=sctp-port:5000\r\n" +
|
|
23
|
+
"a=max-message-size:262144\r\n";
|
|
24
|
+
|
|
25
|
+
const activeAudioOfferSdp =
|
|
26
|
+
"v=0\r\n" +
|
|
27
|
+
"o=- 41053953 0 IN IP4 0.0.0.0\r\n" +
|
|
28
|
+
"s=-\r\n" +
|
|
29
|
+
"t=0 0\r\n" +
|
|
30
|
+
"a=group:BUNDLE 0 1\r\n" +
|
|
31
|
+
"a=extmap-allow-mixed\r\n" +
|
|
32
|
+
"a=msid-semantic:WMS *\r\n" +
|
|
33
|
+
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
|
|
34
|
+
"c=IN IP4 0.0.0.0\r\n" +
|
|
35
|
+
"a=ice-ufrag:d0a8\r\n" +
|
|
36
|
+
"a=ice-pwd:5dc2124050d8b131def772\r\n" +
|
|
37
|
+
"a=ice-options:trickle\r\n" +
|
|
38
|
+
"a=fingerprint:sha-256 6A:1B:8B:C4:EF:AE:BF:48:9E:52:B6:B9:E7:86:5E:CC:1A:74:C6:1E:0E:5C:7C:89:66:EA:D5:D7:E7:23:FE:B7\r\n" +
|
|
39
|
+
"a=setup:passive\r\n" +
|
|
40
|
+
"a=mid:0\r\n" +
|
|
41
|
+
"a=sctp-port:5000\r\n" +
|
|
42
|
+
"a=max-message-size:65536\r\n" +
|
|
43
|
+
"m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n" +
|
|
44
|
+
"c=IN IP4 0.0.0.0\r\n" +
|
|
45
|
+
"a=ice-ufrag:d0a8\r\n" +
|
|
46
|
+
"a=ice-pwd:5dc2124050d8b131def772\r\n" +
|
|
47
|
+
"a=ice-options:trickle\r\n" +
|
|
48
|
+
"a=fingerprint:sha-256 6A:1B:8B:C4:EF:AE:BF:48:9E:52:B6:B9:E7:86:5E:CC:1A:74:C6:1E:0E:5C:7C:89:66:EA:D5:D7:E7:23:FE:B7\r\n" +
|
|
49
|
+
"a=setup:passive\r\n" +
|
|
50
|
+
"a=sendrecv\r\n" +
|
|
51
|
+
"a=mid:1\r\n" +
|
|
52
|
+
"a=rtcp:9 IN IP4 0.0.0.0\r\n" +
|
|
53
|
+
"a=rtcp-mux\r\n" +
|
|
54
|
+
"a=rtpmap:8 PCMA/8000\r\n";
|
|
55
|
+
|
|
56
|
+
const inactiveEndCallOfferSdp =
|
|
57
|
+
"v=0\r\n" +
|
|
58
|
+
"o=- 12159584 0 IN IP4 0.0.0.0\r\n" +
|
|
59
|
+
"s=-\r\n" +
|
|
60
|
+
"t=0 0\r\n" +
|
|
61
|
+
"a=group:BUNDLE 0 1\r\n" +
|
|
62
|
+
"a=extmap-allow-mixed\r\n" +
|
|
63
|
+
"a=msid-semantic:WMS *\r\n" +
|
|
64
|
+
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
|
|
65
|
+
"c=IN IP4 0.0.0.0\r\n" +
|
|
66
|
+
"a=ice-ufrag:5377\r\n" +
|
|
67
|
+
"a=ice-pwd:ab5d1f31727880b2875978\r\n" +
|
|
68
|
+
"a=ice-options:trickle\r\n" +
|
|
69
|
+
"a=fingerprint:sha-256 6A:1B:8B:C4:EF:AE:BF:48:9E:52:B6:B9:E7:86:5E:CC:1A:74:C6:1E:0E:5C:7C:89:66:EA:D5:D7:E7:23:FE:B7\r\n" +
|
|
70
|
+
"a=setup:active\r\n" +
|
|
71
|
+
"a=mid:0\r\n" +
|
|
72
|
+
"a=sctp-port:5000\r\n" +
|
|
73
|
+
"a=max-message-size:65536\r\n" +
|
|
74
|
+
"m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n" +
|
|
75
|
+
"c=IN IP4 0.0.0.0\r\n" +
|
|
76
|
+
"a=ice-ufrag:5377\r\n" +
|
|
77
|
+
"a=ice-pwd:ab5d1f31727880b2875978\r\n" +
|
|
78
|
+
"a=ice-options:trickle\r\n" +
|
|
79
|
+
"a=fingerprint:sha-256 6A:1B:8B:C4:EF:AE:BF:48:9E:52:B6:B9:E7:86:5E:CC:1A:74:C6:1E:0E:5C:7C:89:66:EA:D5:D7:E7:23:FE:B7\r\n" +
|
|
80
|
+
"a=setup:active\r\n" +
|
|
81
|
+
"a=inactive\r\n" +
|
|
82
|
+
"a=mid:1\r\n" +
|
|
83
|
+
"a=rtcp:9 IN IP4 0.0.0.0\r\n" +
|
|
84
|
+
"a=rtcp-mux\r\n" +
|
|
85
|
+
"a=rtpmap:8 PCMA/8000\r\n";
|
|
86
|
+
|
|
87
|
+
const malformedAudioOfferSdp =
|
|
88
|
+
"v=0\r\n" +
|
|
89
|
+
"o=- 41053953 0 IN IP4 0.0.0.0\r\n" +
|
|
90
|
+
"s=-\r\n" +
|
|
91
|
+
"t=0 0\r\n" +
|
|
92
|
+
"a=group:BUNDLE 0 1\r\n" +
|
|
93
|
+
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" +
|
|
94
|
+
"c=IN IP4 0.0.0.0\r\n" +
|
|
95
|
+
"a=mid:0\r\n" +
|
|
96
|
+
"a=sctp-port:5000\r\n" +
|
|
97
|
+
"m=audio 9 UDP/TLS/RTP/SAVPF 8\r\n" +
|
|
98
|
+
"c=IN IP4 0.0.0.0\r\n" +
|
|
99
|
+
"a=sendrecv\r\n" +
|
|
100
|
+
"a=mid:1\r\n" +
|
|
101
|
+
"a=rtpmap:8 PCMA/8000\r\n";
|
|
102
|
+
|
|
103
|
+
const candidateM0 = {
|
|
104
|
+
sdpMLineIndex: 0,
|
|
105
|
+
sdpMid: "0",
|
|
106
|
+
candidate: "candidate:3054239307 1 udp 41820927 134.209.88.4 50878 typ relay raddr 0.0.0.0 rport 0 generation 0 ufrag 57EQ network-id 7 network-cost 900",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const candidateM1Stale = {
|
|
110
|
+
sdpMLineIndex: 1,
|
|
111
|
+
sdpMid: "1",
|
|
112
|
+
candidate: "candidate:123456789 1 udp 41819903 10.0.0.10 20002 typ host generation 0 ufrag dead network-id 1",
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
test("WERIFT REPLAY: data-only offer with stale m-line candidate does not fail ingress", async () => {
|
|
116
|
+
const harness = buildWeriftNegotiationHarness({ withAudioTrack: true, withDataChannel: true });
|
|
117
|
+
try {
|
|
118
|
+
await assert.doesNotReject(() =>
|
|
119
|
+
harness.neg.applyOffer({
|
|
120
|
+
mode: "ring",
|
|
121
|
+
payload: {
|
|
122
|
+
sdp: dataOnlyOfferSdp,
|
|
123
|
+
candidates: [candidateM0, candidateM1Stale],
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
assert.match(String(harness.session.lastAnswerSdp || ""), /m=application/i);
|
|
128
|
+
} finally {
|
|
129
|
+
await harness.dispose();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("WERIFT REPLAY: baseline malformed offer reproduces werift parser rejection", async () => {
|
|
134
|
+
const harness = buildWeriftNegotiationHarness({ withAudioTrack: true, withDataChannel: true });
|
|
135
|
+
try {
|
|
136
|
+
await assert.rejects(
|
|
137
|
+
() =>
|
|
138
|
+
harness.neg.applyOffer({
|
|
139
|
+
mode: "ring",
|
|
140
|
+
payload: { sdp: malformedAudioOfferSdp, candidates: [candidateM0] },
|
|
141
|
+
}),
|
|
142
|
+
/iceParams|media section/i,
|
|
143
|
+
);
|
|
144
|
+
} finally {
|
|
145
|
+
await harness.dispose();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("WERIFT REPLAY: end-call remote offer then redial on reused session", async () => {
|
|
150
|
+
const harness = buildWeriftNegotiationHarness({ withAudioTrack: true, withDataChannel: true });
|
|
151
|
+
try {
|
|
152
|
+
await assert.doesNotReject(() =>
|
|
153
|
+
harness.neg.applyOffer({
|
|
154
|
+
mode: "ring",
|
|
155
|
+
payload: { sdp: activeAudioOfferSdp, candidates: [candidateM0] },
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
await assert.doesNotReject(() =>
|
|
159
|
+
harness.neg.endCall({
|
|
160
|
+
mode: "remote",
|
|
161
|
+
payload: { type: "offer", sdp: inactiveEndCallOfferSdp },
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
await assert.doesNotReject(() =>
|
|
165
|
+
harness.neg.applyOffer({
|
|
166
|
+
mode: "ring",
|
|
167
|
+
payload: { sdp: activeAudioOfferSdp, candidates: [candidateM0] },
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
} finally {
|
|
171
|
+
await harness.dispose();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("WERIFT REPLAY: ackEnd path then redial on same session", async () => {
|
|
176
|
+
const harness = buildWeriftNegotiationHarness({ withAudioTrack: true, withDataChannel: true });
|
|
177
|
+
try {
|
|
178
|
+
await assert.doesNotReject(() =>
|
|
179
|
+
harness.neg.applyOffer({
|
|
180
|
+
mode: "ring",
|
|
181
|
+
payload: { sdp: activeAudioOfferSdp, candidates: [candidateM0] },
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
await assert.doesNotReject(() =>
|
|
185
|
+
harness.neg.ackEnd({
|
|
186
|
+
payload: { sdp: inactiveEndCallOfferSdp },
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
await assert.doesNotReject(() =>
|
|
190
|
+
harness.neg.applyOffer({
|
|
191
|
+
mode: "ring",
|
|
192
|
+
payload: { sdp: activeAudioOfferSdp, candidates: [candidateM0] },
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
} finally {
|
|
196
|
+
await harness.dispose();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const { WebRtcNegotiation } = require("../../negotiation/WebRtcNegotiation");
|
|
2
|
+
const { CallSdpUseCases } = require("../../../useCases/CallSdpUseCases");
|
|
3
|
+
const { applyPolyfills } = require("../../../../media/werift/Polyfills");
|
|
4
|
+
const {
|
|
5
|
+
fixSdpForWerift,
|
|
6
|
+
waitForIceGathering,
|
|
7
|
+
formatIceCandidates,
|
|
8
|
+
getRelayCandidates,
|
|
9
|
+
embedCandidatesInSdp,
|
|
10
|
+
patchInactiveToSendrecv,
|
|
11
|
+
logSdp,
|
|
12
|
+
addIceCandidates: addIceCandidatesUtil,
|
|
13
|
+
} = require("../../../../media/negotiation/SdpUtils");
|
|
14
|
+
const { narrowAudioOfferForCodecPolicy } = require("../../../../media/negotiation/SdpCodecNegotiator");
|
|
15
|
+
|
|
16
|
+
const silentLogger = { log() {}, warn() {}, error() {} };
|
|
17
|
+
|
|
18
|
+
function ensureWeriftPolyfills() {
|
|
19
|
+
if (globalThis.__polyWeriftTestPolyfillsApplied) return;
|
|
20
|
+
applyPolyfills({ fixSdpForWerift, logger: silentLogger });
|
|
21
|
+
globalThis.__polyWeriftTestPolyfillsApplied = true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createSignalingRecorder() {
|
|
25
|
+
return {
|
|
26
|
+
sent: [],
|
|
27
|
+
send(message) {
|
|
28
|
+
this.sent.push(message);
|
|
29
|
+
},
|
|
30
|
+
isOpen() {
|
|
31
|
+
return true;
|
|
32
|
+
},
|
|
33
|
+
lastOfType(msgType, action) {
|
|
34
|
+
return [...this.sent].reverse().find(
|
|
35
|
+
(m) => m.msgType === msgType && (action === undefined || m.action === action || m.payload?.type === action),
|
|
36
|
+
);
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createProductionLikePrimitives({ sessionMap, logger = silentLogger } = {}) {
|
|
42
|
+
const callSdpUseCases = new CallSdpUseCases({
|
|
43
|
+
sessions: sessionMap,
|
|
44
|
+
MediaStreamTrack: globalThis.MediaStreamTrack,
|
|
45
|
+
patchInactiveToSendrecv,
|
|
46
|
+
logSdp: (_id, _label, _sdp) => {},
|
|
47
|
+
enqueueSignaling: (_sessionId, _label, fn) => Promise.resolve().then(fn),
|
|
48
|
+
sendDataChannelMessage: () => {},
|
|
49
|
+
callRuntime: null,
|
|
50
|
+
logger,
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
RTCSessionDescription: globalThis.RTCSessionDescription,
|
|
54
|
+
MediaStreamTrack: globalThis.MediaStreamTrack,
|
|
55
|
+
waitForIceGathering,
|
|
56
|
+
formatIceCandidates,
|
|
57
|
+
getRelayCandidates,
|
|
58
|
+
embedCandidatesInSdp,
|
|
59
|
+
patchInactiveToSendrecv,
|
|
60
|
+
narrowAudioOfferForCodecPolicy,
|
|
61
|
+
logSdp: (...args) => logSdp(...args, logger),
|
|
62
|
+
addIceCandidates: (...args) => addIceCandidatesUtil(...args, globalThis.RTCIceCandidate),
|
|
63
|
+
ensureLocalAudioTrack: (...args) => callSdpUseCases.ensureLocalAudioTrack(...args),
|
|
64
|
+
createAnswerSdp: (...args) => callSdpUseCases.createAnswerSdp(...args),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildWeriftNegotiationHarness({
|
|
69
|
+
id = "alice",
|
|
70
|
+
endpoint = "alice.secnum.global",
|
|
71
|
+
sessionId = "alice|bob",
|
|
72
|
+
callerEns = "alice.secnum.global",
|
|
73
|
+
toIdentity = "bob.secnum.global",
|
|
74
|
+
withDataChannel = true,
|
|
75
|
+
withAudioTrack = true,
|
|
76
|
+
} = {}) {
|
|
77
|
+
ensureWeriftPolyfills();
|
|
78
|
+
const sessionMap = new Map();
|
|
79
|
+
const pc = new globalThis.RTCPeerConnection({ iceServers: [] });
|
|
80
|
+
if (withDataChannel && typeof pc.createDataChannel === "function") {
|
|
81
|
+
pc.createDataChannel("chat");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const session = {
|
|
85
|
+
sessionId,
|
|
86
|
+
callerEns,
|
|
87
|
+
toIdentity,
|
|
88
|
+
peerConnection: pc,
|
|
89
|
+
localAudioTrack: null,
|
|
90
|
+
remoteTracks: [],
|
|
91
|
+
iceCandidates: [],
|
|
92
|
+
};
|
|
93
|
+
if (withAudioTrack) {
|
|
94
|
+
session.localAudioTrack = new globalThis.MediaStreamTrack({ kind: "audio" });
|
|
95
|
+
if (typeof pc.addTrack === "function") {
|
|
96
|
+
pc.addTrack(session.localAudioTrack);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
sessionMap.set(sessionId, session);
|
|
101
|
+
const signaling = createSignalingRecorder();
|
|
102
|
+
const primitives = createProductionLikePrimitives({ sessionMap });
|
|
103
|
+
const neg = new WebRtcNegotiation({
|
|
104
|
+
id,
|
|
105
|
+
endpoint,
|
|
106
|
+
session,
|
|
107
|
+
signaling,
|
|
108
|
+
primitives,
|
|
109
|
+
logger: silentLogger,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const dispose = async () => {
|
|
113
|
+
try { pc.close(); } catch (_) {}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return { neg, session, signaling, primitives, pc, dispose };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = {
|
|
120
|
+
buildWeriftNegotiationHarness,
|
|
121
|
+
ensureWeriftPolyfills,
|
|
122
|
+
createProductionLikePrimitives,
|
|
123
|
+
createSignalingRecorder,
|
|
124
|
+
silentLogger,
|
|
125
|
+
};
|
|
@@ -177,12 +177,13 @@ class WebRtcNegotiation extends CallNegotiationPort {
|
|
|
177
177
|
if (audioDirection(offerSdp) === "inactive" && this.p.patchInactiveToSendrecv) {
|
|
178
178
|
offerSdp = this.p.patchInactiveToSendrecv(offerSdp);
|
|
179
179
|
}
|
|
180
|
-
|
|
180
|
+
const offerHasAudio = this._offerHasAudioMLine(offerSdp);
|
|
181
|
+
if (!isIceRestart && offerHasAudio) this._ensureAudioReadyForOffer(offerSdp, pc);
|
|
181
182
|
await this._setRemoteOfferWithRecovery(pc, offerSdp);
|
|
182
183
|
await this.p.addIceCandidates?.(pc, payload.candidates || []);
|
|
183
184
|
// On ICE restart the track set is unchanged; only the caller-ring path
|
|
184
185
|
// needs to (re)attach the local audio track.
|
|
185
|
-
if (!isIceRestart && this.p.ensureLocalAudioTrack) {
|
|
186
|
+
if (!isIceRestart && offerHasAudio && this.p.ensureLocalAudioTrack) {
|
|
186
187
|
this.p.ensureLocalAudioTrack(this.session, pc, this.id);
|
|
187
188
|
}
|
|
188
189
|
const label = isIceRestart ? "ICE-RESTART ANSWER SDP" : "ANSWER SDP";
|
|
@@ -231,6 +232,47 @@ class WebRtcNegotiation extends CallNegotiationPort {
|
|
|
231
232
|
return /Transceiver with mid=\d+ not found/i.test(msg);
|
|
232
233
|
}
|
|
233
234
|
|
|
235
|
+
_isMLineOrParserMappingError(err) {
|
|
236
|
+
const msg = String(err?.message || "");
|
|
237
|
+
return (
|
|
238
|
+
/m[-\s]?line not found/i.test(msg) ||
|
|
239
|
+
/iceParams/i.test(msg) ||
|
|
240
|
+
/media section/i.test(msg)
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
_offerMediaSections(sdp = "") {
|
|
245
|
+
const out = [];
|
|
246
|
+
const parts = String(sdp).split(/\r?\nm=/);
|
|
247
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
248
|
+
const section = i === 0 ? parts[i] : `m=${parts[i]}`;
|
|
249
|
+
if (!/^m=/m.test(section)) continue;
|
|
250
|
+
const kind = section.match(/^m=([^\s]+)/m)?.[1] || null;
|
|
251
|
+
const mid = section.match(/^a=mid:([^\r\n]+)/m)?.[1] || null;
|
|
252
|
+
out.push({ kind, mid: mid ? String(mid).trim() : null, raw: section });
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
_alignTransceiversToOffer(pc, sdp) {
|
|
258
|
+
const sections = this._offerMediaSections(sdp);
|
|
259
|
+
const hasAudio = sections.some((s) => s.kind === "audio");
|
|
260
|
+
const audioOfferMids = new Set(
|
|
261
|
+
sections
|
|
262
|
+
.filter((s) => s.kind === "audio" && s.mid)
|
|
263
|
+
.map((s) => String(s.mid)),
|
|
264
|
+
);
|
|
265
|
+
const audioTransceivers = pc.getTransceivers?.().filter((t) => t.kind === "audio") || [];
|
|
266
|
+
if (!audioTransceivers.length) return;
|
|
267
|
+
for (const transceiver of audioTransceivers) {
|
|
268
|
+
const currentMid = String(transceiver?.mid || "").trim();
|
|
269
|
+
const midIsOffered = currentMid && audioOfferMids.has(currentMid);
|
|
270
|
+
if (hasAudio && midIsOffered) continue;
|
|
271
|
+
try { transceiver.setDirection?.("inactive"); } catch (_) {}
|
|
272
|
+
try { transceiver.sender?.replaceTrack?.(null); } catch (_) {}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
234
276
|
_extractMissingMidFromError(err) {
|
|
235
277
|
const msg = String(err?.message || "");
|
|
236
278
|
const mid = msg.match(/Transceiver with mid=(\d+) not found/i)?.[1];
|
|
@@ -244,9 +286,15 @@ class WebRtcNegotiation extends CallNegotiationPort {
|
|
|
244
286
|
await pc.setRemoteDescription(new this.p.RTCSessionDescription(offerSdp, "offer"));
|
|
245
287
|
return;
|
|
246
288
|
} catch (err) {
|
|
247
|
-
|
|
248
|
-
const
|
|
249
|
-
this.
|
|
289
|
+
const hasAudio = this._offerHasAudioMLine(offerSdp);
|
|
290
|
+
const isMissingMid = this._isMissingMidError(err);
|
|
291
|
+
const isParserMapping = this._isMLineOrParserMappingError(err);
|
|
292
|
+
if (!isMissingMid && !isParserMapping) throw err;
|
|
293
|
+
this._alignTransceiversToOffer(pc, offerSdp);
|
|
294
|
+
if (hasAudio) {
|
|
295
|
+
const missingMid = this._extractMissingMidFromError(err);
|
|
296
|
+
this._repairAudioForOfferMid(offerSdp, pc, { preferredMid: missingMid, forceCreate });
|
|
297
|
+
}
|
|
250
298
|
forceCreate = true;
|
|
251
299
|
if (attempt === 2) throw err;
|
|
252
300
|
}
|
|
@@ -259,9 +307,15 @@ class WebRtcNegotiation extends CallNegotiationPort {
|
|
|
259
307
|
try {
|
|
260
308
|
return await this.p.createAnswerSdp(pc, this.id, label);
|
|
261
309
|
} catch (err) {
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
this.
|
|
310
|
+
const hasAudio = this._offerHasAudioMLine(offerSdp);
|
|
311
|
+
const isMissingMid = this._isMissingMidError(err);
|
|
312
|
+
const isParserMapping = this._isMLineOrParserMappingError(err);
|
|
313
|
+
if (!isMissingMid && !isParserMapping) throw err;
|
|
314
|
+
this._alignTransceiversToOffer(pc, offerSdp);
|
|
315
|
+
if (hasAudio) {
|
|
316
|
+
const missingMid = this._extractMissingMidFromError(err);
|
|
317
|
+
this._repairAudioForOfferMid(offerSdp, pc, { preferredMid: missingMid, forceCreate });
|
|
318
|
+
}
|
|
265
319
|
forceCreate = true;
|
|
266
320
|
if (attempt === 2) throw err;
|
|
267
321
|
}
|
|
@@ -93,10 +93,24 @@ function logSdp(sessionId, label, sdp, logger = console) {
|
|
|
93
93
|
|
|
94
94
|
async function addIceCandidates(pc, candidates, RTCIceCandidate) {
|
|
95
95
|
let added = 0;
|
|
96
|
+
const isMLineMismatch = (err) => {
|
|
97
|
+
const msg = String(err?.message || "");
|
|
98
|
+
return (
|
|
99
|
+
/m[-\s]?line not found/i.test(msg) ||
|
|
100
|
+
/iceParams/i.test(msg) ||
|
|
101
|
+
/media section/i.test(msg)
|
|
102
|
+
);
|
|
103
|
+
};
|
|
96
104
|
for (const c of (candidates || [])) {
|
|
97
105
|
if (c && c.candidate) {
|
|
98
|
-
|
|
99
|
-
|
|
106
|
+
try {
|
|
107
|
+
await pc.addIceCandidate(new RTCIceCandidate(c));
|
|
108
|
+
added++;
|
|
109
|
+
} catch (err) {
|
|
110
|
+
// Reused sessions can receive stale trickle candidates that refer to
|
|
111
|
+
// an m-line no longer present in the latest negotiated SDP.
|
|
112
|
+
if (!isMLineMismatch(err)) throw err;
|
|
113
|
+
}
|
|
100
114
|
}
|
|
101
115
|
}
|
|
102
116
|
return added;
|