@zero-server/sdk 0.9.5 → 0.9.7
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/README.md +54 -64
- package/index.js +116 -4
- package/lib/app.js +22 -22
- package/lib/auth/authorize.js +11 -11
- package/lib/auth/enrollment.js +5 -5
- package/lib/auth/jwt.js +9 -9
- package/lib/auth/oauth.js +1 -1
- package/lib/auth/session.js +5 -5
- package/lib/auth/trustedDevice.js +2 -2
- package/lib/auth/twoFactor.js +11 -11
- package/lib/auth/webauthn.js +6 -6
- package/lib/body/json.js +1 -1
- package/lib/body/raw.js +1 -1
- package/lib/body/rawBuffer.js +1 -1
- package/lib/body/text.js +1 -1
- package/lib/body/urlencoded.js +3 -3
- package/lib/cli.js +19 -4
- package/lib/cluster.js +3 -3
- package/lib/debug.js +10 -10
- package/lib/env/index.js +11 -11
- package/lib/errors.js +131 -16
- package/lib/fetch/index.js +1 -1
- package/lib/grpc/call.js +14 -14
- package/lib/grpc/client.js +4 -4
- package/lib/grpc/codec.js +7 -7
- package/lib/grpc/credentials.js +2 -2
- package/lib/grpc/frame.js +2 -2
- package/lib/grpc/health.js +3 -3
- package/lib/grpc/index.js +3 -3
- package/lib/grpc/metadata.js +3 -3
- package/lib/grpc/proto.js +5 -5
- package/lib/grpc/reflection.js +2 -2
- package/lib/grpc/server.js +3 -3
- package/lib/grpc/status.js +2 -2
- package/lib/grpc/watch.js +1 -1
- package/lib/http/request.js +13 -13
- package/lib/http/response.js +2 -2
- package/lib/lifecycle.js +5 -5
- package/lib/middleware/compress.js +4 -4
- package/lib/observe/health.js +1 -1
- package/lib/observe/index.js +1 -1
- package/lib/observe/logger.js +3 -3
- package/lib/observe/metrics.js +4 -4
- package/lib/observe/tracing.js +4 -4
- package/lib/orm/adapters/json.js +1 -1
- package/lib/orm/adapters/memory.js +2 -2
- package/lib/orm/adapters/mongo.js +2 -2
- package/lib/orm/adapters/mysql.js +2 -2
- package/lib/orm/adapters/postgres.js +2 -2
- package/lib/orm/adapters/sqlite.js +3 -3
- package/lib/orm/audit.js +1 -1
- package/lib/orm/index.js +7 -7
- package/lib/orm/migrate.js +1 -1
- package/lib/orm/model.js +15 -15
- package/lib/orm/procedures.js +1 -1
- package/lib/orm/profiler.js +1 -1
- package/lib/orm/query.js +9 -9
- package/lib/orm/schema.js +1 -1
- package/lib/orm/seed/data/person.js +1 -1
- package/lib/orm/seed/fake.js +10 -10
- package/lib/orm/seed/index.js +4 -4
- package/lib/orm/seed/rng.js +1 -1
- package/lib/orm/snapshot.js +2 -2
- package/lib/orm/tenancy.js +6 -6
- package/lib/orm/views.js +1 -1
- package/lib/router/index.js +9 -9
- package/lib/webrtc/bot.js +361 -0
- package/lib/webrtc/cli.js +182 -0
- package/lib/webrtc/cluster.js +350 -0
- package/lib/webrtc/e2ee.js +282 -0
- package/lib/webrtc/ice.js +370 -0
- package/lib/webrtc/index.js +132 -0
- package/lib/webrtc/joinToken.js +116 -0
- package/lib/webrtc/observe.js +229 -0
- package/lib/webrtc/peer.js +116 -0
- package/lib/webrtc/room.js +171 -0
- package/lib/webrtc/sdp.js +508 -0
- package/lib/webrtc/sfu/index.js +201 -0
- package/lib/webrtc/sfu/livekit.js +301 -0
- package/lib/webrtc/sfu/mediasoup.js +317 -0
- package/lib/webrtc/sfu/memory.js +204 -0
- package/lib/webrtc/signaling.js +546 -0
- package/lib/webrtc/stun.js +492 -0
- package/lib/webrtc/turn/codec.js +370 -0
- package/lib/webrtc/turn/credentials.js +141 -0
- package/lib/webrtc/turn/server.js +633 -0
- package/package.json +2 -2
- package/types/body.d.ts +1 -1
- package/types/cli.d.ts +1 -1
- package/types/index.d.ts +16 -4
- package/types/middleware.d.ts +1 -1
- package/types/orm.d.ts +3 -3
- package/types/request.d.ts +3 -3
- package/types/webrtc.d.ts +501 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module webrtc/room
|
|
3
|
+
* @description Room / channel abstraction for the WebRTC signaling hub.
|
|
4
|
+
*
|
|
5
|
+
* A `Room` holds a set of `Peer`s plus a list of `require()` policy gates
|
|
6
|
+
* that decide whether a given peer may join. Membership is in-process;
|
|
7
|
+
* the cluster adapter (PR 8) will fan room state out via pub/sub.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const { SignalingError } = require('../errors');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One signaling room.
|
|
16
|
+
*
|
|
17
|
+
* Constructed lazily by `SignalingHub#room(name)`. Application code never
|
|
18
|
+
* calls `new Room()` directly.
|
|
19
|
+
*
|
|
20
|
+
* @class
|
|
21
|
+
* @section Rooms
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* hub.room('lobby').open();
|
|
25
|
+
* hub.room('boardroom')
|
|
26
|
+
* .require(peer => peer.user && peer.user.role === 'exec')
|
|
27
|
+
* .canPublish(peer => peer.user.isHost);
|
|
28
|
+
*/
|
|
29
|
+
class Room
|
|
30
|
+
{
|
|
31
|
+
/**
|
|
32
|
+
* @constructor
|
|
33
|
+
* @param {string} name - Room name, used as routing key.
|
|
34
|
+
* @param {object} [opts]
|
|
35
|
+
* @param {import('./signaling').SignalingHub} [opts.hub] - Owning hub.
|
|
36
|
+
*/
|
|
37
|
+
constructor(name, opts = {})
|
|
38
|
+
{
|
|
39
|
+
if (typeof name !== 'string' || name.length === 0)
|
|
40
|
+
throw new SignalingError('Room name must be a non-empty string');
|
|
41
|
+
|
|
42
|
+
/** @type {string} */
|
|
43
|
+
this.name = name;
|
|
44
|
+
|
|
45
|
+
/** @type {import('./signaling').SignalingHub|null} */
|
|
46
|
+
this.hub = opts.hub || null;
|
|
47
|
+
|
|
48
|
+
/** @type {Set<import('./peer').Peer>} */
|
|
49
|
+
this._peers = new Set();
|
|
50
|
+
|
|
51
|
+
/** @type {Array<(peer:import('./peer').Peer) => boolean>} */
|
|
52
|
+
this._gates = [];
|
|
53
|
+
|
|
54
|
+
/** @type {((peer:import('./peer').Peer) => boolean)|null} */
|
|
55
|
+
this._canPublish = null;
|
|
56
|
+
|
|
57
|
+
/** @type {((peer:import('./peer').Peer) => boolean)|null} */
|
|
58
|
+
this._canSubscribe = null;
|
|
59
|
+
|
|
60
|
+
/** @type {boolean} `true` once `.open()` has been called. */
|
|
61
|
+
this.isOpen = false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// -- Configuration (fluent) --
|
|
65
|
+
|
|
66
|
+
/** Mark the room as public. Returns `this` for chaining. */
|
|
67
|
+
open()
|
|
68
|
+
{
|
|
69
|
+
this.isOpen = true;
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Add a policy gate. Called on every join; first falsy return rejects.
|
|
75
|
+
* @param {(peer:import('./peer').Peer) => boolean | Promise<boolean>} fn
|
|
76
|
+
* @returns {Room}
|
|
77
|
+
*/
|
|
78
|
+
require(fn)
|
|
79
|
+
{
|
|
80
|
+
if (typeof fn !== 'function')
|
|
81
|
+
throw new SignalingError('Room.require(fn) requires a function');
|
|
82
|
+
this._gates.push(fn);
|
|
83
|
+
return this;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Set the publish-permission check. Hub calls this before relaying offers.
|
|
88
|
+
* @param {(peer:import('./peer').Peer) => boolean} fn
|
|
89
|
+
*/
|
|
90
|
+
canPublish(fn) { this._canPublish = fn; return this; }
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Set the subscribe-permission check. Hub calls this before relaying answers.
|
|
94
|
+
* @param {(peer:import('./peer').Peer) => boolean} fn
|
|
95
|
+
*/
|
|
96
|
+
canSubscribe(fn) { this._canSubscribe = fn; return this; }
|
|
97
|
+
|
|
98
|
+
// -- Membership --
|
|
99
|
+
|
|
100
|
+
/** Current member count. */
|
|
101
|
+
get size() { return this._peers.size; }
|
|
102
|
+
|
|
103
|
+
/** @returns {import('./peer').Peer[]} */
|
|
104
|
+
peers() { return Array.from(this._peers); }
|
|
105
|
+
|
|
106
|
+
/** @returns {boolean} */
|
|
107
|
+
has(peer) { return this._peers.has(peer); }
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Evaluate every `require()` gate against the candidate peer.
|
|
111
|
+
* @param {import('./peer').Peer} peer
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
canJoin(peer)
|
|
115
|
+
{
|
|
116
|
+
for (const gate of this._gates)
|
|
117
|
+
{
|
|
118
|
+
try { if (!gate(peer)) return false; }
|
|
119
|
+
catch { return false; }
|
|
120
|
+
}
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Internal - hub uses this; do not call from application code. */
|
|
125
|
+
_add(peer)
|
|
126
|
+
{
|
|
127
|
+
this._peers.add(peer);
|
|
128
|
+
peer.room = this;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Internal - hub uses this; do not call from application code. */
|
|
132
|
+
_remove(peer)
|
|
133
|
+
{
|
|
134
|
+
if (!this._peers.has(peer)) return;
|
|
135
|
+
this._peers.delete(peer);
|
|
136
|
+
if (peer.room === this) peer.room = null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// -- Fan-out --
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Send a `{type, ...payload}` JSON frame to every peer in the room.
|
|
143
|
+
* @param {string} type
|
|
144
|
+
* @param {object} [payload]
|
|
145
|
+
* @param {string} [exceptPeerId] - Optional peer id to skip (e.g. the originator).
|
|
146
|
+
*/
|
|
147
|
+
broadcast(type, payload, exceptPeerId)
|
|
148
|
+
{
|
|
149
|
+
for (const p of this._peers)
|
|
150
|
+
{
|
|
151
|
+
if (exceptPeerId && p.id === exceptPeerId) continue;
|
|
152
|
+
p.send(type, payload);
|
|
153
|
+
}
|
|
154
|
+
if (this.hub && this.hub._cluster)
|
|
155
|
+
this.hub._cluster.fanoutRoom(this.name, type, payload, exceptPeerId);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Kick every peer with code 1001 (going-away) and unregister from the hub. */
|
|
159
|
+
close(reason = 'room-closed')
|
|
160
|
+
{
|
|
161
|
+
for (const p of Array.from(this._peers))
|
|
162
|
+
{
|
|
163
|
+
p.send('bye', { reason });
|
|
164
|
+
p.close(1001, reason);
|
|
165
|
+
}
|
|
166
|
+
this._peers.clear();
|
|
167
|
+
if (this.hub) this.hub._removeRoom(this);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = { Room };
|
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module webrtc/sdp
|
|
3
|
+
* @description Zero-dependency RFC 8866 Session Description Protocol parser and
|
|
4
|
+
* serializer, with WebRTC-specific attribute extraction (JSEP per
|
|
5
|
+
* RFC 8829: ice-ufrag, ice-pwd, fingerprint, setup, mid, rtcp-mux,
|
|
6
|
+
* direction, rtpmap, fmtp, rid, simulcast, ssrc, extmap, candidate).
|
|
7
|
+
*
|
|
8
|
+
* The parser deliberately does NOT validate semantics that belong
|
|
9
|
+
* to a SignalingHub policy layer (codec allowlists, mDNS blocking,
|
|
10
|
+
* etc.) - it only structures the document. Policy lives in
|
|
11
|
+
* `lib/webrtc/signaling.js`.
|
|
12
|
+
*
|
|
13
|
+
* @see https://datatracker.ietf.org/doc/html/rfc8866
|
|
14
|
+
* @see https://datatracker.ietf.org/doc/html/rfc8829
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const { SdpError } = require('../errors');
|
|
20
|
+
|
|
21
|
+
// -- Constants -----------------------------------------------------
|
|
22
|
+
|
|
23
|
+
const CRLF = '\r\n';
|
|
24
|
+
const DEFAULT_MAX_BYTES = 65_536; // 64 KiB - sane WebRTC offer ceiling
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Valid SDP direction attributes (RFC 8866 §6.7).
|
|
28
|
+
* @type {ReadonlyArray<string>}
|
|
29
|
+
*/
|
|
30
|
+
const DIRECTIONS = Object.freeze(['sendrecv', 'sendonly', 'recvonly', 'inactive']);
|
|
31
|
+
|
|
32
|
+
// -- Public types --------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {object} SdpOrigin
|
|
36
|
+
* @property {string} username
|
|
37
|
+
* @property {string} sessionId
|
|
38
|
+
* @property {number} sessionVersion
|
|
39
|
+
* @property {string} netType
|
|
40
|
+
* @property {string} addrType
|
|
41
|
+
* @property {string} address
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {object} SdpConnection
|
|
46
|
+
* @property {string} netType
|
|
47
|
+
* @property {string} addrType
|
|
48
|
+
* @property {string} address
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {object} SdpAttribute
|
|
53
|
+
* @property {string} key
|
|
54
|
+
* @property {string} value - Empty string for flag-only attributes.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @typedef {object} SdpRtpMap
|
|
59
|
+
* @property {number} payload
|
|
60
|
+
* @property {string} codec
|
|
61
|
+
* @property {number} clockRate
|
|
62
|
+
* @property {number|undefined} channels
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @typedef {object} SdpFmtp
|
|
67
|
+
* @property {number} payload
|
|
68
|
+
* @property {string} config
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @typedef {object} SdpRid
|
|
73
|
+
* @property {string} id
|
|
74
|
+
* @property {string} direction - 'send' or 'recv'.
|
|
75
|
+
* @property {string} params - Remaining rid params (may be empty).
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @typedef {object} SdpExtMap
|
|
80
|
+
* @property {number} id
|
|
81
|
+
* @property {string|undefined} direction
|
|
82
|
+
* @property {string} uri
|
|
83
|
+
* @property {string|undefined} config
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @typedef {object} SdpSsrcAttr
|
|
88
|
+
* @property {number} id
|
|
89
|
+
* @property {string} attribute
|
|
90
|
+
* @property {string} value
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @typedef {object} SdpFingerprint
|
|
95
|
+
* @property {string} algorithm
|
|
96
|
+
* @property {string} value
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @typedef {object} SdpMedia
|
|
101
|
+
* @property {string} kind - 'audio', 'video', 'application', etc.
|
|
102
|
+
* @property {number} port
|
|
103
|
+
* @property {number|undefined} numPorts
|
|
104
|
+
* @property {string} proto - e.g. 'UDP/TLS/RTP/SAVPF'.
|
|
105
|
+
* @property {string[]} fmts - Format / payload-type list.
|
|
106
|
+
* @property {SdpConnection|undefined} connection
|
|
107
|
+
* @property {SdpAttribute[]} attributes - Raw attribute list (round-trip source of truth).
|
|
108
|
+
* @property {string|undefined} mid
|
|
109
|
+
* @property {boolean} rtcpMux
|
|
110
|
+
* @property {SdpFingerprint|undefined} fingerprint
|
|
111
|
+
* @property {string|undefined} iceUfrag
|
|
112
|
+
* @property {string|undefined} icePwd
|
|
113
|
+
* @property {string|undefined} setup - 'actpass' | 'active' | 'passive' | 'holdconn'.
|
|
114
|
+
* @property {string|undefined} direction
|
|
115
|
+
* @property {string[]} candidates - Raw candidate lines (without "a=" prefix).
|
|
116
|
+
* @property {SdpRtpMap[]} rtpmaps
|
|
117
|
+
* @property {SdpFmtp[]} fmtps
|
|
118
|
+
* @property {SdpRid[]} rids
|
|
119
|
+
* @property {Object<string,string>} simulcast - { send?: '<layers>', recv?: '<layers>' }.
|
|
120
|
+
* @property {SdpExtMap[]} extmaps
|
|
121
|
+
* @property {SdpSsrcAttr[]} ssrcs
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @typedef {object} SessionDescription
|
|
126
|
+
* @property {number} version
|
|
127
|
+
* @property {SdpOrigin} origin
|
|
128
|
+
* @property {string} sessionName
|
|
129
|
+
* @property {SdpConnection|undefined} connection
|
|
130
|
+
* @property {Array<{start:number,stop:number}>} timing
|
|
131
|
+
* @property {SdpAttribute[]} attributes
|
|
132
|
+
* @property {SdpMedia[]} media
|
|
133
|
+
*/
|
|
134
|
+
|
|
135
|
+
// =================================================================
|
|
136
|
+
// Parser
|
|
137
|
+
// =================================================================
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Parse an SDP document into a structured `SessionDescription`.
|
|
141
|
+
*
|
|
142
|
+
* Accepts CRLF (RFC 8866) or LF-only line endings. Validates the leading
|
|
143
|
+
* `v=` line, refuses oversized payloads, and tolerates unknown attribute
|
|
144
|
+
* keys by preserving them on the raw `attributes` list.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} text - The SDP document text.
|
|
147
|
+
* @param {object} [opts]
|
|
148
|
+
* @param {number} [opts.maxBytes=65536] - Reject payloads larger than this.
|
|
149
|
+
* @returns {SessionDescription} Parsed structure.
|
|
150
|
+
* @throws {SdpError} On malformed input, oversized payload, or non-string arg.
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* const { parseSdp } = require('@zero-server/webrtc');
|
|
154
|
+
* const desc = parseSdp(offer.sdp);
|
|
155
|
+
* console.log(desc.media[0].iceUfrag, desc.media[0].fingerprint);
|
|
156
|
+
*
|
|
157
|
+
* @section Signaling
|
|
158
|
+
*/
|
|
159
|
+
function parseSdp(text, opts = {})
|
|
160
|
+
{
|
|
161
|
+
if (typeof text !== 'string') throw new SdpError('parseSdp: input must be a string');
|
|
162
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
163
|
+
if (text.length > maxBytes) throw new SdpError(`parseSdp: payload exceeds ${maxBytes} bytes`);
|
|
164
|
+
if (text.length === 0) throw new SdpError('parseSdp: empty input');
|
|
165
|
+
|
|
166
|
+
const lines = text.replace(/\r\n/g, '\n').split('\n').filter(l => l.length > 0);
|
|
167
|
+
if (lines.length === 0) throw new SdpError('parseSdp: no non-empty lines');
|
|
168
|
+
|
|
169
|
+
/** @type {SessionDescription} */
|
|
170
|
+
const session = {
|
|
171
|
+
version: 0,
|
|
172
|
+
origin: undefined,
|
|
173
|
+
sessionName: '',
|
|
174
|
+
connection: undefined,
|
|
175
|
+
timing: [],
|
|
176
|
+
attributes: [],
|
|
177
|
+
media: [],
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
let current = session; // session-level or current media section
|
|
181
|
+
let inMedia = false;
|
|
182
|
+
|
|
183
|
+
for (let i = 0; i < lines.length; i++)
|
|
184
|
+
{
|
|
185
|
+
const raw = lines[i];
|
|
186
|
+
const eq = raw.indexOf('=');
|
|
187
|
+
if (eq < 1) throw new SdpError(`parseSdp: malformed line ${i + 1}`, { line: i + 1 });
|
|
188
|
+
const type = raw.slice(0, eq);
|
|
189
|
+
const val = raw.slice(eq + 1);
|
|
190
|
+
|
|
191
|
+
if (i === 0 && type !== 'v')
|
|
192
|
+
throw new SdpError('parseSdp: SDP must start with v=', { line: 1 });
|
|
193
|
+
|
|
194
|
+
switch (type)
|
|
195
|
+
{
|
|
196
|
+
case 'v':
|
|
197
|
+
session.version = Number(val);
|
|
198
|
+
break;
|
|
199
|
+
|
|
200
|
+
case 'o':
|
|
201
|
+
session.origin = _parseOrigin(val, i + 1);
|
|
202
|
+
break;
|
|
203
|
+
|
|
204
|
+
case 's':
|
|
205
|
+
session.sessionName = val;
|
|
206
|
+
break;
|
|
207
|
+
|
|
208
|
+
case 'c':
|
|
209
|
+
current.connection = _parseConnection(val, i + 1);
|
|
210
|
+
break;
|
|
211
|
+
|
|
212
|
+
case 't':
|
|
213
|
+
{
|
|
214
|
+
const [start, stop] = val.split(/\s+/).map(Number);
|
|
215
|
+
session.timing.push({ start, stop });
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
case 'm':
|
|
220
|
+
current = _newMedia(val, i + 1);
|
|
221
|
+
session.media.push(current);
|
|
222
|
+
inMedia = true;
|
|
223
|
+
break;
|
|
224
|
+
|
|
225
|
+
case 'a':
|
|
226
|
+
{
|
|
227
|
+
const attr = _parseAttribute(val);
|
|
228
|
+
current.attributes.push(attr);
|
|
229
|
+
if (inMedia) _absorbMediaAttr(current, attr);
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Other RFC 8866 line types we don't lift into structured fields but
|
|
234
|
+
// also do not reject - they survive as session.attributes is the
|
|
235
|
+
// round-trip source of truth for media attributes only. v/o/s/t/c
|
|
236
|
+
// are the only session-level lines we currently emit on serialize.
|
|
237
|
+
default:
|
|
238
|
+
// Tolerated but not stored (i, u, e, p, b, r, z, k).
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!session.origin)
|
|
244
|
+
throw new SdpError('parseSdp: missing o= line');
|
|
245
|
+
|
|
246
|
+
return session;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// -- Parser helpers ------------------------------------------------
|
|
250
|
+
|
|
251
|
+
/** @private */
|
|
252
|
+
function _parseOrigin(val, line)
|
|
253
|
+
{
|
|
254
|
+
const parts = val.split(/\s+/);
|
|
255
|
+
if (parts.length < 6)
|
|
256
|
+
throw new SdpError('parseSdp: malformed o= line', { line });
|
|
257
|
+
return {
|
|
258
|
+
username: parts[0],
|
|
259
|
+
sessionId: parts[1],
|
|
260
|
+
sessionVersion: Number(parts[2]),
|
|
261
|
+
netType: parts[3],
|
|
262
|
+
addrType: parts[4],
|
|
263
|
+
address: parts.slice(5).join(' '),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** @private */
|
|
268
|
+
function _parseConnection(val, line)
|
|
269
|
+
{
|
|
270
|
+
const parts = val.split(/\s+/);
|
|
271
|
+
if (parts.length < 3)
|
|
272
|
+
throw new SdpError('parseSdp: malformed c= line', { line });
|
|
273
|
+
return { netType: parts[0], addrType: parts[1], address: parts[2] };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** @private */
|
|
277
|
+
function _newMedia(val, line)
|
|
278
|
+
{
|
|
279
|
+
const parts = val.split(/\s+/);
|
|
280
|
+
if (parts.length < 4)
|
|
281
|
+
throw new SdpError('parseSdp: malformed m= line', { line });
|
|
282
|
+
const [kind, portSpec, proto, ...fmts] = parts;
|
|
283
|
+
let port, numPorts;
|
|
284
|
+
if (portSpec.includes('/'))
|
|
285
|
+
{
|
|
286
|
+
const [p, n] = portSpec.split('/');
|
|
287
|
+
port = Number(p); numPorts = Number(n);
|
|
288
|
+
}
|
|
289
|
+
else
|
|
290
|
+
{
|
|
291
|
+
port = Number(portSpec);
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
kind, port, numPorts, proto, fmts,
|
|
295
|
+
connection: undefined,
|
|
296
|
+
attributes: [],
|
|
297
|
+
mid: undefined,
|
|
298
|
+
rtcpMux: false,
|
|
299
|
+
fingerprint: undefined,
|
|
300
|
+
iceUfrag: undefined,
|
|
301
|
+
icePwd: undefined,
|
|
302
|
+
setup: undefined,
|
|
303
|
+
direction: undefined,
|
|
304
|
+
candidates: [],
|
|
305
|
+
rtpmaps: [],
|
|
306
|
+
fmtps: [],
|
|
307
|
+
rids: [],
|
|
308
|
+
simulcast: {},
|
|
309
|
+
extmaps: [],
|
|
310
|
+
ssrcs: [],
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** @private */
|
|
315
|
+
function _parseAttribute(val)
|
|
316
|
+
{
|
|
317
|
+
const colon = val.indexOf(':');
|
|
318
|
+
if (colon === -1) return { key: val, value: '' };
|
|
319
|
+
return { key: val.slice(0, colon), value: val.slice(colon + 1) };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** @private */
|
|
323
|
+
function _absorbMediaAttr(media, attr)
|
|
324
|
+
{
|
|
325
|
+
const { key, value } = attr;
|
|
326
|
+
|
|
327
|
+
if (DIRECTIONS.includes(key)) { media.direction = key; return; }
|
|
328
|
+
|
|
329
|
+
switch (key)
|
|
330
|
+
{
|
|
331
|
+
case 'mid': media.mid = value; return;
|
|
332
|
+
case 'rtcp-mux': media.rtcpMux = true; return;
|
|
333
|
+
case 'ice-ufrag': media.iceUfrag = value; return;
|
|
334
|
+
case 'ice-pwd': media.icePwd = value; return;
|
|
335
|
+
case 'setup': media.setup = value; return;
|
|
336
|
+
|
|
337
|
+
case 'fingerprint':
|
|
338
|
+
{
|
|
339
|
+
const space = value.indexOf(' ');
|
|
340
|
+
if (space === -1) return;
|
|
341
|
+
media.fingerprint = {
|
|
342
|
+
algorithm: value.slice(0, space).toLowerCase(),
|
|
343
|
+
value: value.slice(space + 1).trim().toUpperCase(),
|
|
344
|
+
};
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
case 'candidate':
|
|
349
|
+
media.candidates.push(`candidate:${value}`);
|
|
350
|
+
return;
|
|
351
|
+
|
|
352
|
+
case 'rtpmap':
|
|
353
|
+
{
|
|
354
|
+
// <PT> <codec>/<rate>[/<channels>]
|
|
355
|
+
const space = value.indexOf(' ');
|
|
356
|
+
if (space === -1) return;
|
|
357
|
+
const payload = Number(value.slice(0, space));
|
|
358
|
+
const tail = value.slice(space + 1).split('/');
|
|
359
|
+
media.rtpmaps.push({
|
|
360
|
+
payload,
|
|
361
|
+
codec: tail[0],
|
|
362
|
+
clockRate: Number(tail[1]),
|
|
363
|
+
channels: tail[2] !== undefined ? Number(tail[2]) : undefined,
|
|
364
|
+
});
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
case 'fmtp':
|
|
369
|
+
{
|
|
370
|
+
const space = value.indexOf(' ');
|
|
371
|
+
if (space === -1) return;
|
|
372
|
+
media.fmtps.push({
|
|
373
|
+
payload: Number(value.slice(0, space)),
|
|
374
|
+
config: value.slice(space + 1),
|
|
375
|
+
});
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
case 'rid':
|
|
380
|
+
{
|
|
381
|
+
// <id> <direction> [params]
|
|
382
|
+
const parts = value.split(/\s+/);
|
|
383
|
+
if (parts.length < 2) return;
|
|
384
|
+
media.rids.push({
|
|
385
|
+
id: parts[0],
|
|
386
|
+
direction: parts[1],
|
|
387
|
+
params: parts.slice(2).join(' '),
|
|
388
|
+
});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
case 'simulcast':
|
|
393
|
+
{
|
|
394
|
+
// simulcast:<dir> <layers> [<dir> <layers>]
|
|
395
|
+
const parts = value.split(/\s+/);
|
|
396
|
+
for (let i = 0; i < parts.length; i += 2)
|
|
397
|
+
{
|
|
398
|
+
if (parts[i] && parts[i + 1] !== undefined)
|
|
399
|
+
media.simulcast[parts[i]] = parts[i + 1];
|
|
400
|
+
}
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
case 'extmap':
|
|
405
|
+
{
|
|
406
|
+
// <id>[/<direction>] <uri> [<config>]
|
|
407
|
+
const space = value.indexOf(' ');
|
|
408
|
+
if (space === -1) return;
|
|
409
|
+
const idPart = value.slice(0, space);
|
|
410
|
+
const rest = value.slice(space + 1).trim();
|
|
411
|
+
const [idStr, direction] = idPart.split('/');
|
|
412
|
+
const space2 = rest.indexOf(' ');
|
|
413
|
+
const uri = space2 === -1 ? rest : rest.slice(0, space2);
|
|
414
|
+
const config = space2 === -1 ? undefined : rest.slice(space2 + 1);
|
|
415
|
+
media.extmaps.push({ id: Number(idStr), direction, uri, config });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
case 'ssrc':
|
|
420
|
+
{
|
|
421
|
+
// <id> <attr>[:<value>]
|
|
422
|
+
const space = value.indexOf(' ');
|
|
423
|
+
if (space === -1) return;
|
|
424
|
+
const id = Number(value.slice(0, space));
|
|
425
|
+
const attrTok = value.slice(space + 1);
|
|
426
|
+
const colon = attrTok.indexOf(':');
|
|
427
|
+
const attribute = colon === -1 ? attrTok : attrTok.slice(0, colon);
|
|
428
|
+
const v = colon === -1 ? '' : attrTok.slice(colon + 1);
|
|
429
|
+
media.ssrcs.push({ id, attribute, value: v });
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
default:
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// =================================================================
|
|
439
|
+
// Serializer
|
|
440
|
+
// =================================================================
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Serialize a `SessionDescription` back to RFC 8866 text with CRLF
|
|
444
|
+
* line endings. The serializer is round-trip safe for documents
|
|
445
|
+
* produced by `parseSdp`: it emits the raw attribute list verbatim so
|
|
446
|
+
* any media-level attribute we did not lift into a structured field is
|
|
447
|
+
* still preserved.
|
|
448
|
+
*
|
|
449
|
+
* @param {SessionDescription} session - Parsed session description.
|
|
450
|
+
* @returns {string} SDP document terminated with CRLF.
|
|
451
|
+
* @throws {SdpError} If required session fields are missing.
|
|
452
|
+
*
|
|
453
|
+
* @example
|
|
454
|
+
* const sdp = stringifySdp(parseSdp(offer.sdp));
|
|
455
|
+
*
|
|
456
|
+
* @section Signaling
|
|
457
|
+
*/
|
|
458
|
+
function stringifySdp(session)
|
|
459
|
+
{
|
|
460
|
+
if (!session || typeof session !== 'object')
|
|
461
|
+
throw new SdpError('stringifySdp: session must be an object');
|
|
462
|
+
if (typeof session.version !== 'number')
|
|
463
|
+
throw new SdpError('stringifySdp: missing version');
|
|
464
|
+
if (!session.origin)
|
|
465
|
+
throw new SdpError('stringifySdp: missing origin');
|
|
466
|
+
|
|
467
|
+
const out = [];
|
|
468
|
+
out.push(`v=${session.version}`);
|
|
469
|
+
out.push(`o=${_stringifyOrigin(session.origin)}`);
|
|
470
|
+
out.push(`s=${session.sessionName || '-'}`);
|
|
471
|
+
if (session.connection)
|
|
472
|
+
out.push(`c=${_stringifyConnection(session.connection)}`);
|
|
473
|
+
for (const t of session.timing || [])
|
|
474
|
+
out.push(`t=${t.start} ${t.stop}`);
|
|
475
|
+
for (const a of session.attributes || [])
|
|
476
|
+
out.push(a.value === '' ? `a=${a.key}` : `a=${a.key}:${a.value}`);
|
|
477
|
+
|
|
478
|
+
for (const m of session.media || [])
|
|
479
|
+
{
|
|
480
|
+
const portSpec = m.numPorts ? `${m.port}/${m.numPorts}` : String(m.port);
|
|
481
|
+
out.push(`m=${m.kind} ${portSpec} ${m.proto} ${(m.fmts || []).join(' ')}`.trim());
|
|
482
|
+
if (m.connection)
|
|
483
|
+
out.push(`c=${_stringifyConnection(m.connection)}`);
|
|
484
|
+
for (const a of m.attributes || [])
|
|
485
|
+
out.push(a.value === '' ? `a=${a.key}` : `a=${a.key}:${a.value}`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return out.join(CRLF) + CRLF;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** @private */
|
|
492
|
+
function _stringifyOrigin(o)
|
|
493
|
+
{
|
|
494
|
+
return `${o.username} ${o.sessionId} ${o.sessionVersion} ${o.netType} ${o.addrType} ${o.address}`;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** @private */
|
|
498
|
+
function _stringifyConnection(c)
|
|
499
|
+
{
|
|
500
|
+
return `${c.netType} ${c.addrType} ${c.address}`;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
module.exports = {
|
|
504
|
+
parseSdp,
|
|
505
|
+
stringifySdp,
|
|
506
|
+
SdpError,
|
|
507
|
+
DIRECTIONS,
|
|
508
|
+
};
|