Forward getScriptSource with no fetchable URL (#58625)

Summary:

The proxy intercepted every `Debugger.getScriptSource` request and tried to fetch the URL it had recorded for the script, failing closed on an empty or non-HTTP one. Scripts compiled from debug client expressions (e.g. code typed into the DevTools console) report an empty `url`, so requesting their source failed even once the target can serve it.

The proxy now forwards the request to the target when it has no HTTP(S) URL of its own to fetch, and relays the target's response back. Proxy-side serving is unchanged: with a fetchable URL the proxy still answers itself, and it still never reads local files.

Changelog:
[GENERAL] [FIXED] - Fix "Unable to fetch script source" when debugging code evaluated in the DevTools console

Differential Revision: D121001393
This commit is contained in:
Vitali Zaidman
2026-09-23 05:56:22 -07:00
committed by Facebook GitHub Bot
parent 731ae45ae6
commit c03fbbea53
2 changed files with 85 additions and 26 deletions
@@ -21,6 +21,7 @@ import {
withServerForEachTest,
} from './ServerUtils';
import {createHash} from 'node:crypto';
import until from 'wait-for-expect';
// WebSocket is unreliable when using fake timers.
jest.useRealTimers();
@@ -470,8 +471,8 @@ describe.each(['HTTP', 'HTTPS'])(
}
});
test('throws when attempting to pass a filesystem url', async () => {
const {device, debugger_} = await createAndConnectTarget(
test('forwards to the target for a url the proxy cannot fetch', async () => {
const {device, debugger_, sessionId} = await createAndConnectTarget(
serverRef,
autoCleanup.signal,
{
@@ -495,36 +496,81 @@ describe.each(['HTTP', 'HTTPS'])(
hash: createHash('sha256').update('').digest('hex'),
},
});
const response = await debugger_.sendAndGetResponse({
const message = {
id: 1,
method: 'Debugger.getScriptSource',
params: {
scriptId: 'script1',
},
};
await sendFromDebuggerToTarget(debugger_, device, 'page1', message, {
sessionId,
});
expect(response.result).toEqual(
expect.objectContaining({
error: {
message: expect.stringContaining(
'Can\'t parse requested URL "__fixtures__/mock-source-file.txt"',
),
},
}),
);
// The device does not receive the getScriptSource request, since it
// is handled by the proxy.
expect(device.wrappedEventParsed).not.toBeCalledWith({
// The proxy only fetches HTTP(S) urls itself, so rather than failing
// it hands the request to the target.
expect(device.wrappedEventParsed).toBeCalledWith({
pageId: 'page1',
wrappedEvent: expect.objectContaining({
method: 'Debugger.getScriptSource',
}),
sessionId,
wrappedEvent: message,
});
} finally {
device.close();
debugger_.close();
}
});
test('forwards to the target for a script with no url', async () => {
const {device, debugger_, sessionId} = await createAndConnectTarget(
serverRef,
autoCleanup.signal,
{
app: 'bar-app',
id: 'page1',
title: 'bar-title',
vm: 'bar-vm',
},
);
try {
// Targets report an empty url for code they compiled from a debugger
// expression, such as code typed into the DevTools console.
await sendFromTargetToDebugger(device, debugger_, 'page1', {
method: 'Debugger.scriptParsed',
params: {
scriptId: 'script1',
url: '',
},
});
const message = {
id: 1,
method: 'Debugger.getScriptSource',
params: {
scriptId: 'script1',
},
};
await sendFromDebuggerToTarget(debugger_, device, 'page1', message, {
sessionId,
});
expect(device.wrappedEventParsed).toBeCalledWith({
pageId: 'page1',
sessionId,
wrappedEvent: message,
});
// The target answers, and the proxy relays that back to the debugger.
const response = {id: 1, result: {scriptSource: 'debugger;'}};
device.sendWrappedEvent('page1', response);
await until(() =>
expect(debugger_.handle).toBeCalledWith(
expect.objectContaining(response),
),
);
} finally {
device.close();
debugger_.close();
}
});
});
describe("disabled when target has 'nativeSourceCodeFetching' capability flag", () => {
@@ -953,6 +953,12 @@ export default class Device {
case 'Debugger.setBreakpointByUrl':
return this.#processDebuggerSetBreakpointByUrl(req, debuggerInfo);
case 'Debugger.getScriptSource':
if (!this.#hasFetchableScriptSource(req.params.scriptId)) {
// Forward to the target, which is the only one that can still have
// the source - for instance for code the user typed into the
// DevTools console, which the target compiled without a URL.
return req;
}
// Sends response to debugger via side-effect
void this.#processDebuggerGetScriptSource(req, socket, debuggerInfo);
return null;
@@ -1038,6 +1044,15 @@ export default class Device {
return processedReq;
}
/**
* Whether the proxy recorded an HTTP(S) source URL for a script, and can
* therefore serve its source itself by fetching that URL.
*/
#hasFetchableScriptSource(scriptId: string): boolean {
const pathToSource = this.#scriptIdToSourcePathMapping.get(scriptId);
return pathToSource != null && this.#tryParseHTTPURL(pathToSource) != null;
}
async #processDebuggerGetScriptSource(
req: CDPRequest<'Debugger.getScriptSource'>,
socket: WS,
@@ -1077,16 +1092,14 @@ export default class Device {
const pathToSource = this.#scriptIdToSourcePathMapping.get(
req.params.scriptId,
);
const httpURL =
pathToSource != null ? this.#tryParseHTTPURL(pathToSource) : null;
invariant(
httpURL != null,
'processDebuggerGetScriptSource called for non-fetchable script',
);
try {
const httpURL =
pathToSource == null ? null : this.#tryParseHTTPURL(pathToSource);
if (!httpURL) {
throw new Error(
`Can't parse requested URL ${pathToSource === undefined ? 'undefined' : JSON.stringify(pathToSource)}`,
);
}
const text = await this.#fetchText(httpURL);
sendSuccessResponse(text);