Skip to content

Commit 70a320f

Browse files
authored
fix: eval (#383)
## Summary Previously, `react-server` and `react-server build` would silently treat piped stdin as the server entrypoint whenever `fd 0` happened to be a pipe, file redirect, or (in production) any non-TTY context outside CI. That made the CLI's behavior depend on invisible environmental state: the same command could behave differently depending on whether it was invoked from a shell, an editor task runner, a process manager, or a subprocess whose parent happened to close stdin. It also meant stdin could be read without the user ever asking for it, which is surprising at best and unsafe at worst. This change makes `--eval` the single, explicit opt-in. Stdin is now **only** consulted when the user passes `--eval`, and nothing else changes the CLI's entrypoint resolution. ## Behavior `--eval "<code>"` passes the inline string as the virtualized server entrypoint, the same as before. `--eval` with no value reads the full entrypoint from stdin — this is the new, explicit way to pipe code in. With no `--eval` at all, stdin is never touched, even if it is a pipe or a file redirect; the CLI falls back to the positional root or the file-router as it always did. The JS-level signal is that the plugin's `options.eval` is now tri-valued: `string` for inline code, `true` for "read stdin", and anything else (undefined / `false`) for "don't use eval at all". The CLI option definition changed from `-e, --eval <code>` (required value) to `-e, --eval [code]` (optional value) in both `dev` and `build` commands to support the bare form. ## Why The previous auto-detection had three concrete problems. It could consume stdin from an unrelated parent process that happened to leave fd 0 open as a pipe, producing confusing "Root module not provided" errors or worse, running attacker-controlled code. It was non-deterministic across environments: the production path additionally gated on `!process.env.CI`, so the same invocation would take different branches in CI vs. local. And it made the `--eval` flag's contract ambiguous, because stdin could be the entrypoint even when `--eval` was absent, so users couldn't reason about the CLI from the flags alone. Making stdin opt-in via `--eval` collapses these three cases into one rule that is trivial to explain and impossible to trigger by accident. ## Changes `lib/plugins/react-server-eval.mjs` drops the `fstatSync`-based `isStdinPiped` probe entirely. The load handler now branches cleanly on `options.eval`: string → return it verbatim, `true` → read stdin, otherwise → return the "Root module not provided" stub. `lib/dev/action.mjs` no longer runs its own `fstat` on fd 0 to pick the virtual entrypoint; it selects the virtual module only when `options.eval != null && options.eval !== false`. `lib/build/server.mjs` drops the `!process.stdin.isTTY && !process.env.CI` heuristic at the production root-module decision and uses the same explicit check. `bin/commands/dev.mjs` and `bin/commands/build.mjs` change the cac option shape to `-e, --eval [code]` with clarified help text describing both forms. ## Docs Both the English and Japanese `features/cli.mdx` have been updated for the dev and build `--eval` sections. The prose explicitly calls out that stdin is never auto-consumed, and each section now carries three runnable examples: inline code, bare `--eval` with a pipe, and bare `--eval` with a shell file redirect. ## Tests Two new specs guard the contract at complementary levels. `test/__test__/react-server-eval.spec.mjs` is a fast unit spec that imports the `react-server:eval` plugin directly and exercises its load hook with a *poisoned* `process.stdin` that throws on read. It verifies that the "no `--eval`", "`eval: false`", and "`--eval "<inline>"`" paths all return the expected result without ever touching stdin, and that `eval: true` correctly reads both single-chunk and multi-chunk stdin payloads. The poisoned-stdin trick is what lets the test make a positive assertion about the *absence* of stdin reads, which a black-box HTTP test cannot observe. `test/__test__/cli-eval.spec.mjs` is an end-to-end spec that spawns the real CLI binary as a subprocess. In dev mode it covers three cases: `--eval "<inline>"` serves the inline marker, bare `--eval` + piped stdin serves the stdin marker, and a positional root file + piped *invalid* JavaScript serves the positional marker (the critical regression guard — if auto-eval ever comes back, the bogus stdin would either crash the server or be rendered instead of the positional root). In production mode it runs `build --eval "<inline>"` with bogus stdin piped in and asserts the build exits successfully, covering the `build/server.mjs` code path. The subprocess helper forces `NO_COLOR=1` and strips ANSI defensively so readiness detection isn't broken by colored log output. ## Compatibility This is a behavior change that could in principle affect users who were relying on the undocumented auto-stdin path. In practice, the documented way to pipe code was always `--eval`, and the auto path was an implementation detail of the plugin. Users who were piping code without `--eval` need to add the flag. No API surface outside the CLI is affected.
1 parent 6b845f6 commit 70a320f

9 files changed

Lines changed: 524 additions & 44 deletions

File tree

docs/src/pages/en/(pages)/features/cli.mdx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,18 @@ When set, the development server will not validate your `react-server.config.*`
140140
### --eval
141141
</Link>
142142

143-
Evaluate the server entrypoint from the argument, like `node -e`. You can also use _stdin_ as the entrypoint. This type of entrypoint becomes a virtualized entrypoint and is not written to the file system.
143+
Evaluate the server entrypoint from the argument, like `node -e`. Pass `--eval` without a value to read the entrypoint from _stdin_ instead. Stdin is **never** auto-consumed — it is only read when `--eval` is explicitly passed. This type of entrypoint becomes a virtualized entrypoint and is not written to the file system.
144+
145+
```sh
146+
# Inline code
147+
pnpm exec react-server --eval "export default () => <h1>Hello</h1>;"
148+
149+
# From stdin (note the bare --eval, with no value)
150+
echo 'export default () => <h1>Hello from stdin</h1>;' | pnpm exec react-server --eval
151+
152+
# From a file via shell redirect
153+
pnpm exec react-server --eval < ./entry.jsx
154+
```
144155

145156
<Link name="dev-version">
146157
### --version
@@ -259,7 +270,18 @@ This is useful when you want to override or provide adapter options without modi
259270
### --eval
260271
</Link>
261272

262-
Evaluate the server entrypoint from the argument, like `node -e`. You can also use _stdin_ as the entrypoint. This type of entrypoint becomes a virtualized entrypoint and is not written to the file system.
273+
Evaluate the server entrypoint from the argument, like `node -e`. Pass `--eval` without a value to read the entrypoint from _stdin_ instead. Stdin is **never** auto-consumed — it is only read when `--eval` is explicitly passed. This type of entrypoint becomes a virtualized entrypoint and is not written to the file system.
274+
275+
```sh
276+
# Inline code
277+
pnpm exec react-server build --eval "export default () => <h1>Hello</h1>;"
278+
279+
# From stdin
280+
echo 'export default () => <h1>Hi</h1>;' | pnpm exec react-server build --eval
281+
282+
# From a file via shell redirect
283+
pnpm exec react-server build --eval < ./entry.jsx
284+
```
263285

264286
<Link name="build-outdir">
265287
### --outDir

docs/src/pages/ja/(pages)/features/cli.mdx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,18 @@ export default {
140140
### --eval
141141
</Link>
142142

143-
`node -e`と同じように引数からサーバーのエントリーポイントを評価します。または標準入力も指定することが出来ます。このエントリーポイントは仮想化エントリーポイントとなり、ファイルシステムには書き込まれません。
143+
`node -e`と同じように引数からサーバーのエントリーポイントを評価します。値を指定せずに `--eval` だけを渡すと、エントリーポイントを _標準入力_ から読み込みます。標準入力は **自動的には消費されません**`--eval` が明示的に指定された場合にのみ読み込まれます。このエントリーポイントは仮想化エントリーポイントとなり、ファイルシステムには書き込まれません。
144+
145+
```sh
146+
# インラインコード
147+
pnpm exec react-server --eval "export default () => <h1>Hello</h1>;"
148+
149+
# 標準入力から(値を指定しない `--eval` に注意)
150+
echo 'export default () => <h1>Hello from stdin</h1>;' | pnpm exec react-server --eval
151+
152+
# シェルのリダイレクトでファイルから
153+
pnpm exec react-server --eval < ./entry.jsx
154+
```
144155

145156
<Link name="dev-version">
146157
### --version
@@ -259,7 +270,18 @@ pnpm react-server build --deploy '{"project": "my-project"}'
259270
### --eval
260271
</Link>
261272

262-
`node -e`と同じように引数に指定したエントリーポイントを評価します。または標準入力も指定することが出来ます。このエントリーポイントはファイルシステムに書き込んだものではなく仮想的なエントリーポイントです。
273+
`node -e`と同じように引数に指定したエントリーポイントを評価します。値を指定せずに `--eval` だけを渡すと、エントリーポイントを _標準入力_ から読み込みます。標準入力は **自動的には消費されません**`--eval` が明示的に指定された場合にのみ読み込まれます。このエントリーポイントはファイルシステムに書き込んだものではなく仮想的なエントリーポイントです。
274+
275+
```sh
276+
# インラインコード
277+
pnpm exec react-server build --eval "export default () => <h1>Hello</h1>;"
278+
279+
# 標準入力から
280+
echo 'export default () => <h1>Hi</h1>;' | pnpm exec react-server build --eval
281+
282+
# シェルのリダイレクトでファイルから
283+
pnpm exec react-server build --eval < ./entry.jsx
284+
```
263285

264286
<Link name="build-outdir">
265287
### --outDir

packages/react-server/bin/commands/build.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ export default (cli) =>
2525
"[boolean|json] deploy using adapter, optionally pass JSON adapter options",
2626
{ default: false }
2727
)
28-
.option("-e, --eval <code>", "evaluate code", { type: "string" })
28+
.option(
29+
"-e, --eval [code]",
30+
"evaluate code as the server entrypoint; pass without a value to read from stdin"
31+
)
2932
.option("--outDir <dir>", "[string] output directory", {
3033
default: ".react-server",
3134
})

packages/react-server/bin/commands/dev.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ export default (cli) =>
2424
.option("--no-color", "disable color output", { default: false })
2525
.option("--no-check", "skip dependency checks", { default: false })
2626
.option("--no-validation", "skip config validation", { default: false })
27-
.option("-e, --eval <code>", "evaluate code", { type: "string" })
27+
.option(
28+
"-e, --eval [code]",
29+
"evaluate code as the server entrypoint; pass without a value to read from stdin"
30+
)
2831
.option("-o, --outDir <dir>", "[string] output directory", {
2932
default: ".react-server",
3033
})

packages/react-server/lib/build/server.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ export default async function serverBuild(root, options, clientManifestBus) {
344344
{ paths: [cwd] }
345345
);
346346
const rootModulePath =
347-
!root && (options.eval || (!process.stdin.isTTY && !process.env.CI))
347+
!root && options.eval != null && options.eval !== false
348348
? "virtual:react-server-eval.jsx"
349349
: root?.startsWith("virtual:")
350350
? root

packages/react-server/lib/dev/action.mjs

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { fstatSync } from "node:fs";
21
import { isIPv6 } from "node:net";
32

43
import open from "open";
@@ -132,25 +131,11 @@ export default async function dev(root, options) {
132131
await import("../../server/action-crypto.mjs");
133132
await initSecretFromConfig(configRoot);
134133

135-
// Detect whether the user is piping code via stdin.
136-
// Use fstat on fd 0 to distinguish a real pipe/file redirect
137-
// from a non-TTY background process, Docker, or CI runner
138-
// where stdin is /dev/null or closed.
139-
// Only relevant when no explicit root module was provided —
140-
// if the user passed `react-server ./app.jsx`, use that even
141-
// when stdin happens to be piped (e.g. via npm-run-all/run-p).
142-
let isStdinPiped = false;
143-
if (!root) {
144-
try {
145-
const stat = fstatSync(0);
146-
isStdinPiped = stat.isFIFO() || stat.isFile();
147-
} catch {
148-
// fd 0 not available — not piped
149-
}
150-
}
151-
134+
// Stdin is never auto-detected as the entrypoint — the user must
135+
// explicitly opt in by passing `--eval` (with a string value, or
136+
// bare to read the entrypoint from stdin).
152137
server = await createServer(
153-
options.eval || isStdinPiped
138+
options.eval != null && options.eval !== false
154139
? "virtual:react-server-eval.jsx"
155140
: root,
156141
options

packages/react-server/lib/plugins/react-server-eval.mjs

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,3 @@
1-
import { fstatSync } from "node:fs";
2-
3-
// Check whether stdin is an actual pipe or file redirect (i.e. the user
4-
// is piping code into react-server), as opposed to a TTY, /dev/null
5-
// (background process), or a closed fd (spawned subprocess).
6-
// `isFIFO()` catches `echo "code" | react-server`
7-
// `isFile()` catches `react-server < file.jsx`
8-
function isStdinPiped() {
9-
try {
10-
const stat = fstatSync(0);
11-
return stat.isFIFO() || stat.isFile();
12-
} catch {
13-
return false;
14-
}
15-
}
16-
171
export default function reactServerEval(options) {
182
return {
193
name: "react-server:eval",
@@ -30,9 +14,13 @@ export default function reactServerEval(options) {
3014
id: /^virtual:react-server-eval\.jsx$/,
3115
},
3216
async handler() {
33-
if (options.eval) {
17+
// `--eval <code>` → use the literal code string.
18+
// `--eval` (no value) → read the entrypoint from stdin.
19+
// Stdin is never consulted unless `--eval` was explicitly passed.
20+
if (typeof options.eval === "string") {
3421
return options.eval;
35-
} else if (isStdinPiped()) {
22+
}
23+
if (options.eval === true) {
3624
let code = "";
3725
process.stdin.setEncoding("utf8");
3826
for await (const chunk of process.stdin) {

0 commit comments

Comments
 (0)