@rozenite/network-activity-plugin 1.9.0 → 1.10.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 (72) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/devtools/App.html +2 -2
  3. package/dist/devtools/assets/{App-m6xge0az.css → App-CUXU0mup.css} +152 -2
  4. package/dist/devtools/assets/{App-hSoryVpJ.js → App-DsimzJvx.js} +6827 -970
  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 +366 -58
  24. package/src/ui/components/HexView.tsx +54 -0
  25. package/src/ui/components/MetadataCard.tsx +95 -0
  26. package/src/ui/components/RequestList.tsx +192 -34
  27. package/src/ui/components/SidePanel.tsx +42 -1
  28. package/src/ui/components/ViewToggle.tsx +44 -0
  29. package/src/ui/components/XmlTree.tsx +160 -0
  30. package/src/ui/components/__tests__/CodeBlock.test.tsx +89 -0
  31. package/src/ui/components/__tests__/HexView.test.tsx +41 -0
  32. package/src/ui/components/__tests__/MetadataCard.test.tsx +107 -0
  33. package/src/ui/components/__tests__/ViewToggle.test.tsx +80 -0
  34. package/src/ui/components/__tests__/XmlTree.test.tsx +149 -0
  35. package/src/ui/response-renderers/__tests__/binary-too-large.test.tsx +56 -0
  36. package/src/ui/response-renderers/__tests__/binary.test.tsx +96 -0
  37. package/src/ui/response-renderers/__tests__/dispatch.test.ts +124 -0
  38. package/src/ui/response-renderers/__tests__/html.test.tsx +101 -0
  39. package/src/ui/response-renderers/__tests__/image.test.tsx +73 -0
  40. package/src/ui/response-renderers/__tests__/json.test.tsx +95 -0
  41. package/src/ui/response-renderers/__tests__/svg.test.tsx +46 -0
  42. package/src/ui/response-renderers/__tests__/xml.test.tsx +100 -0
  43. package/src/ui/response-renderers/binary-too-large.tsx +36 -0
  44. package/src/ui/response-renderers/binary.tsx +31 -0
  45. package/src/ui/response-renderers/empty.tsx +14 -0
  46. package/src/ui/response-renderers/html.tsx +36 -0
  47. package/src/ui/response-renderers/image.tsx +37 -0
  48. package/src/ui/response-renderers/index.ts +55 -0
  49. package/src/ui/response-renderers/json.tsx +40 -0
  50. package/src/ui/response-renderers/svg.tsx +27 -0
  51. package/src/ui/response-renderers/text-fallback.tsx +14 -0
  52. package/src/ui/response-renderers/types.ts +38 -0
  53. package/src/ui/response-renderers/unknown.tsx +18 -0
  54. package/src/ui/response-renderers/xml.tsx +46 -0
  55. package/src/ui/state/derived.ts +12 -0
  56. package/src/ui/state/model.ts +6 -1
  57. package/src/ui/state/store.ts +39 -2
  58. package/src/ui/tabs/InitiatorTab.tsx +230 -0
  59. package/src/ui/tabs/ResponseTab.tsx +80 -97
  60. package/src/ui/tabs/__tests__/ResponseTab.test.tsx +102 -0
  61. package/src/ui/utils/__tests__/download.test.ts +115 -0
  62. package/src/ui/utils/__tests__/hex.test.ts +84 -0
  63. package/src/ui/utils/__tests__/symbolication.test.ts +207 -0
  64. package/src/ui/utils/download.ts +154 -0
  65. package/src/ui/utils/hex.ts +59 -0
  66. package/src/ui/utils/initiator.ts +136 -0
  67. package/src/ui/utils/symbolication.ts +248 -0
  68. package/src/ui/views/InspectorView.tsx +8 -5
  69. package/src/utils/__tests__/getContentTypeMimeType.test.ts +34 -0
  70. package/src/utils/getContentTypeMimeType.ts +14 -0
  71. package/vite.config.ts +5 -1
  72. package/vitest.setup.ts +31 -0
@@ -2,9 +2,7 @@ import { useEffect } from 'react';
2
2
  import { useRozenitePluginAgentTool } from '@rozenite/agent-bridge';
