@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/README.md +95 -0
- package/SPEC.md +388 -102
- package/dist/index.cjs +463 -54
- package/dist/index.d.cts +260 -5
- package/dist/index.d.ts +260 -5
- package/dist/index.js +462 -53
- package/package.json +2 -2
- package/src/binary.ts +469 -57
- package/src/cbor-scan.ts +185 -0
- package/src/index.ts +18 -1
- package/src/recorder.ts +10 -0
- package/src/types.ts +207 -2
package/src/binary.ts
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { Encoder } from 'cbor-x'
|
|
2
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
69
|
-
...(obj.endTime != null ? { endTime: obj.endTime
|
|
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
|
-
//
|
|
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: '
|
|
148
|
-
seq
|
|
149
|
-
timestamp
|
|
150
|
-
|
|
151
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
263
|
-
|
|
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
|
|
271
|
-
throw new Error(
|
|
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
|
-
*
|
|
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
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
320
|
-
const
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|