@moqtap/trace 0.1.3 → 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,30 +174,92 @@ 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
262
+ if (event.streamId != null) base.sid = event.streamId
92
263
  if (event.raw != null) base.raw = event.raw
93
264
  break
94
265
  }
@@ -100,22 +271,22 @@ function eventToCbor(event: TraceEvent): Record<string, unknown> {
100
271
  }
101
272
  case 'stream-closed': {
102
273
  base.sid = event.streamId
103
- base.ec = event.errorCode
274
+ base.ec = int(event.errorCode)
104
275
  break
105
276
  }
106
277
  case 'object-header': {
107
278
  base.sid = event.streamId
108
279
  base.g = event.groupId
109
280
  base.o = event.objectId
110
- base.pp = event.publisherPriority
111
- base.os = event.objectStatus
281
+ base.pp = int(event.publisherPriority)
282
+ base.os = int(event.objectStatus)
112
283
  break
113
284
  }
114
285
  case 'object-payload': {
115
286
  base.sid = event.streamId
116
287
  base.g = event.groupId
117
288
  base.o = event.objectId
118
- base.sz = event.size
289
+ base.sz = int(event.size)
119
290
  if (event.payload != null) base.pl = event.payload
120
291
  break
121
292
  }
@@ -125,7 +296,7 @@ function eventToCbor(event: TraceEvent): Record<string, unknown> {
125
296
  break
126
297
  }
127
298
  case 'error': {
128
- base.ec = event.errorCode
299
+ base.ec = int(event.errorCode)
129
300
  base.reason = event.reason
130
301
  break
131
302
  }
@@ -134,37 +305,80 @@ function eventToCbor(event: TraceEvent): Record<string, unknown> {
134
305
  if (event.data !== undefined) base.data = event.data
135
306
  break
136
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
+ }
137
340
  }
138
341
 
139
342
  return base
140
343
  }
141
344
 
142
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 } : {}
143
349
  const eventType = INT_TO_EVENT_TYPE[obj.e as number]
350
+
144
351
  if (eventType == null) {
145
- // 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
+ }
146
360
  return {
147
- type: 'annotation',
148
- seq: Number(obj.n ?? 0),
149
- timestamp: Number(obj.t ?? 0),
150
- label: `unknown-event-${obj.e}`,
151
- data: obj,
361
+ type: 'unknown' as const,
362
+ seq,
363
+ timestamp,
364
+ ...peerFields,
365
+ eventType: Number(obj.e ?? -1),
366
+ fields,
152
367
  }
153
368
  }
154
369
 
155
- const seq = Number(obj.n ?? 0)
156
- const timestamp = Number(obj.t ?? 0)
157
-
158
370
  switch (eventType) {
159
371
  case 'control':
160
372
  return {
161
373
  type: 'control' as const,
162
374
  seq,
163
375
  timestamp,
376
+ ...peerFields,
164
377
  direction: obj.d as 0 | 1,
165
378
  messageType: Number(obj.mt ?? 0),
166
379
  message: (obj.msg ?? {}) as Record<string, unknown>,
167
- ...(obj.raw != null ? { raw: obj.raw as Uint8Array } : {}),
380
+ ...(obj.sid != null ? { streamId: BigInt(obj.sid as bigint | number) } : {}),
381
+ ...(obj.raw != null ? { raw: toBytes(obj.raw) } : {}),
168
382
  }
169
383
 
170
384
  case 'stream-opened':
@@ -172,6 +386,7 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
172
386
  type: 'stream-opened' as const,
173
387
  seq,
174
388
  timestamp,
389
+ ...peerFields,
175
390
  streamId: BigInt(obj.sid as bigint | number),
176
391
  direction: obj.d as 0 | 1,
177
392
  streamType: obj.st as 0 | 1 | 2,
@@ -182,6 +397,7 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
182
397
  type: 'stream-closed' as const,
183
398
  seq,
184
399
  timestamp,
400
+ ...peerFields,
185
401
  streamId: BigInt(obj.sid as bigint | number),
186
402
  errorCode: Number(obj.ec ?? 0),
187
403
  }
@@ -191,6 +407,7 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
191
407
  type: 'object-header' as const,
192
408
  seq,
193
409
  timestamp,
410
+ ...peerFields,
194
411
  streamId: BigInt(obj.sid as bigint | number),
195
412
  groupId: BigInt(obj.g as bigint | number),
196
413
  objectId: BigInt(obj.o as bigint | number),
@@ -203,11 +420,12 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
203
420
  type: 'object-payload' as const,
204
421
  seq,
205
422
  timestamp,
423
+ ...peerFields,
206
424
  streamId: BigInt(obj.sid as bigint | number),
207
425
  groupId: BigInt(obj.g as bigint | number),
208
426
  objectId: BigInt(obj.o as bigint | number),
209
427
  size: Number(obj.sz ?? 0),
