diff --git a/src/NetworkRequestInfo.ts b/src/NetworkRequestInfo.ts index 5f2ff1d..47af05c 100644 --- a/src/NetworkRequestInfo.ts +++ b/src/NetworkRequestInfo.ts @@ -108,9 +108,11 @@ export default class NetworkRequestInfo { } private async parseResponseBlob() { - const blobReader = new BlobFileReader(); - blobReader.readAsText(this.response); + if (this.response === null || this.response === undefined) { + return ''; + } + const blobReader = new BlobFileReader(); return await new Promise((resolve, reject) => { const handleError = () => reject(blobReader.error); @@ -119,14 +121,32 @@ export default class NetworkRequestInfo { }); blobReader.addEventListener('error', handleError); blobReader.addEventListener('abort', handleError); + + try { + blobReader.readAsText(this.response); + } catch (error) { + reject(error); + } }); } async getResponseBody() { - const body = await (this.responseType !== 'blob' - ? this.response - : this.parseResponseBlob()); + if (this.endTime === 0 && this.status < 0) { + return 'Pending response...'; + } - return this.stringifyFormat(body); + try { + const body = await (this.responseType !== 'blob' + ? this.response + : this.parseResponseBlob()); + + if (body === '' || body === null || body === undefined) { + return ''; + } + + return this.stringifyFormat(body); + } catch { + return '[Unable to load response body]'; + } } } diff --git a/src/XHRInterceptor.spec.ts b/src/XHRInterceptor.spec.ts new file mode 100644 index 0000000..ae158af --- /dev/null +++ b/src/XHRInterceptor.spec.ts @@ -0,0 +1,178 @@ +export {}; + +type ReadyStateListener = () => void; + +class FakeXMLHttpRequest { + static readonly UNSENT = 0; + static readonly OPENED = 1; + static readonly HEADERS_RECEIVED = 2; + static readonly LOADING = 3; + static readonly DONE = 4; + + readyState = FakeXMLHttpRequest.UNSENT; + response: any = ''; + responseText = ''; + responseType = ''; + responseURL = ''; + status = 0; + timeout = 0; + onreadystatechange = null; + + openCalled = false; + sendCalled = false; + setRequestHeaderCalled = false; + + private listeners: Record = {}; + private responseHeaders: Record = {}; + + open( + _method?: string, + _url?: string, + _async?: boolean, + _username?: string | null, + _password?: string | null + ) { + this.openCalled = true; + } + + send(_data?: any) { + this.sendCalled = true; + } + + setRequestHeader(_header?: string, _value?: string) { + this.setRequestHeaderCalled = true; + } + + getResponseHeader(name: string): string | null { + return this.responseHeaders[name.toLowerCase()] || null; + } + + getAllResponseHeaders(): string { + return Object.entries(this.responseHeaders) + .map(([name, value]) => `${name}: ${value}`) + .join('\r\n'); + } + + addEventListener(type: string, listener: ReadyStateListener) { + if (!this.listeners[type]) { + this.listeners[type] = []; + } + this.listeners[type].push(listener.bind(this)); + } + + setResponseHeaders(headers: Record) { + this.responseHeaders = headers; + } + + emitReadyState(state: number) { + this.readyState = state; + (this.listeners.readystatechange || []).forEach((listener) => listener()); + } +} + +const loadInterceptor = () => { + jest.resetModules(); + return require('./XHRInterceptor').default; +}; + +describe('XHRInterceptor', () => { + beforeEach(() => { + (global as any).XMLHttpRequest = FakeXMLHttpRequest; + }); + + it('does not block XHR methods when interceptor callbacks throw', () => { + const interceptor = loadInterceptor(); + + interceptor.setOpenCallback(() => { + throw new Error('open failed'); + }); + interceptor.setRequestHeaderCallback(() => { + throw new Error('header failed'); + }); + interceptor.setSendCallback(() => { + throw new Error('send failed'); + }); + + interceptor.enableInterception(); + + const xhr = new FakeXMLHttpRequest(); + expect(() => xhr.open('GET', 'https://example.com')).not.toThrow(); + expect(() => xhr.setRequestHeader('x-test', '1')).not.toThrow(); + expect(() => xhr.send('data')).not.toThrow(); + + expect(xhr.openCalled).toBe(true); + expect(xhr.setRequestHeaderCalled).toBe(true); + expect(xhr.sendCalled).toBe(true); + + interceptor.disableInterception(); + }); + + it('does not require addEventListener to exist', () => { + const interceptor = loadInterceptor(); + interceptor.enableInterception(); + + const xhr = new FakeXMLHttpRequest() as any; + xhr.addEventListener = undefined; + + expect(() => xhr.open('GET', 'https://example.com')).not.toThrow(); + expect(() => xhr.send()).not.toThrow(); + expect(xhr.sendCalled).toBe(true); + + interceptor.disableInterception(); + }); + + it('passes non-text response objects through callback for blob responses', () => { + const interceptor = loadInterceptor(); + const responseCallback = jest.fn(); + interceptor.setResponseCallback(responseCallback); + interceptor.enableInterception(); + + const xhr = new FakeXMLHttpRequest(); + const responseBlob = { _data: 'blob' }; + + xhr.responseType = 'blob'; + xhr.response = responseBlob; + xhr.status = 200; + xhr.timeout = 123; + xhr.responseURL = 'https://example.com'; + + xhr.open('GET', 'https://example.com'); + xhr.send(); + xhr.emitReadyState(FakeXMLHttpRequest.DONE); + + expect(responseCallback).toHaveBeenCalledTimes(1); + expect(responseCallback.mock.calls[0][2]).toBe(responseBlob); + + interceptor.disableInterception(); + }); + + it('invokes header and response callbacks once each', () => { + const interceptor = loadInterceptor(); + const headerReceivedCallback = jest.fn(); + const responseCallback = jest.fn(); + interceptor.setHeaderReceivedCallback(headerReceivedCallback); + interceptor.setResponseCallback(responseCallback); + interceptor.enableInterception(); + + const xhr = new FakeXMLHttpRequest(); + xhr.setResponseHeaders({ + 'content-type': 'application/json; charset=utf-8', + 'content-length': '42', + }); + xhr.responseType = 'text'; + xhr.responseText = '{"ok":true}'; + + xhr.open('GET', 'https://example.com'); + xhr.send(); + + xhr.emitReadyState(FakeXMLHttpRequest.HEADERS_RECEIVED); + xhr.emitReadyState(FakeXMLHttpRequest.HEADERS_RECEIVED); + xhr.emitReadyState(FakeXMLHttpRequest.DONE); + xhr.emitReadyState(FakeXMLHttpRequest.DONE); + + expect(headerReceivedCallback).toHaveBeenCalledTimes(1); + expect(responseCallback).toHaveBeenCalledTimes(1); + + interceptor.disableInterception(); + }); +}); diff --git a/src/XHRInterceptor.ts b/src/XHRInterceptor.ts index 4e9fcd3..2464212 100644 --- a/src/XHRInterceptor.ts +++ b/src/XHRInterceptor.ts @@ -1,32 +1,264 @@ -type XHRInterceptorModule = { - isInterceptorEnabled: () => boolean; - setOpenCallback: (...props: any[]) => void; - setRequestHeaderCallback: (...props: any[]) => void; - setSendCallback: (...props: any[]) => void; - setHeaderReceivedCallback: (...props: any[]) => void; - setResponseCallback: (...props: any[]) => void; - enableInterception: () => void; - disableInterception: () => void; -}; +// Type declarations for globals provided by React Native runtime +declare class URL { + constructor(url: string, base?: string); + toString(): string; +} + +declare class XMLHttpRequest { + static readonly UNSENT: number; + static readonly OPENED: number; + static readonly HEADERS_RECEIVED: number; + static readonly LOADING: number; + static readonly DONE: number; + + readonly readyState: number; + readonly response: any; + readonly responseText: string; + responseType: string; + readonly responseURL: string; + readonly status: number; + timeout: number; + + onreadystatechange: ((this: XMLHttpRequest, ev: any) => any) | null; + + open( + method: string, + url: string | URL, + async?: boolean, + username?: string | null, + password?: string | null + ): void; + send(body?: any): void; + setRequestHeader(header: string, value: string): void; + getResponseHeader(name: string): string | null; + getAllResponseHeaders(): string; + addEventListener( + type: string, + listener: (this: XMLHttpRequest, ev: any) => any + ): void; +} + +// Callback types use 'any' to match React Native's XHRInterceptor API +// This allows Logger.ts to use its own types (RequestMethod, XHR, etc.) +type OpenCallback = (...args: any[]) => void; +type RequestHeaderCallback = (...args: any[]) => void; +type SendCallback = (...args: any[]) => void; +type HeaderReceivedCallback = (...args: any[]) => void; +type ResponseCallback = (...args: any[]) => void; + +// Store original XMLHttpRequest methods +let originalXHROpen: typeof XMLHttpRequest.prototype.open | null = null; +let originalXHRSend: typeof XMLHttpRequest.prototype.send | null = null; +let originalXHRSetRequestHeader: + | typeof XMLHttpRequest.prototype.setRequestHeader + | null = null; + +// Callbacks +let openCallback: OpenCallback = () => {}; +let requestHeaderCallback: RequestHeaderCallback = () => {}; +let sendCallback: SendCallback = () => {}; +let headerReceivedCallback: HeaderReceivedCallback = () => {}; +let responseCallback: ResponseCallback = () => {}; + +let isInterceptorEnabled = false; + +function parseResponseHeaders(headersString: string): Record { + const headers: Record = {}; + if (!headersString) return headers; + + const headerLines = headersString.trim().split('\r\n'); + for (const line of headerLines) { + const index = line.indexOf(':'); + if (index > 0) { + const key = line.substring(0, index).trim(); + const value = line.substring(index + 1).trim(); + headers[key] = value; + } + } + return headers; +} + +function enableInterception(): void { + if (isInterceptorEnabled) return; + + // Store original methods + originalXHROpen = XMLHttpRequest.prototype.open; + originalXHRSend = XMLHttpRequest.prototype.send; + originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader; + + // Override open + XMLHttpRequest.prototype.open = function ( + method: string, + url: string | URL, + async: boolean = true, + username?: string | null, + password?: string | null + ): void { + const xhr = this as any; + xhr._interception = { + method, + url: url.toString(), + }; + + try { + openCallback(method, url.toString(), this); + } catch { + // Interceptor callbacks must not break network requests. + } -let XHRInterceptor: XHRInterceptorModule; -try { - // new location for React Native 0.80+ - const module = require('react-native/src/private/devsupport/devmenu/elementinspector/XHRInterceptor'); - XHRInterceptor = module.default ?? module; -} catch { - try { - // new location for React Native 0.79+ - const module = require('react-native/src/private/inspector/XHRInterceptor'); - XHRInterceptor = module.default ?? module; - } catch { + return originalXHROpen!.call( + this, + method, + url, + async, + username ?? null, + password ?? null + ); + }; + + // Override setRequestHeader + XMLHttpRequest.prototype.setRequestHeader = function ( + header: string, + value: string + ): void { + try { + requestHeaderCallback(header, value, this); + } catch { + // Interceptor callbacks must not break network requests. + } + return originalXHRSetRequestHeader!.call(this, header, value); + }; + + // Override send + XMLHttpRequest.prototype.send = function (body?: any): void { + const xhr = this as any; + + const dataString = body === null || body === undefined ? '' : String(body); try { - const module = require('react-native/Libraries/Network/XHRInterceptor'); - XHRInterceptor = module.default ?? module; + sendCallback(dataString, xhr); } catch { - throw new Error('XHRInterceptor could not be found in either location'); + // Interceptor callbacks must not break network requests. + } + + // Use addEventListener which is more reliable than overriding onreadystatechange + // This works even when handlers are set after send() is called + if (typeof this.addEventListener === 'function') { + this.addEventListener('readystatechange', function () { + if (this.readyState === XMLHttpRequest.HEADERS_RECEIVED) { + if (!xhr._interception?.hasCalledHeaderReceived) { + if (xhr._interception) { + xhr._interception.hasCalledHeaderReceived = true; + } + const contentType = this.getResponseHeader('content-type') || ''; + const contentLength = this.getResponseHeader('content-length'); + const responseSize = contentLength + ? parseInt(contentLength, 10) + : 0; + const responseHeaders = parseResponseHeaders( + this.getAllResponseHeaders() + ); + + // Set responseHeaders on xhr for compatibility with Logger.ts + xhr.responseHeaders = responseHeaders; + + try { + headerReceivedCallback( + contentType, + responseSize, + responseHeaders, + xhr + ); + } catch { + // Interceptor callbacks must not break network requests. + } + } + } + + if (this.readyState === XMLHttpRequest.DONE) { + if (!xhr._interception?.hasCalledResponse) { + if (xhr._interception) { + xhr._interception.hasCalledResponse = true; + } + let responseData: any = this.response; + if (this.responseType === '' || this.responseType === 'text') { + responseData = this.responseText || ''; + } else if (this.responseType === 'json' && this.response) { + try { + responseData = JSON.stringify(this.response); + } catch { + responseData = '[Unable to stringify response]'; + } + } + + try { + responseCallback( + this.status, + this.timeout, + responseData, + this.responseURL, + this.responseType, + xhr + ); + } catch { + // Interceptor callbacks must not break network requests. + } + } + } + }); } + + return originalXHRSend!.call(this, body); + }; + + isInterceptorEnabled = true; +} + +function disableInterception(): void { + if (!isInterceptorEnabled) return; + + // Restore original methods + if (originalXHROpen) { + XMLHttpRequest.prototype.open = originalXHROpen; + originalXHROpen = null; + } + if (originalXHRSend) { + XMLHttpRequest.prototype.send = originalXHRSend; + originalXHRSend = null; } + if (originalXHRSetRequestHeader) { + XMLHttpRequest.prototype.setRequestHeader = originalXHRSetRequestHeader; + originalXHRSetRequestHeader = null; + } + + // Reset callbacks + openCallback = () => {}; + requestHeaderCallback = () => {}; + sendCallback = () => {}; + headerReceivedCallback = () => {}; + responseCallback = () => {}; + + isInterceptorEnabled = false; } +const XHRInterceptor = { + isInterceptorEnabled: () => isInterceptorEnabled, + setOpenCallback: (callback: OpenCallback) => { + openCallback = callback; + }, + setRequestHeaderCallback: (callback: RequestHeaderCallback) => { + requestHeaderCallback = callback; + }, + setSendCallback: (callback: SendCallback) => { + sendCallback = callback; + }, + setHeaderReceivedCallback: (callback: HeaderReceivedCallback) => { + headerReceivedCallback = callback; + }, + setResponseCallback: (callback: ResponseCallback) => { + responseCallback = callback; + }, + enableInterception, + disableInterception, +}; + export default XHRInterceptor; diff --git a/src/__tests__/NetworkRequestInfo.spec.ts b/src/__tests__/NetworkRequestInfo.spec.ts index cd9f179..f305b2b 100644 --- a/src/__tests__/NetworkRequestInfo.spec.ts +++ b/src/__tests__/NetworkRequestInfo.spec.ts @@ -175,36 +175,48 @@ describe('getRequestBody', () => { }); describe('getResponseBody', () => { - const info = new NetworkRequestInfo( - '1', - 'application/json', - 'GET', - 'https://test.com' - ); + it('should show pending response for requests not finished yet', async () => { + const info = new NetworkRequestInfo( + '1', + 'application/json', + 'GET', + 'https://test.com' + ); + info.status = -1; + info.endTime = 0; + info.response = ''; - it('should return stringified data in consistent format', () => { - info.dataSent = '{"data": {"a": 1 }}'; - const result = info.getRequestBody(); - expect(typeof result).toBe('string'); - expect(result).toMatchInlineSnapshot(` - "{ - "data": { - "a": 1 - } - }" - `); + await expect(info.getResponseBody()).resolves.toBe('Pending response...'); }); - it('should return object wrapped in data if parsing fails', () => { - // @ts-ignore - info.dataSent = { test: 1 }; - const result = info.getRequestBody(); - expect(typeof result).toBe('string'); - expect(result).toMatchInlineSnapshot(` + it('should return empty string for successful empty responses', async () => { + const info = new NetworkRequestInfo( + '1', + 'application/json', + 'GET', + 'https://test.com' + ); + info.status = 204; + info.endTime = Date.now(); + info.response = ''; + + await expect(info.getResponseBody()).resolves.toBe(''); + }); + + it('should return stringified JSON response', async () => { + const info = new NetworkRequestInfo( + '1', + 'application/json', + 'GET', + 'https://test.com' + ); + info.status = 200; + info.endTime = Date.now(); + info.response = '{"ok":true}'; + + await expect(info.getResponseBody()).resolves.toMatchInlineSnapshot(` "{ - "data": { - "test": 1 - } + "ok": true }" `); });