diff --git a/.changeset/network-session-export.md b/.changeset/network-session-export.md new file mode 100644 index 00000000..4d8d1b32 --- /dev/null +++ b/.changeset/network-session-export.md @@ -0,0 +1,7 @@ +--- +'@rozenite/network-activity-plugin': minor +--- + +Add a toolbar export button that downloads the current network activity session as a JSON file. + +The export includes HTTP requests, WebSocket connections, SSE streams, and realtime messages captured during the session, along with a summary (entry counts by type) and metadata (`schemaVersion`, `exportedAt`). diff --git a/packages/network-activity-plugin/src/ui/components/Toolbar.tsx b/packages/network-activity-plugin/src/ui/components/Toolbar.tsx index ae045b74..bdb3da5e 100644 --- a/packages/network-activity-plugin/src/ui/components/Toolbar.tsx +++ b/packages/network-activity-plugin/src/ui/components/Toolbar.tsx @@ -1,10 +1,12 @@ import { Button } from './Button'; -import { Circle, Square, Trash2 } from 'lucide-react'; +import { Circle, Download, Square, Trash2 } from 'lucide-react'; import { useIsRecording, useNetworkActivityActions } from '../state/hooks'; +import { useNetworkActivitySessionExport } from '../hooks/useNetworkActivitySessionExport'; export const Toolbar = () => { const actions = useNetworkActivityActions(); const isRecording = useIsRecording(); + const { canExportSession, exportSession } = useNetworkActivitySessionExport(); const onToggleRecording = (): void => { actions.setRecording(!isRecording); @@ -41,6 +43,16 @@ export const Toolbar = () => { > + ); }; diff --git a/packages/network-activity-plugin/src/ui/hooks/useNetworkActivitySessionExport.ts b/packages/network-activity-plugin/src/ui/hooks/useNetworkActivitySessionExport.ts new file mode 100644 index 00000000..2e503690 --- /dev/null +++ b/packages/network-activity-plugin/src/ui/hooks/useNetworkActivitySessionExport.ts @@ -0,0 +1,39 @@ +import { useCallback } from 'react'; +import { useNetworkActivityStore } from '../state/hooks'; +import { store } from '../state/store'; +import { downloadJson } from '../utils/download'; +import { + createNetworkActivitySessionExport, + getNetworkActivitySessionExportFileName, +} from '../utils/sessionExport'; + +export const useNetworkActivitySessionExport = () => { + const canExportSession = useNetworkActivityStore( + (state) => state.networkEntries.size > 0, + ); + + const exportSession = useCallback(() => { + const { networkEntries, websocketMessages } = store.getState(); + + if (networkEntries.size === 0) { + return; + } + + const exportedAt = new Date(); + const exportData = createNetworkActivitySessionExport( + networkEntries, + websocketMessages, + exportedAt, + ); + + downloadJson( + exportData, + getNetworkActivitySessionExportFileName(exportedAt), + ); + }, []); + + return { + canExportSession, + exportSession, + }; +}; diff --git a/packages/network-activity-plugin/src/ui/state/hooks.ts b/packages/network-activity-plugin/src/ui/state/hooks.ts index 3e0f1ab9..9324a8b0 100644 --- a/packages/network-activity-plugin/src/ui/state/hooks.ts +++ b/packages/network-activity-plugin/src/ui/state/hooks.ts @@ -4,7 +4,7 @@ import type { NetworkActivityState } from './store'; import { getProcessedRequests, getSelectedRequest } from './derived'; export const useNetworkActivityStore = ( - selector: (state: NetworkActivityState) => T + selector: (state: NetworkActivityState) => T, ): T => { return useStore(store, selector); }; @@ -39,7 +39,7 @@ export const useNetworkActivityClientManagement = () => { export const useWebSocketMessages = (requestId: string) => { return useNetworkActivityStore( - (state) => state.websocketMessages.get(requestId) || [] + (state) => state.websocketMessages.get(requestId) || [], ); }; diff --git a/packages/network-activity-plugin/src/ui/utils/__tests__/sessionExport.test.ts b/packages/network-activity-plugin/src/ui/utils/__tests__/sessionExport.test.ts new file mode 100644 index 00000000..0f53fb2b --- /dev/null +++ b/packages/network-activity-plugin/src/ui/utils/__tests__/sessionExport.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest'; +import type { + HttpNetworkEntry, + NetworkEntry, + RequestId, + SSENetworkEntry, + WebSocketMessage, + WebSocketNetworkEntry, +} from '../../state/model'; +import { + createNetworkActivitySessionExport, + getNetworkActivitySessionExportFileName, +} from '../sessionExport'; + +const httpEntry: HttpNetworkEntry = { + id: 'request-1', + type: 'http', + timestamp: 100, + duration: 50, + source: 'builtin', + request: { + url: 'https://example.com/api', + method: 'GET', + headers: { + accept: 'application/json', + }, + }, + response: { + url: 'https://example.com/api', + status: 200, + statusText: 'OK', + headers: { + 'content-type': 'application/json', + }, + contentType: 'application/json', + size: 17, + responseTime: 150, + body: { + type: 'application/json', + data: '{"ok":true}', + }, + }, + status: 'finished', + ttfb: 20, + size: 17, + resourceType: 'Fetch', +}; + +const websocketEntry: WebSocketNetworkEntry = { + id: 'ws-socket-1', + type: 'websocket', + timestamp: 200, + duration: 100, + source: 'builtin', + connection: { + url: 'wss://example.com/socket', + socketId: 'socket-1', + protocols: ['chat'], + options: [], + }, + status: 'closed', + closeCode: 1000, +}; + +const sseEntry: SSENetworkEntry = { + id: 'request-sse', + type: 'sse', + timestamp: 300, + duration: 200, + source: 'builtin', + request: { + url: 'https://example.com/events', + method: 'GET', + headers: {}, + }, + response: { + url: 'https://example.com/events', + status: 200, + statusText: 'OK', + headers: { + 'content-type': 'text/event-stream', + }, + contentType: 'text/event-stream', + size: 0, + responseTime: 310, + }, + status: 'closed', + messages: [ + { + id: 'sse-message-1', + type: 'message', + data: 'hello', + timestamp: 320, + }, + ], +}; + +const websocketMessages: WebSocketMessage[] = [ + { + id: 'websocket-message-1', + direction: 'sent', + data: 'ping', + messageType: 'text', + timestamp: 210, + }, + { + id: 'websocket-message-2', + direction: 'received', + data: 'pong', + messageType: 'text', + timestamp: 220, + }, +]; + +describe('sessionExport', () => { + it('exports captured HTTP and realtime session entries', () => { + const networkEntries = new Map([ + [sseEntry.id, sseEntry], + [httpEntry.id, httpEntry], + [websocketEntry.id, websocketEntry], + ]); + const exportData = createNetworkActivitySessionExport( + networkEntries, + new Map([[websocketEntry.id, websocketMessages]]), + new Date('2026-05-14T10:00:00.000Z'), + ); + + expect(exportData).toMatchObject({ + schemaVersion: 1, + tool: 'rozenite-network-activity', + exportedAt: '2026-05-14T10:00:00.000Z', + summary: { + totalEntries: 3, + httpRequests: 1, + webSocketConnections: 1, + sseConnections: 1, + realtimeMessages: 3, + }, + }); + expect(exportData.entries.map((entry) => entry.id)).toEqual([ + httpEntry.id, + websocketEntry.id, + sseEntry.id, + ]); + expect(exportData.entries[0]).toMatchObject({ + type: 'http', + request: { + url: 'https://example.com/api', + }, + response: { + status: 200, + body: { + data: '{"ok":true}', + }, + }, + }); + expect(exportData.entries[1]).toMatchObject({ + type: 'websocket', + messages: websocketMessages, + }); + expect(exportData.entries[2]).toMatchObject({ + type: 'sse', + messages: sseEntry.messages, + }); + }); + + it('creates filesystem-friendly export filenames', () => { + expect( + getNetworkActivitySessionExportFileName( + new Date('2026-05-14T10:00:00.123Z'), + ), + ).toBe('rozenite-network-session-2026-05-14T10-00-00Z.json'); + }); +}); diff --git a/packages/network-activity-plugin/src/ui/utils/download.ts b/packages/network-activity-plugin/src/ui/utils/download.ts index 9234f4ad..ff4a6075 100644 --- a/packages/network-activity-plugin/src/ui/utils/download.ts +++ b/packages/network-activity-plugin/src/ui/utils/download.ts @@ -152,3 +152,10 @@ export const downloadBlob = (blob: Blob, filename: string): void => { // the request when the URL disappears mid-click. setTimeout(() => URL.revokeObjectURL(objectUrl), 0); }; + +export const downloadJson = (data: unknown, filename: string): void => { + downloadBlob( + new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }), + filename, + ); +}; diff --git a/packages/network-activity-plugin/src/ui/utils/sessionExport.ts b/packages/network-activity-plugin/src/ui/utils/sessionExport.ts new file mode 100644 index 00000000..d95e4e2a --- /dev/null +++ b/packages/network-activity-plugin/src/ui/utils/sessionExport.ts @@ -0,0 +1,185 @@ +import type { + HttpNetworkEntry, + NetworkEntry, + RequestId, + SSENetworkEntry, + WebSocketMessage, + WebSocketNetworkEntry, +} from '../state/model'; + +const EXPORT_SCHEMA_VERSION = 1; + +type ExportedHttpEntry = { + id: RequestId; + type: 'http'; + source?: NetworkEntry['source']; + timestamp: number; + duration: number | null; + status: HttpNetworkEntry['status']; + error?: string; + canceled?: boolean; + request: HttpNetworkEntry['request']; + response: HttpNetworkEntry['response'] | null; + size: number | null; + ttfb: number | null; + initiator?: HttpNetworkEntry['initiator']; + resourceType?: HttpNetworkEntry['resourceType']; + progress?: HttpNetworkEntry['progress']; +}; + +type ExportedWebSocketEntry = { + id: RequestId; + type: 'websocket'; + source?: NetworkEntry['source']; + timestamp: number; + duration: number | null; + status: WebSocketNetworkEntry['status']; + connection: WebSocketNetworkEntry['connection']; + error?: string; + closeCode?: number; + closeReason?: string; + messages: WebSocketMessage[]; +}; + +type ExportedSSEEntry = { + id: RequestId; + type: 'sse'; + source?: NetworkEntry['source']; + timestamp: number; + duration: number | null; + status: SSENetworkEntry['status']; + error?: string; + request: SSENetworkEntry['request']; + response: SSENetworkEntry['response'] | null; + initiator?: SSENetworkEntry['initiator']; + resourceType?: SSENetworkEntry['resourceType']; + messages: SSENetworkEntry['messages']; +}; + +export type ExportedNetworkEntry = + | ExportedHttpEntry + | ExportedWebSocketEntry + | ExportedSSEEntry; + +export type NetworkActivitySessionExport = { + schemaVersion: typeof EXPORT_SCHEMA_VERSION; + tool: 'rozenite-network-activity'; + exportedAt: string; + summary: { + totalEntries: number; + httpRequests: number; + webSocketConnections: number; + sseConnections: number; + realtimeMessages: number; + }; + entries: ExportedNetworkEntry[]; +}; + +const getDuration = (duration: number | undefined) => duration ?? null; + +const serializeHttpEntry = (entry: HttpNetworkEntry): ExportedHttpEntry => ({ + id: entry.id, + type: 'http', + source: entry.source, + timestamp: entry.timestamp, + duration: getDuration(entry.duration), + status: entry.status, + error: entry.error, + canceled: entry.canceled, + request: entry.request, + response: entry.response ?? null, + size: entry.size ?? null, + ttfb: entry.ttfb ?? null, + initiator: entry.initiator, + resourceType: entry.resourceType, + progress: entry.progress, +}); + +const serializeWebSocketEntry = ( + entry: WebSocketNetworkEntry, + websocketMessages: Map, +): ExportedWebSocketEntry => ({ + id: entry.id, + type: 'websocket', + source: entry.source, + timestamp: entry.timestamp, + duration: getDuration(entry.duration), + status: entry.status, + connection: entry.connection, + error: entry.error, + closeCode: entry.closeCode, + closeReason: entry.closeReason, + messages: websocketMessages.get(entry.id) ?? [], +}); + +const serializeSSEEntry = (entry: SSENetworkEntry): ExportedSSEEntry => ({ + id: entry.id, + type: 'sse', + source: entry.source, + timestamp: entry.timestamp, + duration: getDuration(entry.duration), + status: entry.status, + error: entry.error, + request: entry.request, + response: entry.response ?? null, + initiator: entry.initiator, + resourceType: entry.resourceType, + messages: entry.messages, +}); + +const serializeEntry = ( + entry: NetworkEntry, + websocketMessages: Map, +): ExportedNetworkEntry => { + switch (entry.type) { + case 'http': + return serializeHttpEntry(entry); + case 'websocket': + return serializeWebSocketEntry(entry, websocketMessages); + case 'sse': + return serializeSSEEntry(entry); + } +}; + +export const createNetworkActivitySessionExport = ( + networkEntries: Map, + websocketMessages: Map, + exportedAt = new Date(), +): NetworkActivitySessionExport => { + const entries = Array.from(networkEntries.values()) + .sort((a, b) => a.timestamp - b.timestamp) + .map((entry) => serializeEntry(entry, websocketMessages)); + + return { + schemaVersion: EXPORT_SCHEMA_VERSION, + tool: 'rozenite-network-activity', + exportedAt: exportedAt.toISOString(), + summary: { + totalEntries: entries.length, + httpRequests: entries.filter((entry) => entry.type === 'http').length, + webSocketConnections: entries.filter( + (entry) => entry.type === 'websocket', + ).length, + sseConnections: entries.filter((entry) => entry.type === 'sse').length, + realtimeMessages: entries.reduce((count, entry) => { + if (entry.type === 'websocket' || entry.type === 'sse') { + return count + entry.messages.length; + } + + return count; + }, 0), + }, + entries, + }; +}; + +export const getNetworkActivitySessionExportFileName = ( + exportedAt = new Date(), +) => { + const timestamp = exportedAt + .toISOString() + .replace(/\.\d{3}Z$/, 'Z') + .replace(/[:]/g, '-'); + + return `rozenite-network-session-${timestamp}.json`; +};