UNPKG

reactotron-react-native

Version:

A development tool to explore, inspect, and diagnose your React Native apps.

311 lines (278 loc) 9.87 kB
import type { ReactotronCore, Plugin } from "reactotron-core-client" import { XHRInterceptor } from "../xhr-interceptor" import { FetchInterceptor, FetchHeaders } from "../fetch-interceptor" /** * Don't include the response bodies for images by default. */ const DEFAULT_CONTENT_TYPES_RX = /^(image)\/.*$/i /** * Streaming response bodies must never be buffered for logging (it would defeat * the stream and grow memory unbounded), so we always skip their bodies. */ const STREAMING_CONTENT_TYPES_RX = /event-stream/i export interface NetworkingOptions { ignoreContentTypes?: RegExp ignoreUrls?: RegExp /** * Set to `true` to skip instrumenting Expo's expo/fetch (the default * `globalThis.fetch` on Expo SDK 56+). Has no effect on non-Expo runtimes, * where the global fetch is XHR-backed and already covered by XHR tracking. */ ignoreExpoFetch?: boolean /** * Explicitly pass the fetch function to track; it will be wrapped and * installed as `globalThis.fetch` on connect, with no environment * detection. Use this when the expo/fetch global has been re-wrapped and no * longer carries the `expo.builtin` symbol (e.g. expo-router apps): * * .useReactNative({ networking: { fetch: globalThis.fetch } }) * * Only pass a fetch that does NOT go through XMLHttpRequest — an XHR-backed * fetch is already tracked by the XHR interceptor and would double-report. */ fetch?: typeof fetch } const DEFAULTS: NetworkingOptions = {} /** * decodeURIComponent that falls back to the raw string on malformed * percent-encoding (e.g. `?q=%E0%A4%A`) instead of throwing. */ function tryDecodeURIComponent(value: string): string { try { return decodeURIComponent(value) } catch (malformedUri) { return value } } const networking = (pluginConfig: NetworkingOptions = {}) => (reactotron: ReactotronCore) => { const options = Object.assign({}, DEFAULTS, pluginConfig) // a RegExp to suppress adding the body cuz it costs a lot to serialize const ignoreContentTypes = options.ignoreContentTypes || DEFAULT_CONTENT_TYPES_RX // a XHR call tracker let reactotronCounter = 1000 // a temporary cache to hold requests so we can match up the data const requestCache = {} /** * Fires when we talk to the server. * * @param {*} data - The data sent to the server. * @param {*} instance - The XMLHTTPRequest instance. */ function onSend(data, xhr) { if (options.ignoreUrls && options.ignoreUrls.test(xhr._url)) { xhr._skipReactotron = true return } // bump the counter reactotronCounter++ // tag xhr._trackingName = reactotronCounter // cache requestCache[reactotronCounter] = { data, xhr, stopTimer: reactotron.startTimer(), } } /** * Fires when the server gives us a response. * * @param {number} status - The HTTP response status. * @param {boolean} timeout - Did we timeout? * @param {*} response - The response data. * @param {string} url - The URL we talked to. * @param {*} type - Not sure. * @param {*} xhr - The XMLHttpRequest instance. */ function onResponse(status, timeout, response, url, type, xhr) { if (xhr._skipReactotron) { return } let params = null const queryParamIdx = url ? url.indexOf("?") : -1 if (queryParamIdx > -1) { params = {} url .substr(queryParamIdx + 1) .split("&") .forEach((pair) => { const [key, value] = pair.split("=") if (key && value !== undefined) { params[key] = decodeURIComponent(value.replace(/\+/g, " ")) } }) } // fetch and clear the request data from the cache const rid = xhr._trackingName const cachedRequest = requestCache[rid] || { xhr } requestCache[rid] = null // assemble the request object const { data, stopTimer } = cachedRequest const tronRequest = { url: url || cachedRequest.xhr._url, method: xhr._method || null, data, headers: xhr._headers || null, params, } // what type of content is this? const contentType = (xhr.responseHeaders && xhr.responseHeaders["content-type"]) || (xhr.responseHeaders && xhr.responseHeaders["Content-Type"]) || "" const sendResponse = (responseBodyText) => { let body = `~~~ skipped ~~~` if (responseBodyText) { try { // all i am saying, is give JSON a chance... body = JSON.parse(responseBodyText) } catch (boom) { body = response } } const tronResponse = { body, status, headers: xhr.responseHeaders || null, } ;(reactotron as any).apiResponse(tronRequest, tronResponse, stopTimer ? stopTimer() : null) // TODO: Fix } // can we use the real response? const useRealResponse = (typeof response === "string" || typeof response === "object") && !ignoreContentTypes.test(contentType || "") // prepare the right body to send if (useRealResponse) { if (type === "blob" && typeof FileReader !== "undefined" && response) { // Disable reason: FileReader should be in global scope since RN 0.54 // eslint-disable-next-line no-undef const bReader = new FileReader() const brListener = () => { sendResponse(bReader.result) bReader.removeEventListener("loadend", brListener) } bReader.addEventListener("loadend", brListener) bReader.readAsText(response) } else { sendResponse(response) } } else { sendResponse("") } } // expo/fetch request tracker (keyed by the interceptor's request id). // `null` marks a request we deliberately skipped (ignoreUrls). const fetchCache: { [id: number]: { tronRequest: any; stopTimer: () => number } | null } = {} /** * Fires (synchronously) when an expo/fetch request is sent. */ function onFetchOpen( method: string, url: string, headers: FetchHeaders, data: string | null, id: number ) { if (options.ignoreUrls && options.ignoreUrls.test(url)) { fetchCache[id] = null return } let params = null const queryParamIdx = url ? url.indexOf("?") : -1 if (queryParamIdx > -1) { params = {} url .substr(queryParamIdx + 1) .split("&") .forEach((pair) => { const [key, value] = pair.split("=") if (key && value !== undefined) { params[key] = tryDecodeURIComponent(value.replace(/\+/g, " ")) } }) } fetchCache[id] = { tronRequest: { url, method, data, headers, params }, stopTimer: reactotron.startTimer(), } } /** * Fires (synchronously) when an expo/fetch response resolves or rejects. * The body is read off a clone, asynchronously, so the caller's response is * never blocked and streaming responses stay intact. */ function onFetchResponse( id: number, status: number, headers: FetchHeaders, response: Response | null, error: unknown ) { const cached = fetchCache[id] delete fetchCache[id] if (!cached) { return } const { tronRequest, stopTimer } = cached const report = (body) => (reactotron as any).apiResponse(tronRequest, { body, status, headers }, stopTimer()) if (error || !response) { report(error instanceof Error ? error.message : String(error)) return } const contentType = (headers && headers["content-type"]) || "" if (ignoreContentTypes.test(contentType) || STREAMING_CONTENT_TYPES_RX.test(contentType)) { // Never read (and therefore never buffer) image or streaming bodies. report("~~~ skipped ~~~") return } // Clone synchronously (before the caller consumes the body), then read // asynchronously so we don't block the request. clone() itself can throw // if the body is already disturbed (e.g. another tool consumed it first). let clone: Response try { clone = response.clone() } catch (cloneError) { report("~~~ unreadable ~~~") return } clone .text() .then((text) => { let body try { // all i am saying, is give JSON a chance... body = JSON.parse(text) } catch (boom) { body = text } report(body) }) .catch(() => report("~~~ unreadable ~~~")) } return { onConnect: () => { // register our monkey-patch XHRInterceptor.setSendCallback(onSend) XHRInterceptor.setResponseCallback(onResponse) XHRInterceptor.enableInterception() // expo/fetch (Expo SDK 56+) bypasses XHR, so instrument it too. // An explicitly passed fetch skips detection; otherwise expo/fetch is // auto-detected unless opted out. if (options.fetch) { FetchInterceptor.setOpenCallback(onFetchOpen) FetchInterceptor.setResponseCallback(onFetchResponse) FetchInterceptor.enableInterception(options.fetch) } else if (!options.ignoreExpoFetch) { FetchInterceptor.setOpenCallback(onFetchOpen) FetchInterceptor.setResponseCallback(onFetchResponse) FetchInterceptor.enableInterception() } }, } satisfies Plugin<ReactotronCore> } export default networking