@tomgiee/tsdp 1.0.1 → 1.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 (68) hide show
  1. package/README.md +0 -39
  2. package/dist/src/builder/media-builder.d.ts.map +1 -1
  3. package/dist/src/builder/media-builder.js +5 -12
  4. package/dist/src/builder/session-builder.d.ts.map +1 -1
  5. package/dist/src/builder/session-builder.js +4 -14
  6. package/dist/src/index.d.ts +6 -10
  7. package/dist/src/index.d.ts.map +1 -1
  8. package/dist/src/index.js +37 -49
  9. package/dist/src/parser/core.d.ts +366 -0
  10. package/dist/src/parser/core.d.ts.map +1 -0
  11. package/dist/src/parser/core.js +802 -0
  12. package/dist/src/parser/field-parser.d.ts +51 -8
  13. package/dist/src/parser/field-parser.d.ts.map +1 -1
  14. package/dist/src/parser/field-parser.js +91 -23
  15. package/dist/src/parser/media-parser.d.ts +1 -1
  16. package/dist/src/parser/media-parser.d.ts.map +1 -1
  17. package/dist/src/parser/media-parser.js +9 -15
  18. package/dist/src/parser/session-parser.d.ts.map +1 -1
  19. package/dist/src/parser/session-parser.js +16 -17
  20. package/dist/src/parser/time-parser.d.ts +1 -1
  21. package/dist/src/parser/time-parser.d.ts.map +1 -1
  22. package/dist/src/parser/time-parser.js +8 -8
  23. package/dist/src/serializer/fields.d.ts +167 -0
  24. package/dist/src/serializer/fields.d.ts.map +1 -0
  25. package/dist/src/serializer/fields.js +320 -0
  26. package/dist/src/serializer/media-serializer.js +6 -7
  27. package/dist/src/serializer/session-serializer.js +13 -15
  28. package/dist/src/types/attributes.d.ts.map +1 -1
  29. package/dist/src/types/fields.d.ts +5 -5
  30. package/dist/src/types/fields.d.ts.map +1 -1
  31. package/dist/src/types/fields.js +4 -4
  32. package/dist/src/types/media.d.ts +9 -10
  33. package/dist/src/types/media.d.ts.map +1 -1
  34. package/dist/src/types/media.js +2 -4
  35. package/dist/src/types/network.d.ts +15 -56
  36. package/dist/src/types/network.d.ts.map +1 -1
  37. package/dist/src/types/network.js +3 -34
  38. package/dist/src/types/primitives.d.ts +3 -147
  39. package/dist/src/types/primitives.d.ts.map +1 -1
  40. package/dist/src/types/primitives.js +2 -171
  41. package/dist/src/types/session.d.ts +14 -14
  42. package/dist/src/types/session.d.ts.map +1 -1
  43. package/dist/src/types/time.d.ts +4 -4
  44. package/dist/src/types/time.d.ts.map +1 -1
  45. package/dist/src/types/time.js +8 -6
  46. package/dist/src/utils/address-parser.d.ts +4 -4
  47. package/dist/src/utils/address-parser.d.ts.map +1 -1
  48. package/dist/src/utils/address-parser.js +9 -16
  49. package/dist/src/validator/validator.d.ts +94 -0
  50. package/dist/src/validator/validator.d.ts.map +1 -0
  51. package/dist/src/validator/validator.js +573 -0
  52. package/dist/tests/unit/parser/attribute-parser.test.js +106 -107
  53. package/dist/tests/unit/parser/field-parser.test.js +66 -66
  54. package/dist/tests/unit/parser/media-parser.test.js +38 -38
  55. package/dist/tests/unit/parser/primitive-parser.test.js +89 -90
  56. package/dist/tests/unit/parser/scanner.test.js +32 -32
  57. package/dist/tests/unit/parser/time-parser.test.js +22 -22
  58. package/dist/tests/unit/serializer/attribute-serializer.test.js +22 -22
  59. package/dist/tests/unit/serializer/field-serializer.test.js +57 -57
  60. package/dist/tests/unit/serializer/media-serializer.test.js +5 -6
  61. package/dist/tests/unit/serializer/session-serializer.test.js +24 -24
  62. package/dist/tests/unit/serializer/time-serializer.test.js +16 -16
  63. package/dist/tests/unit/types/network.test.js +21 -56
  64. package/dist/tests/unit/types/primitives.test.js +0 -39
  65. package/dist/tests/unit/validator/media-validator.test.js +34 -35
  66. package/dist/tests/unit/validator/semantic-validator.test.js +36 -37
  67. package/dist/tests/unit/validator/session-validator.test.js +54 -54
  68. package/package.json +1 -1
