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.