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`;
+};