210
- ...(obj.pl != null ? { payload: obj.pl as Uint8Array } : {}),
428
+ ...(obj.pl != null ? { payload: toBytes(obj.pl) } : {}),
211
429
  }
212
430
 
213
431
  case 'state-change':
@@ -215,6 +433,7 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
215
433
  type: 'state-change' as const,
216
434
  seq,
217
435
  timestamp,
436
+ ...peerFields,
218
437
  from: obj.from as string,
219
438
  to: obj.to as string,
220
439
  }
@@ -224,6 +443,7 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
224
443
  type: 'error' as const,
225
444
  seq,
226
445
  timestamp,
446
+ ...peerFields,
227
447
  errorCode: Number(obj.ec ?? 0),
228
448
  reason: (obj.reason ?? '') as string,
229
449
  }
@@ -233,9 +453,50 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
233
453
  type: 'annotation' as const,
234
454
  seq,
235
455
  timestamp,
456
+ ...peerFields,
236
457
  label: (obj.label ?? '') as string,
237
458
  data: obj.data,
238
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
+ }
239
500
  }
240
501
  }
241
502
 
@@ -251,34 +512,132 @@ function writePreamble(headerCbor: Uint8Array): Uint8Array {
251
512
  return buf
252
513
  }
253
514
 
254
- 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
+ ): {
255
527
  version: number
256
528
  headerLength: number
257
529
  } {
258
- if (bytes.length < PREAMBLE_SIZE) {
530
+ if (bytes.length - offset < PREAMBLE_SIZE) {
259
531
  throw new Error('File too short: expected at least 16 bytes')
260
532
  }
261
533
 
262
- for (let i = 0; i < 8; i++) {
263
- if (bytes[i] !== MAGIC[i]) {
264
- throw new Error('Invalid magic bytes: not a .moqtrace file')
265
- }
534
+ if (!startsWithMagic(bytes, offset)) {
535
+ throw new Error('Invalid magic bytes: not a .moqtrace file')
266
536
  }
267
537
 
268
538
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
269
- const version = view.getUint32(8, true)
270
- if (version !== FORMAT_VERSION) {
271
- 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
+ )
272
544
  }
273
545
 
274
- const headerLength = view.getUint32(12, true)
275
- if (PREAMBLE_SIZE + headerLength > bytes.length) {
546
+ const headerLength = view.getUint32(offset + 12, true)
547
+ if (offset + PREAMBLE_SIZE + headerLength > bytes.length) {
276
548
  throw new Error('File truncated: header extends beyond file')
277
549
  }
278
550
 
279
551
  return { version, headerLength }
280
552
  }
281
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
+
282
641
  // --- Public API ---
283
642
 
284
643
  /**
@@ -308,32 +667,69 @@ export function writeMoqtrace(trace: Trace): Uint8Array {
308
667
  }
309
668
 
310
669
  /**
311
- * 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.
312
677
  */
313
- export function readMoqtrace(bytes: Uint8Array): Trace {
314
- const { headerLength } = validatePreamble(bytes)
315
-
316
- const headerBytes = bytes.slice(PREAMBLE_SIZE, PREAMBLE_SIZE + headerLength)
317
- 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
+ })
318
685
 
319
- const eventBytes = bytes.slice(PREAMBLE_SIZE + headerLength)
320
- 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
+ }
321
695
 
322
- if (eventBytes.length > 0) {
323
- decodeMultiple(eventBytes, (obj: unknown) => {
324
- events.push(cborToEvent(obj as Record<string, unknown>))
325
- })
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')
326
711
  }
712
+ return trace
713
+ }
327
714
 
328
- 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)
329
723
  }
330
724
 
331
725
  /**
332
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.
333
729
  */
334
730
  export function readMoqtraceHeader(bytes: Uint8Array): TraceHeader {
335
731
  const { headerLength } = validatePreamble(bytes)
336
- const headerBytes = bytes.slice(PREAMBLE_SIZE, PREAMBLE_SIZE + headerLength)
732
+ const headerBytes = bytes.subarray(PREAMBLE_SIZE, PREAMBLE_SIZE + headerLength)
337
733
  return cborToHeader(decode(headerBytes) as Record<string, unknown>)
338
734
  }
339
735
 
@@ -345,6 +741,16 @@ export interface MoqtraceWriter {
345
741
  preamble(): Uint8Array
346
742
  /** Encode a single event. Append the returned bytes after the preamble. */
347
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
348
754
  }
349
755
 
350
756
  export function createMoqtraceWriter(header: TraceHeader): MoqtraceWriter {
@@ -358,5 +764,11 @@ export function createMoqtraceWriter(header: TraceHeader): MoqtraceWriter {
358
764
  writeEvent(event: TraceEvent): Uint8Array {
359
765
  return encode(eventToCbor(event))
360
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
+ },
361
773
  }
362
774
  }