@rozenite/network-activity-plugin 1.9.0 → 1.11.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.
Files changed (84) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/devtools/App.html +2 -2
  3. package/dist/devtools/assets/{App-hSoryVpJ.js → App-CEESZAW_.js} +7520 -937
  4. package/dist/devtools/assets/{App-m6xge0az.css → App-xppYUJvX.css} +246 -2
  5. package/dist/react-native/chunks/boot-recording.cjs +138 -14
  6. package/dist/react-native/chunks/boot-recording.js +138 -14
  7. package/dist/react-native/chunks/get-nitro-module.cjs +4 -1
  8. package/dist/react-native/chunks/get-nitro-module.js +4 -1
  9. package/dist/react-native/chunks/useNetworkActivityDevTools.require.cjs +20 -1
  10. package/dist/react-native/chunks/useNetworkActivityDevTools.require.js +20 -1
  11. package/dist/react-native/index.d.ts +37 -1
  12. package/dist/rozenite.json +1 -1
  13. package/dist/sdk/index.d.ts +37 -1
  14. package/package.json +12 -7
  15. package/src/react-native/agent/use-network-activity-agent-tools.ts +22 -4
  16. package/src/react-native/http/__tests__/http-utils.test.ts +228 -0
  17. package/src/react-native/http/http-utils.ts +208 -25
  18. package/src/react-native/network-inspector.ts +2 -2
  19. package/src/react-native/nitro-fetch/get-nitro-module.ts +5 -1
  20. package/src/react-native/nitro-fetch/nitro-network-inspector.ts +8 -2
  21. package/src/shared/http-events.ts +40 -1
  22. package/src/ui/components/CodeBlock.tsx +45 -1
  23. package/src/ui/components/FilterBar.tsx +337 -61
  24. package/src/ui/components/HexView.tsx +54 -0
  25. package/src/ui/components/MetadataCard.tsx +95 -0
  26. package/src/ui/components/NetworkTimeline.tsx +422 -0
  27. package/src/ui/components/RequestList.tsx +19 -40
  28. package/src/ui/components/SidePanel.tsx +42 -1
  29. package/src/ui/components/Toolbar.tsx +13 -1
  30. package/src/ui/components/ViewToggle.tsx +44 -0
  31. package/src/ui/components/XmlTree.tsx +160 -0
  32. package/src/ui/components/__tests__/CodeBlock.test.tsx +89 -0
  33. package/src/ui/components/__tests__/HexView.test.tsx +41 -0
  34. package/src/ui/components/__tests__/MetadataCard.test.tsx +107 -0
  35. package/src/ui/components/__tests__/ViewToggle.test.tsx +80 -0
  36. package/src/ui/components/__tests__/XmlTree.test.tsx +149 -0
  37. package/src/ui/hooks/useNetworkActivitySessionExport.ts +39 -0
  38. package/src/ui/response-renderers/__tests__/binary-too-large.test.tsx +56 -0
  39. package/src/ui/response-renderers/__tests__/binary.test.tsx +96 -0
  40. package/src/ui/response-renderers/__tests__/dispatch.test.ts +124 -0
  41. package/src/ui/response-renderers/__tests__/html.test.tsx +101 -0
  42. package/src/ui/response-renderers/__tests__/image.test.tsx +73 -0
  43. package/src/ui/response-renderers/__tests__/json.test.tsx +95 -0
  44. package/src/ui/response-renderers/__tests__/svg.test.tsx +46 -0
  45. package/src/ui/response-renderers/__tests__/xml.test.tsx +100 -0
  46. package/src/ui/response-renderers/binary-too-large.tsx +36 -0
  47. package/src/ui/response-renderers/binary.tsx +31 -0
  48. package/src/ui/response-renderers/empty.tsx +14 -0
  49. package/src/ui/response-renderers/html.tsx +36 -0
  50. package/src/ui/response-renderers/image.tsx +37 -0
  51. package/src/ui/response-renderers/index.ts +55 -0
  52. package/src/ui/response-renderers/json.tsx +40 -0
  53. package/src/ui/response-renderers/svg.tsx +27 -0
  54. package/src/ui/response-renderers/text-fallback.tsx +14 -0
  55. package/src/ui/response-renderers/types.ts +38 -0
  56. package/src/ui/response-renderers/unknown.tsx +18 -0
  57. package/src/ui/response-renderers/xml.tsx +46 -0
  58. package/src/ui/state/__tests__/store.test.ts +77 -0
  59. package/src/ui/state/derived.ts +14 -0
  60. package/src/ui/state/filter.ts +49 -0
  61. package/src/ui/state/hooks.ts +2 -2
  62. package/src/ui/state/model.ts +7 -1
  63. package/src/ui/state/store.ts +63 -4
  64. package/src/ui/tabs/InitiatorTab.tsx +230 -0
  65. package/src/ui/tabs/ResponseTab.tsx +80 -97
  66. package/src/ui/tabs/__tests__/ResponseTab.test.tsx +102 -0
  67. package/src/ui/utils/__tests__/download.test.ts +115 -0
  68. package/src/ui/utils/__tests__/hex.test.ts +84 -0
  69. package/src/ui/utils/__tests__/requestFilters.test.ts +32 -0
  70. package/src/ui/utils/__tests__/sessionExport.test.ts +174 -0
  71. package/src/ui/utils/__tests__/symbolication.test.ts +207 -0
  72. package/src/ui/utils/__tests__/timelineModel.test.ts +170 -0
  73. package/src/ui/utils/download.ts +161 -0
  74. package/src/ui/utils/hex.ts +59 -0
  75. package/src/ui/utils/initiator.ts +136 -0
  76. package/src/ui/utils/requestFilters.ts +183 -0
  77. package/src/ui/utils/sessionExport.ts +185 -0
  78. package/src/ui/utils/symbolication.ts +248 -0
  79. package/src/ui/utils/timelineModel.ts +352 -0
  80. package/src/ui/views/InspectorView.tsx +43 -8
  81. package/src/utils/__tests__/getContentTypeMimeType.test.ts +34 -0
  82. package/src/utils/getContentTypeMimeType.ts +14 -0
  83. package/vite.config.ts +5 -1
  84. package/vitest.setup.ts +31 -0
@@ -0,0 +1,174 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type {
3
+ HttpNetworkEntry,
4
+ NetworkEntry,
5
+ RequestId,
6
+ SSENetworkEntry,
7
+ WebSocketMessage,
8
+ WebSocketNetworkEntry,
9
+ } from '../../state/model';
10
+ import {
11
+ createNetworkActivitySessionExport,
12
+ getNetworkActivitySessionExportFileName,
13
+ } from '../sessionExport';
14
+
15
+ const httpEntry: HttpNetworkEntry = {
16
+ id: 'request-1',
17
+ type: 'http',
18
+ timestamp: 100,
19
+ duration: 50,
20
+ source: 'builtin',
21
+ request: {
22
+ url: 'https://example.com/api',
23
+ method: 'GET',
24
+ headers: {
25
+ accept: 'application/json',
26
+ },
27
+ },
28
+ response: {
29
+ url: 'https://example.com/api',
30
+ status: 200,
31
+ statusText: 'OK',
32
+ headers: {
33
+ 'content-type': 'application/json',
34
+ },
35
+ contentType: 'application/json',
36
+ size: 17,
37
+ responseTime: 150,
38
+ body: {
39
+ type: 'application/json',
40
+ data: '{"ok":true}',
41
+ },
42
+ },
43
+ status: 'finished',
44
+ ttfb: 20,
45
+ size: 17,
46
+ resourceType: 'Fetch',
47
+ };
48
+
49
+ const websocketEntry: WebSocketNetworkEntry = {
50
+ id: 'ws-socket-1',
51
+ type: 'websocket',
52
+ timestamp: 200,
53
+ duration: 100,
54
+ source: 'builtin',
55
+ connection: {
56
+ url: 'wss://example.com/socket',
57
+ socketId: 'socket-1',
58
+ protocols: ['chat'],
59
+ options: [],
60
+ },
61
+ status: 'closed',
62
+ closeCode: 1000,
63
+ };
64
+
65
+ const sseEntry: SSENetworkEntry = {
66
+ id: 'request-sse',
67
+ type: 'sse',
68
+ timestamp: 300,
69
+ duration: 200,
70
+ source: 'builtin',
71
+ request: {
72
+ url: 'https://example.com/events',
73
+ method: 'GET',
74
+ headers: {},
75
+ },
76
+ response: {
77
+ url: 'https://example.com/events',
78
+ status: 200,
79
+ statusText: 'OK',
80
+ headers: {
81
+ 'content-type': 'text/event-stream',
82
+ },
83
+ contentType: 'text/event-stream',
84
+ size: 0,
85
+ responseTime: 310,
86
+ },
87
+ status: 'closed',
88
+ messages: [
89
+ {
90
+ id: 'sse-message-1',
91
+ type: 'message',
92
+ data: 'hello',
93
+ timestamp: 320,
94
+ },
95
+ ],
96
+ };
97
+
98
+ const websocketMessages: WebSocketMessage[] = [
99
+ {
100
+ id: 'websocket-message-1',
101
+ direction: 'sent',
102
+ data: 'ping',
103
+ messageType: 'text',
104
+ timestamp: 210,
105
+ },
106
+ {
107
+ id: 'websocket-message-2',
108
+ direction: 'received',
109
+ data: 'pong',
110
+ messageType: 'text',
111
+ timestamp: 220,
112
+ },
113
+ ];
114
+
115
+ describe('sessionExport', () => {
116
+ it('exports captured HTTP and realtime session entries', () => {
117
+ const networkEntries = new Map<RequestId, NetworkEntry>([
118
+ [sseEntry.id, sseEntry],
119
+ [httpEntry.id, httpEntry],
120
+ [websocketEntry.id, websocketEntry],
121
+ ]);
122
+ const exportData = createNetworkActivitySessionExport(
123
+ networkEntries,
124
+ new Map([[websocketEntry.id, websocketMessages]]),
125
+ new Date('2026-05-14T10:00:00.000Z'),
126
+ );
127
+
128
+ expect(exportData).toMatchObject({
129
+ schemaVersion: 1,
130
+ tool: 'rozenite-network-activity',
131
+ exportedAt: '2026-05-14T10:00:00.000Z',
132
+ summary: {
133
+ totalEntries: 3,
134
+ httpRequests: 1,
135
+ webSocketConnections: 1,
136
+ sseConnections: 1,
137
+ realtimeMessages: 3,
138
+ },
139
+ });
140
+ expect(exportData.entries.map((entry) => entry.id)).toEqual([
141
+ httpEntry.id,
142
+ websocketEntry.id,
143
+ sseEntry.id,
144
+ ]);
145
+ expect(exportData.entries[0]).toMatchObject({
146
+ type: 'http',
147
+ request: {
148
+ url: 'https://example.com/api',
149
+ },
150
+ response: {
151
+ status: 200,
152
+ body: {
153
+ data: '{"ok":true}',
154
+ },
155
+ },
156
+ });
157
+ expect(exportData.entries[1]).toMatchObject({
158
+ type: 'websocket',
159
+ messages: websocketMessages,
160
+ });
161
+ expect(exportData.entries[2]).toMatchObject({
162
+ type: 'sse',
163
+ messages: sseEntry.messages,
164
+ });
165
+ });
166
+
167
+ it('creates filesystem-friendly export filenames', () => {
168
+ expect(
169
+ getNetworkActivitySessionExportFileName(
170
+ new Date('2026-05-14T10:00:00.123Z'),
171
+ ),
172
+ ).toBe('rozenite-network-session-2026-05-14T10-00-00Z.json');
173
+ });
174
+ });
@@ -0,0 +1,207 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ symbolicateInitiator,
4
+ symbolicateStackTraceWithMetro,
5
+ } from '../symbolication';
6
+ import type { Initiator } from '../../../shared/client';
7
+
8
+ describe('symbolication', () => {
9
+ it('selects the source frame matching the Metro code frame', async () => {
10
+ const initiator: Initiator = {
11
+ type: 'script',
12
+ generatedUrl: 'http://localhost:8081/index.bundle',
13
+ generatedLineNumber: 1,
14
+ generatedColumnNumber: 100,
15
+ symbolicationStatus: 'pending',
16
+ stack: [
17
+ {
18
+ functionName: 'fetch',
19
+ generatedUrl: 'http://localhost:8081/index.bundle',
20
+ generatedLineNumber: 1,
21
+ generatedColumnNumber: 100,
22
+ },
23
+ {
24
+ functionName: 'loadUsers',
25
+ generatedUrl: 'http://localhost:8081/index.bundle',
26
+ generatedLineNumber: 1,
27
+ generatedColumnNumber: 200,
28
+ },
29
+ ],
30
+ };
31
+
32
+ const symbolicatedInitiator = await symbolicateInitiator(
33
+ initiator,
34
+ vi.fn().mockResolvedValue({
35
+ stack: [
36
+ {
37
+ methodName: 'fetch',
38
+ file: 'node_modules/react-native/Libraries/Network/fetch.js',
39
+ lineNumber: 10,
40
+ column: 2,
41
+ },
42
+ {
43
+ methodName: 'loadUsers',
44
+ file: 'apps/playground/src/app/api.ts',
45
+ lineNumber: 30,
46
+ column: 6,
47
+ },
48
+ ],
49
+ codeFrame: {
50
+ content: '\u001b[90m 30 |\u001b[39m loadUsers();\u001b[0m',
51
+ fileName: 'apps/playground/src/app/api.ts',
52
+ location: {
53
+ row: 30,
54
+ column: 6,
55
+ },
56
+ },
57
+ }),
58
+ );
59
+
60
+ expect(symbolicatedInitiator).toMatchObject({
61
+ type: 'script',
62
+ functionName: 'loadUsers',
63
+ url: 'apps/playground/src/app/api.ts',
64
+ lineNumber: 30,
65
+ columnNumber: 6,
66
+ symbolicationStatus: 'complete',
67
+ codeFrame: {
68
+ content: ' 30 | loadUsers();',
69
+ fileName: 'apps/playground/src/app/api.ts',
70
+ },
71
+ });
72
+ });
73
+
74
+ it('falls back to the first non-collapsed source frame', async () => {
75
+ const initiator: Initiator = {
76
+ type: 'script',
77
+ generatedUrl: 'http://localhost:8081/index.bundle',
78
+ generatedLineNumber: 1,
79
+ generatedColumnNumber: 100,
80
+ symbolicationStatus: 'pending',
81
+ stack: [
82
+ {
83
+ functionName: 'send',
84
+ generatedUrl: 'http://localhost:8081/index.bundle',
85
+ generatedLineNumber: 1,
86
+ generatedColumnNumber: 100,
87
+ },
88
+ {
89
+ functionName: 'loadUsers',
90
+ generatedUrl: 'http://localhost:8081/index.bundle',
91
+ generatedLineNumber: 1,
92
+ generatedColumnNumber: 200,
93
+ },
94
+ ],
95
+ };
96
+
97
+ const symbolicatedInitiator = await symbolicateInitiator(
98
+ initiator,
99
+ vi.fn().mockResolvedValue({
100
+ stack: [
101
+ {
102
+ methodName: 'send',
103
+ file: 'packages/network-activity-plugin/src/http.ts',
104
+ lineNumber: 10,
105
+ column: 2,
106
+ collapse: true,
107
+ },
108
+ {
109
+ methodName: 'loadUsers',
110
+ file: 'apps/playground/src/app/api.ts',
111
+ lineNumber: 30,
112
+ column: 6,
113
+ },
114
+ ],
115
+ }),
116
+ );
117
+
118
+ expect(symbolicatedInitiator).toMatchObject({
119
+ type: 'script',
120
+ functionName: 'loadUsers',
121
+ url: 'apps/playground/src/app/api.ts',
122
+ lineNumber: 30,
123
+ columnNumber: 6,
124
+ symbolicationStatus: 'complete',
125
+ codeFrame: null,
126
+ });
127
+ });
128
+
129
+ it('reports symbolication failures on the initiator', async () => {
130
+ const initiator: Initiator = {
131
+ type: 'script',
132
+ symbolicationStatus: 'pending',
133
+ stack: [
134
+ {
135
+ generatedUrl: 'http://localhost:8081/index.bundle',
136
+ generatedLineNumber: 1,
137
+ generatedColumnNumber: 100,
138
+ },
139
+ ],
140
+ };
141
+
142
+ const symbolicatedInitiator = await symbolicateInitiator(
143
+ initiator,
144
+ vi.fn().mockRejectedValue(new Error('Metro is unavailable')),
145
+ );
146
+
147
+ expect(symbolicatedInitiator).toMatchObject({
148
+ symbolicationStatus: 'failed',
149
+ symbolicationError: 'Metro is unavailable',
150
+ });
151
+ });
152
+
153
+ it('posts stack frames to the Metro symbolication endpoint from the panel origin', async () => {
154
+ const originalFetch = globalThis.fetch;
155
+ const originalWindow = globalThis.window;
156
+
157
+ Object.defineProperty(globalThis, 'window', {
158
+ value: {
159
+ location: {
160
+ origin: 'http://localhost:8081',
161
+ },
162
+ },
163
+ configurable: true,
164
+ });
165
+
166
+ globalThis.fetch = vi.fn().mockResolvedValue({
167
+ ok: true,
168
+ json: vi.fn().mockResolvedValue({
169
+ stack: [],
170
+ }),
171
+ } as Partial<Response> as Response);
172
+
173
+ try {
174
+ await symbolicateStackTraceWithMetro([
175
+ {
176
+ methodName: 'loadUsers',
177
+ file: 'http://localhost:8081/index.bundle',
178
+ lineNumber: 1,
179
+ column: 100,
180
+ },
181
+ ]);
182
+
183
+ expect(globalThis.fetch).toHaveBeenCalledWith(
184
+ 'http://localhost:8081/symbolicate',
185
+ expect.objectContaining({
186
+ method: 'POST',
187
+ body: JSON.stringify({
188
+ stack: [
189
+ {
190
+ methodName: 'loadUsers',
191
+ file: 'http://localhost:8081/index.bundle',
192
+ lineNumber: 1,
193
+ column: 100,
194
+ },
195
+ ],
196
+ }),
197
+ }),
198
+ );
199
+ } finally {
200
+ globalThis.fetch = originalFetch;
201
+ Object.defineProperty(globalThis, 'window', {
202
+ value: originalWindow,
203
+ configurable: true,
204
+ });
205
+ }
206
+ });
207
+ });
@@ -0,0 +1,170 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { ProcessedRequest } from '../../state/model';
3
+ import {
4
+ formatTimelineOffset,
5
+ getTimelineModel,
6
+ getTimelineRequestEndTime,
7
+ getTimelineTicks,
8
+ isRequestActive,
9
+ requestOverlapsTimelineRange,
10
+ TIMELINE_LAYOUT,
11
+ } from '../timelineModel';
12
+
13
+ const createRequest = (
14
+ overrides: Partial<ProcessedRequest> = {},
15
+ ): ProcessedRequest => ({
16
+ id: 'request-1',
17
+ type: 'http',
18
+ name: 'https://example.com/api',
19
+ status: 'finished',
20
+ timestamp: 0,
21
+ duration: 100,
22
+ size: null,
23
+ method: 'GET',
24
+ ...overrides,
25
+ });
26
+
27
+ describe('timelineModel', () => {
28
+ it('formats minute offsets without rolling seconds up to 60', () => {
29
+ expect(formatTimelineOffset(119_900)).toBe('2m 00s');
30
+ });
31
+
32
+ it('treats websocket closing state as active', () => {
33
+ expect(
34
+ isRequestActive(
35
+ createRequest({
36
+ type: 'websocket',
37
+ status: 'closing',
38
+ method: 'WS',
39
+ }),
40
+ ),
41
+ ).toBe(true);
42
+ });
43
+
44
+ it('uses the earliest ending lane when all lanes are occupied', () => {
45
+ const requests = Array.from(
46
+ { length: TIMELINE_LAYOUT.laneCount },
47
+ (_, index) =>
48
+ createRequest({
49
+ id: `request-${index}`,
50
+ timestamp: 0,
51
+ duration: index === 1 ? 100 : 1000,
52
+ }),
53
+ );
54
+
55
+ const overflowingRequest = createRequest({
56
+ id: 'overflowing-request',
57
+ timestamp: 50,
58
+ duration: 200,
59
+ });
60
+
61
+ const model = getTimelineModel([...requests, overflowingRequest], 0);
62
+ const overflowingRow = model.rows.find(
63
+ (row) => row.request.id === overflowingRequest.id,
64
+ );
65
+
66
+ expect(overflowingRow?.lane).toBe(1);
67
+ expect(overflowingRow?.isOverflowingLane).toBe(true);
68
+ });
69
+
70
+ it('keeps long-session tick counts near the target count', () => {
71
+ const oneHour = 60 * 60 * 1000;
72
+ const model = getTimelineModel(
73
+ [
74
+ createRequest({
75
+ duration: oneHour,
76
+ }),
77
+ ],
78
+ 0,
79
+ );
80
+
81
+ expect(model.ticks.length).toBeLessThanOrEqual(
82
+ TIMELINE_LAYOUT.tickTargetCount + 2,
83
+ );
84
+ });
85
+
86
+ it('does not add a duplicate final tick label', () => {
87
+ const ticks = getTimelineTicks(1548, {
88
+ ...TIMELINE_LAYOUT,
89
+ tickTargetCount: 7,
90
+ });
91
+
92
+ expect(ticks.map((tick) => tick.label)).toEqual([
93
+ '0 ms',
94
+ '250 ms',
95
+ '500 ms',
96
+ '750 ms',
97
+ '1.0 s',
98
+ '1.3 s',
99
+ '1.5 s',
100
+ ]);
101
+ });
102
+
103
+ it('caps rendered rows for large recordings', () => {
104
+ const maxRenderedRequests = 3;
105
+ const model = getTimelineModel(
106
+ Array.from({ length: 5 }, (_, index) =>
107
+ createRequest({
108
+ id: `request-${index}`,
109
+ timestamp: index,
110
+ }),
111
+ ),
112
+ 0,
113
+ {
114
+ ...TIMELINE_LAYOUT,
115
+ maxRenderedRequests,
116
+ },
117
+ );
118
+
119
+ expect(model.rows.map((row) => row.request.id)).toEqual([
120
+ 'request-2',
121
+ 'request-3',
122
+ 'request-4',
123
+ ]);
124
+ expect(model.totalRequestCount).toBe(5);
125
+ expect(model.hiddenRequestCount).toBe(2);
126
+ });
127
+
128
+ it('caps websocket and SSE duration in the timeline model', () => {
129
+ const now = 60_000;
130
+ const websocketRequest = createRequest({
131
+ type: 'websocket',
132
+ status: 'open',
133
+ method: 'WS',
134
+ timestamp: 0,
135
+ duration: undefined,
136
+ });
137
+
138
+ expect(getTimelineRequestEndTime(websocketRequest, now)).toBe(
139
+ TIMELINE_LAYOUT.streamingRequestMaxDurationMs,
140
+ );
141
+
142
+ const model = getTimelineModel([websocketRequest], now);
143
+
144
+ expect(model.rows[0].duration).toBe(
145
+ TIMELINE_LAYOUT.streamingRequestMaxDurationMs,
146
+ );
147
+ });
148
+
149
+ it('matches requests that overlap a selected timeline range', () => {
150
+ const request = createRequest({
151
+ timestamp: 1000,
152
+ duration: 400,
153
+ });
154
+
155
+ expect(
156
+ requestOverlapsTimelineRange(
157
+ request,
158
+ { startTime: 1200, endTime: 1400 },
159
+ 0,
160
+ ),
161
+ ).toBe(true);
162
+ expect(
163
+ requestOverlapsTimelineRange(
164
+ request,
165
+ { startTime: 1500, endTime: 1600 },
166
+ 0,
167
+ ),
168
+ ).toBe(false);
169
+ });
170
+ });
@@ -0,0 +1,161 @@
1
+ import type { HttpHeaders } from '../../shared/client';
2
+
3
+ // Decode a base64 string to a Uint8Array via `atob`. Mirrors the
4
+ // chunking concern on the encode side: atob produces a binary string,
5
+ // each char is one byte, we re-pack into a typed array byte by byte.
6
+ export const base64ToBytes = (base64: string): Uint8Array => {
7
+ const binary = atob(base64);
8
+ const bytes = new Uint8Array(binary.length);
9
+ for (let i = 0; i < binary.length; i++) {
10
+ bytes[i] = binary.charCodeAt(i);
11
+ }
12
+ return bytes;
13
+ };
14
+
15
+ export const base64ToBlob = (base64: string, contentType: string): Blob => {
16
+ // Allocate the ArrayBuffer up front so the Uint8Array is known to
17
+ // be ArrayBuffer-backed (not SharedArrayBuffer); satisfies the
18
+ // BlobPart constraint without a cast.
19
+ const binary = atob(base64);
20
+ const buffer = new ArrayBuffer(binary.length);
21
+ const view = new Uint8Array(buffer);
22
+ for (let i = 0; i < binary.length; i++) {
23
+ view[i] = binary.charCodeAt(i);
24
+ }
25
+ return new Blob([buffer], {
26
+ type: contentType || 'application/octet-stream',
27
+ });
28
+ };
29
+
30
+ // Fallback extension when neither Content-Disposition nor URL gives us
31
+ // a usable filename. Map a few common content-types so saved files open
32
+ // in the right tool; everything else lands as `.bin`.
33
+ const CONTENT_TYPE_EXTENSIONS: Record<string, string> = {
34
+ 'application/pdf': 'pdf',
35
+ 'application/zip': 'zip',
36
+ 'application/gzip': 'gz',
37
+ 'application/json': 'json',
38
+ 'application/xml': 'xml',
39
+ 'application/javascript': 'js',
40
+ 'application/octet-stream': 'bin',
41
+ 'image/png': 'png',
42
+ 'image/jpeg': 'jpg',
43
+ 'image/gif': 'gif',
44
+ 'image/webp': 'webp',
45
+ 'image/svg+xml': 'svg',
46
+ 'image/bmp': 'bmp',
47
+ 'image/x-icon': 'ico',
48
+ 'audio/mpeg': 'mp3',
49
+ 'audio/ogg': 'ogg',
50
+ 'audio/wav': 'wav',
51
+ 'video/mp4': 'mp4',
52
+ 'video/webm': 'webm',
53
+ 'font/woff': 'woff',
54
+ 'font/woff2': 'woff2',
55
+ 'font/ttf': 'ttf',
56
+ 'font/otf': 'otf',
57
+ 'text/html': 'html',
58
+ 'text/plain': 'txt',
59
+ 'text/css': 'css',
60
+ 'text/csv': 'csv',
61
+ };
62
+
63
+ const extensionForContentType = (contentType: string): string => {
64
+ // Strip parameters: "text/html; charset=utf-8" → "text/html".
65
+ const bare = contentType.split(';', 1)[0]?.trim().toLowerCase() ?? '';
66
+ return CONTENT_TYPE_EXTENSIONS[bare] ?? 'bin';
67
+ };
68
+
69
+ export const readHeader = (
70
+ headers: HttpHeaders | undefined,
71
+ name: string,
72
+ ): string | undefined => {
73
+ if (!headers) return undefined;
74
+ const lowerTarget = name.toLowerCase();
75
+ for (const [key, value] of Object.entries(headers)) {
76
+ if (key.toLowerCase() === lowerTarget) {
77
+ return Array.isArray(value) ? value[0] : value;
78
+ }
79
+ }
80
+ return undefined;
81
+ };
82
+
83
+ // RFC 6266: `Content-Disposition: attachment; filename="report.pdf"`
84
+ // or `filename*=UTF-8''report%20with%20space.pdf`. We only extract the
85
+ // raw value here; downstream callers can sanitize further if they care
86
+ // about path traversal etc. (irrelevant for the playground / debug use
87
+ // case but worth knowing).
88
+ const parseContentDispositionFilename = (
89
+ header: string | undefined,
90
+ ): string | undefined => {
91
+ if (!header) return undefined;
92
+ // Prefer RFC 5987 `filename*` over the legacy `filename` when both
93
+ // are present — it has a stricter encoding contract.
94
+ const extended = /filename\*\s*=\s*[^']*''([^;]+)/i.exec(header);
95
+ if (extended?.[1]) {
96
+ try {
97
+ return decodeURIComponent(extended[1].trim()) || undefined;
98
+ } catch {
99
+ // Fall through to the unencoded form.
100
+ }
101
+ }
102
+ const basic = /filename\s*=\s*("([^"]*)"|([^;]+))/i.exec(header);
103
+ const value = basic?.[2] ?? basic?.[3];
104
+ return value?.trim() || undefined;
105
+ };
106
+
107
+ const filenameFromUrl = (url: string): string | undefined => {
108
+ try {
109
+ const parsed = new URL(url);
110
+ const segments = parsed.pathname.split('/').filter(Boolean);
111
+ const last = segments[segments.length - 1];
112
+ return last && last.length > 0 ? last : undefined;
113
+ } catch {
114
+ return undefined;
115
+ }
116
+ };
117
+
118
+ // Three-tier filename derivation:
119
+ // 1. Content-Disposition filename (RFC 5987 → RFC 6266)
120
+ // 2. Last path segment of the response URL
121
+ // 3. `response.<ext>` where the extension comes from a small
122
+ // Content-Type → extension map (everything unknown becomes `.bin`)
123
+ export const deriveFilename = ({
124
+ headers,
125
+ url,
126
+ contentType,
127
+ }: {
128
+ headers?: HttpHeaders;
129
+ url: string;
130
+ contentType: string;
131
+ }): string => {
132
+ const fromDisposition = parseContentDispositionFilename(
133
+ readHeader(headers, 'Content-Disposition'),
134
+ );
135
+ if (fromDisposition) return fromDisposition;
136
+
137
+ const fromUrl = filenameFromUrl(url);
138
+ if (fromUrl) return fromUrl;
139
+
140
+ return `response.${extensionForContentType(contentType)}`;
141
+ };
142
+
143
+ export const downloadBlob = (blob: Blob, filename: string): void => {
144
+ const objectUrl = URL.createObjectURL(blob);
145
+ const anchor = document.createElement('a');
146
+ anchor.href = objectUrl;
147
+ anchor.download = filename;
148
+ document.body.appendChild(anchor);
149
+ anchor.click();
150
+ document.body.removeChild(anchor);
151
+ // Defer revoke a tick so Safari's download pipeline doesn't drop
152
+ // the request when the URL disappears mid-click.
153
+ setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
154
+ };
155
+
156
+ export const downloadJson = (data: unknown, filename: string): void => {
157
+ downloadBlob(
158
+ new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }),
159
+ filename,
160
+ );
161
+ };