@moqtap/trace 0.2.0 → 0.4.1

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,15 @@
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
+ SamplingInfo,
6
+ SegmentInfo,
7
+ SubscriptionRef,
8
+ Trace,
9
+ TraceEvent,
10
+ TraceHeader,
11
+ } from './types.js'
12
+ import { TRACE_ID_LENGTH } from './types.js'
3
13
 
4
14
  // Use a CBOR codec configured for cross-language interop.
5
15
  // Why: cbor-x's default `useRecords: true` enables a proprietary "record
@@ -8,19 +18,56 @@ import type { DetailLevel, Perspective, Trace, TraceEvent, TraceHeader } from '.
8
18
  // The .moqtrace spec requires standard CBOR, so we disable records and encode
9
19
  // as plain maps. `mapsAsObjects` is set so decoded maps come back as plain
10
20
  // JS objects (matching the code that accesses fields via `obj.protocol`).
11
- const codec = new Encoder({ useRecords: false, mapsAsObjects: true })
21
+ // `tagUint8Array: false` is the second half of the same requirement: by
22
+ // default cbor-x wraps every Uint8Array in tag 64 (RFC 8746 'uint8 typed
23
+ // array'), so a byte string is a tagged item rather than major type 2. A
24
+ // decoder reading the plain form sees no bytes at all — which silently emptied
25
+ // every payload-bearing field this package wrote: raw wire bytes, object
26
+ // payloads, track names, trace ids. Readers still accept the tagged form,
27
+ // because files carrying it already exist.
28
+ //
29
+ // `mapsAsObjects` has one cost this package cannot pay off, and it belongs
30
+ // here, where the decoder is chosen, rather than where the damage shows.
31
+ // cbor-x runs every decoded map key through an internal `safeKey`, which
32
+ // rewrites the text key `"__proto__"` to `"__proto_"` and stringifies a
33
+ // non-text key. Both are silent, both happen before any code in this file
34
+ // runs, and either can collide with a real key of the rewritten name and lose
35
+ // a value: a map carrying both `"__proto__"` and `"__proto_"` decodes to one
36
+ // entry, and so does one carrying both `1` and `"1"`. There is no option for
37
+ // it — in cbor-x 1.6.4 `safeKey` is unconditional on every `mapsAsObjects:
38
+ // true` path, and the one decoder that skips it is `mapsAsObjects: false`,
39
+ // which hands back a `Map` for every map in the file and is a different reader
40
+ // from this one. So a store here faithfully re-emits the renamed key, and this
41
+ // reader writes `"__proto_"` where the file said `"__proto__"` — including
42
+ // inside `"custom"`, which it otherwise hands back key for key, and inside any
43
+ // preserved value at any depth. SPEC.md, "Shapes a CBOR library may normalise
44
+ // before you see them", part 3: the normalisation happens below this
45
+ // implementation, so the reader is not non-conformant and nothing may depend
46
+ // on the outcome — but it is not invisible either, and the Rust reader keeps
47
+ // the two keys apart. The *write* half of the same key is ours, and is not
48
+ // excused: see {@link setKey}.
49
+ const codec = new Encoder({ useRecords: false, mapsAsObjects: true, tagUint8Array: false })
12
50
  const encode = (value: unknown): Uint8Array => codec.encode(value)
13
- 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
- }
51
+ export const decode = (bytes: Uint8Array): unknown => codec.decode(bytes)
52
+
53
+ export const MAGIC = new Uint8Array([0x4d, 0x4f, 0x51, 0x54, 0x52, 0x41, 0x43, 0x45]) // "MOQTRACE"
54
+
55
+ /** Format version this package writes. */
56
+ export const FORMAT_VERSION = 2
57
+
58
+ /**
59
+ * Format versions this package reads.
60
+ *
61
+ * Version 1 is a non-segmented version 2 trace carrying none of the keys
62
+ * version 2 added, so reading it costs nothing and keeps every capture taken
63
+ * before the bump openable.
64
+ */
65
+ export const SUPPORTED_VERSIONS: readonly number[] = [1, FORMAT_VERSION]
17
66
 
18
- const MAGIC = new Uint8Array([0x4d, 0x4f, 0x51, 0x54, 0x52, 0x41, 0x43, 0x45]) // "MOQTRACE"
19
- const FORMAT_VERSION = 1
20
- const PREAMBLE_SIZE = 16 // 8 magic + 4 version + 4 header length
67
+ export const PREAMBLE_SIZE = 16 // 8 magic + 4 version + 4 header length
21
68
 
22
69
  // Event type string ↔ integer mapping
23
- const EVENT_TYPE_TO_INT: Record<TraceEvent['type'], number> = {
70
+ const EVENT_TYPE_TO_INT: Record<Exclude<TraceEvent['type'], 'unknown'>, number> = {
24
71
  control: 0,
25
72
  'stream-opened': 1,
26
73
  'stream-closed': 2,
@@ -29,9 +76,12 @@ const EVENT_TYPE_TO_INT: Record<TraceEvent['type'], number> = {
29
76
  'state-change': 5,
30
77
  error: 6,
31
78
  annotation: 7,
79
+ 'peer-connected': 8,
80
+ 'peer-disconnected': 9,
81
+ 'subscription-derivation': 10,
32
82
  }
33
83
 
34
- const INT_TO_EVENT_TYPE: Record<number, TraceEvent['type']> = {
84
+ const INT_TO_EVENT_TYPE: Record<number, Exclude<TraceEvent['type'], 'unknown'>> = {
35
85
  0: 'control',
36
86
  1: 'stream-opened',
37
87
  2: 'stream-closed',
@@ -40,55 +90,430 @@ const INT_TO_EVENT_TYPE: Record<number, TraceEvent['type']> = {
40
90
  5: 'state-change',
41
91
  6: 'error',
42
92
  7: 'annotation',
93
+ 8: 'peer-connected',
94
+ 9: 'peer-disconnected',
95
+ 10: 'subscription-derivation',
96
+ }
97
+
98
+ /** Keys the common event fields own; everything else belongs to the variant. */
99
+ const COMMON_EVENT_KEYS = new Set(['n', 't', 'p', 'e'])
100
+
101
+ /**
102
+ * One decoded map — an event, the header, or one of the header's inner maps —
103
+ * and the keys the decode has taken from it.
104
+ *
105
+ * Everything left over is a key this version does not recognise, and goes to
106
+ * `extra` rather than being dropped. "Left over" is narrower than "not named by
107
+ * this document", and the difference is the whole point: a defined key whose
108
+ * value is of a type the reader cannot use was not taken either, and is
109
+ * preserved exactly as a key nobody had heard of would be — SPEC.md,
110
+ * "A defined key whose value has an unusable type is treated as unrecognised."
111
+ *
112
+ * Only the decode writes to the set, so there is no separate list of owned keys
113
+ * to keep in step with it. The list there used to be is what made adding
114
+ * `"ta"`, `"sg"`, `"fri"` and `"g"` to Event 1 *reduce* what this reader
115
+ * preserved: those keys counted as owned whatever they carried, so a
116
+ * wrong-typed one was kept out of `extra` while the decode either threw on it,
117
+ * losing the whole file, or coerced it into a value the file never carried.
118
+ */
119
+ interface Decoding {
120
+ readonly obj: Record<string, unknown>
121
+ readonly used: Set<string>
122
+ }
123
+
124
+ /**
125
+ * Put a key carried by a file onto a plain object, as an own property.
126
+ *
127
+ * `target[key] = value` is not this, for one key in the whole of JavaScript.
128
+ * `__proto__` reaches the accessor `Object.prototype` defines, which sets the
129
+ * object's prototype instead of creating a property: `Object.hasOwn(target,
130
+ * '__proto__')` is then false, `Object.entries` does not list it, and the
131
+ * entry is gone. A caller whose store came from `JSON.parse('{"__proto__":
132
+ * "x"}')` — a real own property on the way in, since JSON has no such
133
+ * special case — therefore wrote a file with the key missing, silently, on
134
+ * the one code path whose entire purpose is preservation.
135
+ *
136
+ * `defineProperty` makes the own property for every key alike, and cbor-x
137
+ * encodes it: a store entry named `__proto__` reaches the file as the text key
138
+ * `"__proto__"`, which is what the Rust implementation writes for the same
139
+ * trace. Used for every key that comes from a file or from a store — never for
140
+ * a field name this format defines, which is a literal in the source and can
141
+ * be neither `__proto__` nor anything else surprising.
142
+ *
143
+ * This is only the write half. Reading `"__proto__"` back out of that file
144
+ * hands us `"__proto_"`, below this code and past helping — see the note on
145
+ * the codec above.
146
+ */
147
+ function setKey(target: Record<string, unknown>, key: string, value: unknown): void {
148
+ Object.defineProperty(target, key, {
149
+ value,
150
+ writable: true,
151
+ enumerable: true,
152
+ configurable: true,
153
+ })
154
+ }
155
+
156
+ /** Every key on a decoded map that the decode did not take. */
157
+ function unrecognisedKeys(src: Decoding): Record<string, unknown> | undefined {
158
+ let extra: Record<string, unknown> | undefined
159
+ for (const [key, value] of Object.entries(src.obj)) {
160
+ if (src.used.has(key)) continue
161
+ extra ??= {}
162
+ setKey(extra, key, value)
163
+ }
164
+ return extra
165
+ }
166
+
167
+ /**
168
+ * Write a map's unrecognised-key store back into it, after its own keys.
169
+ *
170
+ * Last, so the map's own keys keep the positions a reader expects and the file
171
+ * stays diffable against one written without a store. An entry naming a key the
172
+ * map already wrote is dropped: a CBOR map carrying one key twice is a map no
173
+ * two readers need agree on, and the field is what a reader produced.
174
+ *
175
+ * The test is what the map *wrote*, not which keys the format defines. A
176
+ * defined key whose value the reader could not use is held in the store and no
177
+ * field wrote it, so on a defined-key test it would be dropped here — silently
178
+ * undoing the preservation the read side had just performed.
179
+ */
180
+ function withStore(
181
+ map: Record<string, unknown>,
182
+ store: Record<string, unknown> | undefined,
183
+ ): Record<string, unknown> {
184
+ if (store == null) return map
185
+ for (const [key, value] of Object.entries(store)) {
186
+ if (Object.hasOwn(map, key)) continue
187
+ setKey(map, key, value)
188
+ }
189
+ return map
190
+ }
191
+
192
+ /** The stream ended part-way through an item — the file was truncated. */
193
+ export class TruncatedTraceError extends Error {
194
+ /** Byte offset at which the incomplete item begins. */
195
+ readonly offset: number
196
+ /**
197
+ * Everything that decoded before the cut, one entry per segment. A
198
+ * truncated trace is still evidence, and the events before the cut are
199
+ * exactly as valid as they were.
200
+ */
201
+ readonly segments: Trace[]
202
+
203
+ constructor(offset: number, segments: Trace[]) {
204
+ super(`Trace truncated: the item starting at byte ${offset} is incomplete`)
205
+ this.name = 'TruncatedTraceError'
206
+ this.offset = offset
207
+ this.segments = segments
208
+ }
209
+
210
+ /** What decoded, flattened as {@link readMoqtrace} would return it. */
211
+ get trace(): Trace | undefined {
212
+ return flatten(this.segments)
213
+ }
214
+ }
215
+
216
+ /**
217
+ * A header carried no usable value for a key there is no header without.
218
+ *
219
+ * Five keys reach here: the four the format requires — `"protocol"`,
220
+ * `"perspective"`, `"detail"`, `"startTime"` — and `"segment.sequence"`, the
221
+ * sole ordering key of a segmented stream. Every other key in the header is
222
+ * optional, and an optional key's value never fails a file: an unusable one
223
+ * goes to the store its map keeps and the read continues.
224
+ *
225
+ * The alternative is worse than the error. A reader that fills a missing
226
+ * required key with `undefined`, an empty string or `0` returns a header the
227
+ * file never carried, and nothing downstream can tell it from a real one — a
228
+ * fabricated trace reported as evidence. `"segment.sequence"` is the sharpest
229
+ * case: the default `0` turns a segment whose place in the stream is unknown
230
+ * into one that claims to be first.
231
+ *
232
+ * Malformed here means malformed for *that segment*. {@link ReadOptions.recover}
233
+ * skips it and carries on at the next one, on the same terms as a malformed
234
+ * event.
235
+ *
236
+ * The writer throws the same error for the same key, before it emits anything.
237
+ * `"startTime"` and `"segment.sequence"` are `number` fields and TypeScript
238
+ * admits `-5` and `1.5` into both, so a header built by hand could be written
239
+ * to a file this package's own reader then refused — a writer emitting files
240
+ * only some other tool can open, out of a caller mistake nothing reported at
241
+ * the point it was made. One error type for both directions because it is one
242
+ * fault about one key, and a read-modify-write catches it once.
243
+ */
244
+ export class MalformedHeaderError extends Error {
245
+ /** The key at fault, dotted for one inside `"segment"`. */
246
+ readonly key: string
247
+
248
+ /**
249
+ * @param key the key at fault
250
+ * @param fault what was wrong with it, as a predicate on the key
251
+ */
252
+ constructor(key: string, fault: string) {
253
+ // Absent and present-but-unusable are the same outcome — there is no
254
+ // header to construct either way — but they are different faults, and one
255
+ // message for both told whoever has to fix the file the wrong thing.
256
+ // "must be a text string" on a header that carries no `"protocol"` at all
257
+ // sends them looking at a value that is not there, and it is the message a
258
+ // person debugging a broken capture reads.
259
+ super(`Malformed header: ${JSON.stringify(key)} ${fault}`)
260
+ this.name = 'MalformedHeaderError'
261
+ this.key = key
262
+ }
43
263
  }
44
264
 
45
265
  // --- Header serialization ---
46
266
 
267
+ /**
268
+ * Write a required header integer, or refuse to write the file.
269
+ *
270
+ * The writer's half of {@link requireHeaderUint}, and deliberately the same
271
+ * test: `"startTime"` and `"segment.sequence"` are the two header keys read as
272
+ * unsigned integers *and* required, so a negative or fractional one produces a
273
+ * file whose own reader throws `MalformedHeaderError` on it. This package
274
+ * emitting a trace it cannot open is a worse failure than a rejected call —
275
+ * the file is written, the process exits 0, and the fault surfaces wherever
276
+ * someone later tries to read it, if anyone does. The Rust implementation
277
+ * cannot reach the state at all: its field is a `u64`.
278
+ *
279
+ * It refuses only these two, and only the field. Every other integer in the
280
+ * header is optional, and a negative `"endTime"` written into one is read back
281
+ * into that map's store rather than failing the read — the value survives and
282
+ * so does the file, which is the contract the reader already states for an
283
+ * optional key. A store entry is never checked here either, for the reason
284
+ * SPEC.md gives for ranking a reader's obligations above a writer's: a value
285
+ * routed to a store is one the reader could not use and must hand back
286
+ * unchanged, and a writer that validated it would delete exactly what the
287
+ * store exists to carry. So a file that legitimately carries such a value can
288
+ * still be read, edited and written back — nothing that survives a read is
289
+ * refused on the way out.
290
+ */
291
+ function requiredUint(value: number, reportedAs: string): number | bigint {
292
+ if (!Number.isInteger(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) {
293
+ throw new MalformedHeaderError(reportedAs, 'must be an unsigned integer')
294
+ }
295
+ return int(value)
296
+ }
297
+
298
+ function segmentToCbor(segment: SegmentInfo): Record<string, unknown> {
299
+ const map: Record<string, unknown> = {
300
+ sequence: requiredUint(segment.sequence, 'segment.sequence'),
301
+ }
302
+ if (segment.durationMs != null) map.durationMs = int(segment.durationMs)
303
+ if (segment.streamId != null) map.streamId = segment.streamId
304
+ if (segment.continues != null) map.continues = segment.continues
305
+ return withStore(map, segment.extra)
306
+ }
307
+
308
+ /**
309
+ * Read the `"segment"` map, or answer `undefined` for a value that is not one.
310
+ *
311
+ * `undefined` leaves the key untaken, so a `"segment"` that is not a map goes
312
+ * to the *header's* store and the trace reads as non-segmented — which is what
313
+ * the format asks for. Failing the file instead would turn one unreadable
314
+ * metadata value into the loss of every event behind it.
315
+ *
316
+ * `"sequence"` is the exception, and the only key inside here that can fail a
317
+ * header: it is the sole ordering key of a segmented stream, so a reader that
318
+ * cannot read it cannot place the segment, and the `0` this function used to
319
+ * default to invents an order the file never had.
320
+ */
321
+ function cborToSegment(value: unknown): SegmentInfo | undefined {
322
+ const obj = asMap(value)
323
+ if (obj == null) return undefined
324
+ const src: Decoding = { obj, used: new Set() }
325
+ const segment: SegmentInfo = {
326
+ sequence: requireHeaderUint(src, 'sequence', 'segment.sequence'),
327
+ ...optional(src, 'durationMs', 'durationMs', asUintNumber),
328
+ ...optional(src, 'streamId', 'streamId', asText),
329
+ ...optional(src, 'continues', 'continues', asBoolean),
330
+ }
331
+ const extra = unrecognisedKeys(src)
332
+ return extra == null ? segment : { ...segment, extra }
333
+ }
334
+
335
+ function samplingToCbor(sampling: SamplingInfo): Record<string, unknown> {
336
+ const map: Record<string, unknown> = {}
337
+ if (sampling.effectiveRate != null) map.effectiveRate = sampling.effectiveRate
338
+ if (sampling.maxEventsPerSec != null) map.maxEventsPerSec = int(sampling.maxEventsPerSec)
339
+ if (sampling.dropPolicy != null) map.dropPolicy = sampling.dropPolicy
340
+ if (sampling.droppedTotal != null) map.droppedTotal = int(sampling.droppedTotal)
341
+ if (sampling.droppedSegment != null) map.droppedSegment = int(sampling.droppedSegment)
342
+ if (sampling.rule != null) map.rule = sampling.rule
343
+ if (sampling.ruleLang != null) map.ruleLang = sampling.ruleLang
344
+ if (sampling.appliesTo != null) map.appliesTo = sampling.appliesTo.map(int)
345
+ return withStore(map, sampling.extra)
346
+ }
347
+
348
+ /** The same for `"sampling"`; every key inside it is optional. */
349
+ function cborToSampling(value: unknown): SamplingInfo | undefined {
350
+ const obj = asMap(value)
351
+ if (obj == null) return undefined
352
+ const src: Decoding = { obj, used: new Set() }
353
+ const sampling: SamplingInfo = {
354
+ // A rate is a fraction, so this is the one header number that is not an
355
+ // integer — and the one whose range the format states, so it is the one
356
+ // key here checked for more than its type. It is tested rather than
357
+ // converted: `Number('half')` is `NaN`, a rate no file carried and none
358
+ // can be written back from.
359
+ ...optional(src, 'effectiveRate', 'effectiveRate', asRate),
360
+ ...optional(src, 'maxEventsPerSec', 'maxEventsPerSec', asUintNumber),
361
+ ...optional(src, 'dropPolicy', 'dropPolicy', asText),
362
+ ...optional(src, 'droppedTotal', 'droppedTotal', asUintNumber),
363
+ ...optional(src, 'droppedSegment', 'droppedSegment', asUintNumber),
364
+ ...optional(src, 'rule', 'rule', asText),
365
+ ...optional(src, 'ruleLang', 'ruleLang', asText),
366
+ ...optional(src, 'appliesTo', 'appliesTo', asUintNumbers),
367
+ }
368
+ const extra = unrecognisedKeys(src)
369
+ return extra == null ? sampling : { ...sampling, extra }
370
+ }
371
+
47
372
  function headerToCbor(header: TraceHeader): Record<string, unknown> {
48
373
  const map: Record<string, unknown> = {
49
374
  protocol: header.protocol,
50
375
  perspective: header.perspective,
51
376
  detail: header.detail,
52
- startTime: header.startTime,
377
+ startTime: requiredUint(header.startTime, 'startTime'),
53
378
  }
54
- if (header.endTime != null) map.endTime = header.endTime
379
+ if (header.endTime != null) map.endTime = int(header.endTime)
55
380
  if (header.transport != null) map.transport = header.transport
56
381
  if (header.source != null) map.source = header.source
57
382
  if (header.endpoint != null) map.endpoint = header.endpoint
58
383
  if (header.sessionId != null) map.sessionId = header.sessionId
384
+ if (header.segment != null) map.segment = segmentToCbor(header.segment)
385
+ if (header.sampling != null) map.sampling = samplingToCbor(header.sampling)
59
386
  if (header.custom != null) map.custom = header.custom
60
- return map
387
+ return withStore(map, header.extra)
61
388
  }
62
389
 
63
- function cborToHeader(obj: Record<string, unknown>): TraceHeader {
64
- return {
65
- protocol: obj.protocol as string,
66
- perspective: obj.perspective as Perspective,
67
- detail: obj.detail as DetailLevel,
68
- startTime: obj.startTime as number,
69
- ...(obj.endTime != null ? { endTime: obj.endTime as number } : {}),
70
- ...(obj.transport != null ? { transport: obj.transport as string } : {}),
71
- ...(obj.source != null ? { source: obj.source as string } : {}),
72
- ...(obj.endpoint != null ? { endpoint: obj.endpoint as string } : {}),
73
- ...(obj.sessionId != null ? { sessionId: obj.sessionId as string } : {}),
74
- ...(obj.custom != null ? { custom: obj.custom as Record<string, unknown> } : {}),
390
+ /**
391
+ * Decode the header, keeping every key the decode could not use.
392
+ *
393
+ * Three maps here have keys the format names — this one, `"segment"` and
394
+ * `"sampling"` — and each keeps its own store, because a private key on
395
+ * `"segment"` and a key of the same name at the top level are different keys.
396
+ * `"custom"` keeps none: every key in it belongs to whoever wrote the trace.
397
+ *
398
+ * The four required keys throw rather than default. Everything else that is
399
+ * unusable stays in the store of the map it appeared in, and the read
400
+ * continues: an optional key's type never fails a file.
401
+ *
402
+ * @throws {MalformedHeaderError} when a required key is absent or unusable.
403
+ */
404
+ export function cborToHeader(obj: Record<string, unknown>): TraceHeader {
405
+ const src: Decoding = { obj, used: new Set() }
406
+ const header: TraceHeader = {
407
+ protocol: requireHeaderText(src, 'protocol'),
408
+ perspective: requireHeaderText(src, 'perspective'),
409
+ detail: requireHeaderText(src, 'detail'),
410
+ startTime: requireHeaderUint(src, 'startTime'),
411
+ ...optional(src, 'endTime', 'endTime', asUintNumber),
412
+ ...optional(src, 'transport', 'transport', asText),
413
+ ...optional(src, 'source', 'source', asText),
414
+ ...optional(src, 'endpoint', 'endpoint', asText),
415
+ ...optional(src, 'sessionId', 'sessionId', asText),
416
+ ...optional(src, 'segment', 'segment', cborToSegment),
417
+ ...optional(src, 'sampling', 'sampling', cborToSampling),
418
+ // Not `Record<string, unknown>` by assertion but by test. A `"custom"`
419
+ // that is not a map used to reach this field wearing that type, so the
420
+ // first caller to iterate its keys met a shape it was told could not
421
+ // occur. The bytes survive either way; only one of the two keeps the
422
+ // field's type true.
423
+ ...optional(src, 'custom', 'custom', asMap),
75
424
  }
425
+ const extra = unrecognisedKeys(src)
426
+ return extra == null ? header : { ...header, extra }
76
427
  }
77
428
 
78
429
  // --- Event serialization ---
79
430
 
431
+ function subscriptionRefToCbor(ref: SubscriptionRef): [string, bigint] {
432
+ return [ref.peer, ref.requestId]
433
+ }
434
+
435
+ function cborToSubscriptionRef(value: unknown): SubscriptionRef {
436
+ if (!Array.isArray(value) || value.length !== 2) {
437
+ throw new Error('Subscription reference must be [peer, requestId]')
438
+ }
439
+ // Both halves are required, so an unusable one is a malformed event rather
440
+ // than something to route elsewhere — there is no subscription to name
441
+ // without them. Checked rather than converted, for the reason `asUint` gives:
442
+ // `BigInt(true)` is `1n`, a request id no file ever carried.
443
+ const peer = asText(value[0])
444
+ const requestId = asUint(value[1])
445
+ if (peer == null || requestId == null) {
446
+ throw new Error('Subscription reference must be a [text, unsigned integer] pair')
447
+ }
448
+ return { peer, requestId }
449
+ }
450
+
451
+ /**
452
+ * Normalise a decoded byte string to a plain `Uint8Array` that owns its bytes.
453
+ *
454
+ * The decoder hands back a Node `Buffer` on one platform and a `Uint8Array` on
455
+ * another, and either may be a view onto the buffer being read — so without
456
+ * the copy, a payload would alias the caller's input and change under it.
457
+ */
458
+ function toBytes(value: unknown): Uint8Array {
459
+ return value instanceof Uint8Array
460
+ ? new Uint8Array(value)
461
+ : new Uint8Array(value as ArrayLike<number>)
462
+ }
463
+
464
+ /**
465
+ * Write an integral value as a CBOR integer rather than a float.
466
+ *
467
+ * cbor-x encodes any JS number past 32 bits as a float64, which every
468
+ * epoch-millisecond timestamp is. That is legal CBOR, but it means the two
469
+ * implementations of this format wrote different major types for the same
470
+ * value — and a decoder reading only integers found `startTime` missing and
471
+ * rejected the file. Handing the encoder a BigInt gets an integer out.
472
+ */
473
+ function int(value: number): number | bigint {
474
+ const outsideInt32 = value > 0xffffffff || value < -0x80000000
475
+ return Number.isInteger(value) && outsideInt32 ? BigInt(value) : value
476
+ }
477
+
478
+ /**
479
+ * Write a trace id, refusing any length but the one the format fixes.
480
+ *
481
+ * A wrong-length value is not something to pad or truncate: the identifier
482
+ * exists so two implementations independently derive byte-identical values for
483
+ * one subscription chain, and reshaping it breaks exactly the property it is
484
+ * there for. The reader's counterpart is {@link asTraceId}, which keeps a
485
+ * wrong-length value instead of refusing it — `"traceId"` is optional, and an
486
+ * optional key's type never fails a file.
487
+ */
488
+ function checkTraceId(value: unknown): Uint8Array {
489
+ const bytes = toBytes(value)
490
+ if (bytes.length !== TRACE_ID_LENGTH) {
491
+ throw new Error(`traceId is ${bytes.length} bytes, must be ${TRACE_ID_LENGTH}`)
492
+ }
493
+ return bytes
494
+ }
495
+
80
496
  function eventToCbor(event: TraceEvent): Record<string, unknown> {
81
497
  const base: Record<string, unknown> = {
82
- n: event.seq,
83
- t: event.timestamp,
84
- e: EVENT_TYPE_TO_INT[event.type],
498
+ n: int(event.seq),
499
+ t: int(event.timestamp),
85
500
  }
501
+ if (event.peer != null) base.p = event.peer
502
+ if (event.type !== 'unknown') base.e = EVENT_TYPE_TO_INT[event.type]
86
503
 
87
504
  switch (event.type) {
88
505
  case 'control': {
89
506
  base.d = event.direction
90
- base.mt = event.messageType
91
- base.msg = event.message
507
+ base.mt = int(event.messageType)
508
+ // Always written, even when nothing was decoded: Event 0 is one of the
509
+ // types sampling must not drop, so a reader strict enough to treat a
510
+ // missing "msg" as a malformed event would discard exactly the events
511
+ // the format promises to keep. `undefined` is the one value not passed
512
+ // through — it encodes as CBOR `undefined`, which is not a map, and it
513
+ // means a caller left the field off rather than that a file carried it.
514
+ // Every other value is written as it stands, so a text `"msg"` read out
515
+ // of a pre-spec recording goes back to disk as the same text.
516
+ base.msg = event.message === undefined ? {} : event.message
92
517
  if (event.streamId != null) base.sid = event.streamId
93
518
  if (event.raw != null) base.raw = event.raw
94
519
  break
@@ -97,26 +522,33 @@ function eventToCbor(event: TraceEvent): Record<string, unknown> {
97
522
  base.sid = event.streamId
98
523
  base.d = event.direction
99
524
  base.st = event.streamType
525
+ // Written only when set, and each is scoped to one stream type: a
526
+ // subgroup id on a fetch stream, or a group id on a subgroup stream,
527
+ // names something the header it came from never carried.
528
+ if (event.trackAlias != null) base.ta = event.trackAlias
529
+ if (event.subgroupId != null) base.sg = event.subgroupId
530
+ if (event.fetchRequestId != null) base.fri = event.fetchRequestId
531
+ if (event.groupId != null) base.g = event.groupId
100
532
  break
101
533
  }
102
534
  case 'stream-closed': {
103
535
  base.sid = event.streamId
104
- base.ec = event.errorCode
536
+ base.ec = int(event.errorCode)
105
537
  break
106
538
  }
107
539
  case 'object-header': {
108
540
  base.sid = event.streamId
109
541
  base.g = event.groupId
110
542
  base.o = event.objectId
111
- base.pp = event.publisherPriority
112
- base.os = event.objectStatus
543
+ base.pp = int(event.publisherPriority)
544
+ base.os = int(event.objectStatus)
113
545
  break
114
546
  }
115
547
  case 'object-payload': {
116
548
  base.sid = event.streamId
117
549
  base.g = event.groupId
118
550
  base.o = event.objectId
119
- base.sz = event.size
551
+ base.sz = int(event.size)
120
552
  if (event.payload != null) base.pl = event.payload
121
553
  break
122
554
  }
@@ -126,8 +558,20 @@ function eventToCbor(event: TraceEvent): Record<string, unknown> {
126
558
  break
127
559
  }
128
560
  case 'error': {
129
- base.ec = event.errorCode
561
+ base.ec = int(event.errorCode)
130
562
  base.reason = event.reason
563
+ if (event.streamId != null) base.sid = event.streamId
564
+ if (event.errorKind != null) base.ek = event.errorKind
565
+ if (event.rawLength != null) base.rawlen = int(event.rawLength)
566
+ // Written at whatever length it arrived. The format caps `"raw"` at 4096
567
+ // bytes and this is deliberately not where that happens: a serializer
568
+ // cannot tell an event a recorder just built from observed bytes from one
569
+ // that arrived by being read, so a cap here would either truncate
570
+ // evidence on a rewrite or refuse a file the reader was required to
571
+ // accept — whichever it did, it would do to the wrong events. The cap
572
+ // belongs where the event is constructed from bytes, which in this
573
+ // package is `recordError` in `recorder.ts`.
574
+ if (event.raw != null) base.raw = event.raw
131
575
  break
132
576
  }
133
577
  case 'annotation': {
@@ -135,38 +579,361 @@ function eventToCbor(event: TraceEvent): Record<string, unknown> {
135
579
  if (event.data !== undefined) base.data = event.data
136
580
  break
137
581
  }
582
+ case 'peer-connected': {
583
+ if (event.endpoint != null) base.endpoint = event.endpoint
584
+ if (event.transport != null) base.transport = event.transport
585
+ if (event.role != null) base.role = event.role
586
+ if (event.side != null) base.side = event.side
587
+ break
588
+ }
589
+ case 'peer-disconnected': {
590
+ base.ec = int(event.errorCode)
591
+ if (event.reason != null) base.reason = event.reason
592
+ break
593
+ }
594
+ case 'subscription-derivation': {
595
+ base.u = subscriptionRefToCbor(event.upstream)
596
+ base.d = event.downstream.map(subscriptionRefToCbor)
597
+ base.kind = event.kind
598
+ if (event.traceId != null) base.traceId = checkTraceId(event.traceId)
599
+ if (event.namespace != null) base.ns = event.namespace
600
+ if (event.trackName != null) base.tn = event.trackName
601
+ if (event.tDownstreamReceived != null) base.tdr = int(event.tDownstreamReceived)
602
+ if (event.tUpstreamSent != null) base.tus = int(event.tUpstreamSent)
603
+ if (event.tUpstreamOkReceived != null) base.tuo = int(event.tUpstreamOkReceived)
604
+ if (event.tDownstreamOkSent != null) base.tdo = int(event.tDownstreamOkSent)
605
+ break
606
+ }
607
+ case 'unknown': {
608
+ base.e = event.eventType
609
+ for (const [key, value] of Object.entries(event.fields)) {
610
+ setKey(base, key, value)
611
+ }
612
+ break
613
+ }
614
+ }
615
+
616
+ // Last, so the event's own keys keep the positions a reader expects and the
617
+ // file stays diffable against one written without them. An entry naming a key
618
+ // this event already wrote from a field is dropped: a CBOR map with a
619
+ // duplicate key is malformed, and the field is what a reader produced.
620
+ //
621
+ // The test is what the event *wrote*, not what its type owns. A defined key
622
+ // whose value the reader could not use is held in `extra` and no field wrote
623
+ // it, so on the owned-key test it would be dropped here — silently undoing
624
+ // the preservation the read side had just performed.
625
+ if (event.type !== 'unknown' && event.extra != null) {
626
+ for (const [key, value] of Object.entries(event.extra)) {
627
+ if (COMMON_EVENT_KEYS.has(key) || Object.hasOwn(base, key)) continue
628
+ setKey(base, key, value)
629
+ }
138
630
  }
139
631
 
140
632
  return base
141
633
  }
142
634
 
143
- function cborToEvent(obj: Record<string, unknown>): TraceEvent {
635
+ // --- Reading one key ---
636
+ //
637
+ // Each of these answers `undefined` for a value it cannot use, and the caller
638
+ // then leaves the key untaken so that `unrecognisedKeys` preserves it.
639
+ //
640
+ // They are type *tests*, not conversion attempts. The distinction is not
641
+ // pedantic even where a conversion throws on the obvious cases: `BigInt`
642
+ // answers `1n` for `true` and `4n` for the text `"4"`, so a `try`/`catch`
643
+ // around it would still put an identifier on the event that the file never
644
+ // carried — and the Rust reader, which coerces neither, would then read the
645
+ // same file differently.
646
+
647
+ /** A CBOR unsigned integer, as a `bigint`. */
648
+ function asUint(value: unknown): bigint | undefined {
649
+ if (typeof value === 'bigint') return value >= 0n ? value : undefined
650
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) return undefined
651
+ return BigInt(value)
652
+ }
653
+
654
+ /**
655
+ * The same, where the field it feeds is a JS `number`.
656
+ *
657
+ * A value past `Number.MAX_SAFE_INTEGER` is refused rather than rounded: the
658
+ * rounded number is not what the file said, and refusing keeps the digits
659
+ * intact in `extra`.
660
+ */
661
+ function asUintNumber(value: unknown): number | undefined {
662
+ const int = asUint(value)
663
+ return int == null || int > BigInt(Number.MAX_SAFE_INTEGER) ? undefined : Number(int)
664
+ }
665
+
666
+ /** A CBOR text string. */
667
+ function asText(value: unknown): string | undefined {
668
+ return typeof value === 'string' ? value : undefined
669
+ }
670
+
671
+ /** A CBOR boolean. */
672
+ function asBoolean(value: unknown): boolean | undefined {
673
+ return typeof value === 'boolean' ? value : undefined
674
+ }
675
+
676
+ /**
677
+ * A CBOR number, for the one header key defined as a float.
678
+ *
679
+ * Non-finite values are refused along with everything that is not a number:
680
+ * CBOR can carry a NaN or an infinity, but neither is a fraction of the events
681
+ * retained, and a field holding one states a rate the file never gave.
682
+ *
683
+ * No test covers the finite check, and none can: {@link asRate} is the only
684
+ * caller, and its `> 0 && <= 1` already rejects NaN and both infinities, so
685
+ * deleting `Number.isFinite` here changes no observable behaviour today. It
686
+ * stays because the redundancy belongs to the caller and not to this function.
687
+ * `asRate` bounds its key by meaning, which the format does for that key and
688
+ * for no other; the next float key added here would arrive through this type
689
+ * test with no range behind it, and would take a NaN into its field. Rather
690
+ * than a test that cannot fail, this note.
691
+ */
692
+ function asNumber(value: unknown): number | undefined {
693
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
694
+ }
695
+
696
+ /**
697
+ * A sampling rate: a number in `(0.0, 1.0]`, and no other number.
698
+ *
699
+ * The one value in the header the format bounds by meaning rather than by
700
+ * type, and the bound is checked here because consumers divide by it. A `0.0`
701
+ * handed to a caller reconstructing true event counts is a division by zero,
702
+ * and a `1.5` is a count larger than what was recorded — each a wrong answer
703
+ * produced silently, from a header the reader had already decided to trust.
704
+ *
705
+ * The interval is open at `0` and closed at `1`, so `1.0` — "no rate-based
706
+ * dropping", the commonest value there is — passes and `0.0` does not. A value
707
+ * outside it is unusable on an optional key, so it goes to the sampling map's
708
+ * store with its bytes intact rather than failing the file. NaN fails both
709
+ * comparisons, which is the wanted answer twice over: it is in no interval,
710
+ * and it is no fraction of the events retained.
711
+ */
712
+ function asRate(value: unknown): number | undefined {
713
+ const rate = asNumber(value)
714
+ return rate != null && rate > 0 && rate <= 1 ? rate : undefined
715
+ }
716
+
717
+ /**
718
+ * A CBOR array of unsigned integers — the shape `"sampling.appliesTo"` takes.
719
+ *
720
+ * All or nothing, like {@link asByteStrings}. An array with one element that is
721
+ * not an event type ID is unusable *entire*: this key names the types a drop
722
+ * policy touched, and readers may treat every type absent from it as complete,
723
+ * so keeping `[3, 5]` out of `[3, "x", 5]` would report a sampled type as fully
724
+ * recorded — the opposite of what the file said, stated just as confidently.
725
+ */
726
+ function asUintNumbers(value: unknown): number[] | undefined {
727
+ if (!Array.isArray(value)) return undefined
728
+ const ids: number[] = []
729
+ for (const item of value) {
730
+ const id = asUintNumber(item)
731
+ if (id == null) return undefined
732
+ ids.push(id)
733
+ }
734
+ return ids
735
+ }
736
+
737
+ /**
738
+ * A CBOR map, as the plain object this codec decodes one to.
739
+ *
740
+ * `typeof value === 'object'` is not the test: it also admits `null`, an array,
741
+ * a byte string and a tagged value, each of which would then be handed back as
742
+ * a field map and read `undefined` for every key. The prototype is what
743
+ * separates them — a decoded map is a plain object, and everything else this
744
+ * decoder produces for a non-map is an instance of something. A `Map` fails the
745
+ * same test, which is right for the same reason: a reader that cannot hold a
746
+ * map exactly must route it to a store rather than hand back a shape its own
747
+ * declared type denies.
748
+ */
749
+ function asMap(value: unknown): Record<string, unknown> | undefined {
750
+ if (value === null || typeof value !== 'object') return undefined
751
+ const proto = Object.getPrototypeOf(value)
752
+ if (proto !== Object.prototype && proto !== null) return undefined
753
+ return value as Record<string, unknown>
754
+ }
755
+
756
+ /** A CBOR byte string, copied by {@link toBytes} so it owns its bytes. */
757
+ function asByteString(value: unknown): Uint8Array | undefined {
758
+ return value instanceof Uint8Array ? toBytes(value) : undefined
759
+ }
760
+
761
+ /** A CBOR array of byte strings — the shape a track namespace takes. */
762
+ function asByteStrings(value: unknown): Uint8Array[] | undefined {
763
+ if (!Array.isArray(value)) return undefined
764
+ const parts: Uint8Array[] = []
765
+ for (const item of value) {
766
+ const bytes = asByteString(item)
767
+ if (bytes == null) return undefined
768
+ parts.push(bytes)
769
+ }
770
+ return parts
771
+ }
772
+
773
+ /**
774
+ * A trace id: {@link TRACE_ID_LENGTH} bytes and no other length.
775
+ *
776
+ * The reader's half of {@link checkTraceId}. It keeps a wrong-length value
777
+ * rather than throwing, because `"traceId"` is optional and an optional key's
778
+ * type never fails a file; the value then survives in `extra` with its bytes
779
+ * intact, which a padded or truncated field would not.
780
+ */
781
+ function asTraceId(value: unknown): Uint8Array | undefined {
782
+ const bytes = asByteString(value)
783
+ return bytes?.length === TRACE_ID_LENGTH ? bytes : undefined
784
+ }
785
+
786
+ /** Take a key's value as it stands, whatever shape it has. */
787
+ function take(src: Decoding, key: string): unknown {
788
+ src.used.add(key)
789
+ return src.obj[key]
790
+ }
791
+
792
+ /**
793
+ * Take a required integer key, or fail the event.
794
+ *
795
+ * The one case where an unusable value is not routed to `extra`: without the
796
+ * key there is no event to build, so the event is malformed exactly as it
797
+ * would be with the key absent. That is as far as it goes — a *file* is never
798
+ * failed over an optional key's type.
799
+ */
800
+ function requireUint(src: Decoding, key: string): bigint {
801
+ const value = asUint(take(src, key))
802
+ if (value == null) {
803
+ throw new Error(`Event key ${JSON.stringify(key)} must be an unsigned integer`)
804
+ }
805
+ return value
806
+ }
807
+
808
+ /**
809
+ * Take a required header text key, or fail the header.
810
+ *
811
+ * The reader used to assert the type instead — `obj.protocol as string` — which
812
+ * let a header with no `"protocol"` reach a caller with `undefined` sitting in
813
+ * a field declared `string`, and re-encode as CBOR `undefined`, a shape the
814
+ * format tells writers never to produce.
815
+ */
816
+ function requireHeaderText(src: Decoding, key: string): string {
817
+ const present = Object.hasOwn(src.obj, key)
818
+ const value = asText(take(src, key))
819
+ if (value == null) {
820
+ throw new MalformedHeaderError(key, present ? 'must be a text string' : 'is missing')
821
+ }
822
+ return value
823
+ }
824
+
825
+ /**
826
+ * Take a required header integer key, or fail the header.
827
+ *
828
+ * @param reportedAs the key's name in the error, dotted for one inside
829
+ * `"segment"`, where `"sequence"` alone would not say which map it was in.
830
+ */
831
+ function requireHeaderUint(src: Decoding, key: string, reportedAs = key): number {
832
+ const present = Object.hasOwn(src.obj, key)
833
+ const value = asUintNumber(take(src, key))
834
+ if (value == null) {
835
+ throw new MalformedHeaderError(
836
+ reportedAs,
837
+ present ? 'must be an unsigned integer' : 'is missing',
838
+ )
839
+ }
840
+ return value
841
+ }
842
+
843
+ /**
844
+ * Take an optional key, as the property to spread onto the event or header map
845
+ * being built — or nothing at all, when the key is absent or carries a value of
846
+ * a type this reader cannot use.
847
+ *
848
+ * Those two cases build the same result and differ in what they leave behind:
849
+ * an absent key leaves nothing, an unusable one stays untaken and is preserved
850
+ * verbatim as an unrecognised key. Neither throws. The events around this one
851
+ * decoded fine, and one key's type is no reason to lose them.
852
+ *
853
+ * `read` may itself throw, which is how `"segment.sequence"` fails a header
854
+ * from inside an otherwise optional `"segment"`.
855
+ */
856
+ function optional<K extends string, V>(
857
+ src: Decoding,
858
+ key: string,
859
+ name: K,
860
+ read: (value: unknown) => V | undefined,
861
+ ): { [P in K]?: V } {
862
+ const usable = read(src.obj[key])
863
+ if (usable === undefined) return {} as { [P in K]?: V }
864
+ src.used.add(key)
865
+ return { [name]: usable } as { [P in K]?: V }
866
+ }
867
+
868
+ /**
869
+ * Decode one event, keeping every key the decode could not use.
870
+ *
871
+ * Those keys are kept on the event rather than dropped so that reading a trace
872
+ * and writing it back does not quietly strip what a newer writer put there.
873
+ * Two kinds land there, and the second is the one easily lost: a key this
874
+ * version has never heard of, and a key it knows whose value is of a type it
875
+ * cannot use — a text `"ta"`, say. Both are unrecognised as far as meaning
876
+ * goes, and both are written back unchanged.
877
+ *
878
+ * An `UnknownEvent` is returned untouched: its `fields` already hold every
879
+ * non-common key, and adding them to `extra` too would write each one twice.
880
+ */
881
+ export function cborToEvent(obj: Record<string, unknown>): TraceEvent {
882
+ const src: Decoding = { obj, used: new Set(COMMON_EVENT_KEYS) }
883
+ const event = decodeEvent(src)
884
+ if (event.type === 'unknown') return event
885
+
886
+ const extra = unrecognisedKeys(src)
887
+ return extra == null ? event : { ...event, extra }
888
+ }
889
+
890
+ function decodeEvent(src: Decoding): TraceEvent {
891
+ const obj = src.obj
892
+ const seq = Number(obj.n ?? 0)
893
+ const timestamp = Number(obj.t ?? 0)
894
+ const peerFields = obj.p != null ? { peer: obj.p as string } : {}
144
895
  const eventType = INT_TO_EVENT_TYPE[obj.e as number]
896
+
145
897
  if (eventType == null) {
146
- // Unknown event type — skip per spec (return annotation as fallback)
898
+ // New event types may be added without a version bump. Dropping one — or
899
+ // relabelling it as an annotation, which an earlier revision of this file
900
+ // did — makes this reader's ignorance permanent for every reader
901
+ // downstream of it, because a read-modify-write then rewrites the type.
902
+ const fields: Record<string, unknown> = {}
903
+ for (const [key, value] of Object.entries(obj)) {
904
+ if (!COMMON_EVENT_KEYS.has(key)) setKey(fields, key, value)
905
+ }
147
906
  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,
907
+ type: 'unknown' as const,
908
+ seq,
909
+ timestamp,
910
+ ...peerFields,
911
+ eventType: Number(obj.e ?? -1),
912
+ fields,
153
913
  }
154
914
  }
155
915
 
156
- const seq = Number(obj.n ?? 0)
157
- const timestamp = Number(obj.t ?? 0)
158
-
159
916
  switch (eventType) {
160
917
  case 'control':
161
918
  return {
162
919
  type: 'control' as const,
163
920
  seq,
164
921
  timestamp,
165
- direction: obj.d as 0 | 1,
166
- messageType: Number(obj.mt ?? 0),
167
- message: (obj.msg ?? {}) as Record<string, unknown>,
168
- ...(obj.sid != null ? { streamId: BigInt(obj.sid as bigint | number) } : {}),
169
- ...(obj.raw != null ? { raw: obj.raw as Uint8Array } : {}),
922
+ ...peerFields,
923
+ direction: take(src, 'd') as 0 | 1,
924
+ messageType: Number(take(src, 'mt') ?? 0),
925
+ // Handed over exactly as it decoded. A `"msg"` that is not a map is
926
+ // still the only decode the recording has — the corpus captures carry
927
+ // a Rust `Debug` string here — and the format requires a reader to
928
+ // preserve it rather than reject the event or substitute a map. That
929
+ // includes `null`, which is a value a writer chose to put on the wire,
930
+ // not an absence; absence is the single case normalised, to `{}`, so
931
+ // that a caller reading keys off a conforming file never meets
932
+ // `undefined`. It is the one key with no unusable shape: the field's
933
+ // type is `unknown`, so every value is one it can hold.
934
+ message: Object.hasOwn(obj, 'msg') ? take(src, 'msg') : {},
935
+ ...optional(src, 'sid', 'streamId', asUint),
936
+ ...optional(src, 'raw', 'raw', asByteString),
170
937
  }
171
938
 
172
939
  case 'stream-opened':
@@ -174,9 +941,28 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
174
941
  type: 'stream-opened' as const,
175
942
  seq,
176
943
  timestamp,
177
- streamId: BigInt(obj.sid as bigint | number),
178
- direction: obj.d as 0 | 1,
179
- streamType: obj.st as 0 | 1 | 2,
944
+ ...peerFields,
945
+ streamId: requireUint(src, 'sid'),
946
+ direction: take(src, 'd') as 0 | 1,
947
+ streamType: take(src, 'st') as 0 | 1 | 2,
948
+ // `bigint`, not `number`, even though a writer that used a narrow CBOR
949
+ // integer hands these back as JS numbers. They are the same
950
+ // identifiers an object-header event carries, and that event converts;
951
+ // leaving one a number here makes `opened.groupId === header.groupId`
952
+ // read `42 === 42n` and answer `false` for values that match.
953
+ //
954
+ // Tested rather than converted, and optional rather than required.
955
+ // `BigInt(obj.ta)` threw on a text or fractional value and took the
956
+ // whole file down with it, since nothing catches one event's decode; it
957
+ // answered `1n` for `true` and `4n` for `"4"`, inventing an identifier
958
+ // the file never carried; and the owned-key list then kept the
959
+ // survivors out of `extra`, so adding these four keys *lost* values
960
+ // this reader had preserved before it knew them. A value of any other
961
+ // shape now stays where it was, on the event as an unrecognised key.
962
+ ...optional(src, 'ta', 'trackAlias', asUint),
963
+ ...optional(src, 'sg', 'subgroupId', asUint),
964
+ ...optional(src, 'fri', 'fetchRequestId', asUint),
965
+ ...optional(src, 'g', 'groupId', asUint),
180
966
  }
181
967
 
182
968
  case 'stream-closed':
@@ -184,8 +970,9 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
184
970
  type: 'stream-closed' as const,
185
971
  seq,
186
972
  timestamp,
187
- streamId: BigInt(obj.sid as bigint | number),
188
- errorCode: Number(obj.ec ?? 0),
973
+ ...peerFields,
974
+ streamId: requireUint(src, 'sid'),
975
+ errorCode: Number(take(src, 'ec') ?? 0),
189
976
  }
190
977
 
191
978
  case 'object-header':
@@ -193,11 +980,12 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
193
980
  type: 'object-header' as const,
194
981
  seq,
195
982
  timestamp,
196
- streamId: BigInt(obj.sid as bigint | number),
197
- groupId: BigInt(obj.g as bigint | number),
198
- objectId: BigInt(obj.o as bigint | number),
199
- publisherPriority: Number(obj.pp ?? 0),
200
- objectStatus: Number(obj.os ?? 0),
983
+ ...peerFields,
984
+ streamId: requireUint(src, 'sid'),
985
+ groupId: requireUint(src, 'g'),
986
+ objectId: requireUint(src, 'o'),
987
+ publisherPriority: Number(take(src, 'pp') ?? 0),
988
+ objectStatus: Number(take(src, 'os') ?? 0),
201
989
  }
202
990
 
203
991
  case 'object-payload':
@@ -205,11 +993,12 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
205
993
  type: 'object-payload' as const,
206
994
  seq,
207
995
  timestamp,
208
- streamId: BigInt(obj.sid as bigint | number),
209
- groupId: BigInt(obj.g as bigint | number),
210
- objectId: BigInt(obj.o as bigint | number),
211
- size: Number(obj.sz ?? 0),
212
- ...(obj.pl != null ? { payload: obj.pl as Uint8Array } : {}),
996
+ ...peerFields,
997
+ streamId: requireUint(src, 'sid'),
998
+ groupId: requireUint(src, 'g'),
999
+ objectId: requireUint(src, 'o'),
1000
+ size: Number(take(src, 'sz') ?? 0),
1001
+ ...optional(src, 'pl', 'payload', asByteString),
213
1002
  }
214
1003
 
215
1004
  case 'state-change':
@@ -217,8 +1006,9 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
217
1006
  type: 'state-change' as const,
218
1007
  seq,
219
1008
  timestamp,
220
- from: obj.from as string,
221
- to: obj.to as string,
1009
+ ...peerFields,
1010
+ from: take(src, 'from') as string,
1011
+ to: take(src, 'to') as string,
222
1012
  }
223
1013
 
224
1014
  case 'error':
@@ -226,8 +1016,25 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
226
1016
  type: 'error' as const,
227
1017
  seq,
228
1018
  timestamp,
229
- errorCode: Number(obj.ec ?? 0),
230
- reason: (obj.reason ?? '') as string,
1019
+ ...peerFields,
1020
+ errorCode: Number(take(src, 'ec') ?? 0),
1021
+ reason: (take(src, 'reason') ?? '') as string,
1022
+ // Optional, so an unusable value costs the key and not the event: the
1023
+ // error code and reason are the point of the event and are fine.
1024
+ // `asUint` rather than a conversion, for the reason Event 1 gives —
1025
+ // `BigInt(true)` is `1n`, a stream this file never named.
1026
+ ...optional(src, 'sid', 'streamId', asUint),
1027
+ // An error kind this version has never heard of is still text, and
1028
+ // still kept: the vocabulary is open and `ErrorKind` widens to any
1029
+ // string for exactly that. What `asText` refuses is a value that is
1030
+ // not text at all.
1031
+ ...optional(src, 'ek', 'errorKind', asText),
1032
+ ...optional(src, 'rawlen', 'rawLength', asUintNumber),
1033
+ // No length check. The 4096-byte cap binds a recorder; a reader that
1034
+ // refused a longer value — or shortened it — would reject a file it was
1035
+ // required to accept, or destroy the evidence the field exists to
1036
+ // carry.
1037
+ ...optional(src, 'raw', 'raw', asByteString),
231
1038
  }
232
1039
 
233
1040
  case 'annotation':
@@ -235,8 +1042,52 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
235
1042
  type: 'annotation' as const,
236
1043
  seq,
237
1044
  timestamp,
238
- label: (obj.label ?? '') as string,
239
- data: obj.data,
1045
+ ...peerFields,
1046
+ label: (take(src, 'label') ?? '') as string,
1047
+ data: take(src, 'data'),
1048
+ }
1049
+
1050
+ case 'peer-connected':
1051
+ return {
1052
+ type: 'peer-connected' as const,
1053
+ seq,
1054
+ timestamp,
1055
+ ...peerFields,
1056
+ ...optional(src, 'endpoint', 'endpoint', asText),
1057
+ ...optional(src, 'transport', 'transport', asText),
1058
+ // A role or side this version has never heard of is still text, and
1059
+ // still kept: the unions they widen to exist for exactly that. What
1060
+ // `asText` refuses is a value that is not text at all.
1061
+ ...optional(src, 'role', 'role', asText),
1062
+ ...optional(src, 'side', 'side', asText),
1063
+ }
1064
+
1065
+ case 'peer-disconnected':
1066
+ return {
1067
+ type: 'peer-disconnected' as const,
1068
+ seq,
1069
+ timestamp,
1070
+ ...peerFields,
1071
+ errorCode: Number(take(src, 'ec') ?? 0),
1072
+ ...optional(src, 'reason', 'reason', asText),
1073
+ }
1074
+
1075
+ case 'subscription-derivation':
1076
+ return {
1077
+ type: 'subscription-derivation' as const,
1078
+ seq,
1079
+ timestamp,
1080
+ ...peerFields,
1081
+ upstream: cborToSubscriptionRef(take(src, 'u')),
1082
+ downstream: ((take(src, 'd') ?? []) as unknown[]).map(cborToSubscriptionRef),
1083
+ kind: (take(src, 'kind') ?? '') as DerivationKind,
1084
+ ...optional(src, 'traceId', 'traceId', asTraceId),
1085
+ ...optional(src, 'ns', 'namespace', asByteStrings),
1086
+ ...optional(src, 'tn', 'trackName', asByteString),
1087
+ ...optional(src, 'tdr', 'tDownstreamReceived', asUintNumber),
1088
+ ...optional(src, 'tus', 'tUpstreamSent', asUintNumber),
1089
+ ...optional(src, 'tuo', 'tUpstreamOkReceived', asUintNumber),
1090
+ ...optional(src, 'tdo', 'tDownstreamOkSent', asUintNumber),
240
1091
  }
241
1092
  }
242
1093
  }
@@ -253,34 +1104,203 @@ function writePreamble(headerCbor: Uint8Array): Uint8Array {
253
1104
  return buf
254
1105
  }
255
1106
 
256
- function validatePreamble(bytes: Uint8Array): {
1107
+ export function startsWithMagic(bytes: Uint8Array, offset: number): boolean {
1108
+ if (offset + MAGIC.length > bytes.length) return false
1109
+ for (let i = 0; i < MAGIC.length; i++) {
1110
+ if (bytes[offset + i] !== MAGIC[i]) return false
1111
+ }
1112
+ return true
1113
+ }
1114
+
1115
+ /**
1116
+ * Read and check a segment preamble, for a buffer that holds the whole file.
1117
+ *
1118
+ * **"Too short" means truncated here, and that is only true of a whole file.**
1119
+ * A caller reading a complete buffer has everything the file will ever have,
1120
+ * so a preamble that runs off the end is a cut recording. A caller reading a
1121
+ * *stream* is in the opposite situation: a short buffer is the ordinary state
1122
+ * between two chunks, and the missing bytes are on their way. An incremental
1123
+ * reader that reuses this function would report a truncation on every chunk
1124
+ * boundary.
1125
+ *
1126
+ * The distinction is invisible from the name, which is why it is written down:
1127
+ * `createMoqtraceReader` does its own length checks for this reason and shares
1128
+ * only the parts that carry meaning — the version check and `cborToHeader`.
1129
+ */
1130
+ function validatePreamble(
1131
+ bytes: Uint8Array,
1132
+ offset = 0,
1133
+ ): {
257
1134
  version: number
258
1135
  headerLength: number
259
1136
  } {
260
- if (bytes.length < PREAMBLE_SIZE) {
1137
+ if (bytes.length - offset < PREAMBLE_SIZE) {
261
1138
  throw new Error('File too short: expected at least 16 bytes')
262
1139
  }
263
1140
 
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
- }
1141
+ if (!startsWithMagic(bytes, offset)) {
1142
+ throw new Error('Invalid magic bytes: not a .moqtrace file')
268
1143
  }
269
1144
 
270
1145
  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})`)
1146
+ const version = view.getUint32(offset + 8, true)
1147
+ if (!SUPPORTED_VERSIONS.includes(version)) {
1148
+ throw new Error(
1149
+ `Unsupported format version: ${version} (supported: ${SUPPORTED_VERSIONS.join(', ')})`,
1150
+ )
274
1151
  }
275
1152
 
276
- const headerLength = view.getUint32(12, true)
277
- if (PREAMBLE_SIZE + headerLength > bytes.length) {
1153
+ const headerLength = view.getUint32(offset + 12, true)
1154
+ if (offset + PREAMBLE_SIZE + headerLength > bytes.length) {
278
1155
  throw new Error('File truncated: header extends beyond file')
279
1156
  }
280
1157
 
281
1158
  return { version, headerLength }
282
1159
  }
283
1160
 
1161
+ // --- Reading ---
1162
+
1163
+ /** Options shared by the readers. */
1164
+ export interface ReadOptions {
1165
+ /**
1166
+ * On a truncated or corrupt region, skip forward to the next segment and
1167
+ * carry on instead of throwing.
1168
+ *
1169
+ * This is the recovery segmentation exists to offer: one damaged segment
1170
+ * costs that segment rather than the rest of the capture. It can only help
1171
+ * a segmented trace — there is nothing to resynchronize to in a single
1172
+ * segment, so a damaged one still ends the read, just without an error.
1173
+ */
1174
+ readonly recover?: boolean
1175
+
1176
+ /**
1177
+ * Called once for each region `recover` skipped.
1178
+ *
1179
+ * Without it a recovered read cannot be told from a clean one: the return
1180
+ * type carries no error channel, so a trace that lost a segment and a trace
1181
+ * that never had one are the same value.
1182
+ */
1183
+ readonly onRecovered?: (region: RecoveredRegion) => void
1184
+ }
1185
+
1186
+ /** A region `recover` could not read. */
1187
+ export interface RecoveredRegion {
1188
+ /** Byte offset the unreadable region began at. */
1189
+ readonly offset: number
1190
+
1191
+ /**
1192
+ * What was lost: `header` drops the whole segment, `event` cuts the segment
1193
+ * in progress short, `truncated` means the item ran past end of file.
1194
+ */
1195
+ readonly kind: 'header' | 'event' | 'truncated'
1196
+
1197
+ /**
1198
+ * Offset the read resumed at, or `undefined` when no further segment
1199
+ * followed — everything from `offset` on was then discarded.
1200
+ */
1201
+ readonly resumedAt: number | undefined
1202
+
1203
+ /** The decode failure, absent when the region was merely truncated. */
1204
+ readonly cause?: unknown
1205
+ }
1206
+
1207
+ function flatten(segments: Trace[]): Trace | undefined {
1208
+ const first = segments[0]
1209
+ if (first == null) return undefined
1210
+ if (segments.length === 1) return first
1211
+ return { header: first.header, events: segments.flatMap((segment) => segment.events) }
1212
+ }
1213
+
1214
+ /** Scan forward for the next segment preamble; -1 if there is none. */
1215
+ function findNextSegment(bytes: Uint8Array, from: number): number {
1216
+ for (let offset = from; offset + MAGIC.length <= bytes.length; offset++) {
1217
+ if (startsWithMagic(bytes, offset)) return offset
1218
+ }
1219
+ return -1
1220
+ }
1221
+
1222
+ function readSegments(bytes: Uint8Array, options?: ReadOptions): Trace[] {
1223
+ const recover = options?.recover ?? false
1224
+ const segments: Trace[] = []
1225
+ let events: TraceEvent[] = []
1226
+ let offset = 0
1227
+
1228
+ // Reports the skip before resuming, including the case where nothing follows
1229
+ // it: discarding the tail of a file is the largest loss recovery can inflict
1230
+ // and the one a caller most needs told about.
1231
+ const skip = (from: number, kind: RecoveredRegion['kind'], cause?: unknown): number => {
1232
+ const next = findNextSegment(bytes, from + 1)
1233
+ options?.onRecovered?.({ offset: from, kind, resumedAt: next < 0 ? undefined : next, cause })
1234
+ return next
1235
+ }
1236
+
1237
+ // The first thing in the buffer must be a preamble; failing that, the
1238
+ // errors below name why rather than reporting a stray byte.
1239
+ validatePreamble(bytes, 0)
1240
+
1241
+ while (offset < bytes.length) {
1242
+ // Checked only here, at a real item boundary. The same eight bytes inside
1243
+ // a captured payload are not a segment start, and treating them as one
1244
+ // would split a trace that was never segmented.
1245
+ if (startsWithMagic(bytes, offset)) {
1246
+ const { headerLength } = validatePreamble(bytes, offset)
1247
+ const start = offset + PREAMBLE_SIZE
1248
+ const headerBytes = bytes.subarray(start, start + headerLength)
1249
+ let header: TraceHeader
1250
+ try {
1251
+ header = cborToHeader(decode(headerBytes) as Record<string, unknown>)
1252
+ } catch (error) {
1253
+ // A header this reader cannot build is malformed for *that segment*,
1254
+ // and the segment is not presented as read — the alternative is a
1255
+ // header the file never carried, which nothing downstream can tell
1256
+ // from a real one. `recover` skips to the next segment, on the same
1257
+ // terms as a malformed event; without it the read stops here.
1258
+ if (!recover) throw error
1259
+ const next = skip(offset, 'header', error)
1260
+ if (next < 0) return segments
1261
+ offset = next
1262
+ continue
1263
+ }
1264
+ events = []
1265
+ segments.push({ header, events })
1266
+ offset = start + headerLength
1267
+ continue
1268
+ }
1269
+
1270
+ let length: number | null
1271
+ let failure: unknown
1272
+ try {
1273
+ length = cborItemLength(bytes, offset)
1274
+ } catch (error) {
1275
+ if (!recover || !(error instanceof MalformedCborError)) throw error
1276
+ length = null
1277
+ failure = error
1278
+ }
1279
+
1280
+ if (length != null) {
1281
+ try {
1282
+ const item = decode(bytes.subarray(offset, offset + length))
1283
+ events.push(cborToEvent(item as Record<string, unknown>))
1284
+ offset += length
1285
+ continue
1286
+ } catch (error) {
1287
+ if (!recover) throw error
1288
+ failure = error
1289
+ }
1290
+ } else if (!recover) {
1291
+ throw new TruncatedTraceError(offset, segments)
1292
+ }
1293
+
1294
+ // No failure means `cborItemLength` returned null: the item runs past the
1295
+ // end of the file rather than being malformed where it sits.
1296
+ const next = skip(offset, failure === undefined ? 'truncated' : 'event', failure)
1297
+ if (next < 0) return segments
1298
+ offset = next
1299
+ }
1300
+
1301
+ return segments
1302
+ }
1303
+
284
1304
  // --- Public API ---
285
1305
 
286
1306
  /**
@@ -310,32 +1330,77 @@ export function writeMoqtrace(trace: Trace): Uint8Array {
310
1330
  }
311
1331
 
312
1332
  /**
313
- * Read a complete .moqtrace file from bytes.
1333
+ * Write several segments back to back as one segmented `.moqtrace` stream.
1334
+ *
1335
+ * Every segment header must carry {@link SegmentInfo}. That field is what
1336
+ * tells a reader the sequence numbers and timestamps it is about to see
1337
+ * restart at zero; a segmented stream written without it reads as a run of
1338
+ * complete files whose timeline jumps backwards at every boundary, and
1339
+ * nothing downstream can detect that it happened.
314
1340
  */
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>)
1341
+ export function writeMoqtraceSegments(segments: Trace[]): Uint8Array {
1342
+ const chunks = segments.map((segment, index) => {
1343
+ if (segment.header.segment == null) {
1344
+ throw new Error(`Segment ${index} has no 'segment' metadata in its header`)
1345
+ }
1346
+ return writeMoqtrace(segment)
1347
+ })
320
1348
 
321
- const eventBytes = bytes.slice(PREAMBLE_SIZE + headerLength)
322
- const events: TraceEvent[] = []
1349
+ const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0)
1350
+ const result = new Uint8Array(total)
1351
+ let offset = 0
1352
+ for (const chunk of chunks) {
1353
+ result.set(chunk, offset)
1354
+ offset += chunk.length
1355
+ }
1356
+ return result
1357
+ }
323
1358
 
324
- if (eventBytes.length > 0) {
325
- decodeMultiple(eventBytes, (obj: unknown) => {
326
- events.push(cborToEvent(obj as Record<string, unknown>))
327
- })
1359
+ /**
1360
+ * Read a complete .moqtrace file from bytes.
1361
+ *
1362
+ * A segmented file is flattened: the returned header is the first segment's,
1363
+ * and the events of every segment follow in order. Use
1364
+ * {@link readMoqtraceSegments} to see the boundaries — you need them to order
1365
+ * events across segments, since `seq` and `timestamp` restart in each.
1366
+ *
1367
+ * @throws {TruncatedTraceError} if the file stops part-way through an item.
1368
+ * The error carries everything that decoded before the cut.
1369
+ * @throws {MalformedHeaderError} if a segment header carries no usable value
1370
+ * for a required key. Pass `recover` to skip that segment instead.
1371
+ */
1372
+ export function readMoqtrace(bytes: Uint8Array, options?: ReadOptions): Trace {
1373
+ const trace = flatten(readSegments(bytes, options))
1374
+ if (trace == null) {
1375
+ throw new Error('Invalid magic bytes: not a .moqtrace file')
328
1376
  }
1377
+ return trace
1378
+ }
329
1379
 
330
- return { header, events }
1380
+ /**
1381
+ * Read a .moqtrace file as its segments, one entry per segment.
1382
+ *
1383
+ * A non-segmented file yields exactly one entry, whose header carries no
1384
+ * `segment` field.
1385
+ *
1386
+ * @throws {MalformedHeaderError} as {@link readMoqtrace} does.
1387
+ */
1388
+ export function readMoqtraceSegments(bytes: Uint8Array, options?: ReadOptions): Trace[] {
1389
+ return readSegments(bytes, options)
331
1390
  }
332
1391
 
333
1392
  /**
334
1393
  * Read only the header from a .moqtrace file (fast metadata peek).
1394
+ *
1395
+ * In a segmented file this is the first segment's header.
1396
+ *
1397
+ * @throws {MalformedHeaderError} if that header carries no usable value for a
1398
+ * required key. There is no partial header to hand back and no later segment
1399
+ * to fall forward to, so this one always throws.
335
1400
  */
336
1401
  export function readMoqtraceHeader(bytes: Uint8Array): TraceHeader {
337
1402
  const { headerLength } = validatePreamble(bytes)
338
- const headerBytes = bytes.slice(PREAMBLE_SIZE, PREAMBLE_SIZE + headerLength)
1403
+ const headerBytes = bytes.subarray(PREAMBLE_SIZE, PREAMBLE_SIZE + headerLength)
339
1404
  return cborToHeader(decode(headerBytes) as Record<string, unknown>)
340
1405
  }
341
1406
 
@@ -347,6 +1412,16 @@ export interface MoqtraceWriter {
347
1412
  preamble(): Uint8Array
348
1413
  /** Encode a single event. Append the returned bytes after the preamble. */
349
1414
  writeEvent(event: TraceEvent): Uint8Array
1415
+ /**
1416
+ * Begin a new segment: returns a fresh preamble to append after the
1417
+ * previous segment's last event.
1418
+ *
1419
+ * The header must carry {@link SegmentInfo} — see
1420
+ * {@link writeMoqtraceSegments} for why. It should keep the same
1421
+ * `protocol`, `perspective`, `detail` and `sessionId` as the segments
1422
+ * before it, and increment `segment.sequence`.
1423
+ */
1424
+ startSegment(header: TraceHeader): Uint8Array
350
1425
  }
351
1426
 
352
1427
  export function createMoqtraceWriter(header: TraceHeader): MoqtraceWriter {
@@ -360,5 +1435,11 @@ export function createMoqtraceWriter(header: TraceHeader): MoqtraceWriter {
360
1435
  writeEvent(event: TraceEvent): Uint8Array {
361
1436
  return encode(eventToCbor(event))
362
1437
  },
1438
+ startSegment(next: TraceHeader): Uint8Array {
1439
+ if (next.segment == null) {
1440
+ throw new Error("A segment header must carry 'segment' metadata")
1441
+ }
1442
+ return writePreamble(encode(headerToCbor(next)))
1443
+ },
363
1444
  }
364
1445
  }