From c03fbbea5393f5783c40a14d673853d7bb856569 Mon Sep 17 00:00:00 2001 From: Vitali Zaidman Date: Wed, 23 Sep 2026 05:56:22 -0700 Subject: [PATCH] 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 --- .../InspectorProxyCdpRewritingHacks-test.js | 82 +++++++++++++++---- .../src/inspector-proxy/Device.js | 29 +++++-- 2 files changed, 85 insertions(+), 26 deletions(-) diff --git a/packages/dev-middleware/src/__tests__/InspectorProxyCdpRewritingHacks-test.js b/packages/dev-middleware/src/__tests__/InspectorProxyCdpRewritingHacks-test.js index 7547b1c17fe6..17839c3c2ab7 100644 --- a/packages/dev-middleware/src/__tests__/InspectorProxyCdpRewritingHacks-test.js +++ b/packages/dev-middleware/src/__tests__/InspectorProxyCdpRewritingHacks-test.js @@ -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", () => { diff --git a/packages/dev-middleware/src/inspector-proxy/Device.js b/packages/dev-middleware/src/inspector-proxy/Device.js index 9c23bb37bcc2..5325a3996254 100644 --- a/packages/dev-middleware/src/inspector-proxy/Device.js +++ b/packages/dev-middleware/src/inspector-proxy/Device.js @@ -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);