@zero-server/sdk 0.9.6 → 0.9.8

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