@moqtap/trace 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/binary.ts ADDED
@@ -0,0 +1,351 @@
1
+ import { encode, decode, decodeMultiple } from 'cbor-x';
2
+ import type {
3
+ Trace,
4
+ TraceHeader,
5
+ TraceEvent,
6
+ DetailLevel,
7
+ Perspective,
8
+ } from './types.js';
9
+
10
+ const MAGIC = new Uint8Array([0x4d, 0x4f, 0x51, 0x54, 0x52, 0x41, 0x43, 0x45]); // "MOQTRACE"
11
+ const FORMAT_VERSION = 1;
12
+ const PREAMBLE_SIZE = 16; // 8 magic + 4 version + 4 header length
13
+
14
+ // Event type string ↔ integer mapping
15
+ const EVENT_TYPE_TO_INT: Record<TraceEvent['type'], number> = {
16
+ 'control': 0,
17
+ 'stream-opened': 1,
18
+ 'stream-closed': 2,
19
+ 'object-header': 3,
20
+ 'object-payload': 4,
21
+ 'state-change': 5,
22
+ 'error': 6,
23
+ 'annotation': 7,
24
+ };
25
+
26
+ const INT_TO_EVENT_TYPE: Record<number, TraceEvent['type']> = {
27
+ 0: 'control',
28
+ 1: 'stream-opened',
29
+ 2: 'stream-closed',
30
+ 3: 'object-header',
31
+ 4: 'object-payload',
32
+ 5: 'state-change',
33
+ 6: 'error',
34
+ 7: 'annotation',
35
+ };
36
+
37
+ // --- Header serialization ---
38
+
39
+ function headerToCbor(header: TraceHeader): Record<string, unknown> {
40
+ const map: Record<string, unknown> = {
41
+ protocol: header.protocol,
42
+ perspective: header.perspective,
43
+ detail: header.detail,
44
+ startTime: header.startTime,
45
+ };
46
+ if (header.endTime != null) map.endTime = header.endTime;
47
+ if (header.transport != null) map.transport = header.transport;
48
+ if (header.source != null) map.source = header.source;
49
+ if (header.endpoint != null) map.endpoint = header.endpoint;
50
+ if (header.sessionId != null) map.sessionId = header.sessionId;
51
+ if (header.custom != null) map.custom = header.custom;
52
+ return map;
53
+ }
54
+
55
+ function cborToHeader(obj: Record<string, unknown>): TraceHeader {
56
+ return {
57
+ protocol: obj.protocol as string,
58
+ perspective: obj.perspective as Perspective,
59
+ detail: obj.detail as DetailLevel,
60
+ startTime: obj.startTime as number,
61
+ ...(obj.endTime != null ? { endTime: obj.endTime as number } : {}),
62
+ ...(obj.transport != null ? { transport: obj.transport as string } : {}),
63
+ ...(obj.source != null ? { source: obj.source as string } : {}),
64
+ ...(obj.endpoint != null ? { endpoint: obj.endpoint as string } : {}),
65
+ ...(obj.sessionId != null ? { sessionId: obj.sessionId as string } : {}),
66
+ ...(obj.custom != null ? { custom: obj.custom as Record<string, unknown> } : {}),
67
+ };
68
+ }
69
+
70
+ // --- Event serialization ---
71
+
72
+ function eventToCbor(event: TraceEvent): Record<string, unknown> {
73
+ const base: Record<string, unknown> = {
74
+ n: event.seq,
75
+ t: event.timestamp,
76
+ e: EVENT_TYPE_TO_INT[event.type],
77
+ };
78
+
79
+ switch (event.type) {
80
+ case 'control': {
81
+ base.d = event.direction;
82
+ base.mt = event.messageType;
83
+ base.msg = event.message;
84
+ if (event.raw != null) base.raw = event.raw;
85
+ break;
86
+ }
87
+ case 'stream-opened': {
88
+ base.sid = event.streamId;
89
+ base.d = event.direction;
90
+ base.st = event.streamType;
91
+ break;
92
+ }
93
+ case 'stream-closed': {
94
+ base.sid = event.streamId;
95
+ base.ec = event.errorCode;
96
+ break;
97
+ }
98
+ case 'object-header': {
99
+ base.sid = event.streamId;
100
+ base.g = event.groupId;
101
+ base.o = event.objectId;
102
+ base.pp = event.publisherPriority;
103
+ base.os = event.objectStatus;
104
+ break;
105
+ }
106
+ case 'object-payload': {
107
+ base.sid = event.streamId;
108
+ base.g = event.groupId;
109
+ base.o = event.objectId;
110
+ base.sz = event.size;
111
+ if (event.payload != null) base.pl = event.payload;
112
+ break;
113
+ }
114
+ case 'state-change': {
115
+ base.from = event.from;
116
+ base.to = event.to;
117
+ break;
118
+ }
119
+ case 'error': {
120
+ base.ec = event.errorCode;
121
+ base.reason = event.reason;
122
+ break;
123
+ }
124
+ case 'annotation': {
125
+ base.label = event.label;
126
+ if (event.data !== undefined) base.data = event.data;
127
+ break;
128
+ }
129
+ }
130
+
131
+ return base;
132
+ }
133
+
134
+ function cborToEvent(obj: Record<string, unknown>): TraceEvent {
135
+ const eventType = INT_TO_EVENT_TYPE[obj.e as number];
136
+ if (eventType == null) {
137
+ // Unknown event type — skip per spec (return annotation as fallback)
138
+ return {
139
+ type: 'annotation',
140
+ seq: Number(obj.n ?? 0),
141
+ timestamp: Number(obj.t ?? 0),
142
+ label: `unknown-event-${obj.e}`,
143
+ data: obj,
144
+ };
145
+ }
146
+
147
+ const seq = Number(obj.n ?? 0);
148
+ const timestamp = Number(obj.t ?? 0);
149
+
150
+ switch (eventType) {
151
+ case 'control':
152
+ return {
153
+ type: 'control' as const,
154
+ seq,
155
+ timestamp,
156
+ direction: obj.d as 0 | 1,
157
+ messageType: Number(obj.mt ?? 0),
158
+ message: (obj.msg ?? {}) as Record<string, unknown>,
159
+ ...(obj.raw != null ? { raw: obj.raw as Uint8Array } : {}),
160
+ };
161
+
162
+ case 'stream-opened':
163
+ return {
164
+ type: 'stream-opened' as const,
165
+ seq,
166
+ timestamp,
167
+ streamId: BigInt(obj.sid as bigint | number),
168
+ direction: obj.d as 0 | 1,
169
+ streamType: obj.st as 0 | 1 | 2,
170
+ };
171
+
172
+ case 'stream-closed':
173
+ return {
174
+ type: 'stream-closed' as const,
175
+ seq,
176
+ timestamp,
177
+ streamId: BigInt(obj.sid as bigint | number),
178
+ errorCode: Number(obj.ec ?? 0),
179
+ };
180
+
181
+ case 'object-header':
182
+ return {
183
+ type: 'object-header' as const,
184
+ seq,
185
+ timestamp,
186
+ streamId: BigInt(obj.sid as bigint | number),
187
+ groupId: BigInt(obj.g as bigint | number),
188
+ objectId: BigInt(obj.o as bigint | number),
189
+ publisherPriority: Number(obj.pp ?? 0),
190
+ objectStatus: Number(obj.os ?? 0),
191
+ };
192
+
193
+ case 'object-payload':
194
+ return {
195
+ type: 'object-payload' as const,
196
+ seq,
197
+ timestamp,
198
+ streamId: BigInt(obj.sid as bigint | number),
199
+ groupId: BigInt(obj.g as bigint | number),
200
+ objectId: BigInt(obj.o as bigint | number),
201
+ size: Number(obj.sz ?? 0),
202
+ ...(obj.pl != null ? { payload: obj.pl as Uint8Array } : {}),
203
+ };
204
+
205
+ case 'state-change':
206
+ return {
207
+ type: 'state-change' as const,
208
+ seq,
209
+ timestamp,
210
+ from: obj.from as string,
211
+ to: obj.to as string,
212
+ };
213
+
214
+ case 'error':
215
+ return {
216
+ type: 'error' as const,
217
+ seq,
218
+ timestamp,
219
+ errorCode: Number(obj.ec ?? 0),
220
+ reason: (obj.reason ?? '') as string,
221
+ };
222
+
223
+ case 'annotation':
224
+ return {
225
+ type: 'annotation' as const,
226
+ seq,
227
+ timestamp,
228
+ label: (obj.label ?? '') as string,
229
+ data: obj.data,
230
+ };
231
+ }
232
+ }
233
+
234
+ // --- Preamble helpers ---
235
+
236
+ function writePreamble(headerCbor: Uint8Array): Uint8Array {
237
+ const buf = new Uint8Array(PREAMBLE_SIZE + headerCbor.length);
238
+ buf.set(MAGIC, 0);
239
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
240
+ view.setUint32(8, FORMAT_VERSION, true);
241
+ view.setUint32(12, headerCbor.length, true);
242
+ buf.set(headerCbor, PREAMBLE_SIZE);
243
+ return buf;
244
+ }
245
+
246
+ function validatePreamble(bytes: Uint8Array): { version: number; headerLength: number } {
247
+ if (bytes.length < PREAMBLE_SIZE) {
248
+ throw new Error('File too short: expected at least 16 bytes');
249
+ }
250
+
251
+ for (let i = 0; i < 8; i++) {
252
+ if (bytes[i] !== MAGIC[i]) {
253
+ throw new Error('Invalid magic bytes: not a .moqtrace file');
254
+ }
255
+ }
256
+
257
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
258
+ const version = view.getUint32(8, true);
259
+ if (version !== FORMAT_VERSION) {
260
+ throw new Error(`Unsupported format version: ${version} (expected ${FORMAT_VERSION})`);
261
+ }
262
+
263
+ const headerLength = view.getUint32(12, true);
264
+ if (PREAMBLE_SIZE + headerLength > bytes.length) {
265
+ throw new Error('File truncated: header extends beyond file');
266
+ }
267
+
268
+ return { version, headerLength };
269
+ }
270
+
271
+ // --- Public API ---
272
+
273
+ /**
274
+ * Write a complete trace to .moqtrace binary format.
275
+ */
276
+ export function writeMoqtrace(trace: Trace): Uint8Array {
277
+ const headerCbor = encode(headerToCbor(trace.header));
278
+ const preamble = writePreamble(headerCbor);
279
+
280
+ const eventChunks: Uint8Array[] = [];
281
+ let totalEventBytes = 0;
282
+ for (const event of trace.events) {
283
+ const chunk = encode(eventToCbor(event));
284
+ eventChunks.push(chunk);
285
+ totalEventBytes += chunk.length;
286
+ }
287
+
288
+ const result = new Uint8Array(preamble.length + totalEventBytes);
289
+ result.set(preamble, 0);
290
+ let offset = preamble.length;
291
+ for (const chunk of eventChunks) {
292
+ result.set(chunk, offset);
293
+ offset += chunk.length;
294
+ }
295
+
296
+ return result;
297
+ }
298
+
299
+ /**
300
+ * Read a complete .moqtrace file from bytes.
301
+ */
302
+ export function readMoqtrace(bytes: Uint8Array): Trace {
303
+ const { headerLength } = validatePreamble(bytes);
304
+
305
+ const headerBytes = bytes.slice(PREAMBLE_SIZE, PREAMBLE_SIZE + headerLength);
306
+ const header = cborToHeader(decode(headerBytes) as Record<string, unknown>);
307
+
308
+ const eventBytes = bytes.slice(PREAMBLE_SIZE + headerLength);
309
+ const events: TraceEvent[] = [];
310
+
311
+ if (eventBytes.length > 0) {
312
+ decodeMultiple(eventBytes, (obj: unknown) => {
313
+ events.push(cborToEvent(obj as Record<string, unknown>));
314
+ });
315
+ }
316
+
317
+ return { header, events };
318
+ }
319
+
320
+ /**
321
+ * Read only the header from a .moqtrace file (fast metadata peek).
322
+ */
323
+ export function readMoqtraceHeader(bytes: Uint8Array): TraceHeader {
324
+ const { headerLength } = validatePreamble(bytes);
325
+ const headerBytes = bytes.slice(PREAMBLE_SIZE, PREAMBLE_SIZE + headerLength);
326
+ return cborToHeader(decode(headerBytes) as Record<string, unknown>);
327
+ }
328
+
329
+ /**
330
+ * Streaming writer for building .moqtrace files incrementally.
331
+ */
332
+ export interface MoqtraceWriter {
333
+ /** Returns the file preamble (magic + version + header). Write this first. */
334
+ preamble(): Uint8Array;
335
+ /** Encode a single event. Append the returned bytes after the preamble. */
336
+ writeEvent(event: TraceEvent): Uint8Array;
337
+ }
338
+
339
+ export function createMoqtraceWriter(header: TraceHeader): MoqtraceWriter {
340
+ const headerCbor = encode(headerToCbor(header));
341
+ const preambleBytes = writePreamble(headerCbor);
342
+
343
+ return {
344
+ preamble(): Uint8Array {
345
+ return preambleBytes;
346
+ },
347
+ writeEvent(event: TraceEvent): Uint8Array {
348
+ return encode(eventToCbor(event));
349
+ },
350
+ };
351
+ }
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ // Binary .moqtrace format
2
+ export {
3
+ writeMoqtrace,
4
+ readMoqtrace,
5
+ readMoqtraceHeader,
6
+ createMoqtraceWriter,
7
+ } from './binary.js';
8
+ export type { MoqtraceWriter } from './binary.js';
9
+
10
+ // Recorder
11
+ export { createRecorder } from './recorder.js';
12
+ export type { TraceRecorder } from './recorder.js';
13
+
14
+ // JSON (convenience)
15
+ export { traceToJSON } from './json.js';
16
+
17
+ // Types
18
+ export type {
19
+ Trace,
20
+ TraceHeader,
21
+ TraceEvent,
22
+ ControlMessageEvent,
23
+ StreamOpenedEvent,
24
+ StreamClosedEvent,
25
+ ObjectHeaderEvent,
26
+ ObjectPayloadEvent,
27
+ StateChangeEvent,
28
+ TraceErrorEvent,
29
+ AnnotationEvent,
30
+ DetailLevel,
31
+ Perspective,
32
+ RecorderOptions,
33
+ } from './types.js';
package/src/json.ts ADDED
@@ -0,0 +1,21 @@
1
+ import type { Trace } from './types.js';
2
+
3
+ /**
4
+ * Serialize a trace to human-readable JSON.
5
+ *
6
+ * Handles bigint → hex string and Uint8Array → hex string.
7
+ * This is a one-way export for human inspection and debugging.
8
+ * For lossless round-tripping, use the binary .moqtrace format
9
+ * (`writeMoqtrace` / `readMoqtrace`).
10
+ */
11
+ export function traceToJSON(trace: Trace): string {
12
+ return JSON.stringify(trace, (_key, value) => {
13
+ if (typeof value === 'bigint') {
14
+ return `0x${value.toString(16)}`;
15
+ }
16
+ if (value instanceof Uint8Array) {
17
+ return Array.from(value).map(b => b.toString(16).padStart(2, '0')).join('');
18
+ }
19
+ return value;
20
+ }, 2);
21
+ }
@@ -0,0 +1,247 @@
1
+ import type { MoqtMessage } from '@moqtap/codec';
2
+ import type { SessionState } from '@moqtap/codec/session';
3
+ import type {
4
+ Trace,
5
+ TraceHeader,
6
+ TraceEvent,
7
+ RecorderOptions,
8
+ DetailLevel,
9
+ } from './types.js';
10
+
11
+ export interface TraceRecorder {
12
+ /** Wrap a SessionState to auto-record control messages and state changes. */
13
+ wrapSession(session: SessionState): SessionState;
14
+
15
+ /** Record an arbitrary event manually. */
16
+ record(event: TraceEvent): void;
17
+
18
+ /** Record a stream-opened event. Ignored at 'control' detail level. */
19
+ recordStreamOpened(streamId: bigint, direction: 0 | 1, streamType: 0 | 1 | 2): void;
20
+
21
+ /** Record a stream-closed event. Ignored at 'control' detail level. */
22
+ recordStreamClosed(streamId: bigint, errorCode?: number): void;
23
+
24
+ /** Record an object header event. Ignored below 'headers' detail level. */
25
+ recordObjectHeader(
26
+ streamId: bigint, groupId: bigint, objectId: bigint,
27
+ publisherPriority: number, objectStatus: number,
28
+ ): void;
29
+
30
+ /** Record an object payload event. Ignored below 'headers+sizes' detail level. */
31
+ recordObjectPayload(
32
+ streamId: bigint, groupId: bigint, objectId: bigint,
33
+ size: number, payload?: Uint8Array,
34
+ ): void;
35
+
36
+ /** Record a protocol error. */
37
+ recordError(errorCode: number, reason: string): void;
38
+
39
+ /** Record a user-defined annotation. */
40
+ annotate(label: string, data?: unknown): void;
41
+
42
+ /** Finalize the trace. Stops recording and returns the trace. */
43
+ finalize(): Trace;
44
+
45
+ /** Whether the recorder is still accepting events. */
46
+ readonly recording: boolean;
47
+ }
48
+
49
+ const DETAIL_RANK: Record<DetailLevel, number> = {
50
+ 'control': 0,
51
+ 'headers': 1,
52
+ 'headers+sizes': 2,
53
+ 'headers+data': 3,
54
+ 'full': 4,
55
+ };
56
+
57
+ export function createRecorder(options: RecorderOptions): TraceRecorder {
58
+ const detail = options.detail;
59
+ const detailRank = DETAIL_RANK[detail];
60
+ const maxEvents = options.maxEvents ?? 100_000;
61
+ const clock = options.clock ?? (() => Math.round(performance.now() * 1000));
62
+ const messageTypeId = options.messageTypeId ?? (() => 0);
63
+
64
+ const events: TraceEvent[] = [];
65
+ let _recording = true;
66
+ let _seq = 0;
67
+ const startTime = Date.now();
68
+
69
+ function addEvent(event: TraceEvent): void {
70
+ if (!_recording) return;
71
+ if (events.length >= maxEvents) {
72
+ events.shift();
73
+ }
74
+ events.push(event);
75
+ }
76
+
77
+ function nextSeq(): number {
78
+ return _seq++;
79
+ }
80
+
81
+ function wrapSession(session: SessionState): SessionState {
82
+ return {
83
+ get phase() { return session.phase; },
84
+ get role() { return session.role; },
85
+ get subscriptions() { return session.subscriptions; },
86
+ get announces() { return session.announces; },
87
+ get legalOutgoing() { return session.legalOutgoing; },
88
+ get legalIncoming() { return session.legalIncoming; },
89
+
90
+ receive(message: MoqtMessage) {
91
+ const prevPhase = session.phase;
92
+ const result = session.receive(message);
93
+
94
+ addEvent({
95
+ type: 'control',
96
+ seq: nextSeq(),
97
+ timestamp: clock(),
98
+ direction: 1, // rx
99
+ messageType: messageTypeId(message.type),
100
+ message: message as unknown as Record<string, unknown>,
101
+ });
102
+
103
+ if (result.ok && result.phase !== prevPhase) {
104
+ addEvent({
105
+ type: 'state-change',
106
+ seq: nextSeq(),
107
+ timestamp: clock(),
108
+ from: prevPhase,
109
+ to: result.phase,
110
+ });
111
+ }
112
+
113
+ return result;
114
+ },
115
+
116
+ validateOutgoing(message: MoqtMessage) {
117
+ return session.validateOutgoing(message);
118
+ },
119
+
120
+ send(message: MoqtMessage) {
121
+ const prevPhase = session.phase;
122
+ const result = session.send(message);
123
+
124
+ addEvent({
125
+ type: 'control',
126
+ seq: nextSeq(),
127
+ timestamp: clock(),
128
+ direction: 0, // tx
129
+ messageType: messageTypeId(message.type),
130
+ message: message as unknown as Record<string, unknown>,
131
+ });
132
+
133
+ if (result.ok && result.phase !== prevPhase) {
134
+ addEvent({
135
+ type: 'state-change',
136
+ seq: nextSeq(),
137
+ timestamp: clock(),
138
+ from: prevPhase,
139
+ to: result.phase,
140
+ });
141
+ }
142
+
143
+ return result;
144
+ },
145
+
146
+ reset() {
147
+ session.reset();
148
+ },
149
+ };
150
+ }
151
+
152
+ return {
153
+ wrapSession,
154
+
155
+ record: addEvent,
156
+
157
+ recordStreamOpened(streamId, direction, streamType) {
158
+ if (detailRank < DETAIL_RANK['headers']) return;
159
+ addEvent({
160
+ type: 'stream-opened',
161
+ seq: nextSeq(),
162
+ timestamp: clock(),
163
+ streamId,
164
+ direction,
165
+ streamType,
166
+ });
167
+ },
168
+
169
+ recordStreamClosed(streamId, errorCode = 0) {
170
+ if (detailRank < DETAIL_RANK['headers']) return;
171
+ addEvent({
172
+ type: 'stream-closed',
173
+ seq: nextSeq(),
174
+ timestamp: clock(),
175
+ streamId,
176
+ errorCode,
177
+ });
178
+ },
179
+
180
+ recordObjectHeader(streamId, groupId, objectId, publisherPriority, objectStatus) {
181
+ if (detailRank < DETAIL_RANK['headers']) return;
182
+ addEvent({
183
+ type: 'object-header',
184
+ seq: nextSeq(),
185
+ timestamp: clock(),
186
+ streamId,
187
+ groupId,
188
+ objectId,
189
+ publisherPriority,
190
+ objectStatus,
191
+ });
192
+ },
193
+
194
+ recordObjectPayload(streamId, groupId, objectId, size, payload) {
195
+ if (detailRank < DETAIL_RANK['headers+sizes']) return;
196
+ const event: TraceEvent = {
197
+ type: 'object-payload',
198
+ seq: nextSeq(),
199
+ timestamp: clock(),
200
+ streamId,
201
+ groupId,
202
+ objectId,
203
+ size,
204
+ ...(detailRank >= DETAIL_RANK['headers+data'] && payload != null ? { payload } : {}),
205
+ };
206
+ addEvent(event);
207
+ },
208
+
209
+ recordError(errorCode, reason) {
210
+ addEvent({
211
+ type: 'error',
212
+ seq: nextSeq(),
213
+ timestamp: clock(),
214
+ errorCode,
215
+ reason,
216
+ });
217
+ },
218
+
219
+ annotate(label, data) {
220
+ addEvent({
221
+ type: 'annotation',
222
+ seq: nextSeq(),
223
+ timestamp: clock(),
224
+ label,
225
+ data,
226
+ });
227
+ },
228
+
229
+ finalize(): Trace {
230
+ _recording = false;
231
+ const header: TraceHeader = {
232
+ protocol: options.protocol,
233
+ perspective: options.perspective,
234
+ detail,
235
+ startTime,
236
+ endTime: Date.now(),
237
+ ...(options.transport != null ? { transport: options.transport } : {}),
238
+ ...(options.source != null ? { source: options.source } : {}),
239
+ ...(options.endpoint != null ? { endpoint: options.endpoint } : {}),
240
+ ...(options.sessionId != null ? { sessionId: options.sessionId } : {}),
241
+ };
242
+ return { header, events: [...events] };
243
+ },
244
+
245
+ get recording() { return _recording; },
246
+ };
247
+ }