3
3
  import type { NetworkActivityDevToolsClient } from '../../shared/client';
4
4
  import type { NetworkInspector } from '../network-inspector';
5
- import {
6
- getNetworkActivityAgentState,
7
- } from './state';
5
+ import { getNetworkActivityAgentState } from './state';
8
6
  import {
9
7
  NETWORK_ACTIVITY_AGENT_PLUGIN_ID,
10
8
  type NetworkActivityGetResponseBodyResult,
@@ -195,7 +193,9 @@ export const useNetworkActivityAgentTools = ({
195
193
  useRozenitePluginAgentTool({
196
194
  pluginId: NETWORK_ACTIVITY_AGENT_PLUGIN_ID,
197
195
  tool: getResponseBodyTool,
198
- handler: async ({ requestId }): Promise<NetworkActivityGetResponseBodyResult> => {
196
+ handler: async ({
197
+ requestId,
198
+ }): Promise<NetworkActivityGetResponseBodyResult> => {
199
199
  const record = state.getHttpRecord(requestId);
200
200
  if (!record) {
201
201
  throw new Error(`Unknown request "${requestId}"`);
@@ -228,6 +228,24 @@ export const useNetworkActivityAgentTools = ({
228
228
  };
229
229
  }
230
230
 
231
+ if (typeof body !== 'string') {
232
+ if (body.kind === 'binary-too-large') {
233
+ return {
234
+ requestId,
235
+ available: false,
236
+ reason: `Response body exceeded the in-capture size cap (${body.size} bytes).`,
237
+ };
238
+ }
239
+ return {
240
+ requestId,
241
+ available: true,
242
+ body: body.base64,
243
+ base64Encoded: true,
244
+ decoded: false,
245
+ mimeType: record.response?.contentType,
246
+ };
247
+ }
248
+
231
249
  return {
232
250
  requestId,
233
251
  available: true,
@@ -0,0 +1,228 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from 'vitest';
3
+ import { BINARY_CAPTURE_SIZE_CAP, getResponseBody } from '../http-utils';
4
+
5
+ type XHRStubOptions = {
6
+ responseType?: XMLHttpRequestResponseType;
7
+ responseText?: string;
8
+ response?: unknown;
9
+ contentType?: string;
10
+ };
11
+
12
+ const makeXHRStub = ({
13
+ responseType = '',
14
+ responseText = '',
15
+ response = null,
16
+ contentType = '',
17
+ }: XHRStubOptions): XMLHttpRequest =>
18
+ ({
19
+ responseType,
20
+ responseText,
21
+ response,
22
+ getResponseHeader: (name: string) =>
23
+ name.toLowerCase() === 'content-type' ? contentType : null,
24
+ }) as unknown as XMLHttpRequest;
25
+
26
+ describe('getResponseBody', () => {
27
+ it('returns plain text when responseType is empty', async () => {
28
+ const xhr = makeXHRStub({
29
+ responseType: '',
30
+ responseText: 'hello world',
31
+ });
32
+ expect(await getResponseBody(xhr)).toBe('hello world');
33
+ });
34
+
35
+ it('returns plain text when responseType is "text"', async () => {
36
+ const xhr = makeXHRStub({
37
+ responseType: 'text',
38
+ responseText: 'hello world',
39
+ });
40
+ expect(await getResponseBody(xhr)).toBe('hello world');
41
+ });
42
+
43
+ it('stringifies the JSON response when responseType is "json"', async () => {
44
+ const xhr = makeXHRStub({
45
+ responseType: 'json',
46
+ response: { ok: true, n: 1 },
47
+ });
48
+ expect(await getResponseBody(xhr)).toBe('{"ok":true,"n":1}');
49
+ });
50
+
51
+ it('reads a text blob as text', async () => {
52
+ const blob = new Blob(['<p>hello</p>'], { type: 'text/html' });
53
+ const xhr = makeXHRStub({
54
+ responseType: 'blob',
55
+ response: blob,
56
+ contentType: 'text/html; charset=utf-8',
57
+ });
58
+ expect(await getResponseBody(xhr)).toBe('<p>hello</p>');
59
+ });
60
+
61
+ it('reads a JSON blob as text', async () => {
62
+ const blob = new Blob(['{"ok":true}'], { type: 'application/json' });
63
+ const xhr = makeXHRStub({
64
+ responseType: 'blob',
65
+ response: blob,
66
+ contentType: 'application/json',
67
+ });
68
+ expect(await getResponseBody(xhr)).toBe('{"ok":true}');
69
+ });
70
+
71
+ it('routes image/svg+xml through the text path so the source is preserved', async () => {
72
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg"><circle r="5"/></svg>';
73
+ const blob = new Blob([svg], { type: 'image/svg+xml' });
74
+ const xhr = makeXHRStub({
75
+ responseType: 'blob',
76
+ response: blob,
77
+ contentType: 'image/svg+xml',
78
+ });
79
+ expect(await getResponseBody(xhr)).toBe(svg);
80
+ });
81
+
82
+ it('reads application/xml as text', async () => {
83
+ const xml = '<feed><title>Demo</title></feed>';
84
+ const blob = new Blob([xml], { type: 'application/xml' });
85
+ const xhr = makeXHRStub({
86
+ responseType: 'blob',
87
+ response: blob,
88
+ contentType: 'application/xml',
89
+ });
90
+ expect(await getResponseBody(xhr)).toBe(xml);
91
+ });
92
+
93
+ it('reads text/xml as text', async () => {
94
+ const xml = '<root/>';
95
+ const blob = new Blob([xml], { type: 'text/xml' });
96
+ const xhr = makeXHRStub({
97
+ responseType: 'blob',
98
+ response: blob,
99
+ contentType: 'text/xml; charset=utf-8',
100
+ });
101
+ expect(await getResponseBody(xhr)).toBe(xml);
102
+ });
103
+
104
+ it('reads RFC 7303 +xml composite types (Atom, RSS, SOAP) as text', async () => {
105
+ const atom =
106
+ '<feed xmlns="http://www.w3.org/2005/Atom"><entry><title>x</title></entry></feed>';
107
+ const xhr = makeXHRStub({
108
+ responseType: 'blob',
109
+ response: new Blob([atom], { type: 'application/atom+xml' }),
110
+ contentType: 'application/atom+xml; charset=utf-8',
111
+ });
112
+ expect(await getResponseBody(xhr)).toBe(atom);
113
+ });
114
+
115
+ it('returns a binary union variant with base64 for an image blob under the cap', async () => {
116
+ // Three bytes (0x01 0x02 0x03) base64-encodes to "AQID".
117
+ const blob = new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' });
118
+ const xhr = makeXHRStub({
119
+ responseType: 'blob',
120
+ response: blob,
121
+ contentType: 'image/png',
122
+ });
123
+ const result = await getResponseBody(xhr);
124
+ expect(result).toEqual({ kind: 'binary', base64: 'AQID' });
125
+ });
126
+
127
+ it('returns a binary-too-large variant without shipping bytes when the blob exceeds the cap', async () => {
128
+ // Stub a blob whose .size lies — we only care about the size-check
129
+ // path here, no FileReader.readAsDataURL should be invoked.
130
+ const oversizedBlob = {
131
+ size: BINARY_CAPTURE_SIZE_CAP + 1,
132
+ type: 'image/jpeg',
133
+ } as unknown as Blob;
134
+ const xhr = makeXHRStub({
135
+ responseType: 'blob',
136
+ response: oversizedBlob,
137
+ contentType: 'image/jpeg',
138
+ });
139
+ expect(await getResponseBody(xhr)).toEqual({
140
+ kind: 'binary-too-large',
141
+ size: BINARY_CAPTURE_SIZE_CAP + 1,
142
+ });
143
+ });
144
+
145
+ it('returns a binary union variant for non-image, non-text blob content-types', async () => {
146
+ const blob = new Blob([new Uint8Array([1, 2, 3])], {
147
+ type: 'application/pdf',
148
+ });
149
+ const xhr = makeXHRStub({
150
+ responseType: 'blob',
151
+ response: blob,
152
+ contentType: 'application/pdf',
153
+ });
154
+ expect(await getResponseBody(xhr)).toEqual({
155
+ kind: 'binary',
156
+ base64: 'AQID',
157
+ });
158
+ });
159
+
160
+ it('captures arraybuffer responses as binary', async () => {
161
+ const buffer = new Uint8Array([1, 2, 3]).buffer;
162
+ const xhr = makeXHRStub({
163
+ responseType: 'arraybuffer',
164
+ response: buffer,
165
+ });
166
+ expect(await getResponseBody(xhr)).toEqual({
167
+ kind: 'binary',
168
+ base64: 'AQID',
169
+ });
170
+ });
171
+
172
+ it('chunks large arraybuffer responses without exhausting fromCharCode', async () => {
173
+ // 100 KB of bytes — past the 32 KB chunk boundary used by the
174
+ // base64 encoder, well under the 5 MB cap. Confirms the chunked
175
+ // path produces correct base64 output.
176
+ const size = 100 * 1024;
177
+ const bytes = new Uint8Array(size);
178
+ for (let i = 0; i < size; i++) {
179
+ bytes[i] = i & 0xff;
180
+ }
181
+ const xhr = makeXHRStub({
182
+ responseType: 'arraybuffer',
183
+ response: bytes.buffer,
184
+ });
185
+ const result = await getResponseBody(xhr);
186
+ expect(typeof result === 'object' && result?.kind === 'binary').toBe(true);
187
+ if (typeof result === 'object' && result?.kind === 'binary') {
188
+ // Round-trip: decode the base64 back and compare with the input.
189
+ const binary = atob(result.base64);
190
+ const decoded = new Uint8Array(binary.length);
191
+ for (let i = 0; i < binary.length; i++) {
192
+ decoded[i] = binary.charCodeAt(i);
193
+ }
194
+ expect(decoded.length).toBe(size);
195
+ expect(decoded[0]).toBe(0);
196
+ expect(decoded[size - 1]).toBe((size - 1) & 0xff);
197
+ }
198
+ });
199
+
200
+ it('short-circuits arraybuffer responses above the size cap', async () => {
201
+ // Stub a buffer whose byteLength lies — the cap check should fire
202
+ // before any encoding work happens.
203
+ const oversized = {
204
+ byteLength: BINARY_CAPTURE_SIZE_CAP + 1,
205
+ } as unknown as ArrayBuffer;
206
+ const xhr = makeXHRStub({
207
+ responseType: 'arraybuffer',
208
+ response: oversized,
209
+ });
210
+ expect(await getResponseBody(xhr)).toEqual({
211
+ kind: 'binary-too-large',
212
+ size: BINARY_CAPTURE_SIZE_CAP + 1,
213
+ });
214
+ });
215
+
216
+ it('returns null for an arraybuffer responseType with no payload', async () => {
217
+ const xhr = makeXHRStub({ responseType: 'arraybuffer' });
218
+ expect(await getResponseBody(xhr)).toBeNull();
219
+ });
220
+
221
+ it('returns null for an arraybuffer responseType with an empty buffer', async () => {
222
+ const xhr = makeXHRStub({
223
+ responseType: 'arraybuffer',
224
+ response: new ArrayBuffer(0),
225
+ });
226
+ expect(await getResponseBody(xhr)).toBeNull();
227
+ });
228
+ });
@@ -1,13 +1,19 @@
1
- import {
1
+ import type {
2
2
  XHRPostData,
3
3
  RequestPostData,
4
4
  RequestTextPostData,
5
5
  RequestBinaryPostData,
6
6
  RequestFormDataPostData,
7
+ ResponseBody,
8
+ Initiator,
9
+ InitiatorStackFrame,
7
10
  } from '../../shared/client';
8
11
  import { safeStringify } from '../../utils/safeStringify';
9
12
  import { getStringSizeInBytes } from '../../utils/getStringSizeInBytes';
10
- import { isJsonContentType } from '../../utils/getContentTypeMimeType';
13
+ import {
14
+ isJsonContentType,
15
+ isXmlContentType,
16
+ } from '../../utils/getContentTypeMimeType';
11
17
  import {
12
18
  isBlob,
13
19
  isArrayBuffer,
@@ -114,9 +120,48 @@ export const getResponseSize = (request: XMLHttpRequest): number | null => {
114
120
  }
115
121
  };
116
122
 
123
+ // Cap on binary capture. Above this, we ship a 'binary-too-large' variant
124
+ // with just the size — no bytes cross the bridge. 5MB comfortably covers
125
+ // debug-relevant images while keeping the bridge from choking on outliers.
126
+ export const BINARY_CAPTURE_SIZE_CAP = 5 * 1024 * 1024;
127
+
128
+ const readBlobAsText = (blob: Blob): Promise<string> =>
129
+ new Promise((resolve) => {
130
+ const reader = new FileReader();
131
+ reader.onload = () => resolve(reader.result as string);
132
+ reader.readAsText(blob);
133
+ });
134
+
135
+ const readBlobAsBase64 = (blob: Blob): Promise<string> =>
136
+ new Promise((resolve) => {
137
+ const reader = new FileReader();
138
+ reader.onload = () => {
139
+ // FileReader.readAsDataURL returns "data:<mime>;base64,<payload>".
140
+ // We only want the base64 payload.
141
+ const dataUrl = reader.result as string;
142
+ resolve(dataUrl.substring(dataUrl.indexOf(',') + 1));
143
+ };
144
+ reader.readAsDataURL(blob);
145
+ });
146
+
147
+ // String.fromCharCode.apply(null, hugeArray) blows up around 64K elements
148
+ // in some engines; chunk through 32K windows so we work uniformly across
149
+ // large arraybuffer responses.
150
+ const ARRAY_BUFFER_BASE64_CHUNK = 0x8000;
151
+
152
+ const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
153
+ const bytes = new Uint8Array(buffer);
154
+ let binary = '';
155
+ for (let i = 0; i < bytes.length; i += ARRAY_BUFFER_BASE64_CHUNK) {
156
+ const chunk = bytes.subarray(i, i + ARRAY_BUFFER_BASE64_CHUNK);
157
+ binary += String.fromCharCode.apply(null, chunk as unknown as number[]);
158
+ }
159
+ return btoa(binary);
160
+ };
161
+
117
162
  export const getResponseBody = async (
118
163
  request: XMLHttpRequest,
119
- ): Promise<string | null> => {
164
+ ): Promise<ResponseBody> => {
120
165
  const responseType = request.responseType;
121
166
 
122
167
  // Response type is empty in certain cases, like when using axios.
@@ -125,22 +170,39 @@ export const getResponseBody = async (
125
170
  }
126
171
 
127
172
  if (responseType === 'blob') {
128
- // This may be a text blob.
129
173
  const contentType = request.getResponseHeader('Content-Type') || '';
130
174
 
175
+ // Text-shaped content stays on the text path so the panel can
176
+ // show source in the Raw view without base64 round-tripping. XML
177
+ // composite types (application/xml, atom+xml, soap+xml, ...) and
178
+ // image/svg+xml are all covered by isXmlContentType per RFC 7303.
131
179
  if (
132
180
  contentType.startsWith('text/') ||
133
- isJsonContentType(contentType)
181
+ isJsonContentType(contentType) ||
182
+ isXmlContentType(contentType)
134
183
  ) {
135
- // It looks like a text blob, let's read it and forward it to the client.
136
- return new Promise((resolve) => {
137
- const reader = new FileReader();
138
- reader.onload = () => {
139
- resolve(reader.result as string);
140
- };
141
- reader.readAsText(request.response);
142
- });
184
+ return readBlobAsText(request.response);
185
+ }
186
+
187
+ // Everything else is binary — image/*, application/pdf, audio/*,
188
+ // video/*, font/*, application/octet-stream, anything novel a
189
+ // server might serve.
190
+ const blob = request.response as Blob;
191
+ if (blob.size > BINARY_CAPTURE_SIZE_CAP) {
192
+ return { kind: 'binary-too-large', size: blob.size };
193
+ }
194
+ return { kind: 'binary', base64: await readBlobAsBase64(blob) };
195
+ }
196
+
197
+ if (responseType === 'arraybuffer') {
198
+ const buffer = request.response as ArrayBuffer | null;
199
+ if (!buffer || buffer.byteLength === 0) {
200
+ return null;
201
+ }
202
+ if (buffer.byteLength > BINARY_CAPTURE_SIZE_CAP) {
203
+ return { kind: 'binary-too-large', size: buffer.byteLength };
143
204
  }
205
+ return { kind: 'binary', base64: arrayBufferToBase64(buffer) };
144
206
  }
145
207
 
146
208
  if (responseType === 'json') {
@@ -150,26 +212,147 @@ export const getResponseBody = async (
150
212
  return null;
151
213
  };
152
214
 
153
- export const getInitiatorFromStack = (): {
154
- type: string;
155
- url?: string;
156
- lineNumber?: number;
157
- columnNumber?: number;
158
- } => {
215
+ const STACK_PREVIEW_FRAME_LIMIT = 8;
216
+ const INITIATOR_STACK_FRAME_OFFSET = 3;
217
+
218
+ const parseStackLocation = (
219
+ location: string,
220
+ ): Pick<InitiatorStackFrame, 'url' | 'lineNumber' | 'columnNumber'> | null => {
221
+ const match = location.match(/^(.*):(\d+):(\d+)$/);
222
+
223
+ if (!match) {
224
+ return null;
225
+ }
226
+
227
+ return {
228
+ url: match[1],
229
+ lineNumber: Number.parseInt(match[2], 10),
230
+ columnNumber: Number.parseInt(match[3], 10),
231
+ };
232
+ };
233
+
234
+ const normalizeFunctionName = (functionName?: string) => {
235
+ const trimmedFunctionName = functionName?.trim();
236
+
237
+ return trimmedFunctionName &&
238
+ trimmedFunctionName !== '<anonymous>' &&
239
+ trimmedFunctionName !== 'anonymous' &&
240
+ trimmedFunctionName !== '<unknown>'
241
+ ? trimmedFunctionName
242
+ : undefined;
243
+ };
244
+
245
+ const parseStackFrame = (line: string): InitiatorStackFrame | null => {
246
+ const trimmedLine = line.trim();
247
+
248
+ if (!trimmedLine) {
249
+ return null;
250
+ }
251
+
252
+ let functionName: string | undefined;
253
+ let location: string | undefined;
254
+
255
+ const v8FunctionFrame = trimmedLine.match(/^at\s+(.*?)\s+\((.*)\)$/);
256
+ if (v8FunctionFrame) {
257
+ functionName = v8FunctionFrame[1];
258
+ location = v8FunctionFrame[2];
259
+ } else {
260
+ const v8LocationFrame = trimmedLine.match(/^at\s+(.*)$/);
261
+ const jscFrame = trimmedLine.match(/^(.*?)@(.*)$/);
262
+
263
+ if (v8LocationFrame) {
264
+ location = v8LocationFrame[1];
265
+ } else if (jscFrame) {
266
+ functionName = jscFrame[1];
267
+ location = jscFrame[2];
268
+ }
269
+ }
270
+
271
+ if (!location) {
272
+ return null;
273
+ }
274
+
275
+ const parsedLocation = parseStackLocation(location);
276
+ if (!parsedLocation) {
277
+ return null;
278
+ }
279
+
280
+ return {
281
+ functionName: normalizeFunctionName(functionName),
282
+ ...parsedLocation,
283
+ };
284
+ };
285
+
286
+ const toGeneratedStackFrame = (
287
+ frame: InitiatorStackFrame,
288
+ ): InitiatorStackFrame => ({
289
+ functionName: frame.functionName,
290
+ generatedUrl: frame.url,
291
+ generatedLineNumber: frame.lineNumber,
292
+ generatedColumnNumber: frame.columnNumber,
293
+ });
294
+
295
+ const getGeneratedFrameLocation = (frame: InitiatorStackFrame) => ({
296
+ url: frame.generatedUrl ?? frame.url,
297
+ lineNumber: frame.generatedLineNumber ?? frame.lineNumber,
298
+ columnNumber: frame.generatedColumnNumber ?? frame.columnNumber,
299
+ });
300
+
301
+ const canSymbolicateStack = (stack?: InitiatorStackFrame[]) =>
302
+ stack?.some((frame) =>
303
+ getGeneratedFrameLocation(frame).url?.startsWith('http'),
304
+ ) ?? false;
305
+
306
+ const getStackPreview = (frames: InitiatorStackFrame[]) => {
307
+ // The first frames are this helper, the HTTP inspector callback and the XHR
308
+ // wrapper. The caller starts after that fixed interception boundary.
309
+ const callerFrames = frames.slice(INITIATOR_STACK_FRAME_OFFSET);
310
+
311
+ return (callerFrames.length > 0 ? callerFrames : frames).slice(
312
+ 0,
313
+ STACK_PREVIEW_FRAME_LIMIT,
314
+ );
315
+ };
316
+
317
+ export const getInitiatorFromStack = (): Initiator => {
159
318
  try {
160
319
  const stack = new Error().stack;
161
320
  if (!stack) {
162
321
  return { type: 'other' };
163
322
  }
164
323
 
165
- const line = stack.split('\n')[9];
166
- const match = line.match(/at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/);
167
- if (match) {
324
+ const parsedFrames = stack
325
+ .split('\n')
326
+ .map(parseStackFrame)
327
+ .filter((frame): frame is InitiatorStackFrame => frame !== null);
328
+
329
+ const stackPreview = getStackPreview(parsedFrames);
330
+ const initiatorFrame = stackPreview[0];
331
+ const generatedStackPreview = stackPreview.map(toGeneratedStackFrame);
332
+
333
+ if (initiatorFrame?.url) {
168
334
  return {
169
335
  type: 'script',
170
- url: match[2],
171
- lineNumber: parseInt(match[3]),
172
- columnNumber: parseInt(match[4]),
336
+ functionName: initiatorFrame.functionName,
337
+ generatedUrl: initiatorFrame.url,
338
+ generatedLineNumber: initiatorFrame.lineNumber,
339
+ generatedColumnNumber: initiatorFrame.columnNumber,
340
+ stack: generatedStackPreview,
341
+ symbolicationStatus: canSymbolicateStack(generatedStackPreview)
342
+ ? 'pending'
343
+ : 'unavailable',
344
+ };
345
+ }
346
+
347
+ if (parsedFrames.length > 0) {
348
+ const fallbackStack = stackPreview.map(toGeneratedStackFrame);
349
+
350
+ return {
351
+ type: 'other',
352
+ stack: fallbackStack,
353
+ symbolicationStatus: canSymbolicateStack(fallbackStack)
354
+ ? 'pending'
355
+ : 'unavailable',
173
356
  };
174
357
  }
175
358
  } catch {
@@ -15,7 +15,7 @@ import {
15
15
  NITRO_NETWORK_EVENTS,
16
16
  } from './nitro-fetch/nitro-network-inspector';
17
17
  import { EventsListener } from './events-listener';
18
- import { NetworkActivityEventMap } from '../shared/client';
18
+ import { NetworkActivityEventMap, ResponseBody } from '../shared/client';
19
19
  import type { InspectorsConfig } from './config';
20
20
  import { getResponseBody as getHTTPResponseBody } from './http/http-utils';
21
21
 
@@ -28,7 +28,7 @@ export type NetworkInspector = {
28
28
  enable: (config?: InspectorsConfig) => void;
29
29
  disable: () => void;
30
30
  dispose: () => void;
31
- getResponseBody: (requestId: string) => Promise<string | null>;
31
+ getResponseBody: (requestId: string) => Promise<ResponseBody>;
32
32
  };
33
33
 
34
34
  const createNetworkInspectorInstance = (): NetworkInspector => {
@@ -1,9 +1,13 @@
1
1
  import type { NitroModule } from './nitro-network-inspector';
2
2
 
3
- export const getNitroModule = (): NitroModule | null => {
3
+ const nitroModule = (() => {
4
4
  try {
5
5
  return require('react-native-nitro-fetch') as NitroModule;
6
6
  } catch {
7
7
  return null;
8
8
  }
9
+ })();
10
+
11
+ export const getNitroModule = (): NitroModule | null => {
12
+ return nitroModule;
9
13
  };
@@ -4,6 +4,7 @@ import type {
4
4
  HttpHeaders,
5
5
  HttpMethod,
6
6
  RequestPostData,
7
+ ResponseBody,
7
8
  } from '../../shared/client';
8
9
  import type { WebSocketEventMap } from '../../shared/websocket-events';
9
10
  import type { Inspector } from '../inspector';
@@ -92,7 +93,12 @@ type NanoEventsMap = {
92
93
  };
93
94
 
94
95
  export type NitroNetworkInspector = Inspector<NitroNetworkEventMap> & {
95
- getResponseBody: (requestId: string) => string | null;
96
+ // Returns ResponseBody so the wire shape is consistent across capture
97
+ // paths. The nitro native module today only surfaces text response
98
+ // bodies, so at runtime this resolves to string | null — but typing it
99
+ // as ResponseBody lets future native support for binary slot in without
100
+ // a wire-format change.
101
+ getResponseBody: (requestId: string) => ResponseBody;
96
102
  };
97
103
 
98
104
  export const NITRO_NETWORK_EVENTS: (keyof NitroNetworkEventMap)[] = [
@@ -163,7 +169,7 @@ export const createNitroNetworkInspector = (
163
169
  ): NitroNetworkInspector => {
164
170
  const eventEmitter = createNanoEvents<NanoEventsMap>();
165
171
  const previousEntries = new Map<string, NitroInspectorEntry>();
166
- const responseBodies = new Map<string, string | null>();
172
+ const responseBodies = new Map<string, ResponseBody>();
167
173
  let nitroModule: NitroModule | null = null;
168
174
  let unsubscribe: (() => void) | null = null;
169
175
 
@@ -1,6 +1,17 @@
1
1
  export type HttpHeaders = Record<string, string | string[]>;
2
2
  export type XHRHeaders = NonNullable<XMLHttpRequest['responseHeaders']>;
3
3
 
4
+ // Discriminated union for response bodies on the bridge.
5
+ // string → text body (today's path)
6
+ // { kind: 'binary' } → base64-encoded binary payload (e.g. images)
7
+ // { kind: 'binary-too-large' } → response exceeded the in-capture size cap; bytes not shipped
8
+ // null → body could not be read at all
9
+ export type ResponseBody =
10
+ | string
11
+ | { kind: 'binary'; base64: string }
12
+ | { kind: 'binary-too-large'; size: number }
13
+ | null;
14
+
4
15
  export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD';
5
16
 
6
17
  export type RequestId = string;
@@ -72,11 +83,39 @@ export type Response = {
72
83
  responseTime: Timestamp;
73
84
  };
74
85
 
86
+ export type InitiatorStackFrame = {
87
+ functionName?: string;
88
+ url?: string;
89
+ lineNumber?: number;
90
+ columnNumber?: number;
91
+ generatedUrl?: string;
92
+ generatedLineNumber?: number;
93
+ generatedColumnNumber?: number;
94
+ isCollapsed?: boolean;
95
+ };
96
+
97
+ export type InitiatorCodeFrame = {
98
+ content: string;
99
+ fileName: string;
100
+ location?: {
101
+ row: number;
102
+ column: number;
103
+ } | null;
104
+ };
105
+
75
106
  export type Initiator = {
76
107
  type: string;
108
+ symbolicationStatus?: 'pending' | 'complete' | 'failed' | 'unavailable';
109
+ symbolicationError?: string;
110
+ functionName?: string;
77
111
  url?: string;
78
112
  lineNumber?: number;
79
113
  columnNumber?: number;
114
+ generatedUrl?: string;
115
+ generatedLineNumber?: number;
116
+ generatedColumnNumber?: number;
117
+ codeFrame?: InitiatorCodeFrame | null;
118
+ stack?: InitiatorStackFrame[];
80
119
  };
81
120
 
82
121
  export type ResourceType = 'XHR' | 'Fetch' | 'Other';
@@ -137,7 +176,7 @@ export type HttpEventMap = {
137
176
 
138
177
  'response-body': {
139
178
  requestId: RequestId;
140
- body: string | null;
179
+ body: ResponseBody;
141
180
  };
142
181
 
143
182
  'set-overrides': {