@rozenite/network-activity-plugin 1.5.0 → 1.6.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.
@@ -0,0 +1,869 @@
1
+ import type {
2
+ HttpEventMap,
3
+ Request,
4
+ RequestId,
5
+ RequestPostData,
6
+ Response,
7
+ ResourceType,
8
+ Initiator,
9
+ } from '../../shared/client';
10
+ import type { WebSocketEventMap } from '../../shared/websocket-events';
11
+ import type { SSEEventMap } from '../../shared/sse-events';
12
+ import { safeStringify } from '../../utils/safeStringify';
13
+
14
+ const DEFAULT_PAGE_LIMIT = 20;
15
+ const MAX_PAGE_LIMIT = 100;
16
+ const HTTP_BUFFER_CAPACITY = 500;
17
+ const REALTIME_BUFFER_CAPACITY = 200;
18
+ const MAX_WEBSOCKET_MESSAGES_PER_CONNECTION = 32;
19
+ const MAX_SSE_MESSAGES_PER_CONNECTION = 32;
20
+
21
+ type HttpAgentRecord = {
22
+ requestId: RequestId;
23
+ request: Request;
24
+ resourceType: ResourceType;
25
+ initiator: Initiator;
26
+ startTimeMs: number;
27
+ status: 'pending' | 'loading' | 'finished' | 'failed';
28
+ progress?: HttpEventMap['request-progress'];
29
+ response?: Response;
30
+ endTimeMs?: number;
31
+ durationMs?: number;
32
+ size?: number | null;
33
+ ttfb?: number;
34
+ error?: string;
35
+ canceled?: boolean;
36
+ };
37
+
38
+ type WebSocketAgentMessage = {
39
+ id: string;
40
+ direction: 'sent' | 'received';
41
+ data: string;
42
+ messageType: 'text' | 'binary';
43
+ timestamp: number;
44
+ };
45
+
46
+ type WebSocketAgentRecord = {
47
+ requestId: string;
48
+ kind: 'websocket';
49
+ url: string;
50
+ socketId: number;
51
+ status: 'connecting' | 'open' | 'closing' | 'closed' | 'error';
52
+ startedAt: number;
53
+ endedAt?: number;
54
+ durationMs?: number;
55
+ protocols?: string[] | null;
56
+ options?: string[];
57
+ error?: string;
58
+ closeCode?: number;
59
+ closeReason?: string;
60
+ messages: WebSocketAgentMessage[];
61
+ };
62
+
63
+ type SSEAgentMessage = {
64
+ id: string;
65
+ type: string;
66
+ data: string;
67
+ timestamp: number;
68
+ };
69
+
70
+ type SSEAgentRecord = {
71
+ requestId: string;
72
+ kind: 'sse';
73
+ status: 'connecting' | 'open' | 'closed' | 'error';
74
+ startedAt: number;
75
+ endedAt?: number;
76
+ durationMs?: number;
77
+ request?: Request;
78
+ response?: Response;
79
+ initiator?: Initiator;
80
+ resourceType?: ResourceType;
81
+ error?: string;
82
+ messages: SSEAgentMessage[];
83
+ };
84
+
85
+ type RealtimeAgentRecord = WebSocketAgentRecord | SSEAgentRecord;
86
+
87
+ export type NetworkActivityAgentBodyResult = {
88
+ requestId: string;
89
+ available: boolean;
90
+ body?: string;
91
+ base64Encoded?: boolean;
92
+ decoded?: boolean;
93
+ mimeType?: string;
94
+ reason?: string;
95
+ };
96
+
97
+ type RecordingMetadata = {
98
+ enabledInspectors: {
99
+ http: boolean;
100
+ websocket: boolean;
101
+ sse: boolean;
102
+ };
103
+ };
104
+
105
+ type NetworkActivityAgentStateInternal = {
106
+ isRecording: boolean;
107
+ startedAt?: number;
108
+ stoppedAt?: number;
109
+ generation: number;
110
+ httpOrder: string[];
111
+ httpRecords: Map<string, HttpAgentRecord>;
112
+ httpTotalRecorded: number;
113
+ httpEvictedCount: number;
114
+ httpTruncated: boolean;
115
+ realtimeOrder: string[];
116
+ realtimeRecords: Map<string, RealtimeAgentRecord>;
117
+ realtimeTotalRecorded: number;
118
+ realtimeEvictedCount: number;
119
+ realtimeTruncated: boolean;
120
+ nextMessageId: number;
121
+ recordingMetadata: RecordingMetadata;
122
+ };
123
+
124
+ type Page<T> = {
125
+ items: T[];
126
+ page: {
127
+ limit: number;
128
+ hasMore: boolean;
129
+ nextCursor?: string;
130
+ };
131
+ };
132
+
133
+ const createInitialState = (): NetworkActivityAgentStateInternal => ({
134
+ isRecording: false,
135
+ generation: 0,
136
+ httpOrder: [],
137
+ httpRecords: new Map(),
138
+ httpTotalRecorded: 0,
139
+ httpEvictedCount: 0,
140
+ httpTruncated: false,
141
+ realtimeOrder: [],
142
+ realtimeRecords: new Map(),
143
+ realtimeTotalRecorded: 0,
144
+ realtimeEvictedCount: 0,
145
+ realtimeTruncated: false,
146
+ nextMessageId: 1,
147
+ recordingMetadata: {
148
+ enabledInspectors: {
149
+ http: true,
150
+ websocket: true,
151
+ sse: true,
152
+ },
153
+ },
154
+ });
155
+
156
+ const getLimit = (value: unknown): number => {
157
+ if (
158
+ typeof value !== 'number' ||
159
+ !Number.isInteger(value) ||
160
+ !Number.isFinite(value) ||
161
+ value < 1
162
+ ) {
163
+ return DEFAULT_PAGE_LIMIT;
164
+ }
165
+
166
+ return Math.min(value, MAX_PAGE_LIMIT);
167
+ };
168
+
169
+ const encodeCursor = (scope: string, index: number): string => {
170
+ return `${scope}:${index}`;
171
+ };
172
+
173
+ const decodeCursor = (cursor: string, scope: string): number => {
174
+ const [cursorScope, rawIndex] = cursor.split(':', 2);
175
+ if (cursorScope !== scope || !rawIndex) {
176
+ throw new Error(
177
+ 'Cursor does not match the requested listing. Run the command again.'
178
+ );
179
+ }
180
+
181
+ const index = Number(rawIndex);
182
+ if (!Number.isInteger(index) || index < 0) {
183
+ throw new Error('Cursor is invalid. Run the command again.');
184
+ }
185
+
186
+ return index;
187
+ };
188
+
189
+ const paginate = <T>(
190
+ rows: T[],
191
+ scope: string,
192
+ limit: number,
193
+ cursor?: string
194
+ ): Page<T> => {
195
+ const startIndex = cursor ? decodeCursor(cursor, scope) : 0;
196
+ const endIndex = Math.min(startIndex + limit, rows.length);
197
+ const hasMore = endIndex < rows.length;
198
+
199
+ return {
200
+ items: rows.slice(startIndex, endIndex),
201
+ page: {
202
+ limit,
203
+ hasMore,
204
+ ...(hasMore ? { nextCursor: encodeCursor(scope, endIndex) } : {}),
205
+ },
206
+ };
207
+ };
208
+
209
+ const serializeRequestBody = (
210
+ requestId: string,
211
+ postData?: RequestPostData
212
+ ): NetworkActivityAgentBodyResult => {
213
+ if (!postData) {
214
+ return {
215
+ requestId,
216
+ available: false,
217
+ reason: 'No request body is available for this request.',
218
+ };
219
+ }
220
+
221
+ if (postData.type === 'text') {
222
+ return {
223
+ requestId,
224
+ available: true,
225
+ body: postData.value,
226
+ base64Encoded: false,
227
+ };
228
+ }
229
+
230
+ return {
231
+ requestId,
232
+ available: true,
233
+ body: safeStringify(postData),
234
+ base64Encoded: false,
235
+ };
236
+ };
237
+
238
+ const createHttpSummary = (record: HttpAgentRecord) => ({
239
+ requestId: record.requestId,
240
+ method: record.request.method,
241
+ url: record.request.url,
242
+ status: record.response?.status ?? null,
243
+ type: record.resourceType,
244
+ startTimeMs: record.startTimeMs,
245
+ endTimeMs: record.endTimeMs ?? null,
246
+ durationMs: record.durationMs ?? null,
247
+ transferSize: record.size ?? null,
248
+ encodedDataLength: record.response?.size ?? null,
249
+ outcome:
250
+ record.status === 'failed'
251
+ ? 'failed'
252
+ : record.status === 'finished'
253
+ ? 'success'
254
+ : 'in-flight',
255
+ });
256
+
257
+ const getRealtimeSummary = (record: RealtimeAgentRecord) => {
258
+ if (record.kind === 'websocket') {
259
+ return {
260
+ requestId: record.requestId,
261
+ kind: record.kind,
262
+ url: record.url,
263
+ status: record.status,
264
+ startedAt: record.startedAt,
265
+ endedAt: record.endedAt ?? null,
266
+ durationMs: record.durationMs ?? null,
267
+ messageCount: record.messages.length,
268
+ error: record.error ?? null,
269
+ closeCode: record.closeCode ?? null,
270
+ };
271
+ }
272
+
273
+ return {
274
+ requestId: record.requestId,
275
+ kind: record.kind,
276
+ url: record.request?.url ?? record.response?.url ?? null,
277
+ status: record.status,
278
+ startedAt: record.startedAt,
279
+ endedAt: record.endedAt ?? null,
280
+ durationMs: record.durationMs ?? null,
281
+ messageCount: record.messages.length,
282
+ error: record.error ?? null,
283
+ httpStatus: record.response?.status ?? null,
284
+ };
285
+ };
286
+
287
+ const trimMap = <T>(
288
+ order: string[],
289
+ records: Map<string, T>,
290
+ capacity: number
291
+ ): number => {
292
+ let evicted = 0;
293
+ while (order.length > capacity) {
294
+ const oldestId = order.shift();
295
+ if (!oldestId) {
296
+ break;
297
+ }
298
+ records.delete(oldestId);
299
+ evicted += 1;
300
+ }
301
+ return evicted;
302
+ };
303
+
304
+ export const createNetworkActivityAgentState = () => {
305
+ const state = createInitialState();
306
+
307
+ const getStatus = () => ({
308
+ recording: {
309
+ isRecording: state.isRecording,
310
+ startedAt: state.startedAt ?? null,
311
+ stoppedAt: state.stoppedAt ?? null,
312
+ httpRequestCount: state.httpOrder.length,
313
+ realtimeConnectionCount: state.realtimeOrder.length,
314
+ http: {
315
+ totalRecorded: state.httpTotalRecorded,
316
+ evictedCount: state.httpEvictedCount,
317
+ truncated: state.httpTruncated,
318
+ capacity: HTTP_BUFFER_CAPACITY,
319
+ },
320
+ realtime: {
321
+ totalRecorded: state.realtimeTotalRecorded,
322
+ evictedCount: state.realtimeEvictedCount,
323
+ truncated: state.realtimeTruncated,
324
+ capacity: REALTIME_BUFFER_CAPACITY,
325
+ },
326
+ generation: state.generation,
327
+ enabledInspectors: state.recordingMetadata.enabledInspectors,
328
+ },
329
+ });
330
+
331
+ const ensureHttpRecord = (
332
+ requestId: string,
333
+ fallback?: Partial<HttpAgentRecord>
334
+ ): HttpAgentRecord => {
335
+ const existing = state.httpRecords.get(requestId);
336
+ if (existing) {
337
+ return existing;
338
+ }
339
+
340
+ const record: HttpAgentRecord = {
341
+ requestId,
342
+ request:
343
+ fallback?.request ||
344
+ ({
345
+ url: '',
346
+ method: 'GET',
347
+ headers: {},
348
+ } as Request),
349
+ resourceType: fallback?.resourceType || 'Other',
350
+ initiator: fallback?.initiator || { type: 'other' },
351
+ startTimeMs: fallback?.startTimeMs ?? Date.now(),
352
+ status: fallback?.status || 'pending',
353
+ };
354
+ state.httpRecords.set(requestId, record);
355
+ state.httpOrder.push(requestId);
356
+ state.httpTotalRecorded += 1;
357
+ const evicted = trimMap(
358
+ state.httpOrder,
359
+ state.httpRecords,
360
+ HTTP_BUFFER_CAPACITY
361
+ );
362
+ if (evicted > 0) {
363
+ state.httpEvictedCount += evicted;
364
+ state.httpTruncated = true;
365
+ }
366
+ return record;
367
+ };
368
+
369
+ const ensureRealtimeRecord = (
370
+ requestId: string,
371
+ createRecord: () => RealtimeAgentRecord
372
+ ): RealtimeAgentRecord => {
373
+ const existing = state.realtimeRecords.get(requestId);
374
+ if (existing) {
375
+ return existing;
376
+ }
377
+
378
+ const record = createRecord();
379
+ state.realtimeRecords.set(requestId, record);
380
+ state.realtimeOrder.push(requestId);
381
+ state.realtimeTotalRecorded += 1;
382
+ const evicted = trimMap(
383
+ state.realtimeOrder,
384
+ state.realtimeRecords,
385
+ REALTIME_BUFFER_CAPACITY
386
+ );
387
+ if (evicted > 0) {
388
+ state.realtimeEvictedCount += evicted;
389
+ state.realtimeTruncated = true;
390
+ }
391
+ return record;
392
+ };
393
+
394
+ const nextMessageId = (prefix: string) => {
395
+ const id = `${prefix}-${state.nextMessageId}`;
396
+ state.nextMessageId += 1;
397
+ return id;
398
+ };
399
+
400
+ return {
401
+ startRecording(metadata?: Partial<RecordingMetadata>) {
402
+ state.isRecording = true;
403
+ state.startedAt = Date.now();
404
+ state.stoppedAt = undefined;
405
+ state.generation += 1;
406
+ state.httpOrder = [];
407
+ state.httpRecords.clear();
408
+ state.httpTotalRecorded = 0;
409
+ state.httpEvictedCount = 0;
410
+ state.httpTruncated = false;
411
+ state.realtimeOrder = [];
412
+ state.realtimeRecords.clear();
413
+ state.realtimeTotalRecorded = 0;
414
+ state.realtimeEvictedCount = 0;
415
+ state.realtimeTruncated = false;
416
+ state.recordingMetadata = {
417
+ enabledInspectors: {
418
+ ...state.recordingMetadata.enabledInspectors,
419
+ ...metadata?.enabledInspectors,
420
+ },
421
+ };
422
+ return getStatus();
423
+ },
424
+
425
+ stopRecording() {
426
+ if (!state.isRecording) {
427
+ throw new Error('No active network recording for this plugin session');
428
+ }
429
+
430
+ state.isRecording = false;
431
+ state.stoppedAt = Date.now();
432
+ return getStatus();
433
+ },
434
+
435
+ getStatus,
436
+
437
+ getHttpRecord(requestId: string) {
438
+ return state.httpRecords.get(requestId) || null;
439
+ },
440
+
441
+ getRealtimeRecord(requestId: string) {
442
+ return state.realtimeRecords.get(requestId) || null;
443
+ },
444
+
445
+ listRequests(input: { limit?: number; cursor?: string }) {
446
+ const limit = getLimit(input.limit);
447
+ const rows = state.httpOrder
448
+ .map((requestId) => state.httpRecords.get(requestId))
449
+ .filter((record): record is HttpAgentRecord => !!record)
450
+ .reverse()
451
+ .map(createHttpSummary);
452
+ return {
453
+ ...getStatus(),
454
+ ...paginate(rows, `http-${state.generation}`, limit, input.cursor),
455
+ };
456
+ },
457
+
458
+ listRealtimeConnections(input: { limit?: number; cursor?: string }) {
459
+ const limit = getLimit(input.limit);
460
+ const rows = state.realtimeOrder
461
+ .map((requestId) => state.realtimeRecords.get(requestId))
462
+ .filter((record): record is RealtimeAgentRecord => !!record)
463
+ .reverse()
464
+ .map(getRealtimeSummary);
465
+ return {
466
+ ...getStatus(),
467
+ ...paginate(rows, `realtime-${state.generation}`, limit, input.cursor),
468
+ };
469
+ },
470
+
471
+ getRequestDetails(requestId: string) {
472
+ const record = state.httpRecords.get(requestId);
473
+ if (!record) {
474
+ throw new Error(`Unknown request "${requestId}"`);
475
+ }
476
+
477
+ return {
478
+ ...getStatus(),
479
+ request: {
480
+ requestId: record.requestId,
481
+ method: record.request.method,
482
+ url: record.request.url,
483
+ type: record.resourceType,
484
+ initiator: record.initiator,
485
+ startTimeMs: record.startTimeMs,
486
+ endTimeMs: record.endTimeMs ?? null,
487
+ durationMs: record.durationMs ?? null,
488
+ request: record.request,
489
+ response: record.response ?? null,
490
+ loadingFinished: record.status === 'finished',
491
+ loadingFailed: record.status === 'failed',
492
+ failureText: record.error ?? null,
493
+ canceled: record.canceled ?? false,
494
+ progress: record.progress
495
+ ? {
496
+ loaded: record.progress.loaded,
497
+ total: record.progress.total,
498
+ lengthComputable: record.progress.lengthComputable,
499
+ }
500
+ : null,
501
+ ttfb: record.ttfb ?? null,
502
+ size: record.size ?? null,
503
+ },
504
+ };
505
+ },
506
+
507
+ getRealtimeConnectionDetails(requestId: string) {
508
+ const record = state.realtimeRecords.get(requestId);
509
+ if (!record) {
510
+ throw new Error(`Unknown realtime connection "${requestId}"`);
511
+ }
512
+
513
+ return {
514
+ ...getStatus(),
515
+ connection:
516
+ record.kind === 'websocket'
517
+ ? {
518
+ requestId: record.requestId,
519
+ kind: record.kind,
520
+ url: record.url,
521
+ socketId: record.socketId,
522
+ status: record.status,
523
+ startedAt: record.startedAt,
524
+ endedAt: record.endedAt ?? null,
525
+ durationMs: record.durationMs ?? null,
526
+ protocols: record.protocols ?? null,
527
+ options: record.options ?? [],
528
+ error: record.error ?? null,
529
+ closeCode: record.closeCode ?? null,
530
+ closeReason: record.closeReason ?? null,
531
+ messages: record.messages,
532
+ }
533
+ : {
534
+ requestId: record.requestId,
535
+ kind: record.kind,
536
+ status: record.status,
537
+ startedAt: record.startedAt,
538
+ endedAt: record.endedAt ?? null,
539
+ durationMs: record.durationMs ?? null,
540
+ request: record.request ?? null,
541
+ response: record.response ?? null,
542
+ initiator: record.initiator ?? null,
543
+ resourceType: record.resourceType ?? null,
544
+ error: record.error ?? null,
545
+ messages: record.messages,
546
+ },
547
+ };
548
+ },
549
+
550
+ getRequestBody(requestId: string): NetworkActivityAgentBodyResult {
551
+ const record = state.httpRecords.get(requestId);
552
+ if (!record) {
553
+ throw new Error(`Unknown request "${requestId}"`);
554
+ }
555
+
556
+ return serializeRequestBody(requestId, record.request.postData);
557
+ },
558
+
559
+ onRequestSent(event: HttpEventMap['request-sent']) {
560
+ if (!state.isRecording) {
561
+ return;
562
+ }
563
+
564
+ const record = ensureHttpRecord(event.requestId, {
565
+ request: event.request,
566
+ resourceType: event.type,
567
+ initiator: event.initiator,
568
+ startTimeMs: event.timestamp,
569
+ status: 'pending',
570
+ });
571
+ record.request = event.request;
572
+ record.resourceType = event.type;
573
+ record.initiator = event.initiator;
574
+ record.startTimeMs = event.timestamp;
575
+ record.status = 'pending';
576
+ record.response = undefined;
577
+ record.endTimeMs = undefined;
578
+ record.durationMs = undefined;
579
+ record.size = undefined;
580
+ record.ttfb = undefined;
581
+ record.error = undefined;
582
+ record.canceled = undefined;
583
+ record.progress = undefined;
584
+ },
585
+
586
+ onRequestProgress(event: HttpEventMap['request-progress']) {
587
+ if (!state.isRecording) {
588
+ return;
589
+ }
590
+
591
+ const record = ensureHttpRecord(event.requestId);
592
+ record.progress = event;
593
+ record.status = 'loading';
594
+ },
595
+
596
+ onResponseReceived(event: HttpEventMap['response-received']) {
597
+ if (!state.isRecording) {
598
+ return;
599
+ }
600
+
601
+ const record = ensureHttpRecord(event.requestId);
602
+ record.response = event.response;
603
+ record.status = 'loading';
604
+ },
605
+
606
+ onRequestCompleted(event: HttpEventMap['request-completed']) {
607
+ if (!state.isRecording) {
608
+ return;
609
+ }
610
+
611
+ const record = ensureHttpRecord(event.requestId);
612
+ record.status = 'finished';
613
+ record.endTimeMs = event.timestamp;
614
+ record.durationMs = event.duration;
615
+ record.size = event.size;
616
+ record.ttfb = event.ttfb;
617
+ },
618
+
619
+ onRequestFailed(event: HttpEventMap['request-failed']) {
620
+ if (!state.isRecording) {
621
+ return;
622
+ }
623
+
624
+ const record = ensureHttpRecord(event.requestId);
625
+ record.status = 'failed';
626
+ record.endTimeMs = event.timestamp;
627
+ record.error = event.error;
628
+ record.canceled = event.canceled;
629
+ },
630
+
631
+ onWebSocketConnect(event: WebSocketEventMap['websocket-connect']) {
632
+ if (!state.isRecording) {
633
+ return;
634
+ }
635
+
636
+ ensureRealtimeRecord(`ws-${event.socketId}`, () => ({
637
+ requestId: `ws-${event.socketId}`,
638
+ kind: 'websocket',
639
+ url: event.url,
640
+ socketId: event.socketId,
641
+ status: 'connecting',
642
+ startedAt: event.timestamp,
643
+ protocols: event.protocols,
644
+ options: event.options,
645
+ messages: [],
646
+ }));
647
+ },
648
+
649
+ onWebSocketOpen(event: WebSocketEventMap['websocket-open']) {
650
+ if (!state.isRecording) {
651
+ return;
652
+ }
653
+
654
+ const record = ensureRealtimeRecord(`ws-${event.socketId}`, () => ({
655
+ requestId: `ws-${event.socketId}`,
656
+ kind: 'websocket',
657
+ url: event.url,
658
+ socketId: event.socketId,
659
+ status: 'connecting',
660
+ startedAt: event.timestamp,
661
+ messages: [],
662
+ })) as WebSocketAgentRecord;
663
+ record.status = 'open';
664
+ },
665
+
666
+ onWebSocketClose(event: WebSocketEventMap['websocket-close']) {
667
+ if (!state.isRecording) {
668
+ return;
669
+ }
670
+
671
+ const record = ensureRealtimeRecord(`ws-${event.socketId}`, () => ({
672
+ requestId: `ws-${event.socketId}`,
673
+ kind: 'websocket',
674
+ url: event.url,
675
+ socketId: event.socketId,
676
+ status: 'connecting',
677
+ startedAt: event.timestamp,
678
+ messages: [],
679
+ })) as WebSocketAgentRecord;
680
+ record.status = 'closed';
681
+ record.endedAt = event.timestamp;
682
+ record.durationMs = event.timestamp - record.startedAt;
683
+ record.closeCode = event.code;
684
+ record.closeReason = event.reason;
685
+ },
686
+
687
+ onWebSocketMessageSent(event: WebSocketEventMap['websocket-message-sent']) {
688
+ if (!state.isRecording) {
689
+ return;
690
+ }
691
+
692
+ const record = ensureRealtimeRecord(`ws-${event.socketId}`, () => ({
693
+ requestId: `ws-${event.socketId}`,
694
+ kind: 'websocket',
695
+ url: event.url,
696
+ socketId: event.socketId,
697
+ status: 'connecting',
698
+ startedAt: event.timestamp,
699
+ messages: [],
700
+ })) as WebSocketAgentRecord;
701
+ const message: WebSocketAgentMessage = {
702
+ id: nextMessageId(record.requestId),
703
+ direction: 'sent',
704
+ data: event.data,
705
+ messageType: event.messageType,
706
+ timestamp: event.timestamp,
707
+ };
708
+ record.messages = [...record.messages, message].slice(
709
+ -MAX_WEBSOCKET_MESSAGES_PER_CONNECTION
710
+ );
711
+ },
712
+
713
+ onWebSocketMessageReceived(
714
+ event: WebSocketEventMap['websocket-message-received']
715
+ ) {
716
+ if (!state.isRecording) {
717
+ return;
718
+ }
719
+
720
+ const record = ensureRealtimeRecord(`ws-${event.socketId}`, () => ({
721
+ requestId: `ws-${event.socketId}`,
722
+ kind: 'websocket',
723
+ url: event.url,
724
+ socketId: event.socketId,
725
+ status: 'connecting',
726
+ startedAt: event.timestamp,
727
+ messages: [],
728
+ })) as WebSocketAgentRecord;
729
+ const message: WebSocketAgentMessage = {
730
+ id: nextMessageId(record.requestId),
731
+ direction: 'received',
732
+ data: event.data,
733
+ messageType: event.messageType,
734
+ timestamp: event.timestamp,
735
+ };
736
+ record.messages = [...record.messages, message].slice(
737
+ -MAX_WEBSOCKET_MESSAGES_PER_CONNECTION
738
+ );
739
+ },
740
+
741
+ onWebSocketError(event: WebSocketEventMap['websocket-error']) {
742
+ if (!state.isRecording) {
743
+ return;
744
+ }
745
+
746
+ const record = ensureRealtimeRecord(`ws-${event.socketId}`, () => ({
747
+ requestId: `ws-${event.socketId}`,
748
+ kind: 'websocket',
749
+ url: event.url,
750
+ socketId: event.socketId,
751
+ status: 'connecting',
752
+ startedAt: event.timestamp,
753
+ messages: [],
754
+ })) as WebSocketAgentRecord;
755
+ record.status = 'error';
756
+ record.error = event.error;
757
+ },
758
+
759
+ onWebSocketConnectionStatusChanged(
760
+ event: WebSocketEventMap['websocket-connection-status-changed']
761
+ ) {
762
+ if (!state.isRecording) {
763
+ return;
764
+ }
765
+
766
+ const record = ensureRealtimeRecord(`ws-${event.socketId}`, () => ({
767
+ requestId: `ws-${event.socketId}`,
768
+ kind: 'websocket',
769
+ url: event.url,
770
+ socketId: event.socketId,
771
+ status: 'connecting',
772
+ startedAt: event.timestamp,
773
+ messages: [],
774
+ })) as WebSocketAgentRecord;
775
+ record.status = event.status;
776
+ },
777
+
778
+ onSSEOpen(event: SSEEventMap['sse-open']) {
779
+ if (!state.isRecording) {
780
+ return;
781
+ }
782
+
783
+ const httpRecord = state.httpRecords.get(event.requestId);
784
+ ensureRealtimeRecord(event.requestId, () => ({
785
+ requestId: event.requestId,
786
+ kind: 'sse',
787
+ status: 'open',
788
+ startedAt: httpRecord?.startTimeMs ?? event.timestamp,
789
+ request: httpRecord?.request,
790
+ response: event.response,
791
+ initiator: httpRecord?.initiator,
792
+ resourceType: httpRecord?.resourceType,
793
+ messages: [],
794
+ }));
795
+ },
796
+
797
+ onSSEMessage(event: SSEEventMap['sse-message']) {
798
+ if (!state.isRecording) {
799
+ return;
800
+ }
801
+
802
+ const record = ensureRealtimeRecord(event.requestId, () => ({
803
+ requestId: event.requestId,
804
+ kind: 'sse',
805
+ status: 'connecting',
806
+ startedAt: event.timestamp,
807
+ messages: [],
808
+ })) as SSEAgentRecord;
809
+ record.messages = [
810
+ ...record.messages,
811
+ {
812
+ id: nextMessageId(record.requestId),
813
+ type: event.payload.type,
814
+ data: event.payload.data,
815
+ timestamp: event.timestamp,
816
+ },
817
+ ].slice(-MAX_SSE_MESSAGES_PER_CONNECTION);
818
+ },
819
+
820
+ onSSEError(event: SSEEventMap['sse-error']) {
821
+ if (!state.isRecording) {
822
+ return;
823
+ }
824
+
825
+ const record = ensureRealtimeRecord(event.requestId, () => ({
826
+ requestId: event.requestId,
827
+ kind: 'sse',
828
+ status: 'connecting',
829
+ startedAt: event.timestamp,
830
+ messages: [],
831
+ })) as SSEAgentRecord;
832
+ record.status = 'error';
833
+ record.error = event.error.message;
834
+ },
835
+
836
+ onSSEClose(event: SSEEventMap['sse-close']) {
837
+ if (!state.isRecording) {
838
+ return;
839
+ }
840
+
841
+ const record = ensureRealtimeRecord(event.requestId, () => ({
842
+ requestId: event.requestId,
843
+ kind: 'sse',
844
+ status: 'connecting',
845
+ startedAt: event.timestamp,
846
+ messages: [],
847
+ })) as SSEAgentRecord;
848
+ record.status = 'closed';
849
+ record.endedAt = event.timestamp;
850
+ record.durationMs = event.timestamp - record.startedAt;
851
+ },
852
+ };
853
+ };
854
+
855
+ export type NetworkActivityAgentState = ReturnType<
856
+ typeof createNetworkActivityAgentState
857
+ >;
858
+
859
+ export const getNetworkActivityAgentState = (() => {
860
+ let instance: NetworkActivityAgentState | null = null;
861
+
862
+ return () => {
863
+ if (!instance) {
864
+ instance = createNetworkActivityAgentState();
865
+ }
866
+
867
+ return instance;
868
+ };
869
+ })();