@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.
- package/CHANGELOG.md +60 -0
- package/dist/devtools/App.html +2 -2
- package/dist/devtools/assets/{App-hSoryVpJ.js → App-CEESZAW_.js} +7520 -937
- package/dist/devtools/assets/{App-m6xge0az.css → App-xppYUJvX.css} +246 -2
- package/dist/react-native/chunks/boot-recording.cjs +138 -14
- package/dist/react-native/chunks/boot-recording.js +138 -14
- package/dist/react-native/chunks/get-nitro-module.cjs +4 -1
- package/dist/react-native/chunks/get-nitro-module.js +4 -1
- package/dist/react-native/chunks/useNetworkActivityDevTools.require.cjs +20 -1
- package/dist/react-native/chunks/useNetworkActivityDevTools.require.js +20 -1
- package/dist/react-native/index.d.ts +37 -1
- package/dist/rozenite.json +1 -1
- package/dist/sdk/index.d.ts +37 -1
- package/package.json +12 -7
- package/src/react-native/agent/use-network-activity-agent-tools.ts +22 -4
- package/src/react-native/http/__tests__/http-utils.test.ts +228 -0
- package/src/react-native/http/http-utils.ts +208 -25
- package/src/react-native/network-inspector.ts +2 -2
- package/src/react-native/nitro-fetch/get-nitro-module.ts +5 -1
- package/src/react-native/nitro-fetch/nitro-network-inspector.ts +8 -2
- package/src/shared/http-events.ts +40 -1
- package/src/ui/components/CodeBlock.tsx +45 -1
- package/src/ui/components/FilterBar.tsx +337 -61
- package/src/ui/components/HexView.tsx +54 -0
- package/src/ui/components/MetadataCard.tsx +95 -0
- package/src/ui/components/NetworkTimeline.tsx +422 -0
- package/src/ui/components/RequestList.tsx +19 -40
- package/src/ui/components/SidePanel.tsx +42 -1
- package/src/ui/components/Toolbar.tsx +13 -1
- package/src/ui/components/ViewToggle.tsx +44 -0
- package/src/ui/components/XmlTree.tsx +160 -0
- package/src/ui/components/__tests__/CodeBlock.test.tsx +89 -0
- package/src/ui/components/__tests__/HexView.test.tsx +41 -0
- package/src/ui/components/__tests__/MetadataCard.test.tsx +107 -0
- package/src/ui/components/__tests__/ViewToggle.test.tsx +80 -0
- package/src/ui/components/__tests__/XmlTree.test.tsx +149 -0
- package/src/ui/hooks/useNetworkActivitySessionExport.ts +39 -0
- package/src/ui/response-renderers/__tests__/binary-too-large.test.tsx +56 -0
- package/src/ui/response-renderers/__tests__/binary.test.tsx +96 -0
- package/src/ui/response-renderers/__tests__/dispatch.test.ts +124 -0
- package/src/ui/response-renderers/__tests__/html.test.tsx +101 -0
- package/src/ui/response-renderers/__tests__/image.test.tsx +73 -0
- package/src/ui/response-renderers/__tests__/json.test.tsx +95 -0
- package/src/ui/response-renderers/__tests__/svg.test.tsx +46 -0
- package/src/ui/response-renderers/__tests__/xml.test.tsx +100 -0
- package/src/ui/response-renderers/binary-too-large.tsx +36 -0
- package/src/ui/response-renderers/binary.tsx +31 -0
- package/src/ui/response-renderers/empty.tsx +14 -0
- package/src/ui/response-renderers/html.tsx +36 -0
- package/src/ui/response-renderers/image.tsx +37 -0
- package/src/ui/response-renderers/index.ts +55 -0
- package/src/ui/response-renderers/json.tsx +40 -0
- package/src/ui/response-renderers/svg.tsx +27 -0
- package/src/ui/response-renderers/text-fallback.tsx +14 -0
- package/src/ui/response-renderers/types.ts +38 -0
- package/src/ui/response-renderers/unknown.tsx +18 -0
- package/src/ui/response-renderers/xml.tsx +46 -0
- package/src/ui/state/__tests__/store.test.ts +77 -0
- package/src/ui/state/derived.ts +14 -0
- package/src/ui/state/filter.ts +49 -0
- package/src/ui/state/hooks.ts +2 -2
- package/src/ui/state/model.ts +7 -1
- package/src/ui/state/store.ts +63 -4
- package/src/ui/tabs/InitiatorTab.tsx +230 -0
- package/src/ui/tabs/ResponseTab.tsx +80 -97
- package/src/ui/tabs/__tests__/ResponseTab.test.tsx +102 -0
- package/src/ui/utils/__tests__/download.test.ts +115 -0
- package/src/ui/utils/__tests__/hex.test.ts +84 -0
- package/src/ui/utils/__tests__/requestFilters.test.ts +32 -0
- package/src/ui/utils/__tests__/sessionExport.test.ts +174 -0
- package/src/ui/utils/__tests__/symbolication.test.ts +207 -0
- package/src/ui/utils/__tests__/timelineModel.test.ts +170 -0
- package/src/ui/utils/download.ts +161 -0
- package/src/ui/utils/hex.ts +59 -0
- package/src/ui/utils/initiator.ts +136 -0
- package/src/ui/utils/requestFilters.ts +183 -0
- package/src/ui/utils/sessionExport.ts +185 -0
- package/src/ui/utils/symbolication.ts +248 -0
- package/src/ui/utils/timelineModel.ts +352 -0
- package/src/ui/views/InspectorView.tsx +43 -8
- package/src/utils/__tests__/getContentTypeMimeType.test.ts +34 -0
- package/src/utils/getContentTypeMimeType.ts +14 -0
- package/vite.config.ts +5 -1
- package/vitest.setup.ts +31 -0
|
@@ -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 {
|
|
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<
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
179
|
+
body: ResponseBody;
|
|
141
180
|
};
|
|
142
181
|
|
|
143
182
|
'set-overrides': {
|