@moqtap/trace 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -0
- package/SPEC.md +378 -111
- package/dist/index.cjs +461 -54
- package/dist/index.d.cts +251 -5
- package/dist/index.d.ts +251 -5
- package/dist/index.js +460 -53
- package/package.json +2 -2
- package/src/binary.ts +467 -57
- package/src/cbor-scan.ts +185 -0
- package/src/index.ts +18 -1
- package/src/recorder.ts +10 -0
- package/src/types.ts +198 -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,29 +174,90 @@ function cborToHeader(obj: Record<string, unknown>): TraceHeader {
|
|
|
65
174
|
protocol: obj.protocol as string,
|
|
66
175
|
perspective: obj.perspective as Perspective,
|
|
67
176
|
detail: obj.detail as DetailLevel,
|
|
68
|
-
startTime: obj.startTime
|
|
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
|
|
92
262
|
if (event.streamId != null) base.sid = event.streamId
|
|
93
263
|
if (event.raw != null) base.raw = event.raw
|
|
@@ -101,22 +271,22 @@ function eventToCbor(event: TraceEvent): Record<string, unknown> {
|
|
|
101
271
|
}
|
|
102
272
|
case 'stream-closed': {
|
|
103
273
|
base.sid = event.streamId
|
|
104
|
-
base.ec = event.errorCode
|
|
274
|
+
base.ec = int(event.errorCode)
|
|
105
275
|
break
|
|
106
276
|
}
|
|
107
277
|
case 'object-header': {
|
|
108
278
|
base.sid = event.streamId
|
|
109
279
|
base.g = event.groupId
|
|
110
280
|
base.o = event.objectId
|
|
111
|
-
base.pp = event.publisherPriority
|
|
112
|
-
base.os = event.objectStatus
|
|
281
|
+
base.pp = int(event.publisherPriority)
|
|
282
|
+
base.os = int(event.objectStatus)
|
|
113
283
|
break
|
|
114
284
|
}
|
|
115
285
|
case 'object-payload': {
|
|
116
286
|
base.sid = event.streamId
|
|
117
287
|
base.g = event.groupId
|
|
118
288
|
base.o = event.objectId
|
|
119
|
-
base.sz = event.size
|
|
289
|
+
base.sz = int(event.size)
|
|
120
290
|
if (event.payload != null) base.pl = event.payload
|
|
121
291
|
break
|
|
122
292
|
}
|
|
@@ -126,7 +296,7 @@ function eventToCbor(event: TraceEvent): Record<string, unknown> {
|
|
|
126
296
|
break
|
|
127
297
|
}
|
|
128
298
|
case 'error': {
|
|
129
|
-
base.ec = event.errorCode
|
|
299
|
+
base.ec = int(event.errorCode)
|
|
130
300
|
base.reason = event.reason
|
|
131
301
|
break
|
|
132
302
|
}
|
|
@@ -135,38 +305,80 @@ function eventToCbor(event: TraceEvent): Record<string, unknown> {
|
|
|
135
305
|
if (event.data !== undefined) base.data = event.data
|
|
136
306
|
break
|
|
137
307
|
}
|
|
308
|
+
case 'peer-connected': {
|
|
309
|
+
if (event.endpoint != null) base.endpoint = event.endpoint
|
|
310
|
+
if (event.transport != null) base.transport = event.transport
|
|
311
|
+
if (event.role != null) base.role = event.role
|
|
312
|
+
if (event.side != null) base.side = event.side
|
|
313
|
+
break
|
|
314
|
+
}
|
|
315
|
+
case 'peer-disconnected': {
|
|
316
|
+
base.ec = int(event.errorCode)
|
|
317
|
+
if (event.reason != null) base.reason = event.reason
|
|
318
|
+
break
|
|
319
|
+
}
|
|
320
|
+
case 'subscription-derivation': {
|
|
321
|
+
base.u = subscriptionRefToCbor(event.upstream)
|
|
322
|
+
base.d = event.downstream.map(subscriptionRefToCbor)
|
|
323
|
+
base.kind = event.kind
|
|
324
|
+
if (event.traceId != null) base.traceId = checkTraceId(event.traceId)
|
|
325
|
+
if (event.namespace != null) base.ns = event.namespace
|
|
326
|
+
if (event.trackName != null) base.tn = event.trackName
|
|
327
|
+
if (event.tDownstreamReceived != null) base.tdr = int(event.tDownstreamReceived)
|
|
328
|
+
if (event.tUpstreamSent != null) base.tus = int(event.tUpstreamSent)
|
|
329
|
+
if (event.tUpstreamOkReceived != null) base.tuo = int(event.tUpstreamOkReceived)
|
|
330
|
+
if (event.tDownstreamOkSent != null) base.tdo = int(event.tDownstreamOkSent)
|
|
331
|
+
break
|
|
332
|
+
}
|
|
333
|
+
case 'unknown': {
|
|
334
|
+
base.e = event.eventType
|
|
335
|
+
for (const [key, value] of Object.entries(event.fields)) {
|
|
336
|
+
base[key] = value
|
|
337
|
+
}
|
|
338
|
+
break
|
|
339
|
+
}
|
|
138
340
|
}
|
|
139
341
|
|
|
140
342
|
return base
|
|
141
343
|
}
|
|
142
344
|
|
|
143
345
|
function cborToEvent(obj: Record<string, unknown>): TraceEvent {
|
|
346
|
+
const seq = Number(obj.n ?? 0)
|
|
347
|
+
const timestamp = Number(obj.t ?? 0)
|
|
348
|
+
const peerFields = obj.p != null ? { peer: obj.p as string } : {}
|
|
144
349
|
const eventType = INT_TO_EVENT_TYPE[obj.e as number]
|
|
350
|
+
|
|
145
351
|
if (eventType == null) {
|
|
146
|
-
//
|
|
352
|
+
// New event types may be added without a version bump. Dropping one — or
|
|
353
|
+
// relabelling it as an annotation, which an earlier revision of this file
|
|
354
|
+
// did — makes this reader's ignorance permanent for every reader
|
|
355
|
+
// downstream of it, because a read-modify-write then rewrites the type.
|
|
356
|
+
const fields: Record<string, unknown> = {}
|
|
357
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
358
|
+
if (!COMMON_EVENT_KEYS.has(key)) fields[key] = value
|
|
359
|
+
}
|
|
147
360
|
return {
|
|
148
|
-
type: '
|
|
149
|
-
seq
|
|
150
|
-
timestamp
|
|
151
|
-
|
|
152
|
-
|
|
361
|
+
type: 'unknown' as const,
|
|
362
|
+
seq,
|
|
363
|
+
timestamp,
|
|
364
|
+
...peerFields,
|
|
365
|
+
eventType: Number(obj.e ?? -1),
|
|
366
|
+
fields,
|
|
153
367
|
}
|
|
154
368
|
}
|
|
155
369
|
|
|
156
|
-
const seq = Number(obj.n ?? 0)
|
|
157
|
-
const timestamp = Number(obj.t ?? 0)
|
|
158
|
-
|
|
159
370
|
switch (eventType) {
|
|
160
371
|
case 'control':
|
|
161
372
|
return {
|
|
162
373
|
type: 'control' as const,
|
|
163
374
|
seq,
|
|
164
375
|
timestamp,
|
|
376
|
+
...peerFields,
|
|
165
377
|
direction: obj.d as 0 | 1,
|
|
166
378
|
messageType: Number(obj.mt ?? 0),
|
|
167
379
|
message: (obj.msg ?? {}) as Record<string, unknown>,
|
|
168
380
|
...(obj.sid != null ? { streamId: BigInt(obj.sid as bigint | number) } : {}),
|
|
169
|
-
...(obj.raw != null ? { raw: obj.raw
|
|
381
|
+
...(obj.raw != null ? { raw: toBytes(obj.raw) } : {}),
|
|
170
382
|
}
|
|
171
383
|
|
|
172
384
|
case 'stream-opened':
|
|
@@ -174,6 +386,7 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
|
|
|
174
386
|
type: 'stream-opened' as const,
|
|
175
387
|
seq,
|
|
176
388
|
timestamp,
|
|
389
|
+
...peerFields,
|
|
177
390
|
streamId: BigInt(obj.sid as bigint | number),
|
|
178
391
|
direction: obj.d as 0 | 1,
|
|
179
392
|
streamType: obj.st as 0 | 1 | 2,
|
|
@@ -184,6 +397,7 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
|
|
|
184
397
|
type: 'stream-closed' as const,
|
|
185
398
|
seq,
|
|
186
399
|
timestamp,
|
|
400
|
+
...peerFields,
|
|
187
401
|
streamId: BigInt(obj.sid as bigint | number),
|
|
188
402
|
errorCode: Number(obj.ec ?? 0),
|
|
189
403
|
}
|
|
@@ -193,6 +407,7 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
|
|
|
193
407
|
type: 'object-header' as const,
|
|
194
408
|
seq,
|
|
195
409
|
timestamp,
|
|
410
|
+
...peerFields,
|
|
196
411
|
streamId: BigInt(obj.sid as bigint | number),
|
|
197
412
|
groupId: BigInt(obj.g as bigint | number),
|
|
198
413
|
objectId: BigInt(obj.o as bigint | number),
|
|
@@ -205,11 +420,12 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
|
|
|
205
420
|
type: 'object-payload' as const,
|
|
206
421
|
seq,
|
|
207
422
|
timestamp,
|
|
423
|
+
...peerFields,
|
|
208
424
|
streamId: BigInt(obj.sid as bigint | number),
|
|
209
425
|
groupId: BigInt(obj.g as bigint | number),
|
|
210
426
|
objectId: BigInt(obj.o as bigint | number),
|
|
211
427
|
size: Number(obj.sz ?? 0),
|
|
212
|
-
...(obj.pl != null ? { payload: obj.pl
|
|
428
|
+
...(obj.pl != null ? { payload: toBytes(obj.pl) } : {}),
|
|
213
429
|
}
|
|
214
430
|
|
|
215
431
|
case 'state-change':
|
|
@@ -217,6 +433,7 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
|
|
|
217
433
|
type: 'state-change' as const,
|
|
218
434
|
seq,
|
|
219
435
|
timestamp,
|
|
436
|
+
...peerFields,
|
|
220
437
|
from: obj.from as string,
|
|
221
438
|
to: obj.to as string,
|
|
222
439
|
}
|
|
@@ -226,6 +443,7 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
|
|
|
226
443
|
type: 'error' as const,
|
|
227
444
|
seq,
|
|
228
445
|
timestamp,
|
|
446
|
+
...peerFields,
|
|
229
447
|
errorCode: Number(obj.ec ?? 0),
|
|
230
448
|
reason: (obj.reason ?? '') as string,
|
|
231
449
|
}
|
|
@@ -235,9 +453,50 @@ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
|
|
|
235
453
|
type: 'annotation' as const,
|
|
236
454
|
seq,
|
|
237
455
|
timestamp,
|
|
456
|
+
...peerFields,
|
|
238
457
|
label: (obj.label ?? '') as string,
|
|
239
458
|
data: obj.data,
|
|
240
459
|
}
|
|
460
|
+
|
|
461
|
+
case 'peer-connected':
|
|
462
|
+
return {
|
|
463
|
+
type: 'peer-connected' as const,
|
|
464
|
+
seq,
|
|
465
|
+
timestamp,
|
|
466
|
+
...peerFields,
|
|
467
|
+
...(obj.endpoint != null ? { endpoint: obj.endpoint as string } : {}),
|
|
468
|
+
...(obj.transport != null ? { transport: obj.transport as string } : {}),
|
|
469
|
+
...(obj.role != null ? { role: obj.role as PeerRole } : {}),
|
|
470
|
+
...(obj.side != null ? { side: obj.side as Side } : {}),
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
case 'peer-disconnected':
|
|
474
|
+
return {
|
|
475
|
+
type: 'peer-disconnected' as const,
|
|
476
|
+
seq,
|
|
477
|
+
timestamp,
|
|
478
|
+
...peerFields,
|
|
479
|
+
errorCode: Number(obj.ec ?? 0),
|
|
480
|
+
...(obj.reason != null ? { reason: obj.reason as string } : {}),
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
case 'subscription-derivation':
|
|
484
|
+
return {
|
|
485
|
+
type: 'subscription-derivation' as const,
|
|
486
|
+
seq,
|
|
487
|
+
timestamp,
|
|
488
|
+
...peerFields,
|
|
489
|
+
upstream: cborToSubscriptionRef(obj.u),
|
|
490
|
+
downstream: ((obj.d ?? []) as unknown[]).map(cborToSubscriptionRef),
|
|
491
|
+
kind: (obj.kind ?? '') as DerivationKind,
|
|
492
|
+
...(obj.traceId != null ? { traceId: checkTraceId(obj.traceId) } : {}),
|
|
493
|
+
...(obj.ns != null ? { namespace: (obj.ns as unknown[]).map(toBytes) } : {}),
|
|
494
|
+
...(obj.tn != null ? { trackName: toBytes(obj.tn) } : {}),
|
|
495
|
+
...(obj.tdr != null ? { tDownstreamReceived: Number(obj.tdr) } : {}),
|
|
496
|
+
...(obj.tus != null ? { tUpstreamSent: Number(obj.tus) } : {}),
|
|
497
|
+
...(obj.tuo != null ? { tUpstreamOkReceived: Number(obj.tuo) } : {}),
|
|
498
|
+
...(obj.tdo != null ? { tDownstreamOkSent: Number(obj.tdo) } : {}),
|
|
499
|
+
}
|
|
241
500
|
}
|
|
242
501
|
}
|
|
243
502
|
|
|
@@ -253,34 +512,132 @@ function writePreamble(headerCbor: Uint8Array): Uint8Array {
|
|
|
253
512
|
return buf
|
|
254
513
|
}
|
|
255
514
|
|
|
256
|
-
function
|
|
515
|
+
function startsWithMagic(bytes: Uint8Array, offset: number): boolean {
|
|
516
|
+
if (offset + MAGIC.length > bytes.length) return false
|
|
517
|
+
for (let i = 0; i < MAGIC.length; i++) {
|
|
518
|
+
if (bytes[offset + i] !== MAGIC[i]) return false
|
|
519
|
+
}
|
|
520
|
+
return true
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function validatePreamble(
|
|
524
|
+
bytes: Uint8Array,
|
|
525
|
+
offset = 0,
|
|
526
|
+
): {
|
|
257
527
|
version: number
|
|
258
528
|
headerLength: number
|
|
259
529
|
} {
|
|
260
|
-
if (bytes.length < PREAMBLE_SIZE) {
|
|
530
|
+
if (bytes.length - offset < PREAMBLE_SIZE) {
|
|
261
531
|
throw new Error('File too short: expected at least 16 bytes')
|
|
262
532
|
}
|
|
263
533
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
throw new Error('Invalid magic bytes: not a .moqtrace file')
|
|
267
|
-
}
|
|
534
|
+
if (!startsWithMagic(bytes, offset)) {
|
|
535
|
+
throw new Error('Invalid magic bytes: not a .moqtrace file')
|
|
268
536
|
}
|
|
269
537
|
|
|
270
538
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
271
|
-
const version = view.getUint32(8, true)
|
|
272
|
-
if (version
|
|
273
|
-
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
|
+
)
|
|
274
544
|
}
|
|
275
545
|
|
|
276
|
-
const headerLength = view.getUint32(12, true)
|
|
277
|
-
if (PREAMBLE_SIZE + headerLength > bytes.length) {
|
|
546
|
+
const headerLength = view.getUint32(offset + 12, true)
|
|
547
|
+
if (offset + PREAMBLE_SIZE + headerLength > bytes.length) {
|
|
278
548
|
throw new Error('File truncated: header extends beyond file')
|
|
279
549
|
}
|
|
280
550
|
|
|
281
551
|
return { version, headerLength }
|
|
282
552
|
}
|
|
283
553
|
|
|
554
|
+
// --- Reading ---
|
|
555
|
+
|
|
556
|
+
/** Options shared by the readers. */
|
|
557
|
+
export interface ReadOptions {
|
|
558
|
+
/**
|
|
559
|
+
* On a truncated or corrupt region, skip forward to the next segment and
|
|
560
|
+
* carry on instead of throwing.
|
|
561
|
+
*
|
|
562
|
+
* This is the recovery segmentation exists to offer: one damaged segment
|
|
563
|
+
* costs that segment rather than the rest of the capture. It can only help
|
|
564
|
+
* a segmented trace — there is nothing to resynchronize to in a single
|
|
565
|
+
* segment, so a damaged one still ends the read, just without an error.
|
|
566
|
+
*/
|
|
567
|
+
readonly recover?: boolean
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function flatten(segments: Trace[]): Trace | undefined {
|
|
571
|
+
const first = segments[0]
|
|
572
|
+
if (first == null) return undefined
|
|
573
|
+
if (segments.length === 1) return first
|
|
574
|
+
return { header: first.header, events: segments.flatMap((segment) => segment.events) }
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** Scan forward for the next segment preamble; -1 if there is none. */
|
|
578
|
+
function findNextSegment(bytes: Uint8Array, from: number): number {
|
|
579
|
+
for (let offset = from; offset + MAGIC.length <= bytes.length; offset++) {
|
|
580
|
+
if (startsWithMagic(bytes, offset)) return offset
|
|
581
|
+
}
|
|
582
|
+
return -1
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function readSegments(bytes: Uint8Array, options?: ReadOptions): Trace[] {
|
|
586
|
+
const recover = options?.recover ?? false
|
|
587
|
+
const segments: Trace[] = []
|
|
588
|
+
let events: TraceEvent[] = []
|
|
589
|
+
let offset = 0
|
|
590
|
+
|
|
591
|
+
// The first thing in the buffer must be a preamble; failing that, the
|
|
592
|
+
// errors below name why rather than reporting a stray byte.
|
|
593
|
+
validatePreamble(bytes, 0)
|
|
594
|
+
|
|
595
|
+
while (offset < bytes.length) {
|
|
596
|
+
// Checked only here, at a real item boundary. The same eight bytes inside
|
|
597
|
+
// a captured payload are not a segment start, and treating them as one
|
|
598
|
+
// would split a trace that was never segmented.
|
|
599
|
+
if (startsWithMagic(bytes, offset)) {
|
|
600
|
+
const { headerLength } = validatePreamble(bytes, offset)
|
|
601
|
+
const start = offset + PREAMBLE_SIZE
|
|
602
|
+
const headerBytes = bytes.subarray(start, start + headerLength)
|
|
603
|
+
events = []
|
|
604
|
+
segments.push({
|
|
605
|
+
header: cborToHeader(decode(headerBytes) as Record<string, unknown>),
|
|
606
|
+
events,
|
|
607
|
+
})
|
|
608
|
+
offset = start + headerLength
|
|
609
|
+
continue
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
let length: number | null
|
|
613
|
+
try {
|
|
614
|
+
length = cborItemLength(bytes, offset)
|
|
615
|
+
} catch (error) {
|
|
616
|
+
if (!recover || !(error instanceof MalformedCborError)) throw error
|
|
617
|
+
length = null
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (length != null) {
|
|
621
|
+
try {
|
|
622
|
+
const item = decode(bytes.subarray(offset, offset + length))
|
|
623
|
+
events.push(cborToEvent(item as Record<string, unknown>))
|
|
624
|
+
offset += length
|
|
625
|
+
continue
|
|
626
|
+
} catch (error) {
|
|
627
|
+
if (!recover) throw error
|
|
628
|
+
}
|
|
629
|
+
} else if (!recover) {
|
|
630
|
+
throw new TruncatedTraceError(offset, segments)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const next = findNextSegment(bytes, offset + 1)
|
|
634
|
+
if (next < 0) return segments
|
|
635
|
+
offset = next
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return segments
|
|
639
|
+
}
|
|
640
|
+
|
|
284
641
|
// --- Public API ---
|
|
285
642
|
|
|
286
643
|
/**
|
|
@@ -310,32 +667,69 @@ export function writeMoqtrace(trace: Trace): Uint8Array {
|
|
|
310
667
|
}
|
|
311
668
|
|
|
312
669
|
/**
|
|
313
|
-
*
|
|
670
|
+
* Write several segments back to back as one segmented `.moqtrace` stream.
|
|
671
|
+
*
|
|
672
|
+
* Every segment header must carry {@link SegmentInfo}. That field is what
|
|
673
|
+
* tells a reader the sequence numbers and timestamps it is about to see
|
|
674
|
+
* restart at zero; a segmented stream written without it reads as a run of
|
|
675
|
+
* complete files whose timeline jumps backwards at every boundary, and
|
|
676
|
+
* nothing downstream can detect that it happened.
|
|
314
677
|
*/
|
|
315
|
-
export function
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
678
|
+
export function writeMoqtraceSegments(segments: Trace[]): Uint8Array {
|
|
679
|
+
const chunks = segments.map((segment, index) => {
|
|
680
|
+
if (segment.header.segment == null) {
|
|
681
|
+
throw new Error(`Segment ${index} has no 'segment' metadata in its header`)
|
|
682
|
+
}
|
|
683
|
+
return writeMoqtrace(segment)
|
|
684
|
+
})
|
|
320
685
|
|
|
321
|
-
const
|
|
322
|
-
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
|
+
}
|
|
323
695
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
696
|
+
/**
|
|
697
|
+
* Read a complete .moqtrace file from bytes.
|
|
698
|
+
*
|
|
699
|
+
* A segmented file is flattened: the returned header is the first segment's,
|
|
700
|
+
* and the events of every segment follow in order. Use
|
|
701
|
+
* {@link readMoqtraceSegments} to see the boundaries — you need them to order
|
|
702
|
+
* events across segments, since `seq` and `timestamp` restart in each.
|
|
703
|
+
*
|
|
704
|
+
* @throws {TruncatedTraceError} if the file stops part-way through an item.
|
|
705
|
+
* The error carries everything that decoded before the cut.
|
|
706
|
+
*/
|
|
707
|
+
export function readMoqtrace(bytes: Uint8Array, options?: ReadOptions): Trace {
|
|
708
|
+
const trace = flatten(readSegments(bytes, options))
|
|
709
|
+
if (trace == null) {
|
|
710
|
+
throw new Error('Invalid magic bytes: not a .moqtrace file')
|
|
328
711
|
}
|
|
712
|
+
return trace
|
|
713
|
+
}
|
|
329
714
|
|
|
330
|
-
|
|
715
|
+
/**
|
|
716
|
+
* Read a .moqtrace file as its segments, one entry per segment.
|
|
717
|
+
*
|
|
718
|
+
* A non-segmented file yields exactly one entry, whose header carries no
|
|
719
|
+
* `segment` field.
|
|
720
|
+
*/
|
|
721
|
+
export function readMoqtraceSegments(bytes: Uint8Array, options?: ReadOptions): Trace[] {
|
|
722
|
+
return readSegments(bytes, options)
|
|
331
723
|
}
|
|
332
724
|
|
|
333
725
|
/**
|
|
334
726
|
* Read only the header from a .moqtrace file (fast metadata peek).
|
|
727
|
+
*
|
|
728
|
+
* In a segmented file this is the first segment's header.
|
|
335
729
|
*/
|
|
336
730
|
export function readMoqtraceHeader(bytes: Uint8Array): TraceHeader {
|
|
337
731
|
const { headerLength } = validatePreamble(bytes)
|
|
338
|
-
const headerBytes = bytes.
|
|
732
|
+
const headerBytes = bytes.subarray(PREAMBLE_SIZE, PREAMBLE_SIZE + headerLength)
|
|
339
733
|
return cborToHeader(decode(headerBytes) as Record<string, unknown>)
|
|
340
734
|
}
|
|
341
735
|
|
|
@@ -347,6 +741,16 @@ export interface MoqtraceWriter {
|
|
|
347
741
|
preamble(): Uint8Array
|
|
348
742
|
/** Encode a single event. Append the returned bytes after the preamble. */
|
|
349
743
|
writeEvent(event: TraceEvent): Uint8Array
|
|
744
|
+
/**
|
|
745
|
+
* Begin a new segment: returns a fresh preamble to append after the
|
|
746
|
+
* previous segment's last event.
|
|
747
|
+
*
|
|
748
|
+
* The header must carry {@link SegmentInfo} — see
|
|
749
|
+
* {@link writeMoqtraceSegments} for why. It should keep the same
|
|
750
|
+
* `protocol`, `perspective`, `detail` and `sessionId` as the segments
|
|
751
|
+
* before it, and increment `segment.sequence`.
|
|
752
|
+
*/
|
|
753
|
+
startSegment(header: TraceHeader): Uint8Array
|
|
350
754
|
}
|
|
351
755
|
|
|
352
756
|
export function createMoqtraceWriter(header: TraceHeader): MoqtraceWriter {
|
|
@@ -360,5 +764,11 @@ export function createMoqtraceWriter(header: TraceHeader): MoqtraceWriter {
|
|
|
360
764
|
writeEvent(event: TraceEvent): Uint8Array {
|
|
361
765
|
return encode(eventToCbor(event))
|
|
362
766
|
},
|
|
767
|
+
startSegment(next: TraceHeader): Uint8Array {
|
|
768
|
+
if (next.segment == null) {
|
|
769
|
+
throw new Error("A segment header must carry 'segment' metadata")
|
|
770
|
+
}
|
|
771
|
+
return writePreamble(encode(headerToCbor(next)))
|
|
772
|
+
},
|
|
363
773
|
}
|
|
364
774
|
}
|