From 1626a3ca9186bd913b61f36b34ebd679c642f21c Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Wed, 21 Oct 2026 17:04:45 +0000 Subject: [PATCH] Move /open-stack-frame into dev-middleware --- flow-typed/npm/launch-editor_v2.x.x.js | 26 ++ .../src/dev-server/loadCommunityMiddleware.js | 1 - .../src/dev-server/runDevServer.js | 6 +- packages/dev-middleware/README.md | 19 ++ packages/dev-middleware/package.json | 1 + .../openStackFrameMiddleware-test.js | 231 ++++++++++++++++++ .../dev-middleware/src/createDevMiddleware.js | 29 ++- .../src/inspector-proxy/InspectorProxy.js | 21 +- .../middleware/openStackFrameMiddleware.js | 183 ++++++++++++++ .../src/types/DevToolLauncher.js | 10 + .../src/utils/DefaultToolLauncher.js | 12 + .../utils/__mocks__/DefaultToolLauncher.js | 1 + .../src/utils/isAllowedOrigin.js | 40 +++ 13 files changed, 560 insertions(+), 20 deletions(-) create mode 100644 flow-typed/npm/launch-editor_v2.x.x.js create mode 100644 packages/dev-middleware/src/__tests__/openStackFrameMiddleware-test.js create mode 100644 packages/dev-middleware/src/middleware/openStackFrameMiddleware.js create mode 100644 packages/dev-middleware/src/utils/isAllowedOrigin.js diff --git a/flow-typed/npm/launch-editor_v2.x.x.js b/flow-typed/npm/launch-editor_v2.x.x.js new file mode 100644 index 000000000000..791515e9baee --- /dev/null +++ b/flow-typed/npm/launch-editor_v2.x.x.js @@ -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; +} diff --git a/packages/community-cli-plugin/src/dev-server/loadCommunityMiddleware.js b/packages/community-cli-plugin/src/dev-server/loadCommunityMiddleware.js index bd4f314bc77e..4b88ede3b17a 100644 --- a/packages/community-cli-plugin/src/dev-server/loadCommunityMiddleware.js +++ b/packages/community-cli-plugin/src/dev-server/loadCommunityMiddleware.js @@ -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: diff --git a/packages/community-cli-plugin/src/dev-server/runDevServer.js b/packages/community-cli-plugin/src/dev-server/runDevServer.js index fe120f4e9537..76fa428fdf26 100644 --- a/packages/community-cli-plugin/src/dev-server/runDevServer.js +++ b/packages/community-cli-plugin/src/dev-server/runDevServer.js @@ -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'; @@ -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 = { diff --git a/packages/dev-middleware/README.md b/packages/dev-middleware/README.md index c6a7f64d68b6..6501e2319623 100644 --- a/packages/dev-middleware/README.md +++ b/packages/dev-middleware/README.md @@ -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=' +#### 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. + +
+Example + + curl -X POST 'http://localhost:8081/open-stack-frame' \ + -H 'Content-Type: application/json' \ + -d '{"file": "/path/to/App.js", "lineNumber": 12}' +
+ ### WebSocket endpoints `DevMiddlewareAPI.websocketEndpoints` diff --git a/packages/dev-middleware/package.json b/packages/dev-middleware/package.json index 814097db9e2c..4525f790af30 100644 --- a/packages/dev-middleware/package.json +++ b/packages/dev-middleware/package.json @@ -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", diff --git a/packages/dev-middleware/src/__tests__/openStackFrameMiddleware-test.js b/packages/dev-middleware/src/__tests__/openStackFrameMiddleware-test.js new file mode 100644 index 000000000000..51129505bab1 --- /dev/null +++ b/packages/dev-middleware/src/__tests__/openStackFrameMiddleware-test.js @@ -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>(); +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(); + }); +}); diff --git a/packages/dev-middleware/src/createDevMiddleware.js b/packages/dev-middleware/src/createDevMiddleware.js index f5f86461da8c..9699f0b9d269 100644 --- a/packages/dev-middleware/src/createDevMiddleware.js +++ b/packages/dev-middleware/src/createDevMiddleware.js @@ -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'; @@ -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, }>; type DevMiddlewareAPI = Readonly<{ @@ -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); @@ -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, diff --git a/packages/dev-middleware/src/inspector-proxy/InspectorProxy.js b/packages/dev-middleware/src/inspector-proxy/InspectorProxy.js index b2be826d04db..b6c1a46675aa 100644 --- a/packages/dev-middleware/src/inspector-proxy/InspectorProxy.js +++ b/packages/dev-middleware/src/inspector-proxy/InspectorProxy.js @@ -24,6 +24,9 @@ import type {IncomingMessage, ServerResponse} from 'node:http'; import getBaseUrlFromRequest from '../utils/getBaseUrlFromRequest'; import getDevToolsFrontendUrl from '../utils/getDevToolsFrontendUrl'; +import isAllowedOrigin, { + ALLOWED_ORIGIN_HOSTNAMES, +} from '../utils/isAllowedOrigin'; import Device from './Device'; import EventLoopPerfTracker from './EventLoopPerfTracker'; import InspectorProxyHeartbeat from './InspectorProxyHeartbeat'; @@ -32,13 +35,6 @@ import WS from 'ws'; const debug = require('debug')('Metro:InspectorProxy'); -const WS_DEBUGGER_ALLOWED_ORIGIN_HOSTNAMES = new Set([ - 'localhost', - '127.0.0.1', - '0.0.0.0', - '[::]', -]); - const WS_DEVICE_URL = '/inspector/device'; const WS_DEBUGGER_URL = '/inspector/debug'; const PAGES_LIST_JSON_URL = '/json'; @@ -500,23 +496,16 @@ export default class InspectorProxy implements InspectorProxyQueries { req: http$IncomingMessage<>, }>, ) => { - if (this.#serverBaseUrl.origin === info.origin) { + if (isAllowedOrigin(info.origin, this.#serverBaseUrl)) { return true; } - if (URL.canParse(info.origin)) { - const {hostname} = new URL(info.origin); - if (WS_DEBUGGER_ALLOWED_ORIGIN_HOSTNAMES.has(hostname)) { - return true; - } - } - this.#logger?.error( "Connection from DevTools failed to be established for origin '%s' and path '%s'. Was expecting origin: '%s', or origin hostname to be one of: %s", info.origin, info.req.url, this.#serverBaseUrl.origin, - Array.from(WS_DEBUGGER_ALLOWED_ORIGIN_HOSTNAMES).join(', '), + Array.from(ALLOWED_ORIGIN_HOSTNAMES).join(', '), ); return false; }, diff --git a/packages/dev-middleware/src/middleware/openStackFrameMiddleware.js b/packages/dev-middleware/src/middleware/openStackFrameMiddleware.js new file mode 100644 index 000000000000..c6d386d9c470 --- /dev/null +++ b/packages/dev-middleware/src/middleware/openStackFrameMiddleware.js @@ -0,0 +1,183 @@ +/** + * 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 type {Logger} from '../types/Logger'; +import type {ReadonlyURL} from '../types/ReadonlyURL'; +import type {NextHandleFunction} from 'connect'; +import type {IncomingMessage, ServerResponse} from 'node:http'; + +import isAllowedOrigin from '../utils/isAllowedOrigin'; +import fs from 'node:fs'; +import path from 'node:path'; + +const MAX_BODY_BYTES = 100 * 1024; +const LAUNCH_THROTTLE_MS = 2000; + +type Options = Readonly<{ + serverBaseUrl: ReadonlyURL, + watchFolders: ReadonlyArray, + toolLauncher: DevToolLauncher, + logger?: Logger, +}>; + +type StackFrame = Readonly<{ + file: string, + lineNumber?: number, +}>; + +/** + * Open a stack frame in the user's editor. The file must resolve, through any + * symlinks, to a path inside one of `watchFolders`; a relative path resolves + * against the first. + */ +export default function openStackFrameMiddleware({ + serverBaseUrl, + watchFolders, + toolLauncher, + logger, +}: Options): NextHandleFunction { + const rootsPromise = Promise.all( + watchFolders.map(folder => + fs.promises.realpath(folder).catch(() => path.resolve(folder)), + ), + ); + + let lastLaunchTime = -Infinity; + + return async ( + req: IncomingMessage, + res: ServerResponse, + next: (err?: Error) => void, + ) => { + // Older versions of `@react-native-community/cli-server-api`, mounted + // ahead of this middleware, serve this route and call `next()` after + // responding. + if (req.method !== 'POST' || res.writableEnded) { + next(); + return; + } + + const {origin} = req.headers; + if (typeof origin === 'string' && !isAllowedOrigin(origin, serverBaseUrl)) { + res.writeHead(403); + res.end('Unauthorized origin'); + return; + } + + let body; + try { + body = JSON.parse(await readBody(req)); + } catch { + res.writeHead(400); + res.end('Invalid request body'); + return; + } + + const frame = parseStackFrame(body); + if (frame == null) { + res.writeHead(400); + res.end('Expected a JSON body with a string `file`'); + return; + } + + const roots = await rootsPromise; + const filePath = await resolveInRoots(frame.file, roots); + if (filePath == null) { + res.writeHead(400); + res.end('File is missing or outside the project watch folders'); + return; + } + + if (toolLauncher.launchEditor == null) { + res.writeHead(501); + res.end('Opening files is not supported by the current tool launcher'); + return; + } + + const now = Date.now(); + if (now - lastLaunchTime < LAUNCH_THROTTLE_MS) { + res.writeHead(429); + res.end('Too many requests'); + return; + } + lastLaunchTime = now; + + try { + await toolLauncher.launchEditor(filePath, frame.lineNumber); + } catch (e) { + logger?.error('Error opening editor: %s', e.message); + res.writeHead(500); + res.end(); + return; + } + + res.writeHead(200); + res.end('OK'); + }; +} + +function parseStackFrame(body: unknown): ?StackFrame { + if (body == null || typeof body !== 'object' || Array.isArray(body)) { + return null; + } + const {file, lineNumber} = body; + if (typeof file !== 'string' || file === '') { + return null; + } + return typeof lineNumber === 'number' && + Number.isSafeInteger(lineNumber) && + lineNumber > 0 + ? {file, lineNumber} + : {file}; +} + +async function resolveInRoots( + file: string, + roots: ReadonlyArray, +): Promise { + if (roots.length === 0) { + return null; + } + let realPath; + try { + realPath = await fs.promises.realpath(path.resolve(roots[0], file)); + } catch { + return null; + } + const isInRoot = (root: string) => { + const relativePath = path.relative(root, realPath); + return ( + relativePath !== '' && + relativePath !== '..' && + !relativePath.startsWith('..' + path.sep) && + !path.isAbsolute(relativePath) + ); + }; + return roots.some(isInRoot) ? realPath : null; +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Array = []; + let size = 0; + req.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + reject(new Error('Request body too large')); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} diff --git a/packages/dev-middleware/src/types/DevToolLauncher.js b/packages/dev-middleware/src/types/DevToolLauncher.js index 3c9e63ee9257..7474884d0b9b 100644 --- a/packages/dev-middleware/src/types/DevToolLauncher.js +++ b/packages/dev-middleware/src/types/DevToolLauncher.js @@ -64,4 +64,14 @@ export interface DevToolLauncher { * errors via the returned result object. */ readonly prepareDebuggerShell?: () => Promise; + + /** + * Attempt to open a source file in the user's editor, at the given line + * when provided. + * + * `file` is an absolute path on the host of dev-middleware. The returned + * promise SHOULD resolve once the launch is issued, without waiting for the + * editor to exit. + */ + readonly launchEditor?: (file: string, lineNumber?: number) => Promise; } diff --git a/packages/dev-middleware/src/utils/DefaultToolLauncher.js b/packages/dev-middleware/src/utils/DefaultToolLauncher.js index 8f8d89961d8b..4c05c0c15c67 100644 --- a/packages/dev-middleware/src/utils/DefaultToolLauncher.js +++ b/packages/dev-middleware/src/utils/DefaultToolLauncher.js @@ -14,6 +14,7 @@ const { unstable_prepareDebuggerShell, unstable_spawnDebuggerShellWithArgs, } = require('@react-native/debugger-shell'); +const launchEditor = require('launch-editor'); const open = require('open'); const {apps, openApp} = open; @@ -71,6 +72,17 @@ const DefaultToolLauncher = { return await unstable_prepareDebuggerShell(); }, + + async launchEditor(file: string, lineNumber?: number): Promise { + if (process.env.NODE_ENV === 'test') { + assertMockedInTests(); + } + + launchEditor( + lineNumber != null ? `${file}:${lineNumber}` : file, + process.env.REACT_EDITOR, + ); + }, }; function assertMockedInTests(): void { diff --git a/packages/dev-middleware/src/utils/__mocks__/DefaultToolLauncher.js b/packages/dev-middleware/src/utils/__mocks__/DefaultToolLauncher.js index 9ba83cc66e6e..66b6879a30d4 100644 --- a/packages/dev-middleware/src/utils/__mocks__/DefaultToolLauncher.js +++ b/packages/dev-middleware/src/utils/__mocks__/DefaultToolLauncher.js @@ -18,6 +18,7 @@ const DefaultToolLauncher = { .mockImplementation(() => Promise.resolve()), launchDebuggerShell: jest.fn().mockImplementation(() => Promise.resolve()), prepareDebuggerShell: jest.fn().mockResolvedValue({code: 'not_implemented'}), + launchEditor: jest.fn().mockImplementation(() => Promise.resolve()), }; export default DefaultToolLauncher; diff --git a/packages/dev-middleware/src/utils/isAllowedOrigin.js b/packages/dev-middleware/src/utils/isAllowedOrigin.js new file mode 100644 index 000000000000..d61c20ab2427 --- /dev/null +++ b/packages/dev-middleware/src/utils/isAllowedOrigin.js @@ -0,0 +1,40 @@ +/** + * 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 {ReadonlyURL} from '../types/ReadonlyURL'; + +export const ALLOWED_ORIGIN_HOSTNAMES: ReadonlySet = new Set([ + 'localhost', + '127.0.0.1', + '0.0.0.0', + '[::]', +]); + +/** + * Whether a browser request from `origin` may use dev server endpoints that + * act on the host machine: the dev server's own origin, or a local hostname. + */ +export default function isAllowedOrigin( + origin: string, + serverBaseUrl: ReadonlyURL, +): boolean { + if (serverBaseUrl.origin === origin) { + return true; + } + + if (URL.canParse(origin)) { + const {hostname} = new URL(origin); + if (ALLOWED_ORIGIN_HOSTNAMES.has(hostname)) { + return true; + } + } + + return false; +}