Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions flow-typed/npm/launch-editor_v2.x.x.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict
* @format
*/

declare module 'launch-editor' {
declare type ErrorCallback = (
fileName: string,
errorMessage: string | null,
) => void;

/**
* Launch an editor to open a file, with an optional line and column
* (`/path/to/file.js:10:2`). `specifiedEditor` is parsed with `shell-quote`.
*/
declare module.exports: (
file: string,
specifiedEditor?: ?string | ErrorCallback,
onErrorCallback?: ErrorCallback,
) => void;
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ const unusedMiddlewareStub: Server = {};
// FIXME: Several features will break without community middleware
// (@react-native-community/cli-server-api) and should be migrated into core.
// e.g. used by packages/react-native/Libraries/Core/Devtools/:
// - /open-stack-frame
// - /open-url
// - /symbolicate
// e.g. used by ./isDevServerRunning.js:
Expand Down
6 changes: 5 additions & 1 deletion packages/community-cli-plugin/src/dev-server/runDevServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ async function runDevServer(
const hostname = args.host?.length ? args.host : 'localhost';
const {
projectRoot,
server: {port},
server: {port, unstable_serverRoot},
watchFolders,
} = metroConfig;
const protocol = args.https === true ? 'https' : 'http';
Expand Down Expand Up @@ -121,6 +121,10 @@ async function runDevServer(
const {middleware, websocketEndpoints} = createDevMiddleware({
serverBaseUrl: devServerUrl,
logger: createDevMiddlewareLogger(terminalReporter),
unstable_watchFolders: [
unstable_serverRoot ?? projectRoot,
...watchFolders,
],
});

const reporter: Reporter = {
Expand Down
19 changes: 19 additions & 0 deletions packages/dev-middleware/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,25 @@ Open the JavaScript debugger for a given CDP target. Must be provided with one o
curl -X POST 'http://localhost:8081/open-debugger?target=<targetId>'
</details>

#### POST `/open-stack-frame`

Open a source file in the user's editor. Mounted when `unstable_watchFolders` is provided. Takes a JSON body with the following keys:

- `file` — The path to the file, absolute or relative to the first watch folder. It must resolve, through any symlinks, to a file inside one of `unstable_watchFolders`; anything else gets a 400.
- `lineNumber` (optional) — The line to open the file at, a positive integer. Other values are ignored.

Launches are throttled to one every 2 seconds; a request inside that window gets a 429.

The editor is launched by `DevToolLauncher.launchEditor`. The default implementation uses [`launch-editor`](https://www.npmjs.com/package/launch-editor), which respects the `REACT_EDITOR` environment variable.

<details>
<summary>Example</summary>

curl -X POST 'http://localhost:8081/open-stack-frame' \
-H 'Content-Type: application/json' \
-d '{"file": "/path/to/App.js", "lineNumber": 12}'
</details>

### WebSocket endpoints

<small>`DevMiddlewareAPI.websocketEndpoints`</small>
Expand Down
1 change: 1 addition & 0 deletions packages/dev-middleware/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"connect": "^3.6.5",
"debug": "^4.4.0",
"invariant": "^2.2.4",
"launch-editor": "^2.9.1",
"nullthrows": "^1.1.1",
"open": "^8.4.2",
"serve-static": "^1.16.2",
Expand Down
231 changes: 231 additions & 0 deletions packages/dev-middleware/src/__tests__/openStackFrameMiddleware-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/

import type {DevToolLauncher} from '../types/DevToolLauncher';

import openStackFrameMiddleware from '../middleware/openStackFrameMiddleware';
import {requestLocal} from './FetchUtils';
import {baseUrlForServer, withServerForEachTest} from './ServerUtils';
import connect from 'connect';
import fs from 'node:fs';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';

jest.useRealTimers();
jest.setTimeout(10000);

const projectRoot = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'openStackFrameMiddleware-test-')),
);
const appFile = path.join(projectRoot, 'App.js');
fs.writeFileSync(appFile, '');
const outsideRoot = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'openStackFrameMiddleware-outside-')),
);
const outsideFile = path.join(outsideRoot, 'secret.js');
fs.writeFileSync(outsideFile, '');
const symlinkOut = path.join(projectRoot, 'link.js');
fs.symlinkSync(outsideFile, symlinkOut);

