diff --git a/apps/playground/src/app/screens/NetworkTestScreen.tsx b/apps/playground/src/app/screens/NetworkTestScreen.tsx index 4eabd12..84aebe1 100644 --- a/apps/playground/src/app/screens/NetworkTestScreen.tsx +++ b/apps/playground/src/app/screens/NetworkTestScreen.tsx @@ -14,191 +14,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import EventSource from 'react-native-sse'; import { NavigationProp, useNavigation } from '@react-navigation/native'; import { RootStackParamList } from '../navigation/types'; - -// Real API service using JSONPlaceholder -const api = { - getUsers: async (): Promise => { - const response = await fetch('https://jsonplaceholder.typicode.com/users', { - headers: { - 'X-Rozenite-Test': 'true', - Cookie: 'sessionid=abc123; theme=dark; user=testuser', - }, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return response.json(); - }, - - getPosts: async (): Promise => { - const response = await fetch( - 'https://jsonplaceholder.typicode.com/posts?_limit=10&userId=1&sort=desc', - { - headers: { - 'X-Rozenite-Test': 'true', - }, - } - ); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return response.json(); - }, - - getTodos: async (): Promise => { - const response = await fetch( - 'https://jsonplaceholder.typicode.com/todos?_limit=15', - { - headers: { - 'X-Rozenite-Test': 'true', - }, - } - ); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return response.json(); - }, - - // Simulate a slow API call - getSlowData: async (): Promise => { - // Add artificial delay to simulate slow network - await new Promise((resolve) => setTimeout(resolve, 3000)); - const response = await fetch( - 'https://jsonplaceholder.typicode.com/users?_limit=5', - { - headers: { - 'X-Rozenite-Test': 'true', - }, - } - ); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return response.json(); - }, - - // Simulate an API that sometimes fails - getUnreliableData: async (): Promise => { - // 20% chance of failure - if (Math.random() < 0.2) { - throw new Error('Random API failure - please try again'); - } - const response = await fetch( - 'https://jsonplaceholder.typicode.com/posts?_limit=8', - { - headers: { - 'X-Rozenite-Test': 'true', - }, - } - ); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return response.json(); - }, - - // Create a new post - createPost: async (postData: Omit): Promise => { - const response = await fetch( - 'https://jsonplaceholder.typicode.com/posts?someParam=value', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Rozenite-Test': 'true', - }, - body: JSON.stringify(postData), - } - ); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return response.json(); - }, - - // Create a new post with FormData - createPostWithFormData: async (postData: Omit): Promise => { - const formData = new FormData(); - formData.append('title', postData.title); - formData.append('body', postData.body); - formData.append('userId', postData.userId.toString()); - - const response = await fetch('https://jsonplaceholder.typicode.com/posts', { - method: 'POST', - headers: { - 'X-Rozenite-Test': 'true', - }, - body: formData, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return response.json(); - }, - - getLargeFile: async (): Promise => { - const cacheBuster = Date.now(); - const response = await fetch( - `https://raw.githubusercontent.com/datasets/geo-countries/master/data/countries.geojson?cb=${cacheBuster}`, - { - headers: { - 'X-Rozenite-Test': 'large-download', - 'Cache-Control': 'no-cache, no-store, must-revalidate', - 'Pragma': 'no-cache', - 'Expires': '0', - }, - } - ); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return response.arrayBuffer(); - }, -}; - -interface User { - id: number; - name: string; - email: string; - username: string; - phone: string; - website: string; - company: { - name: string; - catchPhrase: string; - }; -} - -interface Post { - id: number; - title: string; - body: string; - userId: number; -} - -interface Todo { - id: number; - title: string; - completed: boolean; - userId: number; -} +import { api, User, Post, Todo } from '../utils/network-activity/api'; const useUsersQuery = () => { return useQuery({ @@ -615,7 +431,7 @@ const HTTPTestComponent: React.FC = () => { return ( item.id.toString()} ListHeaderComponent={renderHeader()} diff --git a/apps/playground/src/app/utils/network-activity/api.ts b/apps/playground/src/app/utils/network-activity/api.ts new file mode 100644 index 0000000..8f5ff13 --- /dev/null +++ b/apps/playground/src/app/utils/network-activity/api.ts @@ -0,0 +1,185 @@ +export interface User { + id: number; + name: string; + email: string; + username: string; + phone: string; + website: string; + company: { + name: string; + catchPhrase: string; + }; +} + +export interface Post { + id: number; + title: string; + body: string; + userId: number; +} + +export interface Todo { + id: number; + title: string; + completed: boolean; + userId: number; +} + +export const api = { + getUsers: async (): Promise => { + const response = await fetch('https://jsonplaceholder.typicode.com/users', { + headers: { + 'X-Rozenite-Test': 'true', + Cookie: 'sessionid=abc123; theme=dark; user=testuser', + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); + }, + + getPosts: async (): Promise => { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts?_limit=10&userId=1&sort=desc', + { + headers: { + 'X-Rozenite-Test': 'true', + }, + }, + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); + }, + + getTodos: async (): Promise => { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/todos?_limit=15', + { + headers: { + 'X-Rozenite-Test': 'true', + }, + }, + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); + }, + + getSlowData: async (): Promise => { + // Add artificial delay to simulate slow network + await new Promise((resolve) => setTimeout(resolve, 3000)); + const response = await fetch( + 'https://jsonplaceholder.typicode.com/users?_limit=5', + { + headers: { + 'X-Rozenite-Test': 'true', + }, + }, + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); + }, + + getUnreliableData: async (): Promise => { + // 20% chance of failure + if (Math.random() < 0.2) { + throw new Error('Random API failure - please try again'); + } + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts?_limit=8', + { + headers: { + 'X-Rozenite-Test': 'true', + }, + }, + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); + }, + + get404: async (): Promise => { + const response = await fetch('https://www.google.com/test'); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); + }, + + post404: async (): Promise => { + const response = await fetch('https://www.google.com/test', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Rozenite-Test': 'true', + }, + body: JSON.stringify({ test: 'data' }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); + }, + + createPost: async (postData: Omit): Promise => { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/posts?someParam=value', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Rozenite-Test': 'true', + }, + body: JSON.stringify(postData), + }, + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); + }, + + createPostWithFormData: async (postData: Omit): Promise => { + const formData = new FormData(); + formData.append('title', postData.title); + formData.append('body', postData.body); + formData.append('userId', postData.userId.toString()); + + const response = await fetch('https://jsonplaceholder.typicode.com/posts', { + method: 'POST', + headers: { + 'X-Rozenite-Test': 'true', + }, + body: formData, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); + }, +}; diff --git a/apps/playground/src/main.tsx b/apps/playground/src/main.tsx index 9149577..531bbd6 100644 --- a/apps/playground/src/main.tsx +++ b/apps/playground/src/main.tsx @@ -3,4 +3,17 @@ import App from './app/App'; import 'react-native-get-random-values'; +import { withOnBootNetworkActivityRecording } from '@rozenite/network-activity-plugin'; +import { api } from './app/utils/network-activity/api'; + +withOnBootNetworkActivityRecording(); + +// Make a fetch request during boot to test network inspector queuing +api.getUsers(); +api.createPost({ + title: 'Hello World', + body: 'This is a test post created during app boot.', + userId: 1, +}); + AppRegistry.registerComponent('Playground', () => App); diff --git a/packages/network-activity-plugin/README.md b/packages/network-activity-plugin/README.md index 12644ae..6e33185 100644 --- a/packages/network-activity-plugin/README.md +++ b/packages/network-activity-plugin/README.md @@ -50,6 +50,15 @@ function App() { } ``` +Optional: To capture network requests before your React Native app initialization, add this to your entrypoint: + +```ts +// index.js +import { withOnBootNetworkActivityRecording } from '@rozenite/network-activity-plugin/react-native'; + +withOnBootNetworkActivityRecording(); +``` + ### 3. Access DevTools Start your development server and open React Native DevTools. You'll find the "Network Activity" panel in the DevTools interface. diff --git a/packages/network-activity-plugin/react-native.ts b/packages/network-activity-plugin/react-native.ts index 109323a..09af633 100644 --- a/packages/network-activity-plugin/react-native.ts +++ b/packages/network-activity-plugin/react-native.ts @@ -1,4 +1,5 @@ export let useNetworkActivityDevTools: typeof import('./src/react-native/useNetworkActivityDevTools').useNetworkActivityDevTools; +export let withOnBootNetworkActivityRecording: typeof import('./src/react-native/http/network-inspector').withOnBootNetworkActivityRecording; const isWeb = typeof window !== 'undefined' && window.navigator.product !== 'ReactNative'; @@ -6,8 +7,12 @@ const isDev = process.env.NODE_ENV !== 'production'; const isServer = typeof window === 'undefined'; if (isDev && !isWeb && !isServer) { + withOnBootNetworkActivityRecording = + require('./src/react-native/http/network-inspector').withOnBootNetworkActivityRecording; + useNetworkActivityDevTools = require('./src/react-native/useNetworkActivityDevTools').useNetworkActivityDevTools; } else { useNetworkActivityDevTools = () => null; + withOnBootNetworkActivityRecording = () => null; } diff --git a/packages/network-activity-plugin/src/react-native/http/network-inspector.ts b/packages/network-activity-plugin/src/react-native/http/network-inspector.ts index fa42cb5..769c81b 100644 --- a/packages/network-activity-plugin/src/react-native/http/network-inspector.ts +++ b/packages/network-activity-plugin/src/react-native/http/network-inspector.ts @@ -1,186 +1,53 @@ -import { safeStringify } from '../../utils/safeStringify'; -import { - HttpMethod, - NetworkActivityDevToolsClient, - RequestPostData, - RequestTextPostData, - RequestBinaryPostData, - RequestFormDataPostData, - XHRPostData, -} from '../../shared/client'; -import { getContentType } from '../utils'; +import { NetworkActivityDevToolsClient } from '../../shared/client'; import { getNetworkRequestsRegistry } from './network-requests-registry'; -import { getBlobName } from '../utils/getBlobName'; -import { getFormDataEntries } from '../utils/getFormDataEntries'; +import { getOverridesRegistry } from './overrides-registry'; +import { + BootClientOptions, + getQueuedClientWrapper, +} from './queued-client-wrapper'; import { XHRInterceptor } from './xhr-interceptor'; -import { getStringSizeInBytes } from '../../utils/getStringSizeInBytes'; -import { applyReactNativeResponseHeadersLogic } from '../../utils/applyReactNativeResponseHeadersLogic'; import { - isBlob, - isArrayBuffer, - isFormData, - isNullOrUndefined, -} from '../../utils/typeChecks'; -import { getOverridesRegistry } from './overrides-registry'; + setupRequestTracking, + setupRequestOverride, + getResponseBody, +} from './request-tracker'; const networkRequestsRegistry = getNetworkRequestsRegistry(); const overridesRegistry = getOverridesRegistry(); +const queuedClient = getQueuedClientWrapper(); -const getBinaryPostData = (body: Blob): RequestBinaryPostData => ({ - type: 'binary', - value: { - size: body.size, - type: body.type, - name: getBlobName(body), - }, -}); - -const getArrayBufferPostData = ( - body: ArrayBuffer | ArrayBufferView -): RequestBinaryPostData => ({ - type: 'binary', - value: { - size: body.byteLength, - }, -}); - -const getTextPostData = (body: unknown): RequestTextPostData => ({ - type: 'text', - value: safeStringify(body), -}); - -const getFormDataPostData = (body: FormData): RequestFormDataPostData => ({ - type: 'form-data', - value: getFormDataEntries(body).reduce( - (acc, [key, value]) => { - if (isBlob(value)) { - acc[key] = getBinaryPostData(value); - } else if (isArrayBuffer(value)) { - acc[key] = getArrayBufferPostData(value); - } else { - acc[key] = getTextPostData(value); - } - - return acc; - }, - {} - ), -}); - -const getRequestBody = (body: XHRPostData): RequestPostData => { - if (isNullOrUndefined(body)) { - return body; - } - - if (isBlob(body)) { - return getBinaryPostData(body); - } - - if (isArrayBuffer(body)) { - return getArrayBufferPostData(body); - } - - if (isFormData(body)) { - return getFormDataPostData(body); - } - - return getTextPostData(body); -}; - -const getResponseSize = (request: XMLHttpRequest): number | null => { - try { - const { responseType, response } = request; - - // Handle a case of 204 where no-content was sent. - if (response === null) { - return 0; - } - - if (responseType === '' || responseType === 'text') { - return getStringSizeInBytes(request.responseText); - } - - if (responseType === 'json') { - return getStringSizeInBytes(safeStringify(response)); - } - - if (responseType === 'blob') { - return response.size; - } - - if (responseType === 'arraybuffer') { - return response.byteLength; - } - - return 0; - } catch { - return null; - } +const setupXHRInterceptor = (): void => { + if (XHRInterceptor.isInterceptorEnabled()) return; + XHRInterceptor.disableInterception(); + XHRInterceptor.setSendCallback((data, request) => + setupRequestTracking(queuedClient, networkRequestsRegistry, data, request), + ); + XHRInterceptor.setOverrideCallback((request) => + setupRequestOverride(overridesRegistry, request), + ); + XHRInterceptor.enableInterception(); }; -const getResponseBody = async ( - request: XMLHttpRequest -): Promise => { - const responseType = request.responseType; - - // Response type is empty in certain cases, like when using axios. - if (responseType === '' || responseType === 'text') { - return request.responseText as string; - } +export type BootRecordingOptions = BootClientOptions; - if (responseType === 'blob') { - // This may be a text blob. - const contentType = request.getResponseHeader('Content-Type') || ''; +/** + * Enable XHR interception early to capture boot-time requests. + */ +const enableBootTimeInterception = (options?: BootRecordingOptions): void => { + queuedClient.setMaxQueueSize(options?.maxQueueSize ?? 200); - if ( - contentType.startsWith('text/') || - contentType.startsWith('application/json') - ) { - // It looks like a text blob, let's read it and forward it to the client. - return new Promise((resolve) => { - const reader = new FileReader(); - reader.onload = () => { - resolve(reader.result as string); - }; - reader.readAsText(request.response); - }); - } + if (queuedClient.isBootInterceptionEnabled()) { + return; } - if (responseType === 'json') { - return safeStringify(request.response); - } - - return null; + queuedClient.enableBootInterception(); + setupXHRInterceptor(); }; -const getInitiatorFromStack = (): { - type: string; - url?: string; - lineNumber?: number; - columnNumber?: number; -} => { - try { - const stack = new Error().stack; - if (!stack) { - return { type: 'other' }; - } - - const line = stack.split('\n')[9]; - const match = line.match(/at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/); - if (match) { - return { - type: 'script', - url: match[2], - lineNumber: parseInt(match[3]), - columnNumber: parseInt(match[4]), - }; - } - } catch { - // Ignore stack parsing errors - } - - return { type: 'other' }; +export const withOnBootNetworkActivityRecording = ( + options?: BootRecordingOptions, +): void => { + enableBootTimeInterception(options); }; export type NetworkInspector = { @@ -190,171 +57,16 @@ export type NetworkInspector = { dispose: () => void; }; -const READY_STATE_HEADERS_RECEIVED = 2; - export const getNetworkInspector = ( - pluginClient: NetworkActivityDevToolsClient + pluginClient: NetworkActivityDevToolsClient, ): NetworkInspector => { - const generateRequestId = (): string => { - return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - }; - - const handleRequestSend = ( - data: XHRPostData, - request: XMLHttpRequest - ): void => { - const sendTime = Date.now(); - - const requestId = generateRequestId(); - request._rozeniteRequestId = requestId; - - const initiator = getInitiatorFromStack(); - - networkRequestsRegistry.addEntry(requestId, request); - - let ttfb = 0; - - pluginClient.send('request-sent', { - requestId: requestId, - timestamp: sendTime, - request: { - url: request._url as string, - method: request._method as HttpMethod, - headers: request._headers, - postData: getRequestBody(data), - }, - type: 'XHR', - initiator, - }); - - request.addEventListener('progress', (event) => { - pluginClient.send('request-progress', { - requestId: requestId, - timestamp: Date.now(), - loaded: event.loaded, - total: event.total, - lengthComputable: event.lengthComputable, - }); - }); - - request.addEventListener('readystatechange', () => { - if (request.readyState === READY_STATE_HEADERS_RECEIVED) { - ttfb = Date.now() - sendTime; - } - }); - - request.addEventListener('load', () => { - pluginClient.send('response-received', { - requestId: requestId, - timestamp: Date.now(), - type: 'XHR', - response: { - url: request._url as string, - status: request.status, - statusText: request.statusText, - headers: applyReactNativeResponseHeadersLogic( - request.responseHeaders || {} - ), - contentType: getContentType(request), - size: getResponseSize(request), - responseTime: Date.now(), - }, - }); - }); - - request.addEventListener('loadend', () => { - pluginClient.send('request-completed', { - requestId: requestId, - timestamp: Date.now(), - duration: Date.now() - sendTime, - size: getResponseSize(request), - ttfb, - }); - }); - - request.addEventListener('error', () => { - pluginClient.send('request-failed', { - requestId: requestId, - timestamp: Date.now(), - type: 'XHR', - error: 'Failed', - canceled: false, - }); - }); - - request.addEventListener('abort', () => { - pluginClient.send('request-failed', { - requestId: requestId, - timestamp: Date.now(), - type: 'XHR', - error: 'Aborted', - canceled: true, - }); - }); - - request.addEventListener('timeout', () => { - pluginClient.send('request-failed', { - requestId: requestId, - timestamp: Date.now(), - type: 'XHR', - error: 'Timeout', - canceled: false, - }); - }); - }; - - const handleRequestOverride = (request: XMLHttpRequest): void => { - const override = overridesRegistry.getOverrideForUrl( - request._url as string - ); - - if (!override) { - return; - } - - request.addEventListener('readystatechange', () => { - if (override.body !== undefined) { - Object.defineProperty(request, 'responseType', { - writable: true, - }); - - Object.defineProperty(request, 'response', { - writable: true, - }); - Object.defineProperty(request, 'responseText', { - writable: true, - }); - - const contentType = getContentType(request); - - if (contentType === 'application/json') { - request.responseType = 'json'; - } else if (contentType === 'text/plain') { - request.responseType = 'text'; - } - - // @ts-expect-error - Mocking response - request.response = override.body; - // @ts-expect-error - Mocking responseText - request.responseText = override.body; - } - - if (override.status !== undefined) { - Object.defineProperty(request, 'status', { - writable: true, - }); - - // @ts-expect-error - Mocking status - request.status = override.status; - } - }); - }; + const queuedClient = getQueuedClientWrapper(); + queuedClient.setClient(pluginClient); const enable = () => { - XHRInterceptor.disableInterception(); - XHRInterceptor.setSendCallback(handleRequestSend); - XHRInterceptor.setOverrideCallback(handleRequestOverride); - XHRInterceptor.enableInterception(); + // Switch mode to send queued messages when needed + queuedClient.enableClientMode(); + setupXHRInterceptor(); }; const disable = () => { @@ -389,7 +101,7 @@ export const getNetworkInspector = ( requestId, body, }); - } + }, ); const dispose = () => { diff --git a/packages/network-activity-plugin/src/react-native/http/queued-client-wrapper.ts b/packages/network-activity-plugin/src/react-native/http/queued-client-wrapper.ts new file mode 100644 index 0000000..97b862b --- /dev/null +++ b/packages/network-activity-plugin/src/react-native/http/queued-client-wrapper.ts @@ -0,0 +1,97 @@ +import { + NetworkActivityDevToolsClient, + NetworkActivityEventMap, +} from '../../shared/client'; + +type QueuedMessage< + K extends keyof NetworkActivityEventMap = keyof NetworkActivityEventMap, +> = { + type: K; + data: NetworkActivityEventMap[K]; +}; + +/** + * Wraps a client to queue messages until the client is available. + * This allows capturing network events before the DevTools client connects. + */ +export class QueuedClientWrapper { + private messageQueue: QueuedMessage[] = []; + private actualClient: NetworkActivityDevToolsClient | null = null; + private maxQueueSize = 200; + private enqueueMessages = false; + + public setClient(client: NetworkActivityDevToolsClient): void { + this.actualClient = client; + } + + public setMaxQueueSize(size: number): void { + this.maxQueueSize = size; + } + + public enableBootInterception(): void { + this.enqueueMessages = true; + } + + public enableClientMode(): void { + if (this.isBootInterceptionEnabled()) { + this.enqueueMessages = false; + this.flushQueue(); + } + } + + public isBootInterceptionEnabled(): boolean { + return this.enqueueMessages; + } + + /** + * Send a message (queued if client not available) + */ + public send( + type: K, + data: NetworkActivityEventMap[K], + ): void { + if (!this.enqueueMessages && this.actualClient) { + this.actualClient.send(type, data); + } else { + this.enqueueMessage({ type, data }); + } + } + + private enqueueMessage(message: QueuedMessage): void { + if (this.messageQueue.length >= this.maxQueueSize) { + this.messageQueue.shift(); + } + + this.messageQueue.push(message); + } + + public flushQueue(): void { + if (!this.actualClient) { + return; + } + + while (this.messageQueue.length > 0) { + const message = this.messageQueue.shift(); + if (message) { + this.actualClient.send(message.type, message.data); + } + } + } +} + +export type BootClientOptions = { + /** + * Maximum number of messages to queue before DevTools connects. + * @default 200 + */ + maxQueueSize?: number; +}; + +let instance: QueuedClientWrapper | null = null; + +export const getQueuedClientWrapper = (): QueuedClientWrapper => { + if (!instance) { + instance = new QueuedClientWrapper(); + } + return instance; +}; diff --git a/packages/network-activity-plugin/src/react-native/http/request-tracker.ts b/packages/network-activity-plugin/src/react-native/http/request-tracker.ts new file mode 100644 index 0000000..90acadf --- /dev/null +++ b/packages/network-activity-plugin/src/react-native/http/request-tracker.ts @@ -0,0 +1,307 @@ +import { + HttpMethod, + XHRPostData, + RequestPostData, + RequestTextPostData, + RequestBinaryPostData, + RequestFormDataPostData, +} from '../../shared/client'; +import { safeStringify } from '../../utils/safeStringify'; +import { getStringSizeInBytes } from '../../utils/getStringSizeInBytes'; +import { applyReactNativeResponseHeadersLogic } from '../../utils/applyReactNativeResponseHeadersLogic'; +import { + isBlob, + isArrayBuffer, + isFormData, + isNullOrUndefined, +} from '../../utils/typeChecks'; +import { getContentType } from '../utils'; +import { getBlobName } from '../utils/getBlobName'; +import { getFormDataEntries } from '../utils/getFormDataEntries'; +import type { NetworkRequestRegistry } from './network-requests-registry'; +import type { OverridesRegistry } from './overrides-registry'; +import type { QueuedClientWrapper } from './queued-client-wrapper'; + +/** + * Define how a single network request is tracked. + */ + +const READY_STATE_HEADERS_RECEIVED = 2; + +const getBinaryPostData = (body: Blob): RequestBinaryPostData => ({ + type: 'binary', + value: { + size: body.size, + type: body.type, + name: getBlobName(body), + }, +}); + +const getArrayBufferPostData = ( + body: ArrayBuffer | ArrayBufferView, +): RequestBinaryPostData => ({ + type: 'binary', + value: { + size: body.byteLength, + }, +}); + +const getTextPostData = (body: unknown): RequestTextPostData => ({ + type: 'text', + value: safeStringify(body), +}); + +const getFormDataPostData = (body: FormData): RequestFormDataPostData => ({ + type: 'form-data', + value: getFormDataEntries(body).reduce( + (acc, [key, value]) => { + if (isBlob(value)) { + acc[key] = getBinaryPostData(value); + } else if (isArrayBuffer(value)) { + acc[key] = getArrayBufferPostData(value); + } else { + acc[key] = getTextPostData(value); + } + + return acc; + }, + {}, + ), +}); + +const getRequestBody = (body: XHRPostData): RequestPostData => { + if (isNullOrUndefined(body)) { + return body; + } + + if (isBlob(body)) { + return getBinaryPostData(body); + } + + if (isArrayBuffer(body)) { + return getArrayBufferPostData(body); + } + + if (isFormData(body)) { + return getFormDataPostData(body); + } + + return getTextPostData(body); +}; + +const getResponseSize = (request: XMLHttpRequest): number | null => { + try { + const { responseType, response } = request; + + // Handle a case of 204 where no-content was sent. + if (response === null) { + return 0; + } + + if (responseType === '' || responseType === 'text') { + return getStringSizeInBytes(request.responseText); + } + + if (responseType === 'json') { + return getStringSizeInBytes(safeStringify(response)); + } + + if (responseType === 'blob') { + return response.size; + } + + if (responseType === 'arraybuffer') { + return response.byteLength; + } + + return 0; + } catch { + return null; + } +}; + +export const getResponseBody = async ( + request: XMLHttpRequest, +): Promise => { + const responseType = request.responseType; + + // Response type is empty in certain cases, like when using axios. + if (responseType === '' || responseType === 'text') { + return request.responseText as string; + } + + if (responseType === 'blob') { + // This may be a text blob. + const contentType = request.getResponseHeader('Content-Type') || ''; + + if ( + contentType.startsWith('text/') || + contentType.startsWith('application/json') + ) { + // It looks like a text blob, let's read it and forward it to the client. + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => { + resolve(reader.result as string); + }; + reader.readAsText(request.response); + }); + } + } + + if (responseType === 'json') { + return safeStringify(request.response); + } + + return null; +}; + +const getInitiatorFromStack = (): { + type: string; + url?: string; + lineNumber?: number; + columnNumber?: number; +} => { + try { + const stack = new Error().stack; + if (!stack) { + return { type: 'other' }; + } + + const line = stack.split('\n')[9]; + const match = line.match(/at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/); + if (match) { + return { + type: 'script', + url: match[2], + lineNumber: parseInt(match[3]), + columnNumber: parseInt(match[4]), + }; + } + } catch { + // Ignore stack parsing errors + } + + return { type: 'other' }; +}; + +/** + * Applies override body and status to XMLHttpRequest objects. + */ +export const setupRequestOverride = ( + overridesRegistry: OverridesRegistry, + request: XMLHttpRequest, +): void => { + const override = overridesRegistry.getOverrideForUrl(request._url as string); + if (!override) return; + + request.addEventListener('readystatechange', () => { + if (override.body !== undefined) { + Object.defineProperty(request, 'responseType', { writable: true }); + Object.defineProperty(request, 'response', { writable: true }); + Object.defineProperty(request, 'responseText', { writable: true }); + + const contentType = getContentType(request); + if (contentType === 'application/json') { + request.responseType = 'json'; + } else if (contentType === 'text/plain') { + request.responseType = 'text'; + } + + // @ts-expect-error - Mocking response + request.response = override.body; + // @ts-expect-error - Mocking responseText + request.responseText = override.body; + } + + if (override.status !== undefined) { + Object.defineProperty(request, 'status', { writable: true }); + // @ts-expect-error - Mocking status + request.status = override.status; + } + }); +}; + +export const setupRequestTracking = ( + queuedClient: QueuedClientWrapper, + networkRequestsRegistry: NetworkRequestRegistry, + data: XHRPostData, + request: XMLHttpRequest, +): void => { + const initiator = getInitiatorFromStack(); + const sendTime = Date.now(); + const requestId = `req_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + + request._rozeniteRequestId = requestId; + networkRequestsRegistry.addEntry(requestId, request); + + let ttfb = 0; + + queuedClient.send('request-sent', { + requestId: requestId, + timestamp: sendTime, + request: { + url: request._url as string, + method: request._method as HttpMethod, + headers: request._headers, + postData: getRequestBody(data), + }, + type: 'XHR', + initiator, + }); + + request.addEventListener('readystatechange', () => { + if (request.readyState === READY_STATE_HEADERS_RECEIVED) { + ttfb = Date.now() - sendTime; + } + }); + + request.addEventListener('load', () => { + queuedClient.send('response-received', { + requestId: requestId, + timestamp: Date.now(), + type: 'XHR', + response: { + url: request._url as string, + status: request.status, + statusText: request.statusText, + headers: applyReactNativeResponseHeadersLogic( + request.responseHeaders || {}, + ), + contentType: getContentType(request), + size: getResponseSize(request), + responseTime: Date.now(), + }, + }); + }); + + request.addEventListener('loadend', () => { + queuedClient.send('request-completed', { + requestId: requestId, + timestamp: Date.now(), + duration: Date.now() - sendTime, + size: getResponseSize(request), + ttfb, + }); + }); + + request.addEventListener('error', () => { + queuedClient.send('request-failed', { + requestId: requestId, + timestamp: Date.now(), + type: 'XHR', + error: 'Failed', + canceled: false, + }); + }); + + request.addEventListener('abort', () => { + queuedClient.send('request-failed', { + requestId: requestId, + timestamp: Date.now(), + type: 'XHR', + error: 'Aborted', + canceled: true, + }); + }); +}; diff --git a/packages/network-activity-plugin/src/react-native/useNetworkActivityDevTools.ts b/packages/network-activity-plugin/src/react-native/useNetworkActivityDevTools.ts index 488f6c9..30b3c73 100644 --- a/packages/network-activity-plugin/src/react-native/useNetworkActivityDevTools.ts +++ b/packages/network-activity-plugin/src/react-native/useNetworkActivityDevTools.ts @@ -83,7 +83,7 @@ export const useNetworkActivityDevTools = ( const networkInspector = getNetworkInspector(client); - // If recording was previously enabled, enable the inspector (hot reload) + // On boot, it's enabled by the DevTools with `network-enable`, on hot-reload it is below. if (isRecordingEnabledRef.current) { networkInspector.enable(); } diff --git a/packages/network-activity-plugin/src/ui/components/RequestList.tsx b/packages/network-activity-plugin/src/ui/components/RequestList.tsx index e6d496d..0463d70 100644 --- a/packages/network-activity-plugin/src/ui/components/RequestList.tsx +++ b/packages/network-activity-plugin/src/ui/components/RequestList.tsx @@ -61,7 +61,7 @@ const formatStartTime = (startTime: number): string => { }; const extractDomainAndPath = ( - url: string + url: string, ): { domain: string; path: string } => { try { const { hostname, pathname, search, hash, port } = new URL(url); @@ -80,7 +80,7 @@ const generateName = (url: string, showEntirePathName = false): string => { const urlObj = new URL(url); const pathname = urlObj.pathname; const filename = showEntirePathName ? undefined : pathname.split('/').pop(); - + return filename || pathname || urlObj.hostname; } catch { return url; @@ -128,7 +128,7 @@ const sortTime: SortingFn = (rowA, rowB, columnId) => { const processNetworkRequests = ( processedRequests: ProcessedRequest[], overrides: Map, - showEntirePathAsName = false + showEntirePathAsName = false, ): NetworkRequest[] => { return processedRequests.map((request): NetworkRequest => { const { domain, path } = extractDomainAndPath(request.name); @@ -262,7 +262,11 @@ export const RequestList = ({ filter }: RequestListProps) => { }, [processedRequests, filter]); const requests = useMemo(() => { - return processNetworkRequests(filteredRequests, overrides, clientUISettings?.showUrlAsName); + return processNetworkRequests( + filteredRequests, + overrides, + clientUISettings?.showUrlAsName, + ); }, [filteredRequests, overrides, clientUISettings?.showUrlAsName]); const table = useReactTable({ @@ -302,7 +306,7 @@ export const RequestList = ({ filter }: RequestListProps) => { ? null : flexRender( header.column.columnDef.header, - header.getContext() + header.getContext(), )} {header.column.getCanSort() && ( diff --git a/packages/network-activity-plugin/src/ui/state/derived.ts b/packages/network-activity-plugin/src/ui/state/derived.ts index 78f2871..704c4c8 100644 --- a/packages/network-activity-plugin/src/ui/state/derived.ts +++ b/packages/network-activity-plugin/src/ui/state/derived.ts @@ -21,7 +21,7 @@ export const getProcessedRequests = memoize((state: NetworkActivityState) => { status: httpEntry.status, timestamp: httpEntry.timestamp, duration: httpEntry.duration, - size: httpEntry.size, + size: httpEntry.size ?? null, method: httpEntry.request.method, httpStatus: httpEntry.response?.status, progress: httpEntry.progress, @@ -35,6 +35,7 @@ export const getProcessedRequests = memoize((state: NetworkActivityState) => { status: wsEntry.status, timestamp: wsEntry.timestamp, duration: wsEntry.duration, + size: null, method: 'WS', httpStatus: 0, }); @@ -47,6 +48,7 @@ export const getProcessedRequests = memoize((state: NetworkActivityState) => { status: sseEntry.status, timestamp: sseEntry.timestamp, duration: sseEntry.duration, + size: null, method: 'SSE', httpStatus: 0, }); @@ -63,7 +65,7 @@ export const getSelectedRequest = memoize((state: NetworkActivityState) => { }); export const getRequestSummary = ( - requestId: string + requestId: string, ): ((state: NetworkActivityState) => ProcessedRequest | null) => memoize((state: NetworkActivityState) => { const { networkEntries } = state; @@ -79,7 +81,7 @@ export const getRequestSummary = ( status: httpEntry.status, timestamp: httpEntry.timestamp, duration: httpEntry.duration, - size: httpEntry.size, + size: httpEntry.size ?? null, method: httpEntry.request.method, httpStatus: httpEntry.response?.status || 0, progress: httpEntry.progress, @@ -93,6 +95,7 @@ export const getRequestSummary = ( status: wsEntry.status, timestamp: wsEntry.timestamp, duration: wsEntry.duration, + size: null, method: 'WS', httpStatus: 0, }; @@ -105,6 +108,7 @@ export const getRequestSummary = ( status: sseEntry.status, timestamp: sseEntry.timestamp, duration: sseEntry.duration, + size: null, method: 'SSE', httpStatus: 0, }; diff --git a/packages/network-activity-plugin/src/ui/state/store.ts b/packages/network-activity-plugin/src/ui/state/store.ts index 5a720b9..8c910d0 100644 --- a/packages/network-activity-plugin/src/ui/state/store.ts +++ b/packages/network-activity-plugin/src/ui/state/store.ts @@ -65,7 +65,7 @@ export const createNetworkActivityStore = () => persist( (set, get) => ({ // Initial state - isRecording: false, + isRecording: true, selectedRequestId: null, networkEntries: new Map(), websocketMessages: new Map(), @@ -684,9 +684,10 @@ export const createNetworkActivityStore = () => typeof value === 'object' && value !== null && '_type' in value && - value._type === 'map' + value._type === 'map' && + 'value' in value ) { - return new Map(value.value); + return new Map(value.value as [string, RequestOverride][]); } return value; }, diff --git a/website/src/docs/official-plugins/network-activity.mdx b/website/src/docs/official-plugins/network-activity.mdx index 175a3f5..1520260 100644 --- a/website/src/docs/official-plugins/network-activity.mdx +++ b/website/src/docs/official-plugins/network-activity.mdx @@ -41,6 +41,13 @@ function App() { } ``` +To capture network requests happenning before your React Native app initialization, add this to your entrypoint: +```typescript title="index.js" +import { withOnBootNetworkActivityRecording } from '@rozenite/network-activity-plugin/react-native'; + +withOnBootNetworkActivityRecording(); +``` + ## Usage Once configured, the Network Activity plugin will automatically appear in your React Native DevTools sidebar as "Network Activity". Click on it to access: