Skip to content

Commit 1ab6e4e

Browse files
authored
fix(dev-server): avoid Android app crashes when opening DevTools (#1371)
1 parent 84acbdd commit 1ab6e4e

8 files changed

Lines changed: 259 additions & 10 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@callstack/repack-dev-server": patch
3+
---
4+
5+
Avoid crashing Android apps when opening React Native DevTools by handling cross-origin `Network.loadNetworkResource` requests inside the dev server.

packages/dev-server/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@
2323
"access": "public"
2424
},
2525
"scripts": {
26-
"build": "tsc -b",
26+
"build": "tsc -b tsconfig.build.json",
2727
"typecheck": "tsc --noEmit",
28+
"test": "vitest run",
29+
"test:watch": "vitest",
2830
"archive": "pnpm build && pnpm pack"
2931
},
3032
"dependencies": {
@@ -46,6 +48,7 @@
4648
"@types/babel__code-frame": "^7.0.6",
4749
"@types/node": "catalog:",
4850
"@types/ws": "^8.18.0",
49-
"typescript": "catalog:"
51+
"typescript": "catalog:",
52+
"vitest": "catalog:"
5053
}
5154
}

packages/dev-server/src/createServer.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import multipartPlugin from './plugins/multipart/multipartPlugin.js';
1212
import symbolicatePlugin from './plugins/symbolicate/sybmolicatePlugin.js';
1313
import wssPlugin from './plugins/wss/wssPlugin.js';
1414
import { Internal, type Middleware, type Server } from './types.js';
15+
import { handleCustomNetworkLoadResource } from './utils/networkLoadResourceHandler.js';
1516
import { normalizeOptions } from './utils/normalizeOptions.js';
1617

1718
/**
@@ -97,15 +98,13 @@ export async function createServer(config: Server.Config) {
9798
}
9899
},
99100
},
100-
// we need to let `Network.loadNetworkResource` event pass
101-
// through the InspectorProxy interceptor, otherwise it will
102-
// prevent fetching source maps over the network for MF2 remotes
101+
// Preserve RN default handling for same-origin resources while
102+
// allowing remote-origin source maps/resources to be loaded here.
103103
unstable_customInspectorMessageHandler: (connection) => {
104104
return {
105105
handleDeviceMessage: () => {},
106-
handleDebuggerMessage: (msg: { method?: string }) => {
107-
if (msg.method === 'Network.loadNetworkResource') {
108-
connection.device.sendMessage(msg);
106+
handleDebuggerMessage: (msg) => {
107+
if (handleCustomNetworkLoadResource(connection, msg, options.url)) {
109108
return true;
110109
}
111110
},
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
import { handleCustomNetworkLoadResource } from '../networkLoadResourceHandler.js';
3+
4+
function createConnectionSpy(): {
5+
connection: Parameters<typeof handleCustomNetworkLoadResource>[0];
6+
sendMessage: ReturnType<typeof vi.fn>;
7+
} {
8+
const sendMessage = vi.fn();
9+
10+
return {
11+
connection: {
12+
debugger: {
13+
sendMessage,
14+
},
15+
},
16+
sendMessage,
17+
};
18+
}
19+
20+
describe('handleCustomNetworkLoadResource', () => {
21+
afterEach(() => {
22+
vi.restoreAllMocks();
23+
});
24+
25+
it('should not intercept messages other than Network.loadNetworkResource', () => {
26+
const { connection, sendMessage } = createConnectionSpy();
27+
28+
const result = handleCustomNetworkLoadResource(
29+
connection,
30+
{
31+
id: 1,
32+
method: 'Runtime.enable',
33+
},
34+
'http://127.0.0.1:8081'
35+
);
36+
37+
expect(result).toBe(false);
38+
expect(sendMessage).not.toHaveBeenCalled();
39+
});
40+
41+
it('should not intercept malformed Network.loadNetworkResource messages', () => {
42+
const { connection, sendMessage } = createConnectionSpy();
43+
44+
const result = handleCustomNetworkLoadResource(
45+
connection,
46+
{
47+
id: 1,
48+
method: 'Network.loadNetworkResource',
49+
params: {},
50+
},
51+
'http://127.0.0.1:8081'
52+
);
53+
54+
expect(result).toBe(false);
55+
expect(sendMessage).not.toHaveBeenCalled();
56+
});
57+
58+
it('should not intercept same-origin Network.loadNetworkResource requests', () => {
59+
const { connection, sendMessage } = createConnectionSpy();
60+
const fetchSpy = vi.spyOn(globalThis, 'fetch');
61+
62+
const result = handleCustomNetworkLoadResource(
63+
connection,
64+
{
65+
id: 1,
66+
method: 'Network.loadNetworkResource',
67+
params: {
68+
url: 'http://127.0.0.1:8081/main.bundle.map',
69+
},
70+
},
71+
'http://127.0.0.1:8081'
72+
);
73+
74+
expect(result).toBe(false);
75+
expect(fetchSpy).not.toHaveBeenCalled();
76+
expect(sendMessage).not.toHaveBeenCalled();
77+
});
78+
79+
it('should intercept cross-origin Network.loadNetworkResource requests', async () => {
80+
const { connection, sendMessage } = createConnectionSpy();
81+
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
82+
status: 200,
83+
headers: new Headers([['content-type', 'application/json']]),
84+
text: () => Promise.resolve('{"ok":true}'),
85+
} as unknown as Response);
86+
87+
const handled = handleCustomNetworkLoadResource(
88+
connection,
89+
{
90+
id: 7,
91+
method: 'Network.loadNetworkResource',
92+
params: {
93+
url: 'http://10.10.10.10:9000/remote.bundle.map',
94+
},
95+
},
96+
'http://127.0.0.1:8081'
97+
);
98+
99+
expect(handled).toBe(true);
100+
101+
await vi.waitFor(() => {
102+
expect(sendMessage).toHaveBeenCalledWith({
103+
id: 7,
104+
result: {
105+
resource: {
106+
success: true,
107+
httpStatusCode: 200,
108+
headers: {
109+
'content-type': 'application/json',
110+
},
111+
content: Buffer.from('{"ok":true}').toString('base64'),
112+
base64Encoded: true,
113+
},
114+
},
115+
});
116+
});
117+
});
118+
119+
it('should return a CDP failure response when resource fetch fails', async () => {
120+
const { connection, sendMessage } = createConnectionSpy();
121+
vi.spyOn(globalThis, 'fetch').mockRejectedValue(
122+
new Error('network failed')
123+
);
124+
125+
const handled = handleCustomNetworkLoadResource(
126+
connection,
127+
{
128+
id: 9,
129+
method: 'Network.loadNetworkResource',
130+
params: {
131+
url: 'http://10.10.10.10:9000/remote.bundle.map',
132+
},
133+
},
134+
'http://127.0.0.1:8081'
135+
);
136+
137+
expect(handled).toBe(true);
138+
139+
await vi.waitFor(() => {
140+
expect(sendMessage).toHaveBeenCalledWith({
141+
id: 9,
142+
result: {
143+
resource: {
144+
success: false,
145+
netErrorName: 'net::ERR_FAILED',
146+
netError: -2,
147+
httpStatusCode: 500,
148+
},
149+
},
150+
});
151+
});
152+
});
153+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
const NETWORK_LOAD_RESOURCE_ERROR = {
2+
success: false,
3+
netErrorName: 'net::ERR_FAILED',
4+
netError: -2,
5+
httpStatusCode: 500,
6+
} as const;
7+
8+
type Connection = { debugger: { sendMessage: (message: any) => void } };
9+
10+
async function sendNetworkLoadResourceResponse(
11+
connection: Connection,
12+
id: number,
13+
url: string
14+
) {
15+
// DevTools expects the loaded resource body to be returned as Base64-encoded
16+
// bytes in the CDP response payload.
17+
const response = await fetch(url).catch(() => null);
18+
const resource = !response
19+
? NETWORK_LOAD_RESOURCE_ERROR
20+
: {
21+
success: true,
22+
httpStatusCode: response.status,
23+
headers: Object.fromEntries(response.headers.entries()),
24+
content: Buffer.from(await response.text()).toString('base64'),
25+
base64Encoded: true,
26+
};
27+
28+
connection.debugger.sendMessage({ id, result: { resource } });
29+
}
30+
31+
// RN dev-middleware already handles same-origin requests. We only intercept
32+
// cross-origin requests here so the original debugger behavior stays intact for
33+
// the local server while MF-style remote resources can still be fetched.
34+
//
35+
// The custom inspector hook must synchronously return `true` when it takes
36+
// ownership of a message, so we start the async fetch and report handling now.
37+
export function handleCustomNetworkLoadResource(
38+
connection: Connection,
39+
message: unknown,
40+
serverBaseUrl: string
41+
) {
42+
if (typeof message !== 'object' || message === null) {
43+
return false;
44+
}
45+
46+
const request = message as {
47+
id?: unknown;
48+
method?: unknown;
49+
params?: { url?: unknown };
50+
};
51+
52+
if (
53+
request.method !== 'Network.loadNetworkResource' ||
54+
typeof request.id !== 'number' ||
55+
typeof request.params?.url !== 'string' ||
56+
!URL.canParse(request.params.url) ||
57+
!URL.canParse(serverBaseUrl)
58+
) {
59+
return false;
60+
}
61+
62+
if (new URL(request.params.url).origin === new URL(serverBaseUrl).origin) {
63+
return false;
64+
}
65+
66+
void sendNetworkLoadResourceResponse(
67+
connection,
68+
request.id,
69+
request.params.url
70+
).catch(() =>
71+
console.error(
72+
'[DevServer] Failed to send Network.loadNetworkResource response'
73+
)
74+
);
75+
76+
return true;
77+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"include": ["src/**/*"],
4+
"exclude": ["src/**/__tests__/**/*", "src/**/*.test.ts"],
5+
"compilerOptions": {
6+
"outDir": "dist",
7+
"rootDir": "src",
8+
"paths": {}
9+
}
10+
}

packages/dev-server/tsconfig.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
{
22
"extends": "../../tsconfig.base.json",
33
"compilerOptions": {
4-
"rootDir": "src",
5-
"outDir": "dist"
4+
"types": ["node", "vitest/globals"]
65
},
76
"include": ["src/**/*"]
87
}

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)