mirror of
https://github.com/react/react-native.git
synced 2026-09-28 05:13:06 +08:00
Summary: Pull Request resolved: https://github.com/react/react-native/pull/57611 Directly follows D112733139 (Metro). **Motivation** - New Node builtins are *only* available under the prefix (e.g. `node:sqlite`), as this allows Node to introduce them without ecosystem-breaking changes, so this is the direction of travel and the only choice that'll allow consistency. - Encouraging them and grouping them separately makes it easier to reason about a module's 3rd party dependencies. Changelog: [Internal] Reviewed By: robhogan Differential Revision: D112803684 fbshipit-source-id: 40668d746a7151b3aa4800ff8af997902a18d198
551 lines
15 KiB
JavaScript
551 lines
15 KiB
JavaScript
/**
|
|
* 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 {AsyncCommandResult} from '../runner/utils';
|
|
|
|
import {isCI, isOSS} from '../runner/EnvironmentOptions';
|
|
import {run as runTester} from '../runner/executables/tester';
|
|
import getHostPlatform from '../runner/getHostPlatform';
|
|
import {getTestBuildOutputPath} from '../runner/paths';
|
|
import {HermesVariant, runCommand} from '../runner/utils';
|
|
import {
|
|
PROJECT_ROOT,
|
|
buildWarmupBundle,
|
|
fetchDeltaBundle,
|
|
} from './replBundling';
|
|
import {startMetroServer, stopMetroServer} from './replMetro';
|
|
import {isRecoverableError, transform} from './replTransform';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import readline from 'node:readline';
|
|
import repl from 'node:repl';
|
|
import tty from 'node:tty';
|
|
|
|
type ReplMessage =
|
|
| {type: 'console-log', level: 'info' | 'warn' | 'error', message: string}
|
|
| {type: 'repl-result', text: string}
|
|
| {type: 'repl-error', message: string, stack: string}
|
|
| {type: 'repl-completions', names: Array<string>}
|
|
| {type: 'repl-eval-complete', id: number};
|
|
|
|
type PendingEval = {
|
|
result: ?string,
|
|
error: ?{message: string, stack: string},
|
|
completions: ?Array<string>,
|
|
resolve: () => void,
|
|
};
|
|
|
|
type FrameResult = {
|
|
result: ?string,
|
|
error: ?{message: string, stack: string},
|
|
completions: ?Array<string>,
|
|
};
|
|
|
|
type CliOptions =
|
|
| {mode: 'repl'}
|
|
| {mode: 'eval', code: string}
|
|
| {mode: 'file', filePath: string};
|
|
|
|
// Mirrors Node: no args -> REPL, `-e <code>` -> evaluate and exit, a filename
|
|
// -> run that script and exit.
|
|
function parseArgs(argv: ReadonlyArray<string>): CliOptions {
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
if (arg === '-e' || arg === '--eval') {
|
|
const code = argv[i + 1];
|
|
if (code == null) {
|
|
throw new Error(`${arg} requires an argument`);
|
|
}
|
|
return {mode: 'eval', code};
|
|
}
|
|
if (arg.startsWith('-')) {
|
|
throw new Error(`Unknown option: ${arg}`);
|
|
}
|
|
return {mode: 'file', filePath: path.resolve(process.cwd(), arg)};
|
|
}
|
|
return {mode: 'repl'};
|
|
}
|
|
|
|
const SUPPORTS_COLOR: boolean = tty.isatty(1);
|
|
const SUPPORTS_COLOR_STDERR: boolean = tty.isatty(2);
|
|
|
|
function paint(code: string, text: string, enabled: boolean): string {
|
|
return enabled ? `\x1b[${code}m${text}\x1b[0m` : text;
|
|
}
|
|
|
|
function buildBanner(): string {
|
|
return (
|
|
paint(
|
|
'36',
|
|
'👻 Fantom REPL — evaluating against Hermes inside the Fantom runtime.',
|
|
SUPPORTS_COLOR,
|
|
) +
|
|
'\n' +
|
|
paint(
|
|
'33',
|
|
' ⚠️ Experimental: behavior and APIs may change.',
|
|
SUPPORTS_COLOR,
|
|
) +
|
|
'\n' +
|
|
paint(
|
|
'90',
|
|
' `React`, `ReactNative` and `Fantom` are available globally. Ctrl-D to exit.',
|
|
SUPPORTS_COLOR,
|
|
) +
|
|
'\n'
|
|
);
|
|
}
|
|
|
|
function spawnTester(args: ReadonlyArray<string>): AsyncCommandResult {
|
|
if (isOSS) {
|
|
const ossPath = path.resolve(
|
|
__dirname,
|
|
'..',
|
|
'build',
|
|
'tester',
|
|
'fantom_tester',
|
|
);
|
|
return runCommand(ossPath, args, {});
|
|
}
|
|
|
|
return runTester(
|
|
args,
|
|
{
|
|
hermesVariant: HermesVariant.Hermes,
|
|
enableOptimized: false,
|
|
enableCoverage: false,
|
|
},
|
|
{},
|
|
);
|
|
}
|
|
|
|
export default async function runRepl(): Promise<void> {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
|
|
await startMetroServer();
|
|
|
|
const outputDir = getTestBuildOutputPath();
|
|
fs.mkdirSync(outputDir, {recursive: true});
|
|
|
|
const warmupBundlePath = path.join(outputDir, 'repl-warmup.bundle.js');
|
|
await buildWarmupBundle(warmupBundlePath);
|
|
|
|
const result = spawnTester([
|
|
'--interactive',
|
|
'--bundlePath',
|
|
warmupBundlePath,
|
|
'--featureFlags',
|
|
'{}',
|
|
'--minLogLevel',
|
|
'error',
|
|
]);
|
|
const child = result.childProcess;
|
|
|
|
const stdout = child.stdout;
|
|
const stdin = child.stdin;
|
|
if (stdout == null || stdin == null) {
|
|
throw new Error('Fantom tester process did not expose stdio pipes');
|
|
}
|
|
|
|
if (child.stderr != null) {
|
|
child.stderr.pipe(process.stderr);
|
|
}
|
|
|
|
let pending: ?PendingEval = null;
|
|
|
|
const lineReader = readline.createInterface({input: stdout});
|
|
lineReader.on('line', (rawLine: string) => {
|
|
const line = rawLine.trim();
|
|
if (line === '') {
|
|
return;
|
|
}
|
|
|
|
let message: ?ReplMessage;
|
|
try {
|
|
message = JSON.parse(line);
|
|
} catch {
|
|
// Not a structured message — surface it directly.
|
|
process.stdout.write(rawLine + '\n');
|
|
return;
|
|
}
|
|
|
|
switch (message?.type) {
|
|
case 'console-log':
|
|
if (message.level === 'error') {
|
|
process.stderr.write(message.message + '\n');
|
|
} else {
|
|
process.stdout.write(message.message + '\n');
|
|
}
|
|
break;
|
|
case 'repl-result':
|
|
if (pending != null) {
|
|
pending.result = message.text;
|
|
}
|
|
break;
|
|
case 'repl-error':
|
|
if (pending != null) {
|
|
pending.error = {message: message.message, stack: message.stack};
|
|
}
|
|
break;
|
|
case 'repl-completions':
|
|
if (pending != null) {
|
|
pending.completions = message.names;
|
|
}
|
|
break;
|
|
case 'repl-eval-complete': {
|
|
const current = pending;
|
|
pending = null;
|
|
if (current != null) {
|
|
current.resolve();
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
});
|
|
|
|
function evaluateFrame(payload: string): Promise<FrameResult> {
|
|
return new Promise(resolve => {
|
|
const state: PendingEval = {
|
|
result: null,
|
|
error: null,
|
|
completions: null,
|
|
resolve: () =>
|
|
resolve({
|
|
result: state.result,
|
|
error: state.error,
|
|
completions: state.completions,
|
|
}),
|
|
};
|
|
pending = state;
|
|
|
|
const buffer = Buffer.from(payload, 'utf8');
|
|
stdin.write(String(buffer.length) + '\n');
|
|
stdin.write(buffer);
|
|
});
|
|
}
|
|
|
|
let childExited = false;
|
|
child.on('exit', () => {
|
|
childExited = true;
|
|
if (pending != null) {
|
|
const current = pending;
|
|
pending = null;
|
|
current.resolve();
|
|
}
|
|
});
|
|
|
|
// Wait for the warm-up to finish (the binary only reads stdin after the
|
|
// warm-up bundle has been evaluated) and configure the runtime once, on
|
|
// startup, with the constants computed here and whether to colorize output.
|
|
const colorsEnabled = options.mode === 'repl' && SUPPORTS_COLOR;
|
|
const constants = {
|
|
isOSS,
|
|
isRunningFromCI: isCI,
|
|
runBenchmarks: false,
|
|
fantomConfigSummary: '',
|
|
jsHeapSnapshotOutputPathTemplate: '',
|
|
jsHeapSnapshotOutputPathTemplateToken: '',
|
|
jsTraceOutputPath: null,
|
|
hostPlatform: getHostPlatform(),
|
|
};
|
|
await evaluateFrame(
|
|
`globalThis.$$ReplConfigure$$(${JSON.stringify({
|
|
colors: colorsEnabled,
|
|
constants,
|
|
})});\n`,
|
|
);
|
|
|
|
if (childExited) {
|
|
throw new Error(
|
|
'Fantom tester exited before the REPL was ready. See logs above.',
|
|
);
|
|
}
|
|
|
|
const priorNames = new Set<string>();
|
|
let evalCounter = 0;
|
|
let evalChain: Promise<void> = Promise.resolve();
|
|
|
|
async function shutdownAndExit(code: number): Promise<void> {
|
|
try {
|
|
stdin.end();
|
|
} catch {}
|
|
try {
|
|
await stopMetroServer();
|
|
} catch {}
|
|
// Make sure buffered stdout (e.g. console output from a script) is flushed
|
|
// before exiting.
|
|
await new Promise<void>(resolve => {
|
|
if (process.stdout.write('')) {
|
|
resolve();
|
|
} else {
|
|
process.stdout.once('drain', () => resolve());
|
|
}
|
|
});
|
|
process.exit(code);
|
|
}
|
|
|
|
function replEval(
|
|
input: string,
|
|
context: unknown,
|
|
filename: string,
|
|
callback: (error: ?Error, result: unknown) => void,
|
|
): void {
|
|
// Node's REPL does not wait for our async callback before reading the next
|
|
// line when stdin is not a TTY (e.g. piped input or scripts). Serialize
|
|
// evaluations so they run one at a time and in order.
|
|
evalChain = evalChain.then(() => evalInput(input, callback));
|
|
}
|
|
|
|
async function evalInput(
|
|
rawInput: string,
|
|
callback: (error: ?Error, result: unknown) => void,
|
|
): Promise<void> {
|
|
try {
|
|
await evalInputUnsafe(rawInput, callback);
|
|
} catch (error: unknown) {
|
|
callback(null, formatError(error));
|
|
}
|
|
}
|
|
|
|
async function evalInputUnsafe(
|
|
rawInput: string,
|
|
callback: (error: ?Error, result: unknown) => void,
|
|
): Promise<void> {
|
|
const input = rawInput.trim();
|
|
if (input === '') {
|
|
callback(null, undefined);
|
|
return;
|
|
}
|
|
|
|
let transformed;
|
|
try {
|
|
transformed = transform(input, priorNames);
|
|
} catch (parseError: unknown) {
|
|
if (isRecoverableError(parseError)) {
|
|
callback(new repl.Recoverable(toError(parseError)), undefined);
|
|
} else {
|
|
callback(null, formatError(parseError));
|
|
}
|
|
return;
|
|
}
|
|
|
|
const id = evalCounter++;
|
|
const entryPath = path.join(outputDir, `repl-${process.pid}-${id}.js`);
|
|
fs.writeFileSync(entryPath, transformed.code, 'utf8');
|
|
|
|
let deltaText;
|
|
try {
|
|
deltaText = await fetchDeltaBundle(entryPath);
|
|
} catch (bundleError: unknown) {
|
|
callback(null, formatError(bundleError));
|
|
return;
|
|
} finally {
|
|
try {
|
|
fs.unlinkSync(entryPath);
|
|
} catch {}
|
|
}
|
|
|
|
const payload = deltaText + '\n;globalThis.$$ReplReport$$();\n';
|
|
const {result: evalResult, error: evalError} = await evaluateFrame(payload);
|
|
|
|
if (childExited) {
|
|
callback(new Error('Fantom tester process exited.'), undefined);
|
|
return;
|
|
}
|
|
|
|
if (evalError != null) {
|
|
callback(
|
|
null,
|
|
paint(
|
|
'31',
|
|
evalError.stack !== '' ? evalError.stack : evalError.message,
|
|
SUPPORTS_COLOR,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Only commit the new bindings to our view of the scope once the evaluation
|
|
// succeeded (the runtime only writes them back to $$REPL_SCOPE$$ on success).
|
|
priorNames.clear();
|
|
for (const name of transformed.newNames) {
|
|
priorNames.add(name);
|
|
}
|
|
|
|
callback(null, transformed.hasResult ? evalResult : undefined);
|
|
}
|
|
|
|
// Queries the runtime for completion candidates: either the global
|
|
// identifiers + REPL scope bindings (objectExpr == null), or the property
|
|
// names of `objectExpr` (a simple member chain evaluated with REPL scope
|
|
// bridged in). Property names only — values/getters are never read.
|
|
async function queryCompletionNames(
|
|
objectExpr: ?string,
|
|
): Promise<Array<string>> {
|
|
let payload;
|
|
if (objectExpr == null) {
|
|
payload = 'globalThis.$$ReplComplete$$(null);\n';
|
|
} else {
|
|
const bridge =
|
|
priorNames.size > 0
|
|
? `let {${[...priorNames].join(', ')}} = globalThis.$$REPL_SCOPE$$;\n`
|
|
: '';
|
|
payload = `(() => { ${bridge}globalThis.$$ReplComplete$$(() => (${objectExpr})); })();\n`;
|
|
}
|
|
const {completions} = await evaluateFrame(payload);
|
|
return completions ?? [];
|
|
}
|
|
|
|
async function computeCompletions(
|
|
line: string,
|
|
): Promise<[Array<string>, string]> {
|
|
const partialMatch = line.match(/[A-Za-z_$][\w$]*$/);
|
|
const partial = partialMatch != null ? partialMatch[0] : '';
|
|
const beforePartial = line.slice(0, line.length - partial.length);
|
|
|
|
let names: Array<string>;
|
|
if (beforePartial.endsWith('.')) {
|
|
const objectSource = beforePartial.slice(0, -1);
|
|
// Only complete members of a simple dotted identifier chain to avoid
|
|
// evaluating calls/indexing with side effects.
|
|
const chainMatch = objectSource.match(
|
|
/[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/,
|
|
);
|
|
if (chainMatch == null) {
|
|
return [[], partial];
|
|
}
|
|
names = await queryCompletionNames(chainMatch[0]);
|
|
} else {
|
|
names = await queryCompletionNames(null);
|
|
}
|
|
|
|
const hits = names.filter(name => name.startsWith(partial)).sort();
|
|
return [hits, partial];
|
|
}
|
|
|
|
function completer(
|
|
line: string,
|
|
callback: (error: ?Error, result: [Array<string>, string]) => void,
|
|
): void {
|
|
computeCompletions(line).then(
|
|
completions => callback(null, completions),
|
|
() => callback(null, [[], line]),
|
|
);
|
|
}
|
|
|
|
// One-shot evaluation for `-e <code>` and for running a script file. Unlike
|
|
// the REPL, the value of a trailing expression is not printed (matching
|
|
// `node -e` / running a script); use `console.log` to print.
|
|
async function runOnce(entryPath: string, cleanup: boolean): Promise<void> {
|
|
let deltaText;
|
|
try {
|
|
deltaText = await fetchDeltaBundle(entryPath);
|
|
} catch (bundleError: unknown) {
|
|
process.stderr.write(
|
|
paint('31', formatError(bundleError), SUPPORTS_COLOR_STDERR) + '\n',
|
|
);
|
|
await shutdownAndExit(1);
|
|
return;
|
|
} finally {
|
|
if (cleanup) {
|
|
try {
|
|
fs.unlinkSync(entryPath);
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
// `$$ReplReport$$` surfaces any error captured during evaluation. The raw
|
|
// code does not set a result, so (unlike the REPL) nothing is auto-printed
|
|
// on success — matching `node -e` / running a script.
|
|
const {error} = await evaluateFrame(
|
|
deltaText + '\n;globalThis.$$ReplReport$$();\n',
|
|
);
|
|
|
|
if (childExited) {
|
|
process.stderr.write('Fantom tester process exited.\n');
|
|
await shutdownAndExit(1);
|
|
return;
|
|
}
|
|
if (error != null) {
|
|
process.stderr.write(
|
|
paint(
|
|
'31',
|
|
error.stack !== '' ? error.stack : error.message,
|
|
SUPPORTS_COLOR_STDERR,
|
|
) + '\n',
|
|
);
|
|
await shutdownAndExit(1);
|
|
return;
|
|
}
|
|
await shutdownAndExit(0);
|
|
}
|
|
|
|
if (options.mode === 'eval') {
|
|
const id = evalCounter++;
|
|
const entryPath = path.join(outputDir, `repl-eval-${process.pid}-${id}.js`);
|
|
fs.writeFileSync(entryPath, options.code, 'utf8');
|
|
await runOnce(entryPath, true);
|
|
return;
|
|
}
|
|
|
|
if (options.mode === 'file') {
|
|
if (!fs.existsSync(options.filePath)) {
|
|
process.stderr.write(`Cannot find script '${options.filePath}'\n`);
|
|
await shutdownAndExit(1);
|
|
return;
|
|
}
|
|
// Metro can only bundle files inside the repo (the bundle URL is the path
|
|
// relative to PROJECT_ROOT), so reject scripts outside it with a clear error.
|
|
const relativeToRoot = path.relative(PROJECT_ROOT, options.filePath);
|
|
if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) {
|
|
process.stderr.write(
|
|
`Script must be inside the React Native repository (${PROJECT_ROOT}): ${options.filePath}\n`,
|
|
);
|
|
await shutdownAndExit(1);
|
|
return;
|
|
}
|
|
await runOnce(options.filePath, false);
|
|
return;
|
|
}
|
|
|
|
process.stdout.write(buildBanner());
|
|
|
|
// Node's readline accepts an async completer with a `(line, callback)`
|
|
// signature (required here because computing completions needs an async
|
|
// round-trip to the runtime), and we verified it works via a pseudo-terminal.
|
|
// The Node Flow libdef, however, only types the synchronous
|
|
// `(line) => [completions, line]` form, so it rejects the async signature.
|
|
// $FlowFixMe[incompatible-type]
|
|
const replServer = repl.start({
|
|
prompt: 'fantom> ',
|
|
eval: replEval,
|
|
completer,
|
|
writer: (output: unknown) => (output == null ? '' : String(output)),
|
|
ignoreUndefined: true,
|
|
});
|
|
|
|
replServer.on('exit', () => {
|
|
void shutdownAndExit(0);
|
|
});
|
|
}
|
|
|
|
function toError(error: unknown): Error {
|
|
return error instanceof Error ? error : new Error(String(error));
|
|
}
|
|
|
|
function formatError(error: unknown): string {
|
|
if (error instanceof Error) {
|
|
return error.stack ?? error.message;
|
|
}
|
|
return String(error);
|
|
}
|