afterAll(() => {
fs.rmSync(projectRoot, {recursive: true, force: true});
fs.rmSync(outsideRoot, {recursive: true, force: true});
});

const launchEditor = jest.fn<[string, ?number], Promise<void>>();
const toolLauncher: DevToolLauncher = {
launchDebuggerAppWindow: async () => {},
launchEditor,
};

beforeEach(() => {
launchEditor.mockReset();
launchEditor.mockResolvedValue();
});

function postFrame(
serverBaseUrl: string,
body: string,
headers?: {[string]: string},
) {
return requestLocal(serverBaseUrl + '/open-stack-frame', {
method: 'POST',
headers: {'Content-Type': 'application/json', ...headers},
body,
});
}

describe('/open-stack-frame', () => {
const serverRef = withServerForEachTest({
logger: undefined,
unstable_toolLauncher: toolLauncher,
unstable_watchFolders: [projectRoot],
});

test('opens the frame in the editor', async () => {
const resp = await postFrame(
serverRef.serverBaseUrl,
JSON.stringify({file: appFile, lineNumber: 12}),
);

expect(resp.statusCode).toBe(200);
expect(resp.bodyBuffer.toString()).toBe('OK');
expect(launchEditor).toHaveBeenCalledWith(appFile, 12);
});

test('throttles a second launch inside the window', async () => {
const body = JSON.stringify({file: appFile, lineNumber: 12});

const first = await postFrame(serverRef.serverBaseUrl, body);
const second = await postFrame(serverRef.serverBaseUrl, body);

expect(first.statusCode).toBe(200);
expect(second.statusCode).toBe(429);
expect(launchEditor).toHaveBeenCalledTimes(1);
});

test('accepts a request from the dev server origin', async () => {
const resp = await postFrame(
serverRef.serverBaseUrl,
JSON.stringify({file: appFile, lineNumber: 12}),
{Origin: serverRef.serverBaseUrl},
);

expect(resp.statusCode).toBe(200);
expect(launchEditor).toHaveBeenCalledTimes(1);
});

test('refuses a cross-origin request', async () => {
const resp = await postFrame(
serverRef.serverBaseUrl,
JSON.stringify({file: appFile, lineNumber: 12}),
{Origin: 'https://example.com'},
);

expect(resp.statusCode).toBe(403);
expect(launchEditor).not.toHaveBeenCalled();
});

test('rejects a body that is not JSON', async () => {
const resp = await postFrame(serverRef.serverBaseUrl, 'not json');

expect(resp.statusCode).toBe(400);
expect(launchEditor).not.toHaveBeenCalled();
});

test('opens a path relative to the first watch folder', async () => {
const resp = await postFrame(
serverRef.serverBaseUrl,
JSON.stringify({file: 'App.js', lineNumber: 3}),
);

expect(resp.statusCode).toBe(200);
expect(launchEditor).toHaveBeenCalledWith(appFile, 3);
});

test('drops a line number that is not a positive integer', async () => {
const resp = await postFrame(
serverRef.serverBaseUrl,
JSON.stringify({file: appFile, lineNumber: '12'}),
);

expect(resp.statusCode).toBe(200);
expect(launchEditor).toHaveBeenCalledWith(appFile, undefined);
});

test.each([
['a file outside every watch folder', outsideFile],
[
'a path that climbs out with ..',
path.join(projectRoot, '..', path.basename(outsideRoot), 'secret.js'),
],
['a symlink out of a watch folder', symlinkOut],
['a nonexistent file', path.join(projectRoot, 'Missing.js')],
['a watch folder itself', projectRoot],
])('rejects %s', async (_, file) => {
const resp = await postFrame(
serverRef.serverBaseUrl,
JSON.stringify({file, lineNumber: 1}),
);

expect(resp.statusCode).toBe(400);
expect(launchEditor).not.toHaveBeenCalled();
});

test.each([
['a missing file', {lineNumber: 1}],
['a non-string file', {file: 42}],
['an array', [appFile]],
])('rejects a body with %s', async (_, body) => {
const resp = await postFrame(serverRef.serverBaseUrl, JSON.stringify(body));

expect(resp.statusCode).toBe(400);
expect(launchEditor).not.toHaveBeenCalled();
});

test('rejects a null body', async () => {
const resp = await postFrame(serverRef.serverBaseUrl, 'null');

expect(resp.statusCode).toBe(400);
expect(launchEditor).not.toHaveBeenCalled();
});

test('responds with 500 when the launcher rejects', async () => {
launchEditor.mockRejectedValue(new Error('No editor found'));

const resp = await postFrame(
serverRef.serverBaseUrl,
JSON.stringify({file: appFile, lineNumber: 12}),
);

expect(resp.statusCode).toBe(500);
});
});

