react-server675fbba4
react-servercommit70a320f0e40f

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.

Author
Viktor Lázár <lazarv1982@gmail.com>
Date
Commit
70a320f0e40f2b5d8078d77bf807eb3d3380ecfa
9 files changed+524 -44