reactotron-react-native
Version:
A development tool to explore, inspect, and diagnose your React Native apps.
311 lines (278 loc) • 9.87 kB
text/typescript
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