describe('/open-stack-frame behind a middleware that serves it', () => {
let server: http$Server;
let serverBaseUrl: string;

beforeEach(done => {
const app = connect()
.use('/open-stack-frame', (req, res, next) => {
res.end('handled upstream');
next();
})
.use(
'/open-stack-frame',
openStackFrameMiddleware({
serverBaseUrl: new URL('http://localhost'),
watchFolders: [projectRoot],
toolLauncher,
}),
);
server = http.createServer(app);
server.listen(() => {
serverBaseUrl = baseUrlForServer(server, 'http');
done();
});
});

afterEach(done => {
server.close(() => done());
});

test('leaves the response to that middleware', async () => {
const resp = await postFrame(
serverBaseUrl,
JSON.stringify({file: appFile, lineNumber: 12}),
);

expect(resp.bodyBuffer.toString()).toBe('handled upstream');
expect(launchEditor).not.toHaveBeenCalled();
});
});
29 changes: 27 additions & 2 deletions packages/dev-middleware/src/createDevMiddleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {NextHandleFunction} from 'connect';

import InspectorProxy from './inspector-proxy/InspectorProxy';
import openDebuggerMiddleware from './middleware/openDebuggerMiddleware';
import openStackFrameMiddleware from './middleware/openStackFrameMiddleware';
import DefaultToolLauncher from './utils/DefaultToolLauncher';
import reactNativeDebuggerFrontendPath from '@react-native/debugger-frontend';
import connect from 'connect';
Expand Down Expand Up @@ -77,6 +78,16 @@ type Options = Readonly<{
* This is an unstable API with no semver guarantees.
*/
unstable_trackInspectorProxyEventLoopPerf?: boolean,

/**
* The directories served by the dev server, typically the Metro server root
* followed by `watchFolders`. When provided, the `/open-stack-frame`
* endpoint is mounted to open source files inside these directories in the
* user's editor.
*
* This is an unstable API with no semver guarantees.
*/
unstable_watchFolders?: ReadonlyArray<string>,
}>;

type DevMiddlewareAPI = Readonly<{
Expand All @@ -92,6 +103,7 @@ export default function createDevMiddleware({
unstable_toolLauncher = DefaultToolLauncher,
unstable_customInspectorMessageHandler,
unstable_trackInspectorProxyEventLoopPerf = false,
unstable_watchFolders,
}: Options): DevMiddlewareAPI {
// $FlowFixMe[incompatible-type]
const normalizedServerBaseUrl: ReadonlyURL = new URL(serverBaseUrl);
Expand Down Expand Up @@ -136,8 +148,21 @@ export default function createDevMiddleware({
serveStaticMiddleware(path.join(reactNativeDebuggerFrontendPath), {
fallthrough: false,
}),
)
.use((...args) => inspectorProxy.processRequest(...args));
);

if (unstable_watchFolders != null) {
middleware.use(
'/open-stack-frame',
openStackFrameMiddleware({
serverBaseUrl: normalizedServerBaseUrl,
watchFolders: unstable_watchFolders,
toolLauncher: unstable_toolLauncher,
logger,
}),
);
}

middleware.use((...args) => inspectorProxy.processRequest(...args));

return {
middleware,
Expand Down
Loading
Loading