@moqtap/trace 0.2.0 → 0.3.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.
package/src/binary.ts CHANGED
@@ -1,5 +1,20 @@
1
1
  import { Encoder } from 'cbor-x'
2
- import type { DetailLevel, Perspective, Trace, TraceEvent, TraceHeader } from './types.js'
2
+ import { cborItemLength, MalformedCborError } from './cbor-scan.js'
3
+ import type {
4
+ DerivationKind,
5
+ DetailLevel,
6
+ DropPolicy,
7
+ PeerRole,
8
+ Perspective,
9
+ SamplingInfo,
10
+ SegmentInfo,
11
+ Side,
12
+ SubscriptionRef,
13
+ Trace,
14
+ TraceEvent,
15
+ TraceHeader,
16
+ } from './types.js'
17
+ import { TRACE_ID_LENGTH } from './types.js'
3
18
 
4
19
  // Use a CBOR codec configured for cross-language interop.
5
20
  // Why: cbor-x's default `useRecords: true` enables a proprietary "record
@@ -8,19 +23,35 @@ import type { DetailLevel, Perspective, Trace, TraceEvent, TraceHeader } from '.
8
23
  // The .moqtrace spec requires standard CBOR, so we disable records and encode
9
24
  // as plain maps. `mapsAsObjects` is set so decoded maps come back as plain
10
25
  // JS objects (matching the code that accesses fields via `obj.protocol`).
11
- const codec = new Encoder({ useRecords: false, mapsAsObjects: true })
26
+ // `tagUint8Array: false` is the second half of the same requirement: by
27
+ // default cbor-x wraps every Uint8Array in tag 64 (RFC 8746 'uint8 typed
28
+ // array'), so a byte string is a tagged item rather than major type 2. A
29
+ // decoder reading the plain form sees no bytes at all — which silently emptied
30
+ // every payload-bearing field this package wrote: raw wire bytes, object
31
+ // payloads, track names, trace ids. Readers still accept the tagged form,
32
+ // because files carrying it already exist.
33
+ const codec = new Encoder({ useRecords: false, mapsAsObjects: true, tagUint8Array: false })
12
34
  const encode = (value: unknown): Uint8Array => codec.encode(value)
13
35
  const decode = (bytes: Uint8Array): unknown => codec.decode(bytes)
14
- const decodeMultiple = (bytes: Uint8Array, cb: (value: unknown) => void): void => {
15
- codec.decodeMultiple(bytes, cb as (value: unknown) => boolean | undefined)
16
- }
17
36
 
18
37
  const MAGIC = new Uint8Array([0x4d, 0x4f, 0x51, 0x54, 0x52, 0x41, 0x43, 0x45]) // "MOQTRACE"
19
- const FORMAT_VERSION = 1
38
+
39
+ /** Format version this package writes. */
40
+ export const FORMAT_VERSION = 2
41
+
42
+ /**
43
+ * Format versions this package reads.
44
+ *
45
+ * Version 1 is a non-segmented version 2 trace carrying none of the keys
46
+ * version 2 added, so reading it costs nothing and keeps every capture taken
47
+ * before the bump openable.
48
+ */
49
+ export const SUPPORTED_VERSIONS: readonly number[] = [1, FORMAT_VERSION]
50
+
20
51
  const PREAMBLE_SIZE = 16 // 8 magic + 4 version + 4 header length
21
52
 
22
53
  // Event type string ↔ integer mapping
