Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions docs/plugins/networking.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ title: Networking

The `networking` plugin is `reactotron-react-native` which allows you to track all XMLHttpRequests in React Native.

On Expo SDK 56+, `expo/fetch` is installed as the default `globalThis.fetch`. It is backed by a native module and bypasses `XMLHttpRequest`, so it would otherwise be invisible to this plugin. The plugin detects this case and tracks `expo/fetch` requests as well (see `ignoreExpoFetch` below to opt out). Requests made through React Native's regular XHR-backed `fetch` continue to be tracked via `XMLHttpRequest`, so there is no double-reporting.

## Usage

To use the `networking` plugin, you need to add the additional plugin on the `import` line.
Expand All @@ -26,10 +28,11 @@ And you're done! Now you can see your XMLHttpRequests in Reactotron.

## Advanced Usage

`networking()` also accepts an object with two options:
`networking()` also accepts an object with these options:

- `ignoreContentTypes`: a regular expression which, when matched against the `Content-Type` response header, will prevent the data from being displayed in Reactotron. You typically want to do this for images (which is the default).
- `ignoreUrls`: a regular expression which, when matched against the URL of the XHR, will prevent the request from being tracked in Reactotron. Can be useful for ignoring noisy logging requests.
- `ignoreContentTypes`: a regular expression which, when matched against the `Content-Type` response header, will prevent the data from being displayed in Reactotron. You typically want to do this for images (which is the default). `text/event-stream` response bodies are always skipped so streaming responses are not buffered.
- `ignoreUrls`: a regular expression which, when matched against the URL of the request, will prevent the request from being tracked in Reactotron. Can be useful for ignoring noisy logging requests.
- `ignoreExpoFetch`: 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.