@@ -0,0 +1,802 @@
1
+ "use strict";
2
+ /**
3
+ * Core parsing infrastructure for SDP (RFC 8866)
4
+ *
5
+ * This module consolidates:
6
+ * - Scanner: Low-level tokenization with position tracking
7
+ * - Primitive parsers: Basic grammar elements (tokens, integers, addresses, times)
8
+ * - Attribute parsers: SDP attributes (a= fields)
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.Scanner = void 0;
12
+ exports.parseToken = parseToken;
13
+ exports.parseInteger = parseInteger;
14
+ exports.parseByte = parseByte;
15
+ exports.parseNonWSString = parseNonWSString;
16
+ exports.parseByteString = parseByteString;
17
+ exports.parseIP4Address = parseIP4Address;
18
+ exports.parseIP6Address = parseIP6Address;
19
+ exports.parseFQDN = parseFQDN;
20
+ exports.parseNtpTime = parseNtpTime;
21
+ exports.parseTypedTime = parseTypedTime;
22
+ exports.parseEmail = parseEmail;
23
+ exports.parsePhone = parsePhone;
24
+ exports.parseURI = parseURI;
25
+ exports.parseAttributeField = parseAttributeField;
26
+ exports.parseRtpmapValue = parseRtpmapValue;
27
+ exports.parseFmtpValue = parseFmtpValue;
28
+ exports.parsePtimeValue = parsePtimeValue;
29
+ exports.parseMaxptimeValue = parseMaxptimeValue;
30
+ exports.parseFramerateValue = parseFramerateValue;
31
+ exports.parseQualityValue = parseQualityValue;
32
+ const errors_1 = require("../types/errors");
33
+ const address_parser_1 = require("../utils/address-parser");
34
+ const time_converter_1 = require("../utils/time-converter");
35
+ const attributes_1 = require("../types/attributes");
36
+ // ============================================================================
37
+ // Scanner Class
38
+ // ============================================================================
39
+ /**
40
+ * Scanner class for parsing SDP text
41
+ *
42
+ * Maintains position information and provides character-level operations
43
+ * for parsing SDP fields according to RFC 8866 grammar.
44
+ */
45
+ class Scanner {
46
+ /**
47
+ * Create a new Scanner
48
+ *
49
+ * @param input - SDP text to scan
50
+ */
51
+ constructor(input) {
52
+ this.input = input;
53
+ this.position = 0;
54
+ this.line = 1;
55
+ this.column = 1;
56
+ }
57
+ /**
58
+ * Peek at the current character without consuming it
59
+ *
60
+ * @returns Current character or null if at EOF
61
+ */
62
+ peek() {
63
+ if (this.isEOF()) {
64
+ return null;
65
+ }
66
+ return this.input[this.position];
67
+ }
68
+ /**
69
+ * Peek ahead n characters without consuming
70
+ *
71
+ * @param n - Number of characters to peek ahead
72
+ * @returns Character at position+n or null if beyond EOF
73
+ */
74
+ peekAhead(n) {
75
+ const pos = this.position + n;
76
+ if (pos >= this.input.length) {
77
+ return null;
78
+ }
79
+ return this.input[pos];
80
+ }
81
+ /**
82
+ * Advance to the next character and return the current one
83
+ *
84
+ * Updates line and column tracking when encountering newlines.
85
+ *
86
+ * @returns Current character or null if at EOF
87
+ */
88
+ advance() {
89
+ if (this.isEOF()) {
90
+ return null;
91
+ }
92
+ const ch = this.input[this.position];
93
+ this.position++;
94
+ // Update line and column tracking
95
+ if (ch === '\n') {
96
+ this.line++;
97
+ this.column = 1;
98
+ }
99
+ else if (ch === '\r') {
100
+ // Handle CRLF and CR
101
+ if (this.peek() === '\n') {
102
+ // CRLF - consume the LF too
103
+ this.position++;
104
+ }
105
+ this.line++;
106
+ this.column = 1;
107
+ }
108
+ else {
109
+ this.column++;
110
+ }
111
+ return ch;
112
+ }
113
+ /**
114
+ * Read characters until a delimiter is found
115
+ *
116
+ * Does NOT consume the delimiter.
117
+ *
118
+ * @param delimiter - String or RegExp to match
119
+ * @returns String of characters read (may be empty)
120
+ */
121
+ readUntil(delimiter) {
122
+ let result = '';
123
+ while (!this.isEOF()) {
124
+ const ch = this.peek();
125
+ if (typeof delimiter === 'string') {
126
+ // String delimiter - check for exact match
127
+ if (ch === delimiter) {
128
+ break;
129
+ }
130
+ }
131
+ else {
132
+ // RegExp delimiter - test current character
133
+ if (delimiter.test(ch)) {
134
+ break;
135
+ }
136
+ }
137
+ result += this.advance();
138
+ }
139
+ return result;
140
+ }
141
+ /**
142
+ * Read while a condition is true
143
+ *
144
+ * @param predicate - Function that returns true to continue reading
145
+ * @returns String of characters read
146
+ */
147
+ readWhile(predicate) {
148
+ let result = '';
149
+ while (!this.isEOF()) {
150
+ const ch = this.peek();
151
+ if (!predicate(ch)) {
152
+ break;
153
+ }
154
+ result += this.advance();
155
+ }
156
+ return result;
157
+ }
158
+ /**
159
+ * Skip whitespace characters (space and tab)
160
+ *
161
+ * Does NOT skip newlines (CRLF/LF/CR).
162
+ */
163
+ skipWhitespace() {
164
+ while (!this.isEOF()) {
165
+ const ch = this.peek();
166
+ if (ch !== ' ' && ch !== '\t') {
167
+ break;
168
+ }
169
+ this.advance();
170
+ }
171
+ }
172
+ /**
173
+ * Skip to the next line
174
+ *
175
+ * Consumes all characters until and including the next line ending (CRLF/LF/CR).
176
+ */
177
+ skipToNextLine() {
178
+ while (!this.isEOF()) {
179
+ const ch = this.advance();
180
+ if (ch === '\n' || ch === '\r') {
181
+ break;
182
+ }
183
+ }
184
+ }
185
+ /**
186
+ * Get current position in the input
187
+ *
188
+ * @returns Position object with offset, line, and column
189
+ */
190
+ getPosition() {
191
+ return {
192
+ offset: this.position,
193
+ line: this.line,
194
+ column: this.column,
195
+ };
196
+ }
197
+ /**
198
+ * Check if at end of file
199
+ *
200
+ * @returns true if no more characters to read
201
+ */
202
+ isEOF() {
203
+ return this.position >= this.input.length;
204
+ }
205
+ /**
206
+ * Expect a specific string at the current position
207
+ *
208
+ * Consumes the string if it matches, throws otherwise.
209
+ *
210
+ * @param expected - String to expect
211
+ * @throws ParseError if string doesn't match
212
+ */
213
+ expect(expected) {
214
+ const start = this.getPosition();
215
+ for (let i = 0; i < expected.length; i++) {
216
+ const ch = this.peek();
217
+ if (ch === null) {
218
+ throw new errors_1.ParseError(`Expected "${expected}" but reached end of input`, start.line, start.column, this.getContext(start));
219
+ }
220
+ if (ch !== expected[i]) {
221
+ throw new errors_1.ParseError(`Expected "${expected}" but found "${ch}"`, start.line, start.column, this.getContext(start));
222
+ }
223
+ this.advance();
224
+ }
225
+ }
226
+ /**
227
+ * Expect a character that matches a condition
228
+ *
229
+ * Consumes the character if it matches, throws otherwise.
230
+ *
231
+ * @param predicate - Function that returns true for valid character
232
+ * @param description - Description of expected character for error messages
233
+ * @returns The matched character
234
+ * @throws ParseError if character doesn't match
235
+ */
236
+ expectChar(predicate, description) {
237
+ const pos = this.getPosition();
238
+ const ch = this.peek();
239
+ if (ch === null) {
240
+ throw new errors_1.ParseError(`Expected ${description} but reached end of input`, pos.line, pos.column, this.getContext(pos));
241
+ }
242
+ if (!predicate(ch)) {
243
+ throw new errors_1.ParseError(`Expected ${description} but found "${ch}"`, pos.line, pos.column, this.getContext(pos));
244
+ }
245
+ this.advance();
246
+ return ch;
247
+ }
248
+ /**
249
+ * Expect end of line (CRLF, LF, or CR)
250
+ *
251
+ * @throws ParseError if not at end of line
252
+ */
253
+ expectEOL() {
254
+ const pos = this.getPosition();
255
+ const ch = this.peek();
256
+ if (ch === null) {
257
+ // EOF is acceptable as EOL
258
+ return;
259
+ }
260
+ if (ch !== '\r' && ch !== '\n') {
261
+ throw new errors_1.ParseError(`Expected end of line but found "${ch}"`, pos.line, pos.column, this.getContext(pos));
262
+ }
263
+ this.advance();
264
+ }
265
+ /**
266
+ * Expect space character
267
+ *
268
+ * @throws ParseError if not a space
269
+ */
270
+ expectSpace() {
271
+ const pos = this.getPosition();
272
+ const ch = this.peek();
273
+ if (ch !== ' ') {
274
+ throw new errors_1.ParseError(`Expected space but found "${ch}"`, pos.line, pos.column, this.getContext(pos));
275
+ }
276
+ this.advance();
277
+ }
278
+ /**
279
+ * Get context around a position for error messages
280
+ *
281
+ * Returns a snippet of the input around the position.
282
+ *
283
+ * @param pos - Position to get context for
284
+ * @returns Context string (may be empty)
285
+ */
286
+ getContext(pos) {
287
+ const lines = this.input.split('\n');
288
+ if (pos.line - 1 < lines.length) {
289
+ return lines[pos.line - 1];
290
+ }
291
+ return '';
292
+ }
293
+ /**
294
+ * Get remaining input from current position
295
+ *
296
+ * @returns Remaining input string
297
+ */
298
+ getRemainingInput() {
299
+ return this.input.substring(this.position);
300
+ }
301
+ /**
302
+ * Get a substring of the input
303
+ *
304
+ * @param start - Start position
305
+ * @param end - End position (optional, defaults to current position)
306
+ * @returns Substring
307
+ */
308
+ getSubstring(start, end) {
309
+ return this.input.substring(start, end !== null && end !== void 0 ? end : this.position);
310
+ }
311
+ }
312
+ exports.Scanner = Scanner;
313
+ // ============================================================================
314
+ // Character Classification Helpers
315
+ // ============================================================================
316
+ /**
317
+ * Check if a character is a valid token character (RFC 8866)
318
+ */
319
+ function isTokenChar(ch) {
320
+ const code = ch.charCodeAt(0);
321
+ // token-char per RFC 8866
322
+ return (code === 0x21 || // !
323
+ (code >= 0x23 && code <= 0x27) || // # $ % & '
324
+ (code >= 0x2a && code <= 0x2b) || // * +
325
+ (code >= 0x2d && code <= 0x2e) || // - .
326
+ (code >= 0x30 && code <= 0x39) || // 0-9
327
+ (code >= 0x41 && code <= 0x5a) || // A-Z
328
+ (code >= 0x5e && code <= 0x7e) // ^ _ ` a-z { | } ~
329
+ );
330
+ }
331
+ /**
332
+ * Check if character is a digit
333
+ */
334
+ function isDigit(ch) {
335
+ return ch >= '0' && ch <= '9';
336
+ }
337
+ /**
338
+ * Check if character is non-whitespace
339
+ */
340
+ function isNonWhitespace(ch) {
341
+ return ch !== ' ' && ch !== '\t' && ch !== '\r' && ch !== '\n';
342
+ }
343
+ /**
344
+ * Check if character is valid in byte-string
345
+ */
346
+ function isByteStringChar(ch) {
347
+ const code = ch.charCodeAt(0);
348
+ return ((code >= 0x01 && code <= 0x09) ||
349
+ (code >= 0x0b && code <= 0x0c) ||
350
+ (code >= 0x0e && code <= 0xff));
351
+ }
352
+ /**
353
+ * Check if character is a hexadecimal digit
354
+ */
355
+ function isHexDigit(ch) {
356
+ return ((ch >= '0' && ch <= '9') ||
357
+ (ch >= 'a' && ch <= 'f') ||
358
+ (ch >= 'A' && ch <= 'F'));
359
+ }
360
+ /**
361
+ * Check if character is alphanumeric
362
+ */
363
+ function isAlphanumeric(ch) {
364
+ return ((ch >= '0' && ch <= '9') ||
365
+ (ch >= 'a' && ch <= 'z') ||
366
+ (ch >= 'A' && ch <= 'Z'));
367
+ }
368
+ // ============================================================================
369
+ // Token Parsing
370
+ // ============================================================================
371
+ /**
372
+ * Parse a token (RFC 8866 Section 9)
373
+ *
374
+ * token = 1*token-char
375
+ * token-char = %x21 / %x23-27 / %x2A-2B / %x2D-2E / %x30-39 / %x41-5A / %x5E-7E
376
+ *
377
+ * Essentially: any visible ASCII character except space and separators like ()[]{}:;,<>?=@\"
378
+ *
379
+ * @param scanner - Scanner instance
380
+ * @returns Parsed token string
381
+ * @throws ParseError if no valid token found
382
+ */
383
+ function parseToken(scanner) {
384
+ const start = scanner.getPosition();
385
+ const token = scanner.readWhile(isTokenChar);
386
+ if (token.length === 0) {
387
+ throw new errors_1.ParseError('Expected token', start.line, start.column);
388
+ }
389
+ return token;
390
+ }
391
+ // ============================================================================
392
+ // Integer Parsing
393
+ // ============================================================================
394
+ /**
395
+ * Parse an integer
396
+ *
397
+ * @param scanner - Scanner instance
398
+ * @returns Parsed integer
399
+ * @throws ParseError if no valid integer found
400
+ */
401
+ function parseInteger(scanner) {
402
+ const start = scanner.getPosition();
403
+ const digits = scanner.readWhile(isDigit);
404
+ if (digits.length === 0) {
405
+ throw new errors_1.ParseError('Expected integer', start.line, start.column);
406
+ }
407
+ const value = parseInt(digits, 10);
408
+ if (isNaN(value)) {
409
+ throw new errors_1.ParseError(`Invalid integer: ${digits}`, start.line, start.column);
410
+ }
411
+ return value;
412
+ }
413
+ /**
414
+ * Parse a byte (0-255)
415
+ *
416
+ * @param scanner - Scanner instance
417
+ * @returns Parsed byte value
418
+ * @throws ParseError if not a valid byte
419
+ */
420
+ function parseByte(scanner) {
421
+ const start = scanner.getPosition();
422
+ const value = parseInteger(scanner);
423
+ if (value < 0 || value > 255) {
424
+ throw new errors_1.ParseError(`Byte value out of range: ${value} (expected 0-255)`, start.line, start.column);
425
+ }
426
+ return value;
427
+ }
428
+ // ============================================================================
429
+ // String Parsing
430
+ // ============================================================================
431
+ /**
432
+ * Parse a non-whitespace string
433
+ *
434
+ * Reads characters until whitespace or end of line.
435
+ *
436
+ * @param scanner - Scanner instance
437
+ * @returns Parsed string
438
+ * @throws ParseError if empty
439
+ */
440
+ function parseNonWSString(scanner) {
441
+ const start = scanner.getPosition();
442
+ const str = scanner.readWhile(isNonWhitespace);
443
+ if (str.length === 0) {
444
+ throw new errors_1.ParseError('Expected non-whitespace string', start.line, start.column);
445
+ }
446
+ return str;
447
+ }
448
+ /**
449
+ * Parse a byte-string (RFC 8866 Section 9)
450
+ *
451
+ * byte-string = 1*(%x01-09 / %x0B-0C / %x0E-FF)
452
+ * (any byte except NUL, CR, LF)
453
+ *
454
+ * @param scanner - Scanner instance
455
+ * @returns Parsed byte-string
456
+ * @throws ParseError if invalid characters found
457
+ */
458
+ function parseByteString(scanner) {
459
+ const start = scanner.getPosition();
460
+ const str = scanner.readWhile(isByteStringChar);
461
+ if (str.length === 0) {
462
+ throw new errors_1.ParseError('Expected byte-string', start.line, start.column);
463
+ }
464
+ return str;
465
+ }
466
+ // ============================================================================
467
+ // IP Address Parsing
468
+ // ============================================================================
469
+ /**
470
+ * Parse an IPv4 address
471
+ *
472
+ * Format: a.b.c.d where each octet is 0-255
473
+ *
474
+ * @param scanner - Scanner instance
475
+ * @returns Parsed IPv4 address
476
+ * @throws ParseError if invalid IPv4 address
477
+ */
478
+ function parseIP4Address(scanner) {
479
+ const start = scanner.getPosition();
480
+ // Read until whitespace or special characters
481
+ const addr = scanner.readWhile(ch => isDigit(ch) || ch === '.');
482
+ if (!(0, address_parser_1.isIP4Address)(addr)) {
483
+ throw new errors_1.ParseError(`Invalid IPv4 address: ${addr}`, start.line, start.column);
484
+ }
485
+ return addr;
486
+ }
487
+ /**
488
+ * Parse an IPv6 address
489
+ *
490
+ * Supports all IPv6 formats including compression (::) and mixed IPv4
491
+ *
492
+ * @param scanner - Scanner instance
493
+ * @returns Parsed IPv6 address
494
+ * @throws ParseError if invalid IPv6 address
495
+ */
496
+ function parseIP6Address(scanner) {
497
+ const start = scanner.getPosition();
498
+ // Read until whitespace (IPv6 can contain : and . and hex digits)
499
+ const addr = scanner.readWhile(ch => isHexDigit(ch) || ch === ':' || ch === '.');
500
+ if (!(0, address_parser_1.isIP6Address)(addr)) {
501
+ throw new errors_1.ParseError(`Invalid IPv6 address: ${addr}`, start.line, start.column);
502
+ }
503
+ return addr;
504
+ }
505
+ /**
506
+ * Parse an FQDN (Fully Qualified Domain Name)
507
+ *
508
+ * @param scanner - Scanner instance
509
+ * @returns Parsed FQDN
510
+ * @throws ParseError if invalid FQDN
511
+ */
512
+ function parseFQDN(scanner) {
513
+ const start = scanner.getPosition();
514
+ // Read until whitespace (FQDN can contain alphanumeric, -, and .)
515
+ const fqdn = scanner.readWhile(ch => isAlphanumeric(ch) || ch === '-' || ch === '.');
516
+ if (!(0, address_parser_1.isFQDN)(fqdn)) {
517
+ throw new errors_1.ParseError(`Invalid FQDN: ${fqdn}`, start.line, start.column);
518
+ }
519
+ return fqdn;
520
+ }
521
+ // ============================================================================
522
+ // Time Parsing
523
+ // ============================================================================
524
+ /**
525
+ * Parse an NTP timestamp
526
+ *
527
+ * NTP time is seconds since January 1, 1900 00:00:00 UTC
528
+ * 0 means unbounded/permanent
529
+ *
530
+ * @param scanner - Scanner instance
531
+ * @returns Parsed NTP time
532
+ * @throws ParseError if invalid
533
+ */
534
+ function parseNtpTime(scanner) {
535
+ const start = scanner.getPosition();
536
+ const value = parseInteger(scanner);
537
+ if (value < 0) {
538
+ throw new errors_1.ParseError(`NTP time cannot be negative: ${value}`, start.line, start.column);
539
+ }
540
+ return value;
541
+ }
542
+ /**
543
+ * Parse a typed time value
544
+ *
545
+ * Format: [-]<integer>[d|h|m|s]
546
+ * - d = days
547
+ * - h = hours
548
+ * - m = minutes
549
+ * - s or no suffix = seconds
550
+ * - Can be negative (for timezone offsets)
551
+ *
552
+ * @param scanner - Scanner instance
553
+ * @returns Parsed typed time
554
+ * @throws ParseError if invalid
555
+ */
556
+ function parseTypedTime(scanner) {
557
+ const start = scanner.getPosition();
558
+ // Read optional minus sign, digits, and optional unit suffix
559
+ const timeStr = scanner.readWhile(ch => ch === '-' || isDigit(ch) || ch === 'd' || ch === 'h' || ch === 'm' || ch === 's');
560
+ try {
561
+ return (0, time_converter_1.parseTypedTime)(timeStr);
562
+ }
563
+ catch (error) {
564
+ throw new errors_1.ParseError(`Invalid typed time: ${timeStr}`, start.line, start.column);
565
+ }
566
+ }
567
+ // ============================================================================
568
+ // Email, Phone, and URI Parsing
569
+ // ============================================================================
570
+ /**
571
+ * Parse an email address or display name with email
572
+ *
573
+ * Format: email-address / displayable-name <email-address>
574
+ *
575
+ * @param scanner - Scanner instance
576
+ * @returns Parsed email string
577
+ */
578
+ function parseEmail(scanner) {
579
+ // Email can contain most characters, read until end of line
580
+ return scanner.readUntil(/[\r\n]/);
581
+ }
582
+ /**
583
+ * Parse a phone number or display name with phone
584
+ *
585
+ * Format: phone-number / displayable-name <phone-number>
586
+ *
587
+ * @param scanner - Scanner instance
588
+ * @returns Parsed phone string
589
+ */
590
+ function parsePhone(scanner) {
591
+ // Phone can contain most characters, read until end of line
592
+ return scanner.readUntil(/[\r\n]/);
593
+ }
594
+ /**
595
+ * Parse a URI
596
+ *
597
+ * @param scanner - Scanner instance
598
+ * @returns Parsed URI string
599
+ */
600
+ function parseURI(scanner) {
601
+ // URI can contain many characters, read until end of line
602
+ return scanner.readUntil(/[\r\n]/);
603
+ }
604
+ // ============================================================================
605
+ // Attribute Field Parser (a=)
606
+ // ============================================================================
607
+ /**
608
+ * Parse attribute field
609
+ *
610
+ * Format:
611
+ * - Property attribute: a=<attribute-name>
612
+ * - Value attribute: a=<attribute-name>:<attribute-value>
613
+ *
614
+ * Property attributes have no value (e.g., a=recvonly, a=sendrecv).
615
+ * Value attributes have a value after colon (e.g., a=rtpmap:0 PCMU/8000).
616
+ *
617
+ * @param scanner - Scanner instance
618
+ * @returns Attribute object
619
+ * @throws ParseError if invalid format
620
+ */
621
+ function parseAttributeField(scanner) {
622
+ // Parse attribute name (token up to : or end of line)
623
+ const name = parseToken(scanner);
624
+ // Check for colon indicating value attribute
625
+ const ch = scanner.peek();
626
+ if (ch === ':') {
627
+ // Value attribute
628
+ scanner.advance(); // consume ':'
629
+ // Read value until end of line
630
+ const value = scanner.readUntil(/[\r\n]/);
631
+ return (0, attributes_1.createValueAttribute)(name, value);
632
+ }
633
+ else {
634
+ // Property attribute (no value)
635
+ // Return predefined attributes for known types
636
+ switch (name) {
637
+ case 'recvonly':
638
+ return (0, attributes_1.createRecvonly)();
639
+ case 'sendrecv':
640
+ return (0, attributes_1.createSendrecv)();
641
+ case 'sendonly':
642
+ return (0, attributes_1.createSendonly)();
643
+ case 'inactive':
644
+ return (0, attributes_1.createInactive)();
645
+ default:
646
+ return (0, attributes_1.createPropertyAttribute)(name);
647
+ }
648
+ }
649
+ }
650
+ // ============================================================================
651
+ // Specialized Attribute Value Parsers
652
+ // ============================================================================
653
+ /**
654
+ * Parse rtpmap attribute value
655
+ *
656
+ * Format: <payload-type> <encoding-name>/<clock-rate>[/<encoding-params>]
657
+ *
658
+ * Examples:
659
+ * - "0 PCMU/8000"
660
+ * - "96 H264/90000"
661
+ * - "97 L16/8000/2" (2 channels)
662
+ *
663
+ * @param value - rtpmap attribute value string
664
+ * @returns Parsed RtpmapValue
665
+ * @throws ParseError if invalid format
666
+ */
667
+ function parseRtpmapValue(value) {
668
+ // Split into payload type and encoding spec
669
+ const parts = value.split(/\s+/);
670
+ if (parts.length !== 2) {
671
+ throw new errors_1.ParseError(`Invalid rtpmap format: expected "<payload-type> <encoding-spec>", got "${value}"`, 0, 0);
672
+ }
673
+ const payloadType = parseInt(parts[0], 10);
674
+ if (isNaN(payloadType) || payloadType < 0 || payloadType > 127) {
675
+ throw new errors_1.ParseError(`Invalid payload type: ${parts[0]} (expected 0-127)`, 0, 0);
676
+ }
677
+ // Parse encoding spec: <encoding-name>/<clock-rate>[/<encoding-params>]
678
+ const encodingParts = parts[1].split('/');
679
+ if (encodingParts.length < 2) {
680
+ throw new errors_1.ParseError(`Invalid encoding spec: expected "<encoding-name>/<clock-rate>", got "${parts[1]}"`, 0, 0);
681
+ }
682
+ const encodingName = encodingParts[0];
683
+ const clockRate = parseInt(encodingParts[1], 10);
684
+ if (isNaN(clockRate) || clockRate <= 0) {
685
+ throw new errors_1.ParseError(`Invalid clock rate: ${encodingParts[1]} (must be positive)`, 0, 0);
686
+ }
687
+ // Optional encoding parameters (e.g., number of audio channels)
688
+ let encodingParams;
689
+ if (encodingParts.length > 2) {
690
+ encodingParams = parseInt(encodingParts[2], 10);
691
+ if (isNaN(encodingParams) || encodingParams <= 0) {
692
+ throw new errors_1.ParseError(`Invalid encoding parameters: ${encodingParts[2]} (must be positive)`, 0, 0);
693
+ }
694
+ }
695
+ return {
696
+ payloadType,
697
+ encodingName,
698
+ clockRate,
699
+ encodingParams,
700
+ };
701
+ }
702
+ /**
703
+ * Parse fmtp attribute value
704
+ *
705
+ * Format: <format> <format-specific-params>
706
+ *
707
+ * Examples:
708
+ * - "96 profile-level-id=42e01f;packetization-mode=1"
709
+ * - "97 apt=96"
710
+ *
711
+ * @param value - fmtp attribute value string
712
+ * @returns Parsed FmtpValue
713
+ * @throws ParseError if invalid format
714
+ */
715
+ function parseFmtpValue(value) {
716
+ // Split on first whitespace
717
+ const spaceIndex = value.search(/\s/);
718
+ if (spaceIndex === -1) {
719
+ throw new errors_1.ParseError(`Invalid fmtp format: expected "<format> <parameters>", got "${value}"`, 0, 0);
720
+ }
721
+ const format = value.substring(0, spaceIndex);
722
+ const parameters = value.substring(spaceIndex + 1).trim();
723
+ if (parameters.length === 0) {
724
+ throw new errors_1.ParseError(`Invalid fmtp format: parameters cannot be empty`, 0, 0);
725
+ }
726
+ return {
727
+ format,
728
+ parameters,
729
+ };
730
+ }
731
+ /**
732
+ * Parse ptime attribute value
733
+ *
734
+ * Format: <packet-time-in-milliseconds>
735
+ *
736
+ * RFC 8866 Section 6.4: ptime-value = non-zero-int-or-real
737
+ * Decimal values are allowed (e.g., "20.5")
738
+ *
739
+ * @param value - ptime attribute value string
740
+ * @returns Packet time in milliseconds (can be fractional)
741
+ * @throws ParseError if invalid
742
+ */
743
+ function parsePtimeValue(value) {
744
+ const ptime = parseFloat(value);
745
+ if (isNaN(ptime) || ptime <= 0) {
746
+ throw new errors_1.ParseError(`Invalid ptime value: ${value} (must be positive number)`, 0, 0);
747
+ }
748
+ return ptime;
749
+ }
750
+ /**
751
+ * Parse maxptime attribute value
752
+ *
753
+ * Format: <max-packet-time-in-milliseconds>
754
+ *
755
+ * RFC 8866 Section 6.5: maxptime-value = non-zero-int-or-real
756
+ * Decimal values are allowed (e.g., "40.0")
757
+ *
758
+ * @param value - maxptime attribute value string
759
+ * @returns Maximum packet time in milliseconds (can be fractional)
760
+ * @throws ParseError if invalid
761
+ */
762
+ function parseMaxptimeValue(value) {
763
+ const maxptime = parseFloat(value);
764
+ if (isNaN(maxptime) || maxptime <= 0) {
765
+ throw new errors_1.ParseError(`Invalid maxptime value: ${value} (must be positive number)`, 0, 0);
766
+ }
767
+ return maxptime;
768
+ }
769
+ /**
770
+ * Parse framerate attribute value
771
+ *
772
+ * Format: <frames-per-second>
773
+ *
774
+ * @param value - framerate attribute value string
775
+ * @returns Frame rate (frames per second)
776
+ * @throws ParseError if invalid
777
+ */
778
+ function parseFramerateValue(value) {
779
+ const framerate = parseFloat(value);
780
+ if (isNaN(framerate) || framerate <= 0) {
781
+ throw new errors_1.ParseError(`Invalid framerate value: ${value} (must be positive number)`, 0, 0);
782
+ }
783
+ return framerate;
784
+ }
785
+ /**
786
+ * Parse quality attribute value
787
+ *
788
+ * Format: <quality-value>
789
+ *
790
+ * Quality is 0-10, where 10 is best quality.
791
+ *
792
+ * @param value - quality attribute value string
793
+ * @returns Quality value (0-10)
794
+ * @throws ParseError if invalid
795
+ */
796
+ function parseQualityValue(value) {
797
+ const quality = parseInt(value, 10);
798
+ if (isNaN(quality) || quality < 0 || quality > 10) {
799
+ throw new errors_1.ParseError(`Invalid quality value: ${value} (must be 0-10)`, 0, 0);
800
+ }
801
+ return quality;
802
+ }