23
- const EVENT_TYPE_TO_INT: Record<TraceEvent['type'], number> = {
54
+ const EVENT_TYPE_TO_INT: Record<Exclude<TraceEvent['type'], 'unknown'>, number> = {
24
55
  control: 0,
25
56
  'stream-opened': 1,
26
57
  'stream-closed': 2,
@@ -29,9 +60,12 @@ const EVENT_TYPE_TO_INT: Record<TraceEvent['type'], number> = {
29
60
  'state-change': 5,
30
61
  error: 6,
31
62
  annotation: 7,
63
+ 'peer-connected': 8,
64
+ 'peer-disconnected': 9,
65
+ 'subscription-derivation': 10,
32
66
  }
33
67
 
34
- const INT_TO_EVENT_TYPE: Record<number, TraceEvent['type']> = {
68
+ const INT_TO_EVENT_TYPE: Record<number, Exclude<TraceEvent['type'], 'unknown'>> = {
35
69
  0: 'control',
36
70
  1: 'stream-opened',
37
71
  2: 'stream-closed',
@@ -40,22 +74,97 @@ const INT_TO_EVENT_TYPE: Record<number, TraceEvent['type']> = {
40
74
  5: 'state-change',
41
75
  6: 'error',
42
76
  7: 'annotation',
77
+ 8: 'peer-connected',
78
+ 9: 'peer-disconnected',
79
+ 10: 'subscription-derivation',
80
+ }
81
+
82
+ /** Keys the common event fields own; everything else belongs to the variant. */
83
+ const COMMON_EVENT_KEYS = new Set(['n', 't', 'p', 'e'])
84
+
85
+ /** The stream ended part-way through an item — the file was truncated. */
86
+ export class TruncatedTraceError extends Error {
87
+ /** Byte offset at which the incomplete item begins. */
88
+ readonly offset: number
89
+ /**
90
+ * Everything that decoded before the cut, one entry per segment. A
91
+ * truncated trace is still evidence, and the events before the cut are
92
+ * exactly as valid as they were.
93
+ */
94
+ readonly segments: Trace[]
95
+
96
+ constructor(offset: number, segments: Trace[]) {
97
+ super(`Trace truncated: the item starting at byte ${offset} is incomplete`)
98
+ this.name = 'TruncatedTraceError'
99
+ this.offset = offset
100
+ this.segments = segments
101
+ }
102
+
103
+ /** What decoded, flattened as {@link readMoqtrace} would return it. */
104
+ get trace(): Trace | undefined {
105
+ return flatten(this.segments)
106
+ }
43
107
  }
44
108
 
45
109
  // --- Header serialization ---
46
110
 
111
+ function segmentToCbor(segment: SegmentInfo): Record<string, unknown> {
112
+ const map: Record<string, unknown> = { sequence: int(segment.sequence) }
113
+ if (segment.durationMs != null) map.durationMs = int(segment.durationMs)
114
+ if (segment.streamId != null) map.streamId = segment.streamId
115
+ if (segment.continues != null) map.continues = segment.continues
116
+ return map
117
+ }
118
+
119
+ function cborToSegment(obj: Record<string, unknown>): SegmentInfo {
120
+ return {
121
+ sequence: Number(obj.sequence ?? 0),
122
+ ...(obj.durationMs != null ? { durationMs: Number(obj.durationMs) } : {}),
123
+ ...(obj.streamId != null ? { streamId: obj.streamId as string } : {}),
124
+ ...(obj.continues != null ? { continues: obj.continues as boolean } : {}),
125
+ }
126
+ }
127
+
128
+ function samplingToCbor(sampling: SamplingInfo): Record<string, unknown> {
129
+ const map: Record<string, unknown> = {}
130
+ if (sampling.effectiveRate != null) map.effectiveRate = sampling.effectiveRate
131
+ if (sampling.maxEventsPerSec != null) map.maxEventsPerSec = int(sampling.maxEventsPerSec)
132
+ if (sampling.dropPolicy != null) map.dropPolicy = sampling.dropPolicy
133
+ if (sampling.droppedTotal != null) map.droppedTotal = int(sampling.droppedTotal)
134
+ if (sampling.droppedSegment != null) map.droppedSegment = int(sampling.droppedSegment)
135
+ if (sampling.rule != null) map.rule = sampling.rule
136
+ if (sampling.ruleLang != null) map.ruleLang = sampling.ruleLang
137
+ if (sampling.appliesTo != null) map.appliesTo = sampling.appliesTo.map(int)
138
+ return map
139
+ }
140
+
141
+ function cborToSampling(obj: Record<string, unknown>): SamplingInfo {
142
+ return {
143
+ ...(obj.effectiveRate != null ? { effectiveRate: Number(obj.effectiveRate) } : {}),
144
+ ...(obj.maxEventsPerSec != null ? { maxEventsPerSec: Number(obj.maxEventsPerSec) } : {}),
145
+ ...(obj.dropPolicy != null ? { dropPolicy: obj.dropPolicy as DropPolicy } : {}),
146
+ ...(obj.droppedTotal != null ? { droppedTotal: Number(obj.droppedTotal) } : {}),
147
+ ...(obj.droppedSegment != null ? { droppedSegment: Number(obj.droppedSegment) } : {}),
148
+ ...(obj.rule != null ? { rule: obj.rule as string } : {}),
149
+ ...(obj.ruleLang != null ? { ruleLang: obj.ruleLang as string } : {}),
150
+ ...(obj.appliesTo != null ? { appliesTo: (obj.appliesTo as number[]).map(Number) } : {}),
151
+ }
152
+ }
153
+
47
154
  function headerToCbor(header: TraceHeader): Record<string, unknown> {
48
155
  const map: Record<string, unknown> = {
49
156
  protocol: header.protocol,
50
157
  perspective: header.perspective,
51
158
  detail: header.detail,
52
- startTime: header.startTime,
159
+ startTime: int(header.startTime),
53
160
  }
54
- if (header.endTime != null) map.endTime = header.endTime
161
+ if (header.endTime != null) map.endTime = int(header.endTime)
55
162
  if (header.transport != null) map.transport = header.transport
56
163
  if (header.source != null) map.source = header.source
57
164
  if (header.endpoint != null) map.endpoint = header.endpoint
58
165
  if (header.sessionId != null) map.sessionId = header.sessionId
166
+ if (header.segment != null) map.segment = segmentToCbor(header.segment)
167
+ if (header.sampling != null) map.sampling = samplingToCbor(header.sampling)
59
168
  if (header.custom != null) map.custom = header.custom
60
169
  return map
61
170
  }
@@ -65,29 +174,90 @@ function cborToHeader(obj: Record<string, unknown>): TraceHeader {
65
174
  protocol: obj.protocol as string,
66
175
  perspective: obj.perspective as Perspective,
67
176
  detail: obj.detail as DetailLevel,
68
- startTime: obj.startTime as number,
69
- ...(obj.endTime != null ? { endTime: obj.endTime as number } : {}),
177
+ startTime: Number(obj.startTime),
178
+ ...(obj.endTime != null ? { endTime: Number(obj.endTime) } : {}),
70
179
  ...(obj.transport != null ? { transport: obj.transport as string } : {}),
71
180
  ...(obj.source != null ? { source: obj.source as string } : {}),
72
181
  ...(obj.endpoint != null ? { endpoint: obj.endpoint as string } : {}),
73
182
  ...(obj.sessionId != null ? { sessionId: obj.sessionId as string } : {}),
183
+ ...(obj.segment != null
184
+ ? { segment: cborToSegment(obj.segment as Record<string, unknown>) }
185
+ : {}),
186
+ ...(obj.sampling != null
187
+ ? { sampling: cborToSampling(obj.sampling as Record<string, unknown>) }
188
+ : {}),
74
189
  ...(obj.custom != null ? { custom: obj.custom as Record<string, unknown> } : {}),
75
190
  }
76
191
  }
77
192
 
78
193
  // --- Event serialization ---
79
194
 
195
+ function subscriptionRefToCbor(ref: SubscriptionRef): [string, bigint] {
196
+ return [ref.peer, ref.requestId]
197
+ }
198
+
199
+ function cborToSubscriptionRef(value: unknown): SubscriptionRef {
200
+ if (!Array.isArray(value) || value.length !== 2) {
201
+ throw new Error('Subscription reference must be [peer, requestId]')
202
+ }
203
+ return { peer: value[0] as string, requestId: BigInt(value[1] as bigint | number) }
204
+ }
205
+
206
+ /**
207
+ * Normalise a decoded byte string to a plain `Uint8Array` that owns its bytes.
208
+ *
209
+ * The decoder hands back a Node `Buffer` on one platform and a `Uint8Array` on
210
+ * another, and either may be a view onto the buffer being read — so without
211
+ * the copy, a payload would alias the caller's input and change under it.
212
+ */
213
+ function toBytes(value: unknown): Uint8Array {
214
+ return value instanceof Uint8Array
215
+ ? new Uint8Array(value)
216
+ : new Uint8Array(value as ArrayLike<number>)
217
+ }
218
+
219
+ /**
220
+ * Write an integral value as a CBOR integer rather than a float.
221
+ *
222
+ * cbor-x encodes any JS number past 32 bits as a float64, which every
223
+ * epoch-millisecond timestamp is. That is legal CBOR, but it means the two
224
+ * implementations of this format wrote different major types for the same
225
+ * value — and a decoder reading only integers found `startTime` missing and
226
+ * rejected the file. Handing the encoder a BigInt gets an integer out.
227
+ */
228
+ function int(value: number): number | bigint {
229
+ const outsideInt32 = value > 0xffffffff || value < -0x80000000
230
+ return Number.isInteger(value) && outsideInt32 ? BigInt(value) : value
231
+ }
232
+
233
+ /**
234
+ * Read a trace id, refusing any length but the one the format fixes.
235
+ *
236
+ * A wrong-length value is malformed rather than something to pad or truncate:
237
+ * the identifier exists so two implementations independently derive
238
+ * byte-identical values for one subscription chain, and a reader that quietly
239
+ * reshapes it breaks exactly the property it is there for.
240
+ */
241
+ function checkTraceId(value: unknown): Uint8Array {
242
+ const bytes = toBytes(value)
243
+ if (bytes.length !== TRACE_ID_LENGTH) {
244
+ throw new Error(`traceId is ${bytes.length} bytes, must be ${TRACE_ID_LENGTH}`)
245
+ }
246
+ return bytes
247
+ }
248
+
80
249
  function eventToCbor(event: TraceEvent): Record<string, unknown> {
81
250
  const base: Record<string, unknown> = {
82
- n: event.seq,
83
- t: event.timestamp,
84
- e: EVENT_TYPE_TO_INT[event.type],
251
+ n: int(event.seq),
252
+ t: int(event.timestamp),
85
253
  }
254
+ if (event.peer != null) base.p = event.peer
255
+ if (event.type !== 'unknown') base.e = EVENT_TYPE_TO_INT[event.type]
86
256
 
87
257
  switch (event.type) {
88
258
  case 'control': {
89
259
  base.d = event.direction
90
- base.mt = event.messageType
260
+ base.mt = int(event.messageType)
91
261
  base.msg = event.message
92
262
  if (event.streamId != null) base.sid = event.streamId
93
263
  if (event.raw != null) base.raw = event.raw
@@ -101,22 +271,22 @@ function eventToCbor(event: TraceEvent): Record<string, unknown> {
101
271
  }
102
272
  case 'stream-closed': {
103
273
  base.sid = event.streamId
104
- base.ec = event.errorCode
274
+ base.ec = int(event.errorCode)
105
275
  break
106
276
  }
107
277
  case 'object-header': {
108
278
  base.sid = event.streamId
109
279
  base.g = event.groupId
110
280
  base.o = event.objectId
111
- base.pp = event.publisherPriority
112
- base.os = event.objectStatus
281
+ base.pp = int(event.publisherPriority)
282
+ base.os = int(event.objectStatus)
113
283
  break
114
284
  }
115
285
  case 'object-payload': {
116
286
  base.sid = event.streamId
117
287
  base.g = event.groupId
118
288
  base.o = event.objectId
119
- base.sz = event.size
289
+ base.sz = int(event.size)
120
290
  if (event.payload != null) base.pl = event.payload
121
291
  break
122
292
  }
@@ -126,7 +296,7 @@ function eventToCbor(event: TraceEvent): Record<string, unknown> {
126
296
  break
127
297
  }
128
298
  case 'error': {
129
- base.ec = event.errorCode
299
+ base.ec = int(event.errorCode)
130
300
  base.reason = event.reason
131
301
  break
132
302
  }
@@ -135,38 +305,80 @@ function eventToCbor(event: TraceEvent): Record<string, unknown> {
135
305
  if (event.data !== undefined) base.data = event.data
136
306
  break
137
307
  }
308
+ case 'peer-connected': {
309
+ if (event.endpoint != null) base.endpoint = event.endpoint
310
+ if (event.transport != null) base.transport = event.transport
311
+ if (event.role != null) base.role = event.role
312
+ if (event.side != null) base.side = event.side
313
+ break
314
+ }
315
+ case 'peer-disconnected': {
316
+ base.ec = int(event.errorCode)
317
+ if (event.reason != null) base.reason = event.reason
318
+ break
319
+ }
320
+ case 'subscription-derivation': {
321
+ base.u = subscriptionRefToCbor(event.upstream)
322
+ base.d = event.downstream.map(subscriptionRefToCbor)
323
+ base.kind = event.kind
324
+ if (event.traceId != null) base.traceId = checkTraceId(event.traceId)
325
+ if (event.namespace != null) base.ns = event.namespace
326
+ if (event.trackName != null) base.tn = event.trackName
327
+ if (event.tDownstreamReceived != null) base.tdr = int(event.tDownstreamReceived)
328
+ if (event.tUpstreamSent != null) base.tus = int(event.tUpstreamSent)
329
+ if (event.tUpstreamOkReceived != null) base.tuo = int(event.tUpstreamOkReceived)
330
+ if (event.tDownstreamOkSent != null) base.tdo = int(event.tDownstreamOkSent)
331
+ break
332
+ }
333
+ case 'unknown': {
334
+ base.e = event.eventType
335
+ for (const [key, value] of Object.entries(event.fields)) {
336
+ base[key] = value
337
+ }
338
+ break
339
+ }
138
340
  }
139
341
 
140
342
  return base
141
343
  }
142
344
 
143
345
  function cborToEvent(obj: Record<string, unknown>): TraceEvent {
346
+ const seq = Number(obj.n ?? 0)
347
+ const timestamp = Number(obj.t ?? 0)
348
+ const peerFields = obj.p != null ? { peer: obj.p as string } : {}
144
349
  const eventType = INT_TO_EVENT_TYPE[obj.e as number]
350
+
145
351
  if (eventType == null) {
146
- // Unknown event type — skip per spec (return annotation as fallback)
352
+ // New event types may be added without a version bump. Dropping one — or
353
+ // relabelling it as an annotation, which an earlier revision of this file
354
+ // did — makes this reader's ignorance permanent for every reader
355
+ // downstream of it, because a read-modify-write then rewrites the type.
356
+ const fields: Record<string, unknown> = {}
357
+ for (const [key, value] of Object.entries(obj)) {
358
+ if (!COMMON_EVENT_KEYS.has(key)) fields[key] = value
359
+ }
147
360
  return {
148
- type: 'annotation',
149
- seq: Number(obj.n ?? 0),
150
- timestamp: Number(obj.t ?? 0),
151
- label: `unknown-event-${obj.e}`,
152
- data: obj,
361
+ type: 'unknown' as const,
362
+ seq,
363
+ timestamp,
364
+ ...peerFields,
365
+ eventType: Number(obj.e ?? -1),
366
+ fields,
153
367
  }
154
368
  }
155
369
 
156
- const seq = Number(obj.n ?? 0)
157
- const timestamp = Number(obj.t ?? 0)
158
-
159
370
  switch (eventType) {
160
371
  case 'control':
161
372
  return {
162
373
  type: 'control' as const,
163
374
  seq,
164
375
  timestamp,
376
+ ...peerFields,
165
377
  direction: obj.d as 0 | 1,
166
378
  messageType: Number(obj.mt ?? 0),
167
379
  message: (obj.msg ?? {}) as Record<string, unknown>,
168
380
  ...(obj.sid != null ? { streamId: BigInt(obj.sid as bigint | number) } : {}),
169
- ...(obj.raw != null ? { raw: obj.raw as Uint8Array } : {}),
381
+ ...(obj.raw != null ? { raw: toBytes(obj.raw) } : {}),
170
382
  }
171
383
 
172
384
  case 'stream-opened':
@@ -174,6 +386,7 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
174
386
  type: 'stream-opened' as const,
175
387
  seq,
176
388
  timestamp,
389
+ ...peerFields,
177
390
  streamId: BigInt(obj.sid as bigint | number),
178
391
  direction: obj.d as 0 | 1,
179
392
  streamType: obj.st as 0 | 1 | 2,
@@ -184,6 +397,7 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
184
397
  type: 'stream-closed' as const,
185
398
  seq,
186
399
  timestamp,
400
+ ...peerFields,
187
401
  streamId: BigInt(obj.sid as bigint | number),
188
402
  errorCode: Number(obj.ec ?? 0),
189
403
  }
@@ -193,6 +407,7 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
193
407
  type: 'object-header' as const,
194
408
  seq,
195
409
  timestamp,
410
+ ...peerFields,
196
411
  streamId: BigInt(obj.sid as bigint | number),
197
412
  groupId: BigInt(obj.g as bigint | number),
198
413
  objectId: BigInt(obj.o as bigint | number),
@@ -205,11 +420,12 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
205
420
  type: 'object-payload' as const,
206
421
  seq,
207
422
  timestamp,
423
+ ...peerFields,
208
424
  streamId: BigInt(obj.sid as bigint | number),
209
425
  groupId: BigInt(obj.g as bigint | number),
210
426
  objectId: BigInt(obj.o as bigint | number),
211
427
  size: Number(obj.sz ?? 0),
212
- ...(obj.pl != null ? { payload: obj.pl as Uint8Array } : {}),
428
+ ...(obj.pl != null ? { payload: toBytes(obj.pl) } : {}),
213
429
  }
214
430
 
215
431
  case 'state-change':
@@ -217,6 +433,7 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
217
433
  type: 'state-change' as const,
218
434
  seq,
219
435
  timestamp,
436
+ ...peerFields,
220
437
  from: obj.from as string,
221
438
  to: obj.to as string,
222
439
  }
@@ -226,6 +443,7 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
226
443
  type: 'error' as const,
227
444
  seq,
228
445
  timestamp,
446
+ ...peerFields,
229
447
  errorCode: Number(obj.ec ?? 0),
230
448
  reason: (obj.reason ?? '') as string,
231
449
  }
@@ -235,9 +453,50 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
235
453
  type: 'annotation' as const,
236
454
  seq,
237
455
  timestamp,
456
+ ...peerFields,
238
457
  label: (obj.label ?? '') as string,
239
458
  data: obj.data,
240
459
  }
460
+
461
+ case 'peer-connected':
462
+ return {
463
+ type: 'peer-connected' as const,
464
+ seq,
465
+ timestamp,
466
+ ...peerFields,
467
+ ...(obj.endpoint != null ? { endpoint: obj.endpoint as string } : {}),
468
+ ...(obj.transport != null ? { transport: obj.transport as string } : {}),
469
+ ...(obj.role != null ? { role: obj.role as PeerRole } : {}),
470
+ ...(obj.side != null ? { side: obj.side as Side } : {}),
471
+ }
472
+
473
+ case 'peer-disconnected':
474
+ return {
475
+ type: 'peer-disconnected' as const,
476
+ seq,
477
+ timestamp,
478
+ ...peerFields,
479
+ errorCode: Number(obj.ec ?? 0),
480
+ ...(obj.reason != null ? { reason: obj.reason as string } : {}),
481
+ }
482
+
483
+ case 'subscription-derivation':
484
+ return {
485
+ type: 'subscription-derivation' as const,
486
+ seq,
487
+ timestamp,
488
+ ...peerFields,
489
+ upstream: cborToSubscriptionRef(obj.u),
490
+ downstream: ((obj.d ?? []) as unknown[]).map(cborToSubscriptionRef),
491
+ kind: (obj.kind ?? '') as DerivationKind,
492
+ ...(obj.traceId != null ? { traceId: checkTraceId(obj.traceId) } : {}),
493
+ ...(obj.ns != null ? { namespace: (obj.ns as unknown[]).map(toBytes) } : {}),
494
+ ...(obj.tn != null ? { trackName: toBytes(obj.tn) } : {}),
495
+ ...(obj.tdr != null ? { tDownstreamReceived: Number(obj.tdr) } : {}),
496
+ ...(obj.tus != null ? { tUpstreamSent: Number(obj.tus) } : {}),
497
+ ...(obj.tuo != null ? { tUpstreamOkReceived: Number(obj.tuo) } : {}),
498
+ ...(obj.tdo != null ? { tDownstreamOkSent: Number(obj.tdo) } : {}),
499
+ }
241
500
  }
