UNPKG

@react-native/dev-middleware

Version:
847 lines (845 loc) • 27.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true, }); exports.default = exports.WS_CLOSE_REASON = void 0; var _CdpDebugLogging = _interopRequireDefault(require("./CdpDebugLogging")); var _DeviceEventReporter = _interopRequireDefault( require("./DeviceEventReporter"), ); var _crypto = _interopRequireDefault(require("crypto")); var _invariant = _interopRequireDefault(require("invariant")); var _ws = _interopRequireDefault(require("ws")); function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; } const debug = require("debug")("Metro:InspectorProxy"); const PAGES_POLLING_INTERVAL = 1000; const WS_CLOSURE_CODE = { NORMAL: 1000, INTERNAL_ERROR: 1011, }; const WS_CLOSE_REASON = (exports.WS_CLOSE_REASON = { PAGE_NOT_FOUND: "[PAGE_NOT_FOUND] Debugger page not found", CONNECTION_LOST: "[CONNECTION_LOST] Connection lost to corresponding device", RECREATING_DEVICE: "[RECREATING_DEVICE] Recreating device connection", NEW_DEBUGGER_OPENED: "[NEW_DEBUGGER_OPENED] New debugger opened for the same app instance", }); const FILE_PREFIX = "file://"; const REACT_NATIVE_RELOADABLE_PAGE_ID = "-1"; class Device { #id; #name; #app; #deviceSocket; #pages = new Map(); #debuggerConnections = new Map(); #lastConnectedLegacyReactNativePage = null; #isLegacyPageReloading = false; #lastGetPagesMessage = ""; #scriptIdToSourcePathMapping = new Map(); #deviceEventReporter; #pagesPollingIntervalId; #createCustomMessageHandler; #deviceRelativeBaseUrl; #serverRelativeBaseUrl; #cdpDebugLogging; #experiments; constructor(deviceOptions) { this.#experiments = deviceOptions.experiments; this.#dangerouslyConstruct(deviceOptions); } #dangerouslyConstruct({ id, name, app, socket, eventReporter, createMessageMiddleware, serverRelativeBaseUrl, deviceRelativeBaseUrl, isProfilingBuild, }) { this.#cdpDebugLogging = new _CdpDebugLogging.default(); this.#id = id; this.#name = name; this.#app = app; this.#deviceSocket = socket; this.#serverRelativeBaseUrl = serverRelativeBaseUrl; this.#deviceRelativeBaseUrl = deviceRelativeBaseUrl; this.#deviceEventReporter = eventReporter ? new _DeviceEventReporter.default(eventReporter, { deviceId: id, deviceName: name, appId: app, }) : null; this.#createCustomMessageHandler = createMessageMiddleware; if (isProfilingBuild) { this.#deviceEventReporter?.logProfilingTargetRegistered(); } this.#deviceSocket.on("message", (message) => { try { const parsedMessage = JSON.parse(message); if (parsedMessage.event === "getPages") { if (message !== this.#lastGetPagesMessage) { debug("Device getPages ping has changed: %s", message); this.#lastGetPagesMessage = message; } } else { this.#cdpDebugLogging.log("DeviceToProxy", message); } this.#handleMessageFromDevice(parsedMessage); } catch (error) { debug("%O\nHandling device message: %s", error, message); try { this.#deviceEventReporter?.logProxyMessageHandlingError( "device", error, message, ); } catch (loggingError) { debug( "Error logging message handling error to reporter: %O", loggingError, ); } } }); this.#pagesPollingIntervalId = setInterval( () => this.#sendMessageToDevice({ event: "getPages", }), PAGES_POLLING_INTERVAL, ); this.#deviceSocket.on("close", () => { if (socket === this.#deviceSocket) { this.#deviceEventReporter?.logDisconnection("device"); this.#terminateDebuggerConnection( WS_CLOSURE_CODE.NORMAL, WS_CLOSE_REASON.CONNECTION_LOST, ); clearInterval(this.#pagesPollingIntervalId); } }); } #terminateDebuggerConnection(code, reason, sessionId) { if (sessionId != null) { const debuggerConnection = this.#debuggerConnections.get(sessionId); if (debuggerConnection) { this.#debuggerConnections.delete(sessionId); this.#sendDisconnectEventToDevice( this.#mapToDevicePageId(debuggerConnection.pageId), sessionId, ); debuggerConnection.socket.close(code, reason); } } else { const connections = Array.from(this.#debuggerConnections.entries()); this.#debuggerConnections.clear(); for (const [sid, debuggerConnection] of connections) { this.#sendDisconnectEventToDevice( this.#mapToDevicePageId(debuggerConnection.pageId), sid, ); debuggerConnection.socket.close(code, reason); } } } dangerouslyRecreateDevice(deviceOptions) { (0, _invariant.default)( deviceOptions.id === this.#id, "dangerouslyRecreateDevice() can only be used for the same device ID", ); const oldDebuggerConnections = new Map(this.#debuggerConnections); if (this.#app !== deviceOptions.app || this.#name !== deviceOptions.name) { this.#deviceSocket.close( WS_CLOSURE_CODE.NORMAL, WS_CLOSE_REASON.RECREATING_DEVICE, ); this.#terminateDebuggerConnection( WS_CLOSURE_CODE.NORMAL, WS_CLOSE_REASON.RECREATING_DEVICE, ); } this.#debuggerConnections.clear(); if (oldDebuggerConnections.size > 0) { this.#deviceSocket.close( WS_CLOSURE_CODE.NORMAL, WS_CLOSE_REASON.RECREATING_DEVICE, ); } this.#dangerouslyConstruct(deviceOptions); for (const oldDebugger of oldDebuggerConnections.values()) { oldDebugger.socket.removeAllListeners(); this.handleDebuggerConnection(oldDebugger.socket, oldDebugger.pageId, { debuggerRelativeBaseUrl: oldDebugger.debuggerRelativeBaseUrl, userAgent: oldDebugger.userAgent, }); } } getName() { return this.#name; } getApp() { return this.#app; } getPagesList() { if (this.#lastConnectedLegacyReactNativePage) { return [...this.#pages.values(), this.#createSyntheticPage()]; } else { return [...this.#pages.values()]; } } handleDebuggerConnection( socket, pageId, { debuggerRelativeBaseUrl, userAgent }, ) { const page = pageId === REACT_NATIVE_RELOADABLE_PAGE_ID ? this.#createSyntheticPage() : this.#pages.get(pageId); if (!page) { debug( `Got new debugger connection via ${debuggerRelativeBaseUrl.href} for ` + `page ${pageId} of ${this.#name}, but no such page exists`, ); socket.close( WS_CLOSURE_CODE.INTERNAL_ERROR, WS_CLOSE_REASON.PAGE_NOT_FOUND, ); return; } this.#deviceEventReporter?.logDisconnection("debugger"); if (!this.#pageHasCapability(page, "supportsMultipleDebuggers")) { for (const [sid, conn] of this.#debuggerConnections) { if (conn.pageId === pageId) { this.#terminateDebuggerConnection( WS_CLOSURE_CODE.NORMAL, WS_CLOSE_REASON.NEW_DEBUGGER_OPENED, sid, ); } } } const sessionId = _crypto.default.randomUUID(); this.#deviceEventReporter?.logConnection("debugger", { pageId, frontendUserAgent: userAgent, }); const debuggerInfo = { socket, prependedFilePrefix: false, pageId, userAgent: userAgent, customHandler: null, debuggerRelativeBaseUrl, sessionId, }; this.#debuggerConnections.set(sessionId, debuggerInfo); debug( `Got new debugger connection via ${debuggerRelativeBaseUrl.href} for ` + `page ${pageId} of ${this.#name} with sessionId ${sessionId}`, ); if (this.#createCustomMessageHandler) { debuggerInfo.customHandler = this.#createCustomMessageHandler({ page, debugger: { userAgent: debuggerInfo.userAgent, sendMessage: (message) => { try { const payload = JSON.stringify(message); this.#cdpDebugLogging.log("ProxyToDebugger", payload); socket.send(payload); } catch {} }, }, device: { appId: this.#app, id: this.#id, name: this.#name, sendMessage: (message) => { try { const payload = JSON.stringify({ event: "wrappedEvent", payload: { pageId: this.#mapToDevicePageId(pageId), wrappedEvent: JSON.stringify(message), sessionId, }, }); this.#cdpDebugLogging.log("DebuggerToProxy", payload); this.#deviceSocket.send(payload); } catch {} }, }, }); if (debuggerInfo.customHandler) { debug("Created new custom message handler for debugger connection"); } else { debug( "Skipping new custom message handler for debugger connection, factory function returned null", ); } } this.#sendConnectEventToDevice(this.#mapToDevicePageId(pageId), sessionId); socket.on("message", (message) => { this.#cdpDebugLogging.log("DebuggerToProxy", message); const debuggerRequest = JSON.parse(message); this.#deviceEventReporter?.logRequest(debuggerRequest, "debugger", { pageId: debuggerInfo.pageId, frontendUserAgent: userAgent, }); let processedReq = debuggerRequest; if ( debuggerInfo.customHandler?.handleDebuggerMessage(debuggerRequest) === true ) { return; } if (!this.#pageHasCapability(page, "nativeSourceCodeFetching")) { processedReq = this.#interceptClientMessageForSourceFetching( debuggerRequest, debuggerInfo, socket, ); } if (processedReq) { this.#sendMessageToDevice({ event: "wrappedEvent", payload: { pageId: this.#mapToDevicePageId(pageId), wrappedEvent: JSON.stringify(processedReq), sessionId, }, }); } }); socket.on("close", () => { debug( `Debugger for page ${pageId} and ${this.#name} disconnected (sessionId: ${sessionId}).`, ); this.#deviceEventReporter?.logDisconnection("debugger"); this.#terminateDebuggerConnection(undefined, undefined, sessionId); }); const cdpDebugLogging = this.#cdpDebugLogging; const sendFunc = socket.send; socket.send = function (message) { cdpDebugLogging.log("ProxyToDebugger", message); return sendFunc.call(socket, message); }; } #sendConnectEventToDevice(devicePageId, sessionId) { this.#sendMessageToDevice({ event: "connect", payload: { pageId: devicePageId, sessionId, }, }); } #sendDisconnectEventToDevice(devicePageId, sessionId) { this.#sendMessageToDevice({ event: "disconnect", payload: { pageId: devicePageId, sessionId, }, }); } #pageHasCapability(page, flag) { return page.capabilities[flag] === true; } #createSyntheticPage() { return { id: REACT_NATIVE_RELOADABLE_PAGE_ID, title: "React Native Experimental (Improved Chrome Reloads)", vm: "don't use", app: this.#app, capabilities: {}, }; } #handleMessageFromDevice(message) { if (message.event === "getPages") { const shouldDisableMultipleDebuggers = !this.#experiments.enableStandaloneFuseboxShell; this.#pages = new Map( message.payload.map(({ capabilities: rawCapabilities, ...page }) => { const capabilities = shouldDisableMultipleDebuggers ? { ...(rawCapabilities ?? {}), supportsMultipleDebuggers: false, } : (rawCapabilities ?? {}); return [ page.id, { ...page, capabilities, }, ]; }), ); if (message.payload.length !== this.#pages.size) { const duplicateIds = new Set(); const idsSeen = new Set(); for (const page of message.payload) { if (!idsSeen.has(page.id)) { idsSeen.add(page.id); } else { duplicateIds.add(page.id); } } debug( `Received duplicate page IDs from device: ${[...duplicateIds].join(", ")}`, ); } for (const page of this.#pages.values()) { if (this.#pageHasCapability(page, "nativePageReloads")) { continue; } if (page.title.includes("React")) { if (page.id !== this.#lastConnectedLegacyReactNativePage?.id) { this.#newLegacyReactNativePage(page); break; } } } } else if (message.event === "disconnect") { const pageId = message.payload.pageId; const sessionId = message.payload.sessionId; const page = this.#pages.get(pageId); if (page != null && this.#pageHasCapability(page, "nativePageReloads")) { return; } if (sessionId != null) { const debuggerConnection = this.#debuggerConnections.get(sessionId); if ( debuggerConnection && debuggerConnection.socket.readyState === _ws.default.OPEN ) { if (debuggerConnection.pageId !== REACT_NATIVE_RELOADABLE_PAGE_ID) { debug( `Legacy page ${pageId} is reloading (sessionId: ${sessionId}).`, ); debuggerConnection.socket.send( JSON.stringify({ method: "reload", }), ); } } } else { for (const debuggerConnection of this.#debuggerConnections.values()) { if ( debuggerConnection.pageId !== REACT_NATIVE_RELOADABLE_PAGE_ID && debuggerConnection.socket.readyState === _ws.default.OPEN ) { debug(`Legacy page ${pageId} is reloading.`); debuggerConnection.socket.send( JSON.stringify({ method: "reload", }), ); } } } } else if (message.event === "wrappedEvent") { const sessionId = message.payload.sessionId; let debuggerConnection = null; if (sessionId != null) { debuggerConnection = this.#debuggerConnections.get(sessionId); } else { if (this.#debuggerConnections.size > 1) { debug( "WARNING: Device sent message without sessionId but multiple debuggers are connected. " + "This indicates a device/proxy version mismatch.", ); } debuggerConnection = this.#debuggerConnections.values().next().value ?? null; } if (debuggerConnection == null) { return; } const debuggerSocket = debuggerConnection.socket; if ( debuggerSocket == null || debuggerSocket.readyState !== _ws.default.OPEN ) { return; } const parsedPayload = JSON.parse(message.payload.wrappedEvent); const pageId = debuggerConnection.pageId; if ("id" in parsedPayload) { this.#deviceEventReporter?.logResponse(parsedPayload, "device", { pageId, frontendUserAgent: debuggerConnection.userAgent ?? null, }); } if ( debuggerConnection.customHandler?.handleDeviceMessage(parsedPayload) === true ) { return; } this.#processMessageFromDeviceLegacy( parsedPayload, debuggerConnection, pageId, ); const messageToSend = JSON.stringify(parsedPayload); debuggerSocket.send(messageToSend); } } #sendMessageToDevice(message) { try { const messageToSend = JSON.stringify(message); if (message.event !== "getPages") { this.#cdpDebugLogging.log("ProxyToDevice", messageToSend); } this.#deviceSocket.send(messageToSend); } catch (error) {} } #newLegacyReactNativePage(page) { debug(`React Native page updated to ${page.id}`); let reloadablePageDebugger = null; for (const debuggerConnection of this.#debuggerConnections.values()) { if (debuggerConnection.pageId === REACT_NATIVE_RELOADABLE_PAGE_ID) { reloadablePageDebugger = debuggerConnection; break; } } if (reloadablePageDebugger == null) { this.#lastConnectedLegacyReactNativePage = page; return; } const oldPageId = this.#lastConnectedLegacyReactNativePage?.id; this.#lastConnectedLegacyReactNativePage = page; this.#isLegacyPageReloading = true; if (oldPageId != null) { this.#sendDisconnectEventToDevice( oldPageId, reloadablePageDebugger.sessionId, ); } this.#sendConnectEventToDevice(page.id, reloadablePageDebugger.sessionId); const toSend = [ { method: "Runtime.enable", id: 1e9, }, { method: "Debugger.enable", id: 1e9, }, ]; for (const message of toSend) { const pageId = reloadablePageDebugger.pageId; this.#deviceEventReporter?.logRequest(message, "proxy", { pageId, frontendUserAgent: reloadablePageDebugger.userAgent ?? null, }); this.#sendMessageToDevice({ event: "wrappedEvent", payload: { pageId: this.#mapToDevicePageId(page.id), wrappedEvent: JSON.stringify(message), sessionId: reloadablePageDebugger.sessionId, }, }); } } #debuggerRelativeToDeviceRelativeUrl( debuggerRelativeUrl, { debuggerRelativeBaseUrl }, ) { const deviceRelativeUrl = new URL(debuggerRelativeUrl.href); if (debuggerRelativeUrl.origin === debuggerRelativeBaseUrl.origin) { deviceRelativeUrl.hostname = this.#deviceRelativeBaseUrl.hostname; deviceRelativeUrl.port = this.#deviceRelativeBaseUrl.port; deviceRelativeUrl.protocol = this.#deviceRelativeBaseUrl.protocol; } return deviceRelativeUrl; } #deviceRelativeUrlToDebuggerRelativeUrl( deviceRelativeUrl, { debuggerRelativeBaseUrl }, ) { const debuggerRelativeUrl = new URL(deviceRelativeUrl.href); if (deviceRelativeUrl.origin === this.#deviceRelativeBaseUrl.origin) { debuggerRelativeUrl.hostname = debuggerRelativeBaseUrl.hostname; debuggerRelativeUrl.port = debuggerRelativeBaseUrl.port; debuggerRelativeUrl.protocol = debuggerRelativeUrl.protocol; } return debuggerRelativeUrl; } #deviceRelativeUrlToServerRelativeUrl(deviceRelativeUrl) { const debuggerRelativeUrl = new URL(deviceRelativeUrl.href); if (deviceRelativeUrl.origin === this.#deviceRelativeBaseUrl.origin) { debuggerRelativeUrl.hostname = this.#serverRelativeBaseUrl.hostname; debuggerRelativeUrl.port = this.#serverRelativeBaseUrl.port; debuggerRelativeUrl.protocol = this.#serverRelativeBaseUrl.protocol; } return debuggerRelativeUrl; } #processMessageFromDeviceLegacy(payload, debuggerInfo, pageId) { const page = pageId != null ? this.#pages.get(pageId) : null; if ( (!page || !this.#pageHasCapability(page, "nativeSourceCodeFetching")) && payload.method === "Debugger.scriptParsed" && payload.params != null ) { const params = payload.params; if ("sourceMapURL" in params) { const sourceMapURL = this.#tryParseHTTPURL(params.sourceMapURL); if (sourceMapURL) { payload.params.sourceMapURL = this.#deviceRelativeUrlToDebuggerRelativeUrl( sourceMapURL, debuggerInfo, ).href; } } if ("url" in params) { let serverRelativeUrl = params.url; const parsedUrl = this.#tryParseHTTPURL(params.url); if (parsedUrl) { payload.params.url = this.#deviceRelativeUrlToDebuggerRelativeUrl( parsedUrl, debuggerInfo, ).href; serverRelativeUrl = this.#deviceRelativeUrlToServerRelativeUrl(parsedUrl).href; } if (payload.params.url.match(/^[0-9a-z]+$/)) { payload.params.url = FILE_PREFIX + payload.params.url; debuggerInfo.prependedFilePrefix = true; } if ("scriptId" in params && params.scriptId != null) { this.#scriptIdToSourcePathMapping.set( params.scriptId, serverRelativeUrl, ); } } } if ( payload.method === "Runtime.executionContextCreated" && this.#isLegacyPageReloading ) { debuggerInfo.socket.send( JSON.stringify({ method: "Runtime.executionContextsCleared", }), ); const resumeMessage = { method: "Debugger.resume", id: 0, }; this.#deviceEventReporter?.logRequest(resumeMessage, "proxy", { pageId: debuggerInfo.pageId, frontendUserAgent: debuggerInfo.userAgent ?? null, }); this.#sendMessageToDevice({ event: "wrappedEvent", payload: { pageId: this.#mapToDevicePageId(debuggerInfo.pageId), wrappedEvent: JSON.stringify(resumeMessage), sessionId: debuggerInfo.sessionId, }, }); this.#isLegacyPageReloading = false; } if (payload.method === "Runtime.consoleAPICalled") { const callFrames = payload.params?.stackTrace?.callFrames ?? []; for (const callFrame of callFrames) { if (callFrame.url) { const parsedUrl = this.#tryParseHTTPURL(callFrame.url); if (parsedUrl) { callFrame.url = this.#deviceRelativeUrlToDebuggerRelativeUrl( parsedUrl, debuggerInfo, ).href; } } } } } #interceptClientMessageForSourceFetching(req, debuggerInfo, socket) { switch (req.method) { case "Debugger.setBreakpointByUrl": return this.#processDebuggerSetBreakpointByUrl(req, debuggerInfo); case "Debugger.getScriptSource": void this.#processDebuggerGetScriptSource(req, socket, debuggerInfo); return null; case "Network.loadNetworkResource": const response = { id: req.id, result: { error: { code: -32601, message: "[inspector-proxy]: Page lacks nativeSourceCodeFetching capability.", }, }, }; socket.send(JSON.stringify(response)); const pageId = debuggerInfo.pageId; this.#deviceEventReporter?.logResponse(response, "proxy", { pageId, frontendUserAgent: debuggerInfo.userAgent ?? null, }); return null; default: return req; } } #processDebuggerSetBreakpointByUrl(req, debuggerInfo) { const { debuggerRelativeBaseUrl, prependedFilePrefix } = debuggerInfo; const processedReq = { ...req, params: { ...req.params, }, }; if (processedReq.params.url != null) { const originalUrlParam = processedReq.params.url; const httpUrl = this.#tryParseHTTPURL(originalUrlParam); if (httpUrl) { processedReq.params.url = this.#debuggerRelativeToDeviceRelativeUrl( httpUrl, debuggerInfo, ).href; } else if ( originalUrlParam.startsWith(FILE_PREFIX) && prependedFilePrefix ) { processedReq.params.url = originalUrlParam.slice(FILE_PREFIX.length); } } if ( new Set(["10.0.2.2", "10.0.3.2"]).has( this.#deviceRelativeBaseUrl.hostname, ) && debuggerRelativeBaseUrl.hostname === "localhost" && processedReq.params.urlRegex != null ) { processedReq.params.urlRegex = processedReq.params.urlRegex.replaceAll( "localhost", this.#deviceRelativeBaseUrl.hostname.replaceAll(".", "\\."), ); } return processedReq; } async #processDebuggerGetScriptSource(req, socket, debuggerInfo) { const sendSuccessResponse = (scriptSource) => { const response = { id: req.id, result: { scriptSource, }, }; socket.send(JSON.stringify(response)); const pageId = debuggerInfo.pageId; this.#deviceEventReporter?.logResponse(response, "proxy", { pageId, frontendUserAgent: debuggerInfo.userAgent ?? null, }); }; const sendErrorResponse = (error) => { const response = { id: req.id, result: { error: { message: error, }, }, }; socket.send(JSON.stringify(response)); this.#sendErrorToDebugger(error, debuggerInfo); const pageId = debuggerInfo.pageId; this.#deviceEventReporter?.logResponse(response, "proxy", { pageId, frontendUserAgent: debuggerInfo.userAgent ?? null, }); }; const pathToSource = this.#scriptIdToSourcePathMapping.get( req.params.scriptId, ); 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); } catch (err) { sendErrorResponse( `Failed to fetch source url ${pathToSource === undefined ? "undefined" : JSON.stringify(pathToSource)} for scriptId ${req.params.scriptId}: ${err.message}`, ); } } #mapToDevicePageId(pageId) { if ( pageId === REACT_NATIVE_RELOADABLE_PAGE_ID && this.#lastConnectedLegacyReactNativePage != null ) { return this.#lastConnectedLegacyReactNativePage.id; } else { return pageId; } } #tryParseHTTPURL(url) { let parsedURL; try { parsedURL = new URL(url); } catch {} const protocol = parsedURL?.protocol; if (protocol !== "http:" && protocol !== "https:") { parsedURL = undefined; } return parsedURL; } async #fetchText(url) { const response = await fetch(url); if (!response.ok) { throw new Error("HTTP " + response.status + " " + response.statusText); } const text = await response.text(); if (text.length > 350000000) { throw new Error("file too large to fetch via HTTP"); } return text; } #sendErrorToDebugger(message, debuggerInfo) { const debuggerSocket = debuggerInfo?.socket; if (debuggerSocket && debuggerSocket.readyState === _ws.default.OPEN) { debuggerSocket.send( JSON.stringify({ method: "Runtime.consoleAPICalled", params: { args: [ { type: "string", value: message, }, ], executionContextId: 0, type: "error", }, }), ); } } dangerouslyGetSocket() { return this.#deviceSocket; } } exports.default = Device;