Skip to content

Commit

Permalink
Fetch source maps from Metro
Browse files Browse the repository at this point in the history
Summary:
Metro currently offers Source Maps via an HTTP interface. Recent versions of Chrome have stopped fetching Source Maps from HTTP URLs.

Source Maps can be delivered as a data URL, so this change makes the Inspector Proxy:
- extract source map HTTP URLs from the `scriptParsed` CDP message
- perform the HTTP fetch on the source map URL
- encode the resulting source map as a data URL
- replace the HTTP URL with the data URL before forwarding the message to Chrome

This restores source mapping functionality to applications being debugged using Metro and the Inspector Proxy.

This change includes a safeguard to ensure we don't try to embed excessively large source maps (~350 megabytes plain, ~500 megabytes when base64 encoded).

There are longer-term plans to fetch source maps via new mechanisms. In the meantime, this change will allow re-use of the existing HTTP interface for fetching source maps.

Reviewed By: newobj

Differential Revision: D42973408

fbshipit-source-id: c3d4512bb6ec9524b7f27a5b35e990b719648ee3
  • Loading branch information
Matt Blagden authored and facebook-github-bot committed Feb 16, 2023
1 parent 1a9c719 commit 6690b39
Show file tree
Hide file tree
Showing 2 changed files with 75 additions and 48 deletions.
3 changes: 2 additions & 1 deletion packages/metro-inspector-proxy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"connect": "^3.6.5",
"debug": "^2.2.0",
"ws": "^7.5.1",
"yargs": "^17.5.1"
"yargs": "^17.5.1",
"node-fetch": "^2.2.0"
},
"engines": {
"node": ">=14.17.0"
Expand Down
120 changes: 73 additions & 47 deletions packages/metro-inspector-proxy/src/Device.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ import type {
} from './types';

import * as fs from 'fs';
import * as http from 'http';
import * as path from 'path';
import fetch from 'node-fetch';
import WS from 'ws';

const debug = require('debug')('Metro:InspectorProxy');
Expand Down Expand Up @@ -269,11 +269,14 @@ class Device {

if (this._debuggerConnection) {
// Wrapping just to make flow happy :)
this._processMessageFromDevice(parsedPayload, this._debuggerConnection);
this._processMessageFromDevice(
parsedPayload,
this._debuggerConnection,
).then(() => {
const messageToSend = JSON.stringify(parsedPayload);
debuggerSocket.send(messageToSend);
});
}

const messageToSend = JSON.stringify(parsedPayload);
debuggerSocket.send(messageToSend);
}
}

Expand Down Expand Up @@ -350,7 +353,7 @@ class Device {
}

// Allows to make changes in incoming message from device.
_processMessageFromDevice(
async _processMessageFromDevice(
payload: {method: string, params: {sourceMapURL: string, url: string}},
debuggerInfo: DebuggerInfo,
) {
Expand All @@ -368,6 +371,27 @@ class Device {
debuggerInfo.originalSourceURLAddress = address;
}
}

const sourceMapURL = this._tryParseHTTPURL(params.sourceMapURL);
if (sourceMapURL) {
// Some debug clients do not support fetching HTTP URLs. If the
// message headed to the debug client identifies the source map with
// an HTTP URL, fetch the content here and convert the content to a
// Data URL (which is more widely supported) before passing the
// message to the debug client.
try {
const sourceMap = await this._fetchText(sourceMapURL);
payload.params.sourceMapURL =
'data:application/json;charset=utf-8;base64,' +
new Buffer(sourceMap).toString('base64');
} catch (exception) {
payload.params.sourceMapURL =
'data:application/json;charset=utf-8;base64,' +
new Buffer(
`Failed to fetch source map: ${exception.message}`,
).toString('base64');
}
}
}
if ('url' in params) {
for (let i = 0; i < EMULATOR_LOCALHOST_ADDRESSES.length; ++i) {
Expand All @@ -392,18 +416,6 @@ class Device {
this._scriptIdToSourcePathMapping.set(params.scriptId, params.url);
}
}

if (debuggerInfo.pageId == REACT_NATIVE_RELOADABLE_PAGE_ID) {
// Chrome won't use the source map unless it appears to be new.
if (payload.params.sourceMapURL) {
payload.params.sourceMapURL +=
'&cachePrevention=' + this._mapToDevicePageId(debuggerInfo.pageId);
}
if (payload.params.url) {
payload.params.url +=
'&cachePrevention=' + this._mapToDevicePageId(debuggerInfo.pageId);
}
}
}

if (
Expand Down Expand Up @@ -485,7 +497,7 @@ class Device {
}
}

_processDebuggerGetScriptSource(
async _processDebuggerGetScriptSource(
req: GetScriptSourceRequest,
socket: typeof WS,
) {
Expand All @@ -500,35 +512,18 @@ class Device {
req.params.scriptId,
);
if (pathToSource) {
let pathIsURL = false;
try {
pathIsURL = new URL(pathToSource).hostname == 'localhost';
} catch {}

if (pathIsURL) {
http
// $FlowFixMe[missing-local-annot]
.get(pathToSource, httpResponse => {
const {statusCode} = httpResponse;
if (statusCode == 200) {
httpResponse.setEncoding('utf8');
scriptSource = '';
httpResponse.on('data', (body: string) => {
scriptSource += body;
});
httpResponse.on('end', () => {
sendResponse();
});
} else {
scriptSource = `Fetching ${pathToSource} returned status ${statusCode}`;
sendResponse();
httpResponse.resume();
}
})
.on('error', e => {
scriptSource = `Fetching ${pathToSource} failed with error ${e.message}`;
const httpURL = this._tryParseHTTPURL(pathToSource);
if (httpURL) {
this._fetchText(httpURL).then(
text => {
scriptSource = text;
sendResponse();
});
},
err => {
scriptSource = err.message;
sendResponse();
},
);
} else {
try {
scriptSource = fs.readFileSync(
Expand All @@ -553,6 +548,37 @@ class Device {
return pageId;
}
}

_tryParseHTTPURL(url: string): ?URL {
let parsedURL: ?URL;
try {
parsedURL = new URL(url);
} catch {}

const protocol = parsedURL?.protocol;
if (protocol !== 'http:' && protocol !== 'https:') {
parsedURL = undefined;
}

return parsedURL;
}

// Fetch text, raising an exception if the text could not be fetched,
// or is too large.
async _fetchText(url: URL): Promise<string> {
if (url.hostname !== 'localhost') {
throw new Error('remote fetches not permitted');
}

const response = await fetch(url);
const text = await response.text();
// Restrict the length to well below the 500MB limit for nodejs (leaving
// room some some later manipulation, e.g. base64 or wrapping in JSON)
if (text.length > 350000000) {
throw new Error('file too large to fetch via HTTP');
}
return text;
}
}

module.exports = Device;

0 comments on commit 6690b39

Please sign in to comment.