@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/README.md +184 -1
- package/SPEC.md +1083 -122
- package/dist/index.cjs +915 -97
- package/dist/index.d.cts +794 -9
- package/dist/index.d.ts +794 -9
- package/dist/index.js +914 -96
- package/package.json +6 -4
- package/src/binary.ts +1178 -97
- package/src/cbor-scan.ts +185 -0
- package/src/index.ts +25 -3
- package/src/json.ts +7 -0
- package/src/reader.ts +286 -0
- package/src/recorder.ts +199 -12
- package/src/types.ts +460 -3
package/src/binary.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { Encoder } from 'cbor-x'
|
|
2
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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: '
|
|
149
|
-
seq
|
|
150
|
-
timestamp
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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
|
|
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
|
-
|
|
265
|
-
|
|
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
|
|
273
|
-
throw new Error(
|
|
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
|
-
*
|
|
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
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
322
|
-
const
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|