diff --git a/.changeset/devtools-android-stability.md b/.changeset/devtools-android-stability.md new file mode 100644 index 000000000..19ce29739 --- /dev/null +++ b/.changeset/devtools-android-stability.md @@ -0,0 +1,5 @@ +--- +"@callstack/repack-dev-server": patch +--- + +Avoid crashing Android apps when opening React Native DevTools by handling cross-origin `Network.loadNetworkResource` requests inside the dev server. diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index d7342b395..e23874716 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -23,8 +23,10 @@ "access": "public" }, "scripts": { - "build": "tsc -b", + "build": "tsc -b tsconfig.build.json", "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", "archive": "pnpm build && pnpm pack" }, "dependencies": { @@ -46,6 +48,7 @@ "@types/babel__code-frame": "^7.0.6", "@types/node": "catalog:", "@types/ws": "^8.18.0", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/dev-server/src/createServer.ts b/packages/dev-server/src/createServer.ts index 302812305..5fba02391 100644 --- a/packages/dev-server/src/createServer.ts +++ b/packages/dev-server/src/createServer.ts @@ -12,6 +12,7 @@ import multipartPlugin from './plugins/multipart/multipartPlugin.js'; import symbolicatePlugin from './plugins/symbolicate/sybmolicatePlugin.js'; import wssPlugin from './plugins/wss/wssPlugin.js'; import { Internal, type Middleware, type Server } from './types.js'; +import { handleCustomNetworkLoadResource } from './utils/networkLoadResourceHandler.js'; import { normalizeOptions } from './utils/normalizeOptions.js'; /** @@ -97,15 +98,13 @@ export async function createServer(config: Server.Config) { } }, }, - // we need to let `Network.loadNetworkResource` event pass - // through the InspectorProxy interceptor, otherwise it will - // prevent fetching source maps over the network for MF2 remotes + // Preserve RN default handling for same-origin resources while + // allowing remote-origin source maps/resources to be loaded here. unstable_customInspectorMessageHandler: (connection) => { return { handleDeviceMessage: () => {}, - handleDebuggerMessage: (msg: { method?: string }) => { - if (msg.method === 'Network.loadNetworkResource') { - connection.device.sendMessage(msg); + handleDebuggerMessage: (msg) => { + if (handleCustomNetworkLoadResource(connection, msg, options.url)) { return true; } }, diff --git a/packages/dev-server/src/utils/__tests__/networkLoadResourceHandler.test.ts b/packages/dev-server/src/utils/__tests__/networkLoadResourceHandler.test.ts new file mode 100644 index 000000000..8d3c592b2 --- /dev/null +++ b/packages/dev-server/src/utils/__tests__/networkLoadResourceHandler.test.ts @@ -0,0 +1,153 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { handleCustomNetworkLoadResource } from '../networkLoadResourceHandler.js'; + +function createConnectionSpy(): { + connection: Parameters[0]; + sendMessage: ReturnType; +} { + const sendMessage = vi.fn(); + + return { + connection: { + debugger: { + sendMessage, + }, + }, + sendMessage, + }; +} + +describe('handleCustomNetworkLoadResource', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should not intercept messages other than Network.loadNetworkResource', () => { + const { connection, sendMessage } = createConnectionSpy(); + + const result = handleCustomNetworkLoadResource( + connection, + { + id: 1, + method: 'Runtime.enable', + }, + 'http://127.0.0.1:8081' + ); + + expect(result).toBe(false); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it('should not intercept malformed Network.loadNetworkResource messages', () => { + const { connection, sendMessage } = createConnectionSpy(); + + const result = handleCustomNetworkLoadResource( + connection, + { + id: 1, + method: 'Network.loadNetworkResource', + params: {}, + }, + 'http://127.0.0.1:8081' + ); + + expect(result).toBe(false); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it('should not intercept same-origin Network.loadNetworkResource requests', () => { + const { connection, sendMessage } = createConnectionSpy(); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const result = handleCustomNetworkLoadResource( + connection, + { + id: 1, + method: 'Network.loadNetworkResource', + params: { + url: 'http://127.0.0.1:8081/main.bundle.map', + }, + }, + 'http://127.0.0.1:8081' + ); + + expect(result).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it('should intercept cross-origin Network.loadNetworkResource requests', async () => { + const { connection, sendMessage } = createConnectionSpy(); + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + status: 200, + headers: new Headers([['content-type', 'application/json']]), + text: () => Promise.resolve('{"ok":true}'), + } as unknown as Response); + + const handled = handleCustomNetworkLoadResource( + connection, + { + id: 7, + method: 'Network.loadNetworkResource', + params: { + url: 'http://10.10.10.10:9000/remote.bundle.map', + }, + }, + 'http://127.0.0.1:8081' + ); + + expect(handled).toBe(true); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledWith({ + id: 7, + result: { + resource: { + success: true, + httpStatusCode: 200, + headers: { + 'content-type': 'application/json', + }, + content: Buffer.from('{"ok":true}').toString('base64'), + base64Encoded: true, + }, + }, + }); + }); + }); + + it('should return a CDP failure response when resource fetch fails', async () => { + const { connection, sendMessage } = createConnectionSpy(); + vi.spyOn(globalThis, 'fetch').mockRejectedValue( + new Error('network failed') + ); + + const handled = handleCustomNetworkLoadResource( + connection, + { + id: 9, + method: 'Network.loadNetworkResource', + params: { + url: 'http://10.10.10.10:9000/remote.bundle.map', + }, + }, + 'http://127.0.0.1:8081' + ); + + expect(handled).toBe(true); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledWith({ + id: 9, + result: { + resource: { + success: false, + netErrorName: 'net::ERR_FAILED', + netError: -2, + httpStatusCode: 500, + }, + }, + }); + }); + }); +}); diff --git a/packages/dev-server/src/utils/networkLoadResourceHandler.ts b/packages/dev-server/src/utils/networkLoadResourceHandler.ts new file mode 100644 index 000000000..1a95af71a --- /dev/null +++ b/packages/dev-server/src/utils/networkLoadResourceHandler.ts @@ -0,0 +1,77 @@ +const NETWORK_LOAD_RESOURCE_ERROR = { + success: false, + netErrorName: 'net::ERR_FAILED', + netError: -2, + httpStatusCode: 500, +} as const; + +type Connection = { debugger: { sendMessage: (message: any) => void } }; + +async function sendNetworkLoadResourceResponse( + connection: Connection, + id: number, + url: string +) { + // DevTools expects the loaded resource body to be returned as Base64-encoded + // bytes in the CDP response payload. + const response = await fetch(url).catch(() => null); + const resource = !response + ? NETWORK_LOAD_RESOURCE_ERROR + : { + success: true, + httpStatusCode: response.status, + headers: Object.fromEntries(response.headers.entries()), + content: Buffer.from(await response.text()).toString('base64'), + base64Encoded: true, + }; + + connection.debugger.sendMessage({ id, result: { resource } }); +} + +// RN dev-middleware already handles same-origin requests. We only intercept +// cross-origin requests here so the original debugger behavior stays intact for +// the local server while MF-style remote resources can still be fetched. +// +// The custom inspector hook must synchronously return `true` when it takes +// ownership of a message, so we start the async fetch and report handling now. +export function handleCustomNetworkLoadResource( + connection: Connection, + message: unknown, + serverBaseUrl: string +) { + if (typeof message !== 'object' || message === null) { + return false; + } + + const request = message as { + id?: unknown; + method?: unknown; + params?: { url?: unknown }; + }; + + if ( + request.method !== 'Network.loadNetworkResource' || + typeof request.id !== 'number' || + typeof request.params?.url !== 'string' || + !URL.canParse(request.params.url) || + !URL.canParse(serverBaseUrl) + ) { + return false; + } + + if (new URL(request.params.url).origin === new URL(serverBaseUrl).origin) { + return false; + } + + void sendNetworkLoadResourceResponse( + connection, + request.id, + request.params.url + ).catch(() => + console.error( + '[DevServer] Failed to send Network.loadNetworkResource response' + ) + ); + + return true; +} diff --git a/packages/dev-server/tsconfig.build.json b/packages/dev-server/tsconfig.build.json new file mode 100644 index 000000000..e12c9cff5 --- /dev/null +++ b/packages/dev-server/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "exclude": ["src/**/__tests__/**/*", "src/**/*.test.ts"], + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "paths": {} + } +} diff --git a/packages/dev-server/tsconfig.json b/packages/dev-server/tsconfig.json index d59715633..0c0909128 100644 --- a/packages/dev-server/tsconfig.json +++ b/packages/dev-server/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "rootDir": "src", - "outDir": "dist" + "types": ["node", "vitest/globals"] }, "include": ["src/**/*"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1c31c55c..1b5af7989 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -448,6 +448,9 @@ importers: typescript: specifier: 'catalog:' version: 5.8.3 + vitest: + specifier: 'catalog:' + version: 4.0.18(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.28.2)(terser@5.31.3)(yaml@2.8.2) packages/init: dependencies: