diff --git a/apps/playground/src/app/screens/NetworkTestScreen.tsx b/apps/playground/src/app/screens/NetworkTestScreen.tsx index aed924f3..15fd8875 100644 --- a/apps/playground/src/app/screens/NetworkTestScreen.tsx +++ b/apps/playground/src/app/screens/NetworkTestScreen.tsx @@ -24,6 +24,10 @@ import { } from 'react-native-nitro-websockets'; import { RootStackParamList } from '../navigation/types'; import { api, User, Post, Todo } from '../utils/network-activity/api'; +import { + expoFetchApi, + type ExpoFetchDemoResult, +} from '../utils/network-activity/expo'; import { nitroApi, type NitroDemoResult, @@ -792,6 +796,107 @@ const NitroHTTPTestComponent: React.FC = () => { ); }; +const ExpoFetchHTTPTestComponent: React.FC = () => { + const [isRunning, setIsRunning] = React.useState(false); + const [result, setResult] = React.useState(null); + const [error, setError] = React.useState(null); + + const runExpoFetchAction = React.useCallback( + async (action: () => Promise) => { + setIsRunning(true); + setError(null); + + try { + const nextResult = await action(); + setResult(nextResult); + } catch (actionError) { + setResult(null); + setError( + actionError instanceof Error + ? actionError.message + : String(actionError), + ); + } finally { + setIsRunning(false); + } + }, + [], + ); + + return ( + + + Expo Fetch Test + + Runs requests through `expo/fetch`. Watch the Network Activity panel + for Expo source badges. Prefetch is not exposed by `expo/fetch`, so + this tab focuses on GET, POST, and abort handling. + + + + runExpoFetchAction(expoFetchApi.getUsers)} + > + Expo GET + + + runExpoFetchAction(expoFetchApi.createPost)} + > + Expo POST + + + runExpoFetchAction(expoFetchApi.abortSlowRequest)} + > + Abort + + + + {isRunning && ( + + + Running Expo request... + + )} + + {error && Error: {error}} + + {result && ( + + {result.title} + + Status: {result.status} {result.statusText} + + {result.extra ? ( + {result.extra} + ) : null} + + {result.body} + + + )} + + + ); +}; + const HTTPTestComponent: React.FC = () => { const [activeTab, setActiveTab] = React.useState< 'users' | 'posts' | 'todos' | 'slow' | 'unreliable' | 'create' | 'large' @@ -1775,6 +1880,7 @@ export const NetworkTestScreen: React.FC = () => { const [activeTest, setActiveTest] = React.useState< | 'http' | 'nitro' + | 'expo-fetch' | 'websocket' | 'nitro-websocket' | 'sse' @@ -1828,8 +1934,8 @@ export const NetworkTestScreen: React.FC = () => { Network Test - Testing built-in HTTP, Nitro HTTP, built-in WebSocket, Nitro WebSocket, - and SSE connections + Testing built-in HTTP, Expo Fetch, Nitro HTTP, built-in WebSocket, + Nitro WebSocket, and SSE connections { {[ { key: 'http', label: 'HTTP Test' }, + { key: 'expo-fetch', label: 'Expo Fetch' }, { key: 'nitro', label: 'Nitro HTTP' }, { key: 'websocket', label: 'WebSocket Test' }, { key: 'nitro-websocket', label: 'Nitro WS' }, @@ -1862,6 +1969,7 @@ export const NetworkTestScreen: React.FC = () => { setActiveTest( tab.key as | 'http' + | 'expo-fetch' | 'nitro' | 'websocket' | 'nitro-websocket' @@ -1893,6 +2001,8 @@ export const NetworkTestScreen: React.FC = () => { {renderHeader()} {activeTest === 'http' ? ( + ) : activeTest === 'expo-fetch' ? ( + ) : activeTest === 'nitro' ? ( ) : activeTest === 'websocket' ? ( diff --git a/apps/playground/src/app/utils/network-activity/expo.ts b/apps/playground/src/app/utils/network-activity/expo.ts new file mode 100644 index 00000000..f9523f72 --- /dev/null +++ b/apps/playground/src/app/utils/network-activity/expo.ts @@ -0,0 +1,107 @@ +import { fetch as expoFetch } from 'expo/fetch'; +import type { Post, User } from './api'; + +export type ExpoFetchDemoResult = { + title: string; + status: number; + statusText: string; + body: string; + extra?: string; +}; + +const prettyPrint = (value: unknown) => JSON.stringify(value, null, 2); + +export const expoFetchApi = { + async getUsers(): Promise { + const response = await expoFetch( + 'https://jsonplaceholder.typicode.com/users?_limit=3', + { + headers: { + 'X-Rozenite-Test': 'expo-fetch-users', + }, + }, + ); + + if (!response.ok) { + throw new Error(`Expo fetch request failed with status ${response.status}`); + } + + const users = (await response.json()) as User[]; + + return { + title: 'Expo GET users', + status: response.status, + statusText: response.statusText, + body: prettyPrint(users), + extra: `Fetched ${users.length} users via expo/fetch.`, + }; + }, + + async createPost(): Promise { + const payload: Omit = { + userId: 1, + title: 'Rozenite Expo fetch test post', + body: 'This request was created from the playground using expo/fetch.', + }; + + const response = await expoFetch( + 'https://jsonplaceholder.typicode.com/posts?source=expo-fetch-playground', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Rozenite-Test': 'expo-fetch-create-post', + }, + body: JSON.stringify(payload), + }, + ); + + if (!response.ok) { + throw new Error(`Expo fetch request failed with status ${response.status}`); + } + + const post = (await response.json()) as Post; + + return { + title: 'Expo POST JSON', + status: response.status, + statusText: response.statusText, + body: prettyPrint(post), + extra: 'Creates a POST entry with an Expo source badge in Network Activity.', + }; + }, + + async abortSlowRequest(): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 250); + + try { + await expoFetch('https://httpbin.org/delay/5', { + signal: controller.signal, + headers: { + 'X-Rozenite-Test': 'expo-fetch-abort', + }, + }); + + return { + title: 'Expo AbortController', + status: 200, + statusText: 'Unexpected success', + body: 'The delayed request completed before aborting.', + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + return { + title: 'Expo AbortController', + status: 0, + statusText: 'Aborted', + body: message, + extra: + 'Use this to verify failed Expo fetch requests show up in Network Activity.', + }; + } finally { + clearTimeout(timeout); + } + }, +}; diff --git a/packages/network-activity-plugin/src/react-native/http/__tests__/fetch-interceptor.test.ts b/packages/network-activity-plugin/src/react-native/http/__tests__/fetch-interceptor.test.ts new file mode 100644 index 00000000..a03411a6 --- /dev/null +++ b/packages/network-activity-plugin/src/react-native/http/__tests__/fetch-interceptor.test.ts @@ -0,0 +1,318 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; + +let captureResponseBodySpy: ReturnType | null = null; + +const loadFetchInterceptor = async ( + expoFetchModule: { fetch: typeof fetch } | null = null, +) => { + vi.resetModules(); + vi.doMock('../http-utils', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + getInitiatorFromStack: () => ({ type: 'other' }), + }; + }); + vi.doMock('../fetch-utils', async (importOriginal) => { + const actual = await importOriginal(); + captureResponseBodySpy = vi.fn(actual.captureFetchResponseBodyFromBytes); + + return { + ...actual, + BINARY_CAPTURE_SIZE_CAP: 10, + captureFetchResponseBodyFromBytes: captureResponseBodySpy, + }; + }); + vi.doMock('../get-expo-fetch-module', () => ({ + getExpoFetchModule: () => expoFetchModule, + })); + + return import('../fetch-interceptor'); +}; + +const createExpoResponse = ( + bodyChunks: string[], + headers: Record, +) => { + const encodedChunks = bodyChunks.map((chunk) => new TextEncoder().encode(chunk)); + const totalSize = encodedChunks.reduce( + (sum, chunk) => sum + chunk.byteLength, + 0, + ); + + const createBody = () => + new ReadableStream({ + start(controller) { + encodedChunks.forEach((chunk) => controller.enqueue(chunk)); + controller.close(); + }, + }); + + const createResponse = (): Response => + ({ + url: 'https://example.com/api', + status: 200, + statusText: 'OK', + headers: new Headers({ + 'content-type': 'application/json', + 'content-length': String(totalSize), + ...headers, + }), + body: createBody(), + clone: () => createResponse(), + }) as unknown as Response; + + return createResponse(); +}; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unmock('../http-utils'); + vi.unmock('../fetch-utils'); + vi.unmock('../get-expo-fetch-module'); + captureResponseBodySpy = null; +}); + +describe('FetchInterceptor', () => { + it('patches expo/fetch directly and preserves the original export', async () => { + const fetchMock = vi.fn(async () => + createExpoResponse(['{"ok":', 'true}'], { + 'x-request-id': 'expo-1', + }), + ); + const expoFetchModule = { fetch: fetchMock as typeof fetch }; + + const { FetchInterceptor } = await loadFetchInterceptor(expoFetchModule); + const events: Array<{ type: string; event: unknown }> = []; + const originalModuleFetch = expoFetchModule.fetch; + + FetchInterceptor.setCallbacks({ + onRequestSent: (event) => events.push({ type: 'request-sent', event }), + onResponseReceived: (event) => + events.push({ type: 'response-received', event }), + onRequestProgress: (event) => + events.push({ type: 'request-progress', event }), + onRequestCompleted: (event) => + events.push({ type: 'request-completed', event }), + onRequestFailed: (event) => + events.push({ type: 'request-failed', event }), + onResponseBody: (requestId, body) => + events.push({ + type: 'response-body', + event: { requestId, body }, + }), + }); + + FetchInterceptor.enableInterception(); + + expect(FetchInterceptor.isInterceptorEnabled()).toBe(true); + expect(expoFetchModule.fetch).not.toBe(originalModuleFetch); + + const response = await expoFetchModule.fetch('https://example.com/api', { + method: 'post', + headers: { + 'x-request-id': 'expo-1', + }, + body: JSON.stringify({ ok: true }), + }); + + expect(response).toBeDefined(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const requestSent = events.find((entry) => entry.type === 'request-sent'); + const responseReceived = events.find( + (entry) => entry.type === 'response-received', + ); + const progressEvents = events.filter( + (entry) => entry.type === 'request-progress', + ); + const completed = events.find( + (entry) => entry.type === 'request-completed', + ); + const bodyEvent = events.find((entry) => entry.type === 'response-body'); + + expect(requestSent?.event).toMatchObject({ + request: { + url: 'https://example.com/api', + method: 'POST', + headers: { + 'x-request-id': 'expo-1', + }, + postData: { + type: 'text', + value: '{"ok":true}', + }, + }, + type: 'Fetch', + source: 'expo', + }); + + expect(responseReceived?.event).toMatchObject({ + type: 'Fetch', + source: 'expo', + response: { + url: 'https://example.com/api', + status: 200, + statusText: 'OK', + contentType: 'application/json', + size: 11, + }, + }); + + expect(progressEvents.length).toBeGreaterThan(0); + expect(progressEvents.at(-1)?.event).toMatchObject({ + loaded: 11, + total: 11, + lengthComputable: true, + source: 'expo', + }); + + expect(bodyEvent?.event).toEqual({ + requestId: expect.any(String), + body: '{"ok":true}', + }); + + expect(completed?.event).toMatchObject({ + duration: expect.any(Number), + size: 11, + ttfb: expect.any(Number), + source: 'expo', + }); + + FetchInterceptor.disableInterception(); + + expect(FetchInterceptor.isInterceptorEnabled()).toBe(false); + expect(expoFetchModule.fetch).toBe(originalModuleFetch); + }); + + it('does not start when expo/fetch is unavailable', async () => { + const { FetchInterceptor } = await loadFetchInterceptor(null); + + FetchInterceptor.enableInterception(); + + expect(FetchInterceptor.isInterceptorEnabled()).toBe(false); + }); + + it('emits a canceled failure when expo/fetch rejects with an AbortError', async () => { + const abortError = new DOMException('Aborted', 'AbortError'); + const fetchMock = vi.fn(async () => { + throw abortError; + }); + const expoFetchModule = { fetch: fetchMock as typeof fetch }; + + const { FetchInterceptor } = await loadFetchInterceptor(expoFetchModule); + const onRequestFailed = vi.fn(); + + FetchInterceptor.setCallbacks({ + onRequestFailed, + }); + FetchInterceptor.enableInterception(); + + await expect( + expoFetchModule.fetch('https://example.com/api'), + ).rejects.toThrow('Aborted'); + + expect(onRequestFailed).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'Fetch', + canceled: true, + source: 'expo', + }), + ); + + FetchInterceptor.disableInterception(); + }); + + it('emits request metadata before a failed expo/fetch rejection', async () => { + const fetchMock = vi.fn(async () => { + throw new Error('Network down'); + }); + const expoFetchModule = { fetch: fetchMock as typeof fetch }; + + const { FetchInterceptor } = await loadFetchInterceptor(expoFetchModule); + const events: Array<{ type: string; event: unknown }> = []; + + FetchInterceptor.setCallbacks({ + onRequestSent: (event) => events.push({ type: 'request-sent', event }), + onRequestFailed: (event) => + events.push({ type: 'request-failed', event }), + }); + FetchInterceptor.enableInterception(); + + await expect( + expoFetchModule.fetch('https://example.com/api'), + ).rejects.toThrow('Network down'); + + expect(events.map((entry) => entry.type)).toEqual([ + 'request-sent', + 'request-failed', + ]); + expect(events[0]?.event).toMatchObject({ + request: { + url: 'https://example.com/api', + method: 'GET', + }, + type: 'Fetch', + source: 'expo', + }); + expect(events[1]?.event).toMatchObject({ + type: 'Fetch', + canceled: false, + source: 'expo', + }); + + FetchInterceptor.disableInterception(); + }); + + it('avoids buffering binary bodies past the capture cap', async () => { + const size = 11; + const fetchMock = vi.fn(async () => { + const bytes = new Uint8Array(size); + bytes.fill(7); + + const createBody = () => + new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); + + const createResponse = (): Response => + ({ + url: 'https://example.com/file', + status: 200, + statusText: 'OK', + headers: new Headers({ + 'content-type': 'application/octet-stream', + 'content-length': String(size), + }), + body: createBody(), + clone: () => createResponse(), + }) as unknown as Response; + + return createResponse(); + }); + const expoFetchModule = { fetch: fetchMock as typeof fetch }; + + const { FetchInterceptor } = await loadFetchInterceptor(expoFetchModule); + const sentEvents: Array = []; + + FetchInterceptor.setCallbacks({ + onRequestSent: (event) => sentEvents.push(event), + }); + FetchInterceptor.enableInterception(); + + await expoFetchModule.fetch('https://example.com/file'); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(sentEvents).toHaveLength(1); + expect(captureResponseBodySpy).not.toHaveBeenCalled(); + + FetchInterceptor.disableInterception(); + }); +}); diff --git a/packages/network-activity-plugin/src/react-native/http/__tests__/fetch-utils.test.ts b/packages/network-activity-plugin/src/react-native/http/__tests__/fetch-utils.test.ts new file mode 100644 index 00000000..5d9c801d --- /dev/null +++ b/packages/network-activity-plugin/src/react-native/http/__tests__/fetch-utils.test.ts @@ -0,0 +1,169 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { + BINARY_CAPTURE_SIZE_CAP, + captureFetchResponseBodyFromBytes, + createProgressThrottler, + getFetchContentLength, + getFetchContentType, + normalizeFetchRequest, + normalizeHeaders, +} from '../fetch-utils'; + +describe('normalizeHeaders', () => { + it('normalizes plain object headers', () => { + expect( + normalizeHeaders({ + Accept: 'application/json', + 'x-token': 'abc', + }), + ).toEqual({ + Accept: 'application/json', + 'x-token': 'abc', + }); + }); + + it('normalizes array headers and preserves repeated values', () => { + expect( + normalizeHeaders([ + ['set-cookie', 'a=1'], + ['set-cookie', 'b=2'], + ]), + ).toEqual({ + 'set-cookie': ['a=1', 'b=2'], + }); + }); + + it('normalizes Headers instances', () => { + const headers = new Headers([ + ['content-type', 'application/json'], + ['x-id', '123'], + ]); + + expect(normalizeHeaders(headers)).toEqual({ + 'content-type': 'application/json', + 'x-id': '123', + }); + }); +}); + +describe('normalizeFetchRequest', () => { + it('normalizes string input and request init options', () => { + const result = normalizeFetchRequest('https://example.com/api', { + method: 'post', + headers: { + Accept: 'application/json', + }, + body: JSON.stringify({ ok: true }), + }); + + expect(result).toEqual({ + url: 'https://example.com/api', + method: 'POST', + headers: { + Accept: 'application/json', + }, + postData: { + type: 'text', + value: '{"ok":true}', + }, + }); + }); + + it('normalizes Request input and merges override headers', () => { + const request = new Request('https://example.com/items', { + method: 'put', + headers: { + 'x-original': 'one', + }, + }); + + expect( + normalizeFetchRequest(request, { + headers: { + 'x-original': 'two', + 'x-extra': 'three', + }, + }), + ).toMatchObject({ + url: 'https://example.com/items', + method: 'PUT', + headers: { + 'x-original': 'two', + 'x-extra': 'three', + }, + postData: undefined, + signal: expect.any(AbortSignal), + }); + }); + + it('serializes URLSearchParams request bodies as text', () => { + const result = normalizeFetchRequest('https://example.com/search', { + body: new URLSearchParams({ q: 'expo fetch' }), + }); + + expect(result.postData).toEqual({ + type: 'text', + value: 'q=expo+fetch', + }); + }); +}); + +describe('captureFetchResponseBodyFromBytes', () => { + it('returns text for JSON, XML, and text content types', async () => { + const json = new TextEncoder().encode('{"ok":true}'); + expect( + await captureFetchResponseBodyFromBytes(json, 'application/json'), + ).toBe('{"ok":true}'); + + const xml = new TextEncoder().encode(''); + expect( + await captureFetchResponseBodyFromBytes(xml, 'application/xml'), + ).toBe(''); + + const svg = new TextEncoder().encode(''); + expect( + await captureFetchResponseBodyFromBytes(svg, 'image/svg+xml'), + ).toBe(''); + }); + + it('returns a binary union for non-text bytes under the cap', async () => { + const bytes = new Uint8Array([1, 2, 3]); + expect( + await captureFetchResponseBodyFromBytes(bytes, 'application/pdf'), + ).toEqual({ kind: 'binary', base64: 'AQID' }); + }); + + it('short-circuits binary capture above the cap', async () => { + const bytes = new Uint8Array(BINARY_CAPTURE_SIZE_CAP + 1); + expect( + await captureFetchResponseBodyFromBytes(bytes, 'application/octet-stream'), + ).toEqual({ + kind: 'binary-too-large', + size: BINARY_CAPTURE_SIZE_CAP + 1, + }); + }); +}); + +describe('progress throttling and response metadata helpers', () => { + it('throttles progress emissions and allows forced updates', () => { + const shouldEmit = createProgressThrottler(100); + + expect(shouldEmit(1_000)).toBe(true); + expect(shouldEmit(1_050)).toBe(false); + expect(shouldEmit(1_100)).toBe(true); + expect(shouldEmit(1_120, true)).toBe(true); + }); + + it('reads fetch response metadata from headers', () => { + const response = new Response('hello', { + headers: { + 'content-type': 'application/json; charset=utf-8', + 'content-length': '12', + }, + }); + + expect(getFetchContentType(response)).toBe('application/json'); + expect(getFetchContentLength(response)).toBe(12); + }); +}); diff --git a/packages/network-activity-plugin/src/react-native/http/__tests__/network-requests-registry.test.ts b/packages/network-activity-plugin/src/react-native/http/__tests__/network-requests-registry.test.ts new file mode 100644 index 00000000..67c219f1 --- /dev/null +++ b/packages/network-activity-plugin/src/react-native/http/__tests__/network-requests-registry.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { getNetworkRequestsRegistry } from '../network-requests-registry'; + +describe('getNetworkRequestsRegistry', () => { + it('stores response bodies for fetch-owned requests without an XHR entry', () => { + const registry = getNetworkRequestsRegistry(); + + registry.setResponseBody('fetch-1', '{"ok":true}'); + + expect(registry.getEntry('fetch-1')).toBeNull(); + expect(registry.getResponseBody('fetch-1')).toBe('{"ok":true}'); + }); + + it('keeps the XHR request entry and response body side by side', () => { + const registry = getNetworkRequestsRegistry(); + const xhr = { + responseType: '', + } as unknown as XMLHttpRequest; + + registry.addEntry('xhr-1', xhr); + registry.setResponseBody('xhr-1', 'hello'); + + expect(registry.getEntry('xhr-1')).toBe(xhr); + expect(registry.getResponseBody('xhr-1')).toBe('hello'); + }); +}); diff --git a/packages/network-activity-plugin/src/react-native/http/fetch-interceptor.ts b/packages/network-activity-plugin/src/react-native/http/fetch-interceptor.ts new file mode 100644 index 00000000..a58ae9ca --- /dev/null +++ b/packages/network-activity-plugin/src/react-native/http/fetch-interceptor.ts @@ -0,0 +1,285 @@ +import type { HttpEventMap } from '../../shared/http-events'; +import { getInitiatorFromStack } from './http-utils'; +import { getExpoFetchModule } from './get-expo-fetch-module'; +import { + BINARY_CAPTURE_SIZE_CAP, + captureFetchResponseBodyFromBytes, + createProgressThrottler, + getFetchContentLength, + getFetchContentType, + getFetchResponseHeaders, + normalizeFetchRequest, + isFetchAbortError, +} from './fetch-utils'; +import { isTextLikeContentType } from './response-body-utils'; + +type FetchInterceptorCallbacks = { + onRequestSent?: (event: HttpEventMap['request-sent']) => void; + onResponseReceived?: (event: HttpEventMap['response-received']) => void; + onRequestCompleted?: (event: HttpEventMap['request-completed']) => void; + onRequestFailed?: (event: HttpEventMap['request-failed']) => void; + onRequestProgress?: (event: HttpEventMap['request-progress']) => void; + onResponseBody?: ( + requestId: string, + body: HttpEventMap['response-body']['body'], + ) => void; +}; + +type FetchArgs = Parameters; + +type ExpoFetchModule = { + fetch: typeof globalThis.fetch; +}; + +let callbacks: FetchInterceptorCallbacks | null = null; +let isInterceptorEnabled = false; +let expoFetchModule: ExpoFetchModule | null = null; +let originalExpoFetch: typeof globalThis.fetch | null = null; + +const createRequestId = () => + `req_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + +const concatChunks = (chunks: Uint8Array[], totalBytes: number) => { + const bytes = new Uint8Array(totalBytes); + let offset = 0; + + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + + return bytes; +}; + +const emitRequestFailed = ( + requestId: string, + error: unknown, + canceled: boolean, +) => { + callbacks?.onRequestFailed?.({ + requestId, + timestamp: Date.now(), + type: 'Fetch', + error: + error instanceof Error && error.message + ? error.message + : typeof error === 'string' + ? error + : 'Failed', + canceled, + source: 'expo', + }); +}; + +const captureExpoFetchResponse = async ( + requestId: string, + sendTime: number, + response: Response, +) => { + try { + const responseReceivedAt = Date.now(); + const contentLength = getFetchContentLength(response); + const contentType = getFetchContentType(response); + const isTextLikeResponse = isTextLikeContentType(contentType); + + callbacks?.onResponseReceived?.({ + requestId, + timestamp: responseReceivedAt, + type: 'Fetch', + response: { + url: response.url, + status: response.status, + statusText: response.statusText, + headers: getFetchResponseHeaders(response), + contentType, + size: contentLength ?? null, + responseTime: responseReceivedAt, + }, + source: 'expo', + }); + + const clone = response.clone(); + const reader = clone.body?.getReader(); + const progressThrottle = createProgressThrottler(); + const chunks: Uint8Array[] = []; + let captureBinaryBody = !isTextLikeResponse; + let loaded = 0; + + if (!reader) { + const body = await captureFetchResponseBodyFromBytes( + new Uint8Array(), + contentType, + ); + callbacks?.onResponseBody?.(requestId, body); + callbacks?.onRequestCompleted?.({ + requestId, + timestamp: Date.now(), + duration: Date.now() - sendTime, + size: 0, + ttfb: responseReceivedAt - sendTime, + source: 'expo', + }); + return; + } + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + if (!value) { + continue; + } + + loaded += value.byteLength; + + if (isTextLikeResponse || captureBinaryBody) { + chunks.push(value); + } + + if (!isTextLikeResponse && captureBinaryBody) { + if (loaded > BINARY_CAPTURE_SIZE_CAP) { + chunks.length = 0; + captureBinaryBody = false; + } + } + + const timestamp = Date.now(); + if (progressThrottle(timestamp)) { + callbacks?.onRequestProgress?.({ + requestId, + timestamp, + loaded, + total: contentLength ?? 0, + lengthComputable: contentLength !== undefined && contentLength > 0, + source: 'expo', + }); + } + } + + const timestamp = Date.now(); + if (loaded > 0 || contentLength !== undefined) { + callbacks?.onRequestProgress?.({ + requestId, + timestamp, + loaded, + total: contentLength ?? 0, + lengthComputable: contentLength !== undefined && contentLength > 0, + source: 'expo', + }); + } + + const body: HttpEventMap['response-body']['body'] = + captureBinaryBody || isTextLikeResponse + ? await captureFetchResponseBodyFromBytes( + concatChunks(chunks, loaded), + contentType, + ) + : { kind: 'binary-too-large', size: loaded }; + callbacks?.onResponseBody?.(requestId, body); + callbacks?.onRequestCompleted?.({ + requestId, + timestamp: Date.now(), + duration: Date.now() - sendTime, + size: loaded, + ttfb: responseReceivedAt - sendTime, + source: 'expo', + }); + } catch (error) { + const canceled = + isFetchAbortError(error) || + ((error instanceof Error && error.name === 'AbortError') ?? false); + callbacks?.onResponseBody?.(requestId, null); + emitRequestFailed(requestId, error, canceled); + } +}; + +const patchExpoFetch = (fetchFn: typeof globalThis.fetch) => { + if (!expoFetchModule) { + return false; + } + + if (isInterceptorEnabled) { + return true; + } + + originalExpoFetch = fetchFn; + expoFetchModule.fetch = (async (...args: FetchArgs) => { + const sendTime = Date.now(); + const requestId = createRequestId(); + const normalizedRequest = normalizeFetchRequest(args[0], args[1] ?? {}); + const initiator = getInitiatorFromStack(); + + callbacks?.onRequestSent?.({ + requestId, + timestamp: sendTime, + request: normalizedRequest, + initiator, + type: 'Fetch', + source: 'expo', + }); + + try { + const response = await fetchFn(...args); + void captureExpoFetchResponse( + requestId, + sendTime, + response, + ); + + // The original response is returned to app code unchanged. The + // interceptor works off a clone so it can record the response body + // without consuming the app's copy. + return response; + } catch (error) { + const signal = normalizedRequest.signal; + const canceled = + isFetchAbortError(error) || signal?.aborted === true; + + emitRequestFailed(requestId, error, canceled); + throw error; + } + }) as typeof globalThis.fetch; + + isInterceptorEnabled = true; + return true; +}; + +export const FetchInterceptor = { + setCallbacks(nextCallbacks: FetchInterceptorCallbacks | null) { + callbacks = nextCallbacks; + }, + + isInterceptorEnabled(): boolean { + return isInterceptorEnabled; + }, + + enableInterception() { + if (isInterceptorEnabled) { + return; + } + + expoFetchModule = getExpoFetchModule(); + if (!expoFetchModule) { + return; + } + + patchExpoFetch(expoFetchModule.fetch); + }, + + disableInterception() { + if (!isInterceptorEnabled) { + return; + } + + if (expoFetchModule && originalExpoFetch) { + expoFetchModule.fetch = originalExpoFetch; + } + + expoFetchModule = null; + originalExpoFetch = null; + isInterceptorEnabled = false; + callbacks = null; + }, +}; diff --git a/packages/network-activity-plugin/src/react-native/http/fetch-utils.ts b/packages/network-activity-plugin/src/react-native/http/fetch-utils.ts new file mode 100644 index 00000000..6c4d0240 --- /dev/null +++ b/packages/network-activity-plugin/src/react-native/http/fetch-utils.ts @@ -0,0 +1,159 @@ +import type { + HttpHeaders, + HttpMethod, + RequestPostData, + ResponseBody, +} from '../../shared/client'; +import { getRequestBody } from './http-utils'; +import { captureResponseBodyFromBytes } from './response-body-utils'; +import { getContentTypeMime } from '../../utils/getContentTypeMimeType'; +export { BINARY_CAPTURE_SIZE_CAP } from './response-body-utils'; + +export type FetchInput = RequestInfo | URL; + +export type NormalizedFetchRequest = { + url: string; + method: HttpMethod; + headers: HttpHeaders; + postData: RequestPostData; + signal?: AbortSignal; +}; + +export const normalizeHeaders = (headers?: HeadersInit): HttpHeaders => { + const normalized: HttpHeaders = {}; + + if (!headers) { + return normalized; + } + + const assign = (key: string, value: string) => { + const existing = normalized[key]; + + if (existing === undefined) { + normalized[key] = value; + return; + } + + normalized[key] = Array.isArray(existing) + ? [...existing, value] + : [existing, value]; + }; + + if (headers instanceof Headers) { + headers.forEach((value, key) => { + assign(key, value); + }); + return normalized; + } + + if (Array.isArray(headers)) { + headers.forEach(([key, value]) => { + assign(key, value); + }); + return normalized; + } + + Object.entries(headers).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach((item) => assign(key, item)); + return; + } + + assign(key, value); + }); + + return normalized; +}; + +const normalizeFetchBody = (body?: BodyInit | null): RequestPostData => { + if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) { + return undefined; + } + + if (body instanceof URLSearchParams) { + return getRequestBody(body.toString()); + } + + return getRequestBody(body as RequestPostData); +}; + +export const normalizeFetchRequest = ( + input: FetchInput, + init: RequestInit = {}, +): NormalizedFetchRequest => { + const request = input instanceof Request ? input : null; + const headers = { + ...normalizeHeaders(request?.headers), + ...normalizeHeaders(init.headers), + }; + + return { + url: request?.url ?? input.toString(), + method: (init.method ?? request?.method ?? 'GET').toUpperCase() as HttpMethod, + headers, + postData: normalizeFetchBody(init.body), + signal: init.signal ?? request?.signal, + }; +}; + +export const getFetchResponseHeaders = (response: Response): HttpHeaders => { + return normalizeHeaders(response.headers); +}; + +export const getFetchContentType = (response: Response): string => { + const contentType = response.headers.get('content-type'); + return contentType + ? getContentTypeMime({ 'content-type': contentType }) ?? '' + : ''; +}; + +export const getFetchContentLength = ( + response: Response, +): number | undefined => { + const contentLength = response.headers.get('content-length'); + + if (!contentLength) { + return undefined; + } + + const parsed = Number.parseInt(contentLength, 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; +}; + +export const createProgressThrottler = (minIntervalMs = 100) => { + let lastEmittedAt = 0; + + return (timestamp: number, force = false) => { + if ( + force || + lastEmittedAt === 0 || + timestamp - lastEmittedAt >= minIntervalMs + ) { + lastEmittedAt = timestamp; + return true; + } + + return false; + }; +}; + +export const captureFetchResponseBodyFromBytes = async ( + bytes: Uint8Array, + contentType: string, +): Promise => { + return captureResponseBodyFromBytes(bytes, contentType); +}; + +export const isFetchAbortError = (error: unknown): boolean => { + if (typeof error !== 'object' || error === null) { + return false; + } + + const { name, message } = error as { name?: string; message?: string }; + + return ( + name === 'AbortError' || + name === 'TimeoutError' || + message?.toLowerCase().includes('aborted') === true + ); +}; diff --git a/packages/network-activity-plugin/src/react-native/http/get-expo-fetch-module.ts b/packages/network-activity-plugin/src/react-native/http/get-expo-fetch-module.ts new file mode 100644 index 00000000..40814911 --- /dev/null +++ b/packages/network-activity-plugin/src/react-native/http/get-expo-fetch-module.ts @@ -0,0 +1,15 @@ +type ExpoFetchModule = { + fetch: typeof globalThis.fetch; +}; + +const expoFetchModule = (() => { + try { + return require('expo/fetch') as ExpoFetchModule; + } catch { + return null; + } +})(); + +export const getExpoFetchModule = (): ExpoFetchModule | null => { + return expoFetchModule; +}; diff --git a/packages/network-activity-plugin/src/react-native/http/http-inspector.ts b/packages/network-activity-plugin/src/react-native/http/http-inspector.ts index ac1a7ab6..a0185531 100644 --- a/packages/network-activity-plugin/src/react-native/http/http-inspector.ts +++ b/packages/network-activity-plugin/src/react-native/http/http-inspector.ts @@ -1,6 +1,7 @@ import { createNanoEvents } from 'nanoevents'; import { getNetworkRequestsRegistry } from './network-requests-registry'; import { XHRInterceptor } from './xhr-interceptor'; +import { FetchInterceptor } from './fetch-interceptor'; import { getRequestBody, getResponseSize, @@ -51,9 +52,15 @@ export const getHTTPInspector = (): HTTPInspector => { return { enable: () => { - if (XHRInterceptor.isInterceptorEnabled()) return; + if ( + XHRInterceptor.isInterceptorEnabled() && + FetchInterceptor.isInterceptorEnabled() + ) { + return; + } XHRInterceptor.disableInterception(); + FetchInterceptor.disableInterception(); XHRInterceptor.setSendCallback((data, request) => { const initiator = getInitiatorFromStack(); @@ -161,19 +168,46 @@ export const getHTTPInspector = (): HTTPInspector => { }); }); + FetchInterceptor.setCallbacks({ + onRequestSent: (event) => { + eventEmitter.emit('request-sent', event); + }, + onResponseReceived: (event) => { + eventEmitter.emit('response-received', event); + }, + onRequestCompleted: (event) => { + eventEmitter.emit('request-completed', event); + }, + onRequestFailed: (event) => { + eventEmitter.emit('request-failed', event); + }, + onRequestProgress: (event) => { + eventEmitter.emit('request-progress', event); + }, + onResponseBody: (requestId, body) => { + networkRequestsRegistry.setResponseBody(requestId, body); + }, + }); + XHRInterceptor.enableInterception(); + FetchInterceptor.enableInterception(); }, disable: () => { XHRInterceptor.disableInterception(); + FetchInterceptor.disableInterception(); }, isEnabled: () => { - return XHRInterceptor.isInterceptorEnabled(); + return ( + XHRInterceptor.isInterceptorEnabled() || + FetchInterceptor.isInterceptorEnabled() + ); }, dispose: () => { XHRInterceptor.disableInterception(); + FetchInterceptor.disableInterception(); networkRequestsRegistry.clear(); }, diff --git a/packages/network-activity-plugin/src/react-native/http/http-utils.ts b/packages/network-activity-plugin/src/react-native/http/http-utils.ts index 42eb2c45..efc20301 100644 --- a/packages/network-activity-plugin/src/react-native/http/http-utils.ts +++ b/packages/network-activity-plugin/src/react-native/http/http-utils.ts @@ -10,10 +10,6 @@ import type { } from '../../shared/client'; import { safeStringify } from '../../utils/safeStringify'; import { getStringSizeInBytes } from '../../utils/getStringSizeInBytes'; -import { - isJsonContentType, - isXmlContentType, -} from '../../utils/getContentTypeMimeType'; import { isBlob, isArrayBuffer, @@ -21,9 +17,16 @@ import { isNullOrUndefined, } from '../../utils/typeChecks'; import { getContentType } from '../utils'; +import { isJsonContentType } from '../../utils/getContentTypeMimeType'; import { getBlobName } from '../utils/getBlobName'; import { getFormDataEntries } from '../utils/getFormDataEntries'; import type { OverridesRegistry } from './overrides-registry'; +import { + captureResponseBodyFromArrayBuffer, + captureResponseBodyFromBlob, +} from './response-body-utils'; + +export { BINARY_CAPTURE_SIZE_CAP } from './response-body-utils'; /** * Utility functions for tracking HTTP requests @@ -120,45 +123,6 @@ export const getResponseSize = (request: XMLHttpRequest): number | null => { } }; -// Cap on binary capture. Above this, we ship a 'binary-too-large' variant -// with just the size — no bytes cross the bridge. 5MB comfortably covers -// debug-relevant images while keeping the bridge from choking on outliers. -export const BINARY_CAPTURE_SIZE_CAP = 5 * 1024 * 1024; - -const readBlobAsText = (blob: Blob): Promise => - new Promise((resolve) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.readAsText(blob); - }); - -const readBlobAsBase64 = (blob: Blob): Promise => - new Promise((resolve) => { - const reader = new FileReader(); - reader.onload = () => { - // FileReader.readAsDataURL returns "data:;base64,". - // We only want the base64 payload. - const dataUrl = reader.result as string; - resolve(dataUrl.substring(dataUrl.indexOf(',') + 1)); - }; - reader.readAsDataURL(blob); - }); - -// String.fromCharCode.apply(null, hugeArray) blows up around 64K elements -// in some engines; chunk through 32K windows so we work uniformly across -// large arraybuffer responses. -const ARRAY_BUFFER_BASE64_CHUNK = 0x8000; - -const arrayBufferToBase64 = (buffer: ArrayBuffer): string => { - const bytes = new Uint8Array(buffer); - let binary = ''; - for (let i = 0; i < bytes.length; i += ARRAY_BUFFER_BASE64_CHUNK) { - const chunk = bytes.subarray(i, i + ARRAY_BUFFER_BASE64_CHUNK); - binary += String.fromCharCode.apply(null, chunk as unknown as number[]); - } - return btoa(binary); -}; - export const getResponseBody = async ( request: XMLHttpRequest, ): Promise => { @@ -171,38 +135,11 @@ export const getResponseBody = async ( if (responseType === 'blob') { const contentType = request.getResponseHeader('Content-Type') || ''; - - // Text-shaped content stays on the text path so the panel can - // show source in the Raw view without base64 round-tripping. XML - // composite types (application/xml, atom+xml, soap+xml, ...) and - // image/svg+xml are all covered by isXmlContentType per RFC 7303. - if ( - contentType.startsWith('text/') || - isJsonContentType(contentType) || - isXmlContentType(contentType) - ) { - return readBlobAsText(request.response); - } - - // Everything else is binary — image/*, application/pdf, audio/*, - // video/*, font/*, application/octet-stream, anything novel a - // server might serve. - const blob = request.response as Blob; - if (blob.size > BINARY_CAPTURE_SIZE_CAP) { - return { kind: 'binary-too-large', size: blob.size }; - } - return { kind: 'binary', base64: await readBlobAsBase64(blob) }; + return captureResponseBodyFromBlob(request.response as Blob, contentType); } if (responseType === 'arraybuffer') { - const buffer = request.response as ArrayBuffer | null; - if (!buffer || buffer.byteLength === 0) { - return null; - } - if (buffer.byteLength > BINARY_CAPTURE_SIZE_CAP) { - return { kind: 'binary-too-large', size: buffer.byteLength }; - } - return { kind: 'binary', base64: arrayBufferToBase64(buffer) }; + return captureResponseBodyFromArrayBuffer(request.response as ArrayBuffer | null); } if (responseType === 'json') { diff --git a/packages/network-activity-plugin/src/react-native/http/network-requests-registry.ts b/packages/network-activity-plugin/src/react-native/http/network-requests-registry.ts index 9ab43af0..8a28c349 100644 --- a/packages/network-activity-plugin/src/react-native/http/network-requests-registry.ts +++ b/packages/network-activity-plugin/src/react-native/http/network-requests-registry.ts @@ -1,12 +1,17 @@ +import type { ResponseBody } from '../../shared/client'; + type NetworkRegistryEntry = { id: string; sentAt: number; - request: XMLHttpRequest; + request?: XMLHttpRequest; + responseBody?: ResponseBody; }; export type NetworkRequestRegistry = { addEntry: (id: string, request: XMLHttpRequest) => void; getEntry: (id: string) => XMLHttpRequest | null; + setResponseBody: (id: string, body: ResponseBody) => void; + getResponseBody: (id: string) => ResponseBody | undefined; clear: () => void; }; @@ -29,10 +34,13 @@ export const getNetworkRequestsRegistry = (): NetworkRequestRegistry => { const addEntry = (id: string, request: XMLHttpRequest): void => { trimRegistry(); + const existing = registry.get(id); + registry.set(id, { id, request, - sentAt: Date.now(), + responseBody: existing?.responseBody, + sentAt: existing?.sentAt ?? Date.now(), }); }; @@ -40,6 +48,22 @@ export const getNetworkRequestsRegistry = (): NetworkRequestRegistry => { return registry.get(id)?.request ?? null; }; + const setResponseBody = (id: string, body: ResponseBody): void => { + trimRegistry(); + const existing = registry.get(id); + + registry.set(id, { + id, + request: existing?.request, + responseBody: body, + sentAt: existing?.sentAt ?? Date.now(), + }); + }; + + const getResponseBody = (id: string): ResponseBody | undefined => { + return registry.get(id)?.responseBody; + }; + const clear = () => { registry.clear(); }; @@ -47,6 +71,8 @@ export const getNetworkRequestsRegistry = (): NetworkRequestRegistry => { return { addEntry, getEntry, + setResponseBody, + getResponseBody, clear, }; }; diff --git a/packages/network-activity-plugin/src/react-native/http/response-body-utils.ts b/packages/network-activity-plugin/src/react-native/http/response-body-utils.ts new file mode 100644 index 00000000..c2c35974 --- /dev/null +++ b/packages/network-activity-plugin/src/react-native/http/response-body-utils.ts @@ -0,0 +1,88 @@ +import type { ResponseBody } from '../../shared/client'; +import { + isJsonContentType, + isXmlContentType, +} from '../../utils/getContentTypeMimeType'; + +// Cap on binary capture. Above this, we ship a `binary-too-large` variant +// with just the size — no bytes cross the bridge. 5MB comfortably covers +// debug-relevant images while keeping the bridge from choking on outliers. +export const BINARY_CAPTURE_SIZE_CAP = 5 * 1024 * 1024; + +const ARRAY_BUFFER_BASE64_CHUNK = 0x8000; + +export const bytesToBase64 = (bytes: Uint8Array): string => { + let binary = ''; + + for (let i = 0; i < bytes.length; i += ARRAY_BUFFER_BASE64_CHUNK) { + const chunk = bytes.subarray(i, i + ARRAY_BUFFER_BASE64_CHUNK); + binary += String.fromCharCode.apply(null, chunk as unknown as number[]); + } + + return btoa(binary); +}; + +export const isTextLikeContentType = (contentType?: string | null) => { + if (!contentType) { + return false; + } + + return ( + contentType.startsWith('text/') || + isJsonContentType(contentType) || + isXmlContentType(contentType) + ); +}; + +export const captureResponseBodyFromBytes = ( + bytes: Uint8Array, + contentType?: string | null, +): ResponseBody => { + if (isTextLikeContentType(contentType)) { + return new TextDecoder().decode(bytes); + } + + if (bytes.byteLength > BINARY_CAPTURE_SIZE_CAP) { + return { kind: 'binary-too-large', size: bytes.byteLength }; + } + + return { kind: 'binary', base64: bytesToBase64(bytes) }; +}; + +export const captureResponseBodyFromArrayBuffer = async ( + buffer: ArrayBuffer | null, + contentType?: string | null, +): Promise => { + if (!buffer || buffer.byteLength === 0) { + return null; + } + + if (buffer.byteLength > BINARY_CAPTURE_SIZE_CAP) { + return { kind: 'binary-too-large', size: buffer.byteLength }; + } + + return captureResponseBodyFromBytes(new Uint8Array(buffer), contentType); +}; + +const readBlobAsArrayBuffer = (blob: Blob): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as ArrayBuffer); + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(blob); + }); + +export const captureResponseBodyFromBlob = async ( + blob: Blob, + contentType?: string | null, +): Promise => { + if (blob.size > BINARY_CAPTURE_SIZE_CAP) { + return { kind: 'binary-too-large', size: blob.size }; + } + + const buffer = + typeof blob.arrayBuffer === 'function' + ? await blob.arrayBuffer() + : await readBlobAsArrayBuffer(blob); + return captureResponseBodyFromBytes(new Uint8Array(buffer), contentType); +}; diff --git a/packages/network-activity-plugin/src/react-native/network-inspector.ts b/packages/network-activity-plugin/src/react-native/network-inspector.ts index 2cecb1ff..99ba6a28 100644 --- a/packages/network-activity-plugin/src/react-native/network-inspector.ts +++ b/packages/network-activity-plugin/src/react-native/network-inspector.ts @@ -98,6 +98,12 @@ const createNetworkInspectorInstance = (): NetworkInspector => { return getHTTPResponseBody(request); } + const capturedResponseBody = + http.getNetworkRequestsRegistry().getResponseBody(requestId); + if (capturedResponseBody !== undefined) { + return capturedResponseBody; + } + return nitro.getResponseBody(requestId); }, }; diff --git a/packages/network-activity-plugin/src/shared/http-events.ts b/packages/network-activity-plugin/src/shared/http-events.ts index c61083ed..ba1db4cf 100644 --- a/packages/network-activity-plugin/src/shared/http-events.ts +++ b/packages/network-activity-plugin/src/shared/http-events.ts @@ -16,7 +16,7 @@ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD'; export type RequestId = string; export type Timestamp = number; -export type NetworkEventSource = 'builtin' | 'nitro'; +export type NetworkEventSource = 'builtin' | 'nitro' | 'expo'; export type XHRPostData = | string diff --git a/packages/network-activity-plugin/src/ui/components/FilterBar.tsx b/packages/network-activity-plugin/src/ui/components/FilterBar.tsx index 4f7715bc..4dd8356f 100644 --- a/packages/network-activity-plugin/src/ui/components/FilterBar.tsx +++ b/packages/network-activity-plugin/src/ui/components/FilterBar.tsx @@ -39,7 +39,7 @@ const HTTP_METHODS: HttpMethod[] = [ 'DELETE', 'HEAD', ]; -const SOURCES: NetworkEventSource[] = ['builtin', 'nitro']; +const SOURCES: NetworkEventSource[] = ['builtin', 'expo', 'nitro']; const getTypeLabel = (type: RequestTypeFilter) => { switch (type) { @@ -56,6 +56,8 @@ const getSourceLabel = (source: NetworkEventSource) => { switch (source) { case 'builtin': return 'Built-in'; + case 'expo': + return 'Expo'; case 'nitro': return 'Nitro'; } diff --git a/packages/network-activity-plugin/src/ui/components/RequestList.tsx b/packages/network-activity-plugin/src/ui/components/RequestList.tsx index 4dc48e76..8b265291 100644 --- a/packages/network-activity-plugin/src/ui/components/RequestList.tsx +++ b/packages/network-activity-plugin/src/ui/components/RequestList.tsx @@ -51,6 +51,10 @@ const getSourceLabel = (source?: NetworkEventSource) => { return 'Built-in'; } + if (source === 'expo') { + return 'Expo'; + } + return null; }; diff --git a/packages/network-activity-plugin/src/ui/components/SidePanel.tsx b/packages/network-activity-plugin/src/ui/components/SidePanel.tsx index f1d3bc10..48fe762b 100644 --- a/packages/network-activity-plugin/src/ui/components/SidePanel.tsx +++ b/packages/network-activity-plugin/src/ui/components/SidePanel.tsx @@ -142,7 +142,8 @@ export const SidePanel = () => { } const override = legacyEntry !== null ? overrides.get(legacyEntry.url) : null; - const supportsOverrides = httpDetails?.source !== 'nitro'; + const supportsOverrides = + httpDetails?.source !== 'nitro' && httpDetails?.source !== 'expo'; const hasResponseOverride = supportsOverrides && override && override.body ? true : false; diff --git a/packages/network-activity-plugin/src/ui/tabs/HeadersTab.tsx b/packages/network-activity-plugin/src/ui/tabs/HeadersTab.tsx index ac61b4fa..8900992c 100644 --- a/packages/network-activity-plugin/src/ui/tabs/HeadersTab.tsx +++ b/packages/network-activity-plugin/src/ui/tabs/HeadersTab.tsx @@ -32,6 +32,8 @@ export const HeadersTab = ({ selectedRequest }: HeadersTabProps) => { const sourceLabel = selectedRequest.source === 'nitro' ? 'Nitro' + : selectedRequest.source === 'expo' + ? 'Expo' : selectedRequest.source === 'builtin' ? 'Built-in' : null;