242
501
  }
243
502
 
@@ -253,34 +512,132 @@ function writePreamble(headerCbor: Uint8Array): Uint8Array {
253
512
  return buf
254
513
  }
255
514
 
256
- function validatePreamble(bytes: Uint8Array): {
515
+ function startsWithMagic(bytes: Uint8Array, offset: number): boolean {
516
+ if (offset + MAGIC.length > bytes.length) return false
517
+ for (let i = 0; i < MAGIC.length; i++) {
518
+ if (bytes[offset + i] !== MAGIC[i]) return false
519
+ }
520
+ return true
521
+ }
522
+
523
+ function validatePreamble(
524
+ bytes: Uint8Array,
525
+ offset = 0,
526
+ ): {
257
527
  version: number
258
528
  headerLength: number
259
529
  } {
260
- if (bytes.length < PREAMBLE_SIZE) {
530
+ if (bytes.length - offset < PREAMBLE_SIZE) {
261
531
  throw new Error('File too short: expected at least 16 bytes')
262
532
  }
263
533
 
264
- for (let i = 0; i < 8; i++) {
265
- if (bytes[i] !== MAGIC[i]) {
266
- throw new Error('Invalid magic bytes: not a .moqtrace file')
267
- }
534
+ if (!startsWithMagic(bytes, offset)) {
535
+ throw new Error('Invalid magic bytes: not a .moqtrace file')
268
536
  }
269
537
 
270
538
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
271
- const version = view.getUint32(8, true)
272
- if (version !== FORMAT_VERSION) {
273
- throw new Error(`Unsupported format version: ${version} (expected ${FORMAT_VERSION})`)
539
+ const version = view.getUint32(offset + 8, true)
540
+ if (!SUPPORTED_VERSIONS.includes(version)) {
541
+ throw new Error(
542
+ `Unsupported format version: ${version} (supported: ${SUPPORTED_VERSIONS.join(', ')})`,
543
+ )
274
544
  }
275
545
 
276
- const headerLength = view.getUint32(12, true)
277
- if (PREAMBLE_SIZE + headerLength > bytes.length) {
546
+ const headerLength = view.getUint32(offset + 12, true)
547
+ if (offset + PREAMBLE_SIZE + headerLength > bytes.length) {
278
548
  throw new Error('File truncated: header extends beyond file')
279
549
  }
280
550
 
281
551
  return { version, headerLength }
282
552
  }
283
553
 
554
+ // --- Reading ---
555
+
556
+ /** Options shared by the readers. */
557
+ export interface ReadOptions {
558
+ /**
559
+ * On a truncated or corrupt region, skip forward to the next segment and
560
+ * carry on instead of throwing.
561
+ *
562
+ * This is the recovery segmentation exists to offer: one damaged segment
563
+ * costs that segment rather than the rest of the capture. It can only help
564
+ * a segmented trace — there is nothing to resynchronize to in a single
565
+ * segment, so a damaged one still ends the read, just without an error.
566
+ */
567
+ readonly recover?: boolean
568
+ }
569
+
570
+ function flatten(segments: Trace[]): Trace | undefined {
571
+ const first = segments[0]
572
+ if (first == null) return undefined
573
+ if (segments.length === 1) return first
574
+ return { header: first.header, events: segments.flatMap((segment) => segment.events) }
575
+ }
576
+
577
+ /** Scan forward for the next segment preamble; -1 if there is none. */
578
+ function findNextSegment(bytes: Uint8Array, from: number): number {
579
+ for (let offset = from; offset + MAGIC.length <= bytes.length; offset++) {
580
+ if (startsWithMagic(bytes, offset)) return offset
581
+ }
582
+ return -1
583
+ }
584
+
585
+ function readSegments(bytes: Uint8Array, options?: ReadOptions): Trace[] {
586
+ const recover = options?.recover ?? false
587
+ const segments: Trace[] = []
588
+ let events: TraceEvent[] = []
589
+ let offset = 0
590
+
591
+ // The first thing in the buffer must be a preamble; failing that, the
592
+ // errors below name why rather than reporting a stray byte.
593
+ validatePreamble(bytes, 0)
594
+
595
+ while (offset < bytes.length) {
596
+ // Checked only here, at a real item boundary. The same eight bytes inside
597
+ // a captured payload are not a segment start, and treating them as one
598
+ // would split a trace that was never segmented.
599
+ if (startsWithMagic(bytes, offset)) {
600
+ const { headerLength } = validatePreamble(bytes, offset)
601
+ const start = offset + PREAMBLE_SIZE
602
+ const headerBytes = bytes.subarray(start, start + headerLength)
603
+ events = []
604
+ segments.push({
605
+ header: cborToHeader(decode(headerBytes) as Record<string, unknown>),
606
+ events,
607
+ })
608
+ offset = start + headerLength
609
+ continue
610
+ }
611
+
612
+ let length: number | null
613
+ try {
614
+ length = cborItemLength(bytes, offset)
615
+ } catch (error) {
616
+ if (!recover || !(error instanceof MalformedCborError)) throw error
617
+ length = null
618
+ }
619
+
620
+ if (length != null) {
621
+ try {
622
+ const item = decode(bytes.subarray(offset, offset + length))
623
+ events.push(cborToEvent(item as Record<string, unknown>))
624
+ offset += length
625
+ continue
626
+ } catch (error) {
627
+ if (!recover) throw error
628
+ }
629
+ } else if (!recover) {
630
+ throw new TruncatedTraceError(offset, segments)
631
+ }
632
+
633
+ const next = findNextSegment(bytes, offset + 1)
634
+ if (next < 0) return segments
635
+ offset = next
636
+ }
637
+
638
+ return segments
639
+ }
640
+
284
641
  // --- Public API ---
285
642
 
286
643
  /**
@@ -310,32 +667,69 @@ export function writeMoqtrace(trace: Trace): Uint8Array {
310
667
  }
311
668
 
312
669
  /**
313
- * Read a complete .moqtrace file from bytes.
670
+ * Write several segments back to back as one segmented `.moqtrace` stream.
671
+ *
672
+ * Every segment header must carry {@link SegmentInfo}. That field is what
673
+ * tells a reader the sequence numbers and timestamps it is about to see
674
+ * restart at zero; a segmented stream written without it reads as a run of
675
+ * complete files whose timeline jumps backwards at every boundary, and
676
+ * nothing downstream can detect that it happened.
314
677
  */
315
- export function readMoqtrace(bytes: Uint8Array): Trace {
316
- const { headerLength } = validatePreamble(bytes)
317
-
318
- const headerBytes = bytes.slice(PREAMBLE_SIZE, PREAMBLE_SIZE + headerLength)
319
- const header = cborToHeader(decode(headerBytes) as Record<string, unknown>)
678
+ export function writeMoqtraceSegments(segments: Trace[]): Uint8Array {
679
+ const chunks = segments.map((segment, index) => {
680
+ if (segment.header.segment == null) {
681
+ throw new Error(`Segment ${index} has no 'segment' metadata in its header`)
682
+ }
683
+ return writeMoqtrace(segment)
684
+ })
320
685
 
321
- const eventBytes = bytes.slice(PREAMBLE_SIZE + headerLength)
322
- const events: TraceEvent[] = []
686
+ const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0)
687
+ const result = new Uint8Array(total)
688
+ let offset = 0
689
+ for (const chunk of chunks) {
690
+ result.set(chunk, offset)
691
+ offset += chunk.length
692
+ }
693
+ return result
694
+ }
323
695
 
324
- if (eventBytes.length > 0) {
325
- decodeMultiple(eventBytes, (obj: unknown) => {
326
- events.push(cborToEvent(obj as Record<string, unknown>))
327
- })
696
+ /**
697
+ * Read a complete .moqtrace file from bytes.
698
+ *
699
+ * A segmented file is flattened: the returned header is the first segment's,
700
+ * and the events of every segment follow in order. Use
701
+ * {@link readMoqtraceSegments} to see the boundaries — you need them to order
702
+ * events across segments, since `seq` and `timestamp` restart in each.
703
+ *
704
+ * @throws {TruncatedTraceError} if the file stops part-way through an item.
705
+ * The error carries everything that decoded before the cut.
706
+ */
707
+ export function readMoqtrace(bytes: Uint8Array, options?: ReadOptions): Trace {
708
+ const trace = flatten(readSegments(bytes, options))
709
+ if (trace == null) {
710
+ throw new Error('Invalid magic bytes: not a .moqtrace file')
328
711
  }
712
+ return trace
713
+ }
329
714
 
330
- return { header, events }
715
+ /**
716
+ * Read a .moqtrace file as its segments, one entry per segment.
717
+ *
718
+ * A non-segmented file yields exactly one entry, whose header carries no
719
+ * `segment` field.
720
+ */
721
+ export function readMoqtraceSegments(bytes: Uint8Array, options?: ReadOptions): Trace[] {
722
+ return readSegments(bytes, options)
331
723
  }
332
724
 
333
725
  /**
334
726
  * Read only the header from a .moqtrace file (fast metadata peek).
727
+ *
728
+ * In a segmented file this is the first segment's header.
335
729
  */
336
730
  export function readMoqtraceHeader(bytes: Uint8Array): TraceHeader {
337
731
  const { headerLength } = validatePreamble(bytes)
338
- const headerBytes = bytes.slice(PREAMBLE_SIZE, PREAMBLE_SIZE + headerLength)
732
+ const headerBytes = bytes.subarray(PREAMBLE_SIZE, PREAMBLE_SIZE + headerLength)
339
733
  return cborToHeader(decode(headerBytes) as Record<string, unknown>)
340
734
  }
341
735
 
@@ -347,6 +741,16 @@ export interface MoqtraceWriter {
347
741
  preamble(): Uint8Array
348
742
  /** Encode a single event. Append the returned bytes after the preamble. */
349
743
  writeEvent(event: TraceEvent): Uint8Array
744
+ /**
745
+ * Begin a new segment: returns a fresh preamble to append after the
746
+ * previous segment's last event.
747
+ *
748
+ * The header must carry {@link SegmentInfo} — see
749
+ * {@link writeMoqtraceSegments} for why. It should keep the same
750
+ * `protocol`, `perspective`, `detail` and `sessionId` as the segments
751
+ * before it, and increment `segment.sequence`.
752
+ */
753
+ startSegment(header: TraceHeader): Uint8Array
350
754
  }
351
755
 
352
756
  export function createMoqtraceWriter(header: TraceHeader): MoqtraceWriter {
@@ -360,5 +764,11 @@ export function createMoqtraceWriter(header: TraceHeader): MoqtraceWriter {
360
764
  writeEvent(event: TraceEvent): Uint8Array {
361
765
  return encode(eventToCbor(event))
362
766
  },
767
+ startSegment(next: TraceHeader): Uint8Array {
768
+ if (next.segment == null) {
769
+ throw new Error("A segment header must carry 'segment' metadata")
770
+ }
771
+ return writePreamble(encode(headerToCbor(next)))
772
+ },
363
773
  }
364
774
  }