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
5 changes: 5 additions & 0 deletions .changeset/devtools-android-stability.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 5 additions & 2 deletions packages/dev-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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:"
}
}
11 changes: 5 additions & 6 deletions packages/dev-server/src/createServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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;
}
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { handleCustomNetworkLoadResource } from '../networkLoadResourceHandler.js';

function createConnectionSpy(): {
connection: Parameters<typeof handleCustomNetworkLoadResource>[0];
sendMessage: ReturnType<typeof vi.fn>;
} {
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,
},
},
});
});
});
});
77 changes: 77 additions & 0 deletions packages/dev-server/src/utils/networkLoadResourceHandler.ts
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 10 additions & 0 deletions packages/dev-server/tsconfig.build.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*"],
"exclude": ["src/**/__tests__/**/*", "src/**/*.test.ts"],
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"paths": {}
}
}
3 changes: 1 addition & 2 deletions packages/dev-server/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*"]
}
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading