@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/cbor-scan.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Measure one CBOR item without decoding it.
|
|
3
|
+
*
|
|
4
|
+
* A `.moqtrace` file is a CBOR sequence, and a segmented one splices a
|
|
5
|
+
* preamble between two of its items. Finding those boundaries means knowing
|
|
6
|
+
* where each item ends — and knowing it *only* at real item boundaries. A
|
|
7
|
+
* plain byte scan for the magic would also match the same eight bytes sitting
|
|
8
|
+
* inside a captured payload, and would split a trace that was never segmented.
|
|
9
|
+
*
|
|
10
|
+
* Measuring is cheaper than decoding: a byte or text string is skipped by its
|
|
11
|
+
* declared length rather than read, so the cost follows the structure of the
|
|
12
|
+
* data and not its size.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The item at the given offset ran past the end of the buffer. */
|
|
16
|
+
export const INCOMPLETE = null
|
|
17
|
+
|
|
18
|
+
/** Thrown when the bytes are not a well-formed CBOR item at all. */
|
|
19
|
+
export class MalformedCborError extends Error {
|
|
20
|
+
readonly offset: number
|
|
21
|
+
|
|
22
|
+
constructor(message: string, offset: number) {
|
|
23
|
+
super(`${message} at byte ${offset}`)
|
|
24
|
+
this.name = 'MalformedCborError'
|
|
25
|
+
this.offset = offset
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const MAJOR_UNSIGNED = 0
|
|
30
|
+
const MAJOR_NEGATIVE = 1
|
|
31
|
+
const MAJOR_BYTES = 2
|
|
32
|
+
const MAJOR_TEXT = 3
|
|
33
|
+
const MAJOR_ARRAY = 4
|
|
34
|
+
const MAJOR_MAP = 5
|
|
35
|
+
const MAJOR_TAG = 6
|
|
36
|
+
const MAJOR_SIMPLE = 7
|
|
37
|
+
|
|
38
|
+
const AI_ONE_BYTE = 24
|
|
39
|
+
const AI_TWO_BYTES = 25
|
|
40
|
+
const AI_FOUR_BYTES = 26
|
|
41
|
+
const AI_EIGHT_BYTES = 27
|
|
42
|
+
const AI_INDEFINITE = 31
|
|
43
|
+
const BREAK = 0xff
|
|
44
|
+
|
|
45
|
+
/** Bytes that follow the initial byte for each additional-information value. */
|
|
46
|
+
function headerExtraBytes(additionalInfo: number, offset: number): number {
|
|
47
|
+
if (additionalInfo < AI_ONE_BYTE) return 0
|
|
48
|
+
switch (additionalInfo) {
|
|
49
|
+
case AI_ONE_BYTE:
|
|
50
|
+
return 1
|
|
51
|
+
case AI_TWO_BYTES:
|
|
52
|
+
return 2
|
|
53
|
+
case AI_FOUR_BYTES:
|
|
54
|
+
return 4
|
|
55
|
+
case AI_EIGHT_BYTES:
|
|
56
|
+
return 8
|
|
57
|
+
case AI_INDEFINITE:
|
|
58
|
+
return 0
|
|
59
|
+
default:
|
|
60
|
+
throw new MalformedCborError(`reserved additional information ${additionalInfo}`, offset)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Read the argument encoded in the item header. */
|
|
65
|
+
function readArgument(bytes: Uint8Array, offset: number, additionalInfo: number): number {
|
|
66
|
+
if (additionalInfo < AI_ONE_BYTE) return additionalInfo
|
|
67
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
68
|
+
switch (additionalInfo) {
|
|
69
|
+
case AI_ONE_BYTE:
|
|
70
|
+
return view.getUint8(offset + 1)
|
|
71
|
+
case AI_TWO_BYTES:
|
|
72
|
+
return view.getUint16(offset + 1, false)
|
|
73
|
+
case AI_FOUR_BYTES:
|
|
74
|
+
return view.getUint32(offset + 1, false)
|
|
75
|
+
case AI_EIGHT_BYTES: {
|
|
76
|
+
const value = view.getBigUint64(offset + 1, false)
|
|
77
|
+
// A length this large cannot be indexed anyway; treating it as a number
|
|
78
|
+
// is safe because the caller only ever compares it against the buffer.
|
|
79
|
+
return Number(value)
|
|
80
|
+
}
|
|
81
|
+
default:
|
|
82
|
+
throw new MalformedCborError(`reserved additional information ${additionalInfo}`, offset)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Advance past the item at `offset`, returning the offset just after it, or
|
|
88
|
+
* {@link INCOMPLETE} if the buffer ends part-way through.
|
|
89
|
+
*
|
|
90
|
+
* @param allowBreak whether a break code is a legal item here — true only
|
|
91
|
+
* directly inside an indefinite-length container.
|
|
92
|
+
*/
|
|
93
|
+
function skipItem(bytes: Uint8Array, offset: number, allowBreak: boolean): number | null {
|
|
94
|
+
if (offset >= bytes.length) return INCOMPLETE
|
|
95
|
+
|
|
96
|
+
const initial = bytes[offset] as number
|
|
97
|
+
if (initial === BREAK) {
|
|
98
|
+
if (!allowBreak) {
|
|
99
|
+
throw new MalformedCborError('break outside an indefinite-length item', offset)
|
|
100
|
+
}
|
|
101
|
+
return offset + 1
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const major = initial >> 5
|
|
105
|
+
const additionalInfo = initial & 0x1f
|
|
106
|
+
const extra = headerExtraBytes(additionalInfo, offset)
|
|
107
|
+
if (offset + 1 + extra > bytes.length) return INCOMPLETE
|
|
108
|
+
|
|
109
|
+
const indefinite = additionalInfo === AI_INDEFINITE
|
|
110
|
+
if (indefinite && (major === MAJOR_UNSIGNED || major === MAJOR_NEGATIVE || major === MAJOR_TAG)) {
|
|
111
|
+
throw new MalformedCborError(`major type ${major} cannot be indefinite-length`, offset)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const afterHeader = offset + 1 + extra
|
|
115
|
+
|
|
116
|
+
switch (major) {
|
|
117
|
+
case MAJOR_UNSIGNED:
|
|
118
|
+
case MAJOR_NEGATIVE:
|
|
119
|
+
return afterHeader
|
|
120
|
+
|
|
121
|
+
case MAJOR_BYTES:
|
|
122
|
+
case MAJOR_TEXT: {
|
|
123
|
+
if (!indefinite) {
|
|
124
|
+
const length = readArgument(bytes, offset, additionalInfo)
|
|
125
|
+
const end = afterHeader + length
|
|
126
|
+
return end > bytes.length ? INCOMPLETE : end
|
|
127
|
+
}
|
|
128
|
+
// Indefinite strings are a run of definite-length chunks, then a break.
|
|
129
|
+
let cursor = afterHeader
|
|
130
|
+
for (;;) {
|
|
131
|
+
if (cursor >= bytes.length) return INCOMPLETE
|
|
132
|
+
if (bytes[cursor] === BREAK) return cursor + 1
|
|
133
|
+
const next = skipItem(bytes, cursor, false)
|
|
134
|
+
if (next === INCOMPLETE) return INCOMPLETE
|
|
135
|
+
cursor = next
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
case MAJOR_ARRAY:
|
|
140
|
+
case MAJOR_MAP: {
|
|
141
|
+
const perEntry = major === MAJOR_MAP ? 2 : 1
|
|
142
|
+
if (!indefinite) {
|
|
143
|
+
const count = readArgument(bytes, offset, additionalInfo) * perEntry
|
|
144
|
+
let cursor = afterHeader
|
|
145
|
+
for (let i = 0; i < count; i++) {
|
|
146
|
+
const next = skipItem(bytes, cursor, false)
|
|
147
|
+
if (next === INCOMPLETE) return INCOMPLETE
|
|
148
|
+
cursor = next
|
|
149
|
+
}
|
|
150
|
+
return cursor
|
|
151
|
+
}
|
|
152
|
+
let cursor = afterHeader
|
|
153
|
+
for (;;) {
|
|
154
|
+
if (cursor >= bytes.length) return INCOMPLETE
|
|
155
|
+
if (bytes[cursor] === BREAK) return cursor + 1
|
|
156
|
+
for (let i = 0; i < perEntry; i++) {
|
|
157
|
+
const next = skipItem(bytes, cursor, false)
|
|
158
|
+
if (next === INCOMPLETE) return INCOMPLETE
|
|
159
|
+
cursor = next
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
case MAJOR_TAG:
|
|
165
|
+
return skipItem(bytes, afterHeader, false)
|
|
166
|
+
|
|
167
|
+
case MAJOR_SIMPLE:
|
|
168
|
+
// Floats and simple values carry no content beyond the header.
|
|
169
|
+
return afterHeader
|
|
170
|
+
|
|
171
|
+
default:
|
|
172
|
+
throw new MalformedCborError(`unknown major type ${major}`, offset)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Byte length of the CBOR item beginning at `start`, or {@link INCOMPLETE} if
|
|
178
|
+
* the buffer ends before the item does.
|
|
179
|
+
*
|
|
180
|
+
* @throws {MalformedCborError} if the bytes are not a well-formed item.
|
|
181
|
+
*/
|
|
182
|
+
export function cborItemLength(bytes: Uint8Array, start: number): number | null {
|
|
183
|
+
const end = skipItem(bytes, start, false)
|
|
184
|
+
return end === INCOMPLETE ? INCOMPLETE : end - start
|
|
185
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,32 +1,54 @@
|
|
|
1
1
|
// Binary .moqtrace format
|
|
2
2
|
|
|
3
|
-
export type { MoqtraceWriter } from './binary.js'
|
|
3
|
+
export type { MoqtraceWriter, ReadOptions, RecoveredRegion } from './binary.js'
|
|
4
4
|
export {
|
|
5
5
|
createMoqtraceWriter,
|
|
6
|
+
FORMAT_VERSION,
|
|
7
|
+
MalformedHeaderError,
|
|
6
8
|
readMoqtrace,
|
|
7
9
|
readMoqtraceHeader,
|
|
10
|
+
readMoqtraceSegments,
|
|
11
|
+
SUPPORTED_VERSIONS,
|
|
12
|
+
TruncatedTraceError,
|
|
8
13
|
writeMoqtrace,
|
|
14
|
+
writeMoqtraceSegments,
|
|
9
15
|
} from './binary.js'
|
|
10
16
|
// JSON (convenience)
|
|
11
17
|
export { traceToJSON } from './json.js'
|
|
12
|
-
|
|
18
|
+
// Incremental reader, for a stream that arrives in pieces
|
|
19
|
+
export type { MoqtraceReader, ReadItem } from './reader.js'
|
|
20
|
+
export { createMoqtraceReader, TruncatedStreamError } from './reader.js'
|
|
21
|
+
export type { ErrorDetails, TraceRecorder } from './recorder.js'
|
|
13
22
|
// Recorder
|
|
14
|
-
export { createRecorder } from './recorder.js'
|
|
23
|
+
export { createRecorder, MAX_ERROR_RAW_BYTES } from './recorder.js'
|
|
15
24
|
|
|
16
25
|
// Types
|
|
17
26
|
export type {
|
|
18
27
|
AnnotationEvent,
|
|
19
28
|
ControlMessageEvent,
|
|
29
|
+
DerivationKind,
|
|
20
30
|
DetailLevel,
|
|
31
|
+
DropPolicy,
|
|
32
|
+
ErrorKind,
|
|
21
33
|
ObjectHeaderEvent,
|
|
22
34
|
ObjectPayloadEvent,
|
|
35
|
+
PeerConnectedEvent,
|
|
36
|
+
PeerDisconnectedEvent,
|
|
37
|
+
PeerRole,
|
|
23
38
|
Perspective,
|
|
24
39
|
RecorderOptions,
|
|
40
|
+
SamplingInfo,
|
|
41
|
+
SegmentInfo,
|
|
42
|
+
Side,
|
|
25
43
|
StateChangeEvent,
|
|
26
44
|
StreamClosedEvent,
|
|
27
45
|
StreamOpenedEvent,
|
|
46
|
+
SubscriptionDerivationEvent,
|
|
47
|
+
SubscriptionRef,
|
|
28
48
|
Trace,
|
|
29
49
|
TraceErrorEvent,
|
|
30
50
|
TraceEvent,
|
|
31
51
|
TraceHeader,
|
|
52
|
+
UnknownEvent,
|
|
32
53
|
} from './types.js'
|
|
54
|
+
export { controlMessageFields, TRACE_ID_LENGTH } from './types.js'
|
package/src/json.ts
CHANGED
|
@@ -7,6 +7,13 @@ import type { Trace } from './types.js'
|
|
|
7
7
|
* This is a one-way export for human inspection and debugging.
|
|
8
8
|
* For lossless round-tripping, use the binary .moqtrace format
|
|
9
9
|
* (`writeMoqtrace` / `readMoqtrace`).
|
|
10
|
+
*
|
|
11
|
+
* The unrecognised-key stores are part of the rendering rather than filtered
|
|
12
|
+
* out of it: `extra` appears on the header, on `segment`, on `sampling` and on
|
|
13
|
+
* each event, nested values and all. A view that showed only the keys this
|
|
14
|
+
* version names would reintroduce, for anyone reading a trace through it,
|
|
15
|
+
* exactly the loss the binary reader stopped making — and it is the view
|
|
16
|
+
* someone reaches for precisely when they suspect a key went missing.
|
|
10
17
|
*/
|
|
11
18
|
export function traceToJSON(trace: Trace): string {
|
|
12
19
|
return JSON.stringify(
|
package/src/reader.ts
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Incremental reader for the `.moqtrace` binary format.
|
|
3
|
+
*
|
|
4
|
+
* The three entry points in `binary.ts` all take the whole file at once, which
|
|
5
|
+
* a live capture does not have: the bytes arrive in chunks whose boundaries
|
|
6
|
+
* fall wherever the transport put them, almost never on an item boundary. This
|
|
7
|
+
* reader takes those chunks and hands back the items that have become whole,
|
|
8
|
+
* keeping the remainder for the next one.
|
|
9
|
+
*
|
|
10
|
+
* It is the read side of {@link createMoqtraceWriter}, which has been
|
|
11
|
+
* incremental from the start — the asymmetry this closes was one-sided.
|
|
12
|
+
*
|
|
13
|
+
* Decoding is not reimplemented here. The header and event decoders are
|
|
14
|
+
* imported from `binary.ts` so that a trace read in instalments and the same
|
|
15
|
+
* trace read whole produce the same values. A second decoder would be a second
|
|
16
|
+
* set of answers, and nothing tests two readers of one format against each
|
|
17
|
+
* other, so the disagreement would surface as a rendering bug somewhere far
|
|
18
|
+
* from here.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
cborToEvent,
|
|
23
|
+
cborToHeader,
|
|
24
|
+
decode,
|
|
25
|
+
MAGIC,
|
|
26
|
+
PREAMBLE_SIZE,
|
|
27
|
+
SUPPORTED_VERSIONS,
|
|
28
|
+
startsWithMagic,
|
|
29
|
+
} from './binary.js'
|
|
30
|
+
import { cborItemLength } from './cbor-scan.js'
|
|
31
|
+
import type { TraceEvent, TraceHeader } from './types.js'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The stream ended part-way through an item.
|
|
35
|
+
*
|
|
36
|
+
* Deliberately not `TruncatedTraceError`, whose `segments` field promises
|
|
37
|
+
* "everything that decoded before the cut". An incremental reader has already
|
|
38
|
+
* handed those events to its caller and does not keep them, so it cannot fill
|
|
39
|
+
* that field — and filling it with an empty array would report a trace that
|
|
40
|
+
* decoded nothing, which is a wrong answer rather than a missing one. The
|
|
41
|
+
* caller holds what decoded; this says only where the bytes stopped.
|
|
42
|
+
*/
|
|
43
|
+
export class TruncatedStreamError extends Error {
|
|
44
|
+
/**
|
|
45
|
+
* Offset into the whole stream at which the incomplete item begins — not
|
|
46
|
+
* where the bytes ran out. It matches the Rust reader's
|
|
47
|
+
* `MoqTraceError::Truncated { offset }`, so the same cut is named the same
|
|
48
|
+
* number in both languages.
|
|
49
|
+
*/
|
|
50
|
+
readonly offset: number
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* How many events reached the caller before the cut.
|
|
54
|
+
*
|
|
55
|
+
* The events themselves are gone — that is what makes this error different
|
|
56
|
+
* from {@link TruncatedTraceError} — but their count is not, and it costs one
|
|
57
|
+
* integer to keep. It is the difference between "the stream was cut" and "the
|
|
58
|
+
* stream was cut after 4,812 events at byte 190,224", and only the second
|
|
59
|
+
* tells whoever is holding a damaged capture how much of it they still have.
|
|
60
|
+
*/
|
|
61
|
+
readonly events: number
|
|
62
|
+
|
|
63
|
+
constructor(offset: number, events: number) {
|
|
64
|
+
super(
|
|
65
|
+
`Trace stream truncated: the item starting at byte ${offset} is incomplete, ` +
|
|
66
|
+
`after ${events} event${events === 1 ? '' : 's'}`,
|
|
67
|
+
)
|
|
68
|
+
this.name = 'TruncatedStreamError'
|
|
69
|
+
this.offset = offset
|
|
70
|
+
this.events = events
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One thing the reader has finished reading.
|
|
76
|
+
*
|
|
77
|
+
* The two kinds mirror the Rust crate's `ReadItem`, so a consumer that has read
|
|
78
|
+
* one implementation recognises the other. A `segment` arrives before any event
|
|
79
|
+
* that belongs to it, including the first one: unlike the Rust reader, which
|
|
80
|
+
* takes a blocking source and parses the opening header in its constructor,
|
|
81
|
+
* this one cannot have a header until bytes arrive, so every header reaches the
|
|
82
|
+
* caller the same way and there is no special case for the first.
|
|
83
|
+
*/
|
|
84
|
+
export type ReadItem =
|
|
85
|
+
| { readonly kind: 'segment'; readonly header: TraceHeader }
|
|
86
|
+
| { readonly kind: 'event'; readonly event: TraceEvent }
|
|
87
|
+
|
|
88
|
+
/** Reads a `.moqtrace` stream in whatever instalments it arrives in. */
|
|
89
|
+
export interface MoqtraceReader {
|
|
90
|
+
/**
|
|
91
|
+
* Add the next chunk and take everything it completed.
|
|
92
|
+
*
|
|
93
|
+
* Returns an empty array when the chunk did not finish an item, which is
|
|
94
|
+
* ordinary and not a signal of anything. Bytes left over are kept.
|
|
95
|
+
*
|
|
96
|
+
* @throws {MalformedHeaderError} if a segment header carries no usable value
|
|
97
|
+
* for a key the format requires.
|
|
98
|
+
* @throws {MalformedCborError} if the bytes at an item boundary are not a
|
|
99
|
+
* well-formed CBOR item.
|
|
100
|
+
*/
|
|
101
|
+
push(chunk: Uint8Array): ReadItem[]
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Declare the stream complete and take any last item.
|
|
105
|
+
*
|
|
106
|
+
* This can return items. An event shorter than the magic cannot be told from
|
|
107
|
+
* the start of a segment preamble while more bytes might still arrive, so
|
|
108
|
+
* such an event waits here until `end()` rules the preamble out.
|
|
109
|
+
*
|
|
110
|
+
* @throws {TruncatedStreamError} if bytes remain that do not form a whole
|
|
111
|
+
* item — the file stops mid-event, mid-preamble or mid-header.
|
|
112
|
+
*/
|
|
113
|
+
end(): ReadItem[]
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The header of the segment now being read, or `undefined` before the first
|
|
117
|
+
* one is whole. In a segmented stream this changes at every boundary.
|
|
118
|
+
*/
|
|
119
|
+
readonly header: TraceHeader | undefined
|
|
120
|
+
|
|
121
|
+
/** Bytes held back because they do not yet form a whole item. */
|
|
122
|
+
readonly pending: number
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A reader that takes the stream in chunks.
|
|
127
|
+
*
|
|
128
|
+
* ```ts
|
|
129
|
+
* const reader = createMoqtraceReader()
|
|
130
|
+
* for await (const chunk of stream) {
|
|
131
|
+
* for (const item of reader.push(chunk)) {
|
|
132
|
+
* if (item.kind === 'event') render(item.event)
|
|
133
|
+
* }
|
|
134
|
+
* }
|
|
135
|
+
* for (const item of reader.end()) { ... }
|
|
136
|
+
* ```
|
|
137
|
+
*
|
|
138
|
+
* `ReadOptions.recover` has no counterpart here **on purpose.** Its behaviour
|
|
139
|
+
* in the whole-buffer readers — drop the damaged segment, return the rest, say
|
|
140
|
+
* nothing — is under revision, because a caller cannot tell three segments from
|
|
141
|
+
* four with one unreadable. Guessing an answer here would make an incremental
|
|
142
|
+
* reader the place that shipped it, and a live view is where a silently
|
|
143
|
+
* dropped segment does the most harm. Until that is settled this reader stops
|
|
144
|
+
* at damage and says where.
|
|
145
|
+
*/
|
|
146
|
+
export function createMoqtraceReader(): MoqtraceReader {
|
|
147
|
+
// One buffer rather than a chunk list. After a consume it is `subarray`d, so
|
|
148
|
+
// the retained bytes are a view and not a copy, and in the steady state the
|
|
149
|
+
// remainder is smaller than one item — the concatenation on each push costs
|
|
150
|
+
// that remainder, not the stream.
|
|
151
|
+
let buffer = new Uint8Array(0)
|
|
152
|
+
// Where `buffer[0]` sits in the whole stream, so an error names an offset
|
|
153
|
+
// into the file rather than into whatever is left of it.
|
|
154
|
+
let consumed = 0
|
|
155
|
+
// Events handed to the caller, kept only so a truncation can say how much of
|
|
156
|
+
// the capture survived it.
|
|
157
|
+
let delivered = 0
|
|
158
|
+
let current: TraceHeader | undefined
|
|
159
|
+
let ended = false
|
|
160
|
+
|
|
161
|
+
function take(count: number): Uint8Array {
|
|
162
|
+
const slice = buffer.subarray(0, count)
|
|
163
|
+
buffer = buffer.subarray(count)
|
|
164
|
+
consumed += count
|
|
165
|
+
return slice
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Read a preamble and its header if all of it is here.
|
|
170
|
+
*
|
|
171
|
+
* Returns `undefined` to mean "not yet", which is why the length checks are
|
|
172
|
+
* not `validatePreamble`'s: that function is written for a buffer that is the
|
|
173
|
+
* whole file, so it reads a short one as a truncated file and throws. Here a
|
|
174
|
+
* short buffer is the normal state between two chunks. What it does share is
|
|
175
|
+
* the part worth sharing — the version check against
|
|
176
|
+
* {@link SUPPORTED_VERSIONS} and, once the bytes are all present,
|
|
177
|
+
* `cborToHeader`.
|
|
178
|
+
*/
|
|
179
|
+
function tryReadSegment(): TraceHeader | undefined {
|
|
180
|
+
if (buffer.length < PREAMBLE_SIZE) return undefined
|
|
181
|
+
|
|
182
|
+
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength)
|
|
183
|
+
const version = view.getUint32(8, true)
|
|
184
|
+
if (!SUPPORTED_VERSIONS.includes(version)) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`Unsupported format version: ${version} (supported: ${SUPPORTED_VERSIONS.join(', ')})`,
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const headerLength = view.getUint32(12, true)
|
|
191
|
+
if (buffer.length < PREAMBLE_SIZE + headerLength) return undefined
|
|
192
|
+
|
|
193
|
+
take(PREAMBLE_SIZE)
|
|
194
|
+
const headerBytes = take(headerLength)
|
|
195
|
+
return cborToHeader(decode(headerBytes) as Record<string, unknown>)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function drain(atEnd: boolean): ReadItem[] {
|
|
199
|
+
const items: ReadItem[] = []
|
|
200
|
+
|
|
201
|
+
for (;;) {
|
|
202
|
+
if (current === undefined) {
|
|
203
|
+
// Nothing has been read yet, so the stream must open with a preamble.
|
|
204
|
+
// Saying so here means a caller who points this at some other file
|
|
205
|
+
// learns that from the magic rather than from a CBOR error deep in it.
|
|
206
|
+
if (buffer.length >= MAGIC.length && !startsWithMagic(buffer, 0)) {
|
|
207
|
+
throw new Error('Invalid magic bytes: not a .moqtrace file')
|
|
208
|
+
}
|
|
209
|
+
const header = tryReadSegment()
|
|
210
|
+
if (header === undefined) break
|
|
211
|
+
current = header
|
|
212
|
+
items.push({ kind: 'segment', header })
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (buffer.length === 0) break
|
|
217
|
+
|
|
218
|
+
// A segment boundary is looked for only here, at a real item boundary —
|
|
219
|
+
// the same eight bytes inside a captured payload are not one. Deciding
|
|
220
|
+
// needs the whole magic: with fewer bytes than that in hand, "does not
|
|
221
|
+
// start with the magic" is not yet a fact about the stream, only about
|
|
222
|
+
// how much of it has arrived. Waiting is wrong at the end of the stream
|
|
223
|
+
// and right everywhere else, which is what `atEnd` distinguishes.
|
|
224
|
+
if (buffer.length < MAGIC.length && !atEnd) break
|
|
225
|
+
|
|
226
|
+
if (startsWithMagic(buffer, 0)) {
|
|
227
|
+
const header = tryReadSegment()
|
|
228
|
+
if (header === undefined) break
|
|
229
|
+
current = header
|
|
230
|
+
items.push({ kind: 'segment', header })
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const length = cborItemLength(buffer, 0)
|
|
235
|
+
if (length === null) break
|
|
236
|
+
|
|
237
|
+
const item = decode(take(length))
|
|
238
|
+
items.push({ kind: 'event', event: cborToEvent(item as Record<string, unknown>) })
|
|
239
|
+
delivered += 1
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return items
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
push(chunk: Uint8Array): ReadItem[] {
|
|
247
|
+
if (ended) throw new Error('Cannot push to a reader that has ended')
|
|
248
|
+
if (chunk.length > 0) {
|
|
249
|
+
const grown = new Uint8Array(buffer.length + chunk.length)
|
|
250
|
+
grown.set(buffer, 0)
|
|
251
|
+
grown.set(chunk, buffer.length)
|
|
252
|
+
buffer = grown
|
|
253
|
+
}
|
|
254
|
+
return drain(false)
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
end(): ReadItem[] {
|
|
258
|
+
if (ended) return []
|
|
259
|
+
ended = true
|
|
260
|
+
const items = drain(true)
|
|
261
|
+
// The one place a truncation is declared. `drain` only ever stops at
|
|
262
|
+
// bytes it cannot use yet, which is the ordinary state between chunks and
|
|
263
|
+
// a fault only once no more are coming — so it reports nothing and leaves
|
|
264
|
+
// the leftovers, and the verdict is taken here, where "no more are
|
|
265
|
+
// coming" is known. Raising it in both places, as an earlier revision
|
|
266
|
+
// did, gave one fault two throw sites that could drift apart.
|
|
267
|
+
//
|
|
268
|
+
// A reader that never produced a header is the same fault: it saw no
|
|
269
|
+
// preamble at all, which includes the empty stream. Returning no items
|
|
270
|
+
// for that would report a trace that recorded nothing, when what happened
|
|
271
|
+
// is that no trace arrived.
|
|
272
|
+
if (buffer.length > 0 || current === undefined) {
|
|
273
|
+
throw new TruncatedStreamError(consumed, delivered)
|
|
274
|
+
}
|
|
275
|
+
return items
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
get header(): TraceHeader | undefined {
|
|
279
|
+
return current
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
get pending(): number {
|
|
283
|
+
return buffer.length
|
|
284
|
+
},
|
|
285
|
+
}
|
|
286
|
+
}
|