```js
networking({
Expand Down
144 changes: 144 additions & 0 deletions lib/reactotron-react-native/src/fetch-interceptor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { FetchInterceptor } from "./fetch-interceptor"

const EXPO_BUILTIN = Symbol.for("expo.builtin")

function makeExpoFetch(impl: (...args: any[]) => Promise<any>) {
const fn: any = (...args: any[]) => impl(...args)
fn[EXPO_BUILTIN] = true
return fn
}

function makeResponse(status: number, headersObj: Record<string, string>) {
return {
status,
headers: {
get: (k: string) => headersObj[k.toLowerCase()] ?? null,
forEach: (cb: (v: string, k: string) => void) =>
Object.entries(headersObj).forEach(([k, v]) => cb(v, k)),
},
clone() {
return this
},
text: () => Promise.resolve(""),
}
}

describe("FetchInterceptor", () => {
const realFetch = globalThis.fetch

afterEach(() => {
FetchInterceptor.disableInterception()
globalThis.fetch = realFetch
})

it("is a no-op when the global fetch is not expo/fetch", () => {
const plain: any = jest.fn()
globalThis.fetch = plain
FetchInterceptor.enableInterception()
expect(FetchInterceptor.isInterceptorEnabled()).toBe(false)
expect(globalThis.fetch).toBe(plain)
})

it("wraps expo/fetch, fires callbacks, and returns the original response untouched", async () => {
const response = makeResponse(201, { "content-type": "application/json" })
const original = makeExpoFetch(() => Promise.resolve(response))
globalThis.fetch = original

const open = jest.fn()
const onResponse = jest.fn()
FetchInterceptor.setOpenCallback(open)
FetchInterceptor.setResponseCallback(onResponse)
FetchInterceptor.enableInterception()

expect(FetchInterceptor.isInterceptorEnabled()).toBe(true)
expect(globalThis.fetch).not.toBe(original)

const result = await (globalThis.fetch as any)("https://example.com/x?a=1", {
method: "post",
headers: { Authorization: "Bearer t" },
body: "hello",
})

// the caller receives the original, untouched response (non-blocking / stream-safe)
expect(result).toBe(response)

expect(open).toHaveBeenCalledTimes(1)
const [method, url, reqHeaders, data, id] = open.mock.calls[0]
expect(method).toBe("POST")
expect(url).toBe("https://example.com/x?a=1")
expect(reqHeaders).toEqual({ Authorization: "Bearer t" })
expect(data).toBe("hello")

expect(onResponse).toHaveBeenCalledTimes(1)
const [rid, status, respHeaders, passedResponse, error] = onResponse.mock.calls[0]
expect(rid).toBe(id)
expect(status).toBe(201)
expect(respHeaders).toEqual({ "content-type": "application/json" })
expect(passedResponse).toBe(response)
expect(error).toBeNull()
})

it("reports rejections with status -1 and a null response", async () => {
const boom = new Error("offline")
globalThis.fetch = makeExpoFetch(() => Promise.reject(boom))
const onResponse = jest.fn()
FetchInterceptor.setResponseCallback(onResponse)
FetchInterceptor.enableInterception()

await expect((globalThis.fetch as any)("https://x.test")).rejects.toBe(boom)
const [, status, headers, response, error] = onResponse.mock.calls[0]
expect(status).toBe(-1)
expect(headers).toBeNull()
expect(response).toBeNull()
expect(error).toBe(boom)
})

it("does not double-wrap an already-wrapped fetch", () => {
globalThis.fetch = makeExpoFetch(() => Promise.resolve(makeResponse(200, {})))
FetchInterceptor.enableInterception()
const wrapped = globalThis.fetch
FetchInterceptor.enableInterception()
expect(globalThis.fetch).toBe(wrapped)
})

it("wraps an explicitly provided fetch even without the expo symbol", async () => {
const response = makeResponse(200, { "content-type": "application/json" })
// no expo.builtin symbol — e.g. expo-router's re-wrapped global fetch
const routerWrapped: any = jest.fn(() => Promise.resolve(response))
globalThis.fetch = routerWrapped

const open = jest.fn()
FetchInterceptor.setOpenCallback(open)
FetchInterceptor.enableInterception(routerWrapped)

expect(FetchInterceptor.isInterceptorEnabled()).toBe(true)
expect(globalThis.fetch).not.toBe(routerWrapped)
// the wrapper must not pretend to be the expo builtin
expect((globalThis.fetch as any)[EXPO_BUILTIN]).toBeUndefined()

const result = await (globalThis.fetch as any)("https://example.com/y")
expect(result).toBe(response)
expect(routerWrapped).toHaveBeenCalledTimes(1)
expect(open).toHaveBeenCalledTimes(1)
expect(open.mock.calls[0][1]).toBe("https://example.com/y")
})

it("does not wrap an explicitly provided fetch twice", () => {
const fn: any = jest.fn()
globalThis.fetch = fn
FetchInterceptor.enableInterception(fn)
const wrapped = globalThis.fetch
FetchInterceptor.enableInterception(fn)
expect(globalThis.fetch).toBe(wrapped)
})

it("restores the original fetch on disable", () => {
const original = makeExpoFetch(() => Promise.resolve(makeResponse(200, {})))
globalThis.fetch = original
FetchInterceptor.enableInterception()
expect(globalThis.fetch).not.toBe(original)
FetchInterceptor.disableInterception()
expect(globalThis.fetch).toBe(original)
expect(FetchInterceptor.isInterceptorEnabled()).toBe(false)
})
})
196 changes: 196 additions & 0 deletions lib/reactotron-react-native/src/fetch-interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/**
* Intercepts the global `fetch` when it is Expo's expo/fetch implementation
* (the default `globalThis.fetch` on Expo SDK 56+). expo/fetch is backed by a
* native module and bypasses `XMLHttpRequest`, so it is invisible to
* `XHRInterceptor`. This wraps it so the networking plugin can report
* expo/fetch traffic the same way it reports XHR traffic.
*
* The shape mirrors ./xhr-interceptor (set*Callback / enableInterception /
* disableInterception) so `networking.ts` can wire Reactotron into it the same
* way. On platforms/SDKs where the global fetch is not expo/fetch (e.g. RN's
* XHR-backed fetch, which is already covered by `XHRInterceptor`), this is a
* no-op.
*/

// Expo stamps its installed globals (see expo's `installGlobal`) with this symbol.
const EXPO_BUILTIN = Symbol.for("expo.builtin")

export type FetchHeaders = Record<string, string> | null

type FetchInterceptorOpenCallback = (
method: string,
url: string,
headers: FetchHeaders,
data: string | null,
id: number
) => void

/**
* Invoked synchronously as soon as the response resolves, BEFORE the response
* is returned to the caller. To read the body, clone `response` synchronously
* inside the callback (do not `await` before cloning) so the caller's copy is
* left intact and streaming responses are not blocked. `response` is null when
* the request rejected (network error); `error` then holds the rejection.
*/
type FetchInterceptorResponseCallback = (
id: number,
status: number,
headers: FetchHeaders,
response: Response | null,
error: unknown
) => void

interface ReactotronFetch {
(input: any, init?: any): Promise<Response>
__reactotronWrapped?: boolean
[EXPO_BUILTIN]?: boolean
}

let openCallback: FetchInterceptorOpenCallback | null
let responseCallback: FetchInterceptorResponseCallback | null
let originalFetch: typeof fetch | null = null
let requestId = 0

function isExpoFetch(fn: unknown): boolean {
return typeof fn === "function" && (fn as ReactotronFetch)[EXPO_BUILTIN] === true
}

function isRequest(value: unknown): value is Request {
return typeof Request !== "undefined" && value instanceof Request
}

function getUrl(input: unknown): string {
if (typeof input === "string") return input
if (isRequest(input)) return input.url
return String(input)
}

function getMethod(input: unknown, init?: { method?: string }): string {
if (isRequest(input) && input.method) return input.method.toUpperCase()
if (init && init.method) return String(init.method).toUpperCase()
return "GET"
}

/**
* Normalizes a fetch `HeadersInit` / `Headers` into a plain object.
*/
function headersToObject(headers: unknown): FetchHeaders {
if (!headers) return null
const anyHeaders = headers as any
if (typeof anyHeaders.forEach === "function" && typeof anyHeaders.get === "function") {
const out: Record<string, string> = {}
anyHeaders.forEach((value: string, key: string) => {
out[key] = value
})
return out
}
if (Array.isArray(headers)) {
return (headers as [string, string][]).reduce((acc: Record<string, string>, pair) => {
if (pair && pair.length === 2) acc[pair[0]] = pair[1]
return acc
}, {})
}
if (typeof headers === "object") return { ...(headers as Record<string, string>) }
return null
}

/**
* A network interceptor for Expo's expo/fetch. Mirrors `XHRInterceptor` so the
* networking plugin can register callbacks and enable/disable it identically.
*/
export const FetchInterceptor = {
/**
* Invoked synchronously before the wrapped fetch is sent.
*/
setOpenCallback(callback: FetchInterceptorOpenCallback) {
openCallback = callback
},

/**
* Invoked synchronously when the response resolves (or rejects). See the
* callback type for the body-cloning contract.
*/
setResponseCallback(callback: FetchInterceptorResponseCallback) {
responseCallback = callback
},

isInterceptorEnabled(): boolean {
return originalFetch !== null
},

/**
* Wraps the global fetch and reassigns `globalThis.fetch`.
*
* With no argument, only expo/fetch is wrapped (detected by the
* `expo.builtin` symbol). RN's XHR-backed fetch is already covered by
* `XHRInterceptor`, and wrapping it here too would double-report.
*
* Pass `fetchToWrap` to skip the detection and wrap that function instead —
* for runtimes where the expo/fetch global has been re-wrapped and lost the
* symbol (e.g. expo-router's window.location polyfill). The caller is
* asserting the function does not go through XMLHttpRequest.
*/
enableInterception(fetchToWrap?: typeof fetch) {
if (originalFetch) {
return
}
const current = (fetchToWrap ?? globalThis.fetch) as ReactotronFetch | undefined
if (!current || current.__reactotronWrapped || (!fetchToWrap && !isExpoFetch(current))) {
return
}

originalFetch = current as typeof fetch

const wrapped: ReactotronFetch = function (input: any, init?: any) {
const id = (requestId += 1)
const requestHeaders = headersToObject(
(init && init.headers) || (isRequest(input) ? input.headers : null)
)
const data =
init && typeof init.body === "string"
? init.body
: init && init.body
? "[non-string body]"
: null

if (openCallback) {
openCallback(getMethod(input, init), getUrl(input), requestHeaders, data, id)
}

return (originalFetch as typeof fetch)(input, init).then(
(response) => {
// Fire synchronously and return the original response untouched, so
// the caller is never blocked and streaming bodies stay intact.
if (responseCallback) {
responseCallback(id, response.status, headersToObject(response.headers), response, null)
}
return response
},
(error) => {
if (responseCallback) {
responseCallback(id, -1, null, null, error)
}
throw error
}
)
}

wrapped.__reactotronWrapped = true
// Keep it detectable as the expo builtin fetch for anything else that checks.
if (isExpoFetch(current)) {
wrapped[EXPO_BUILTIN] = true
}
globalThis.fetch = wrapped
},

// Unpatch the global fetch and remove the callbacks.
disableInterception() {
if (!originalFetch) {
return
}
globalThis.fetch = originalFetch
originalFetch = null
openCallback = null
responseCallback = null
},
}
Loading