diff --git a/docs/src/pages/en/(pages)/features/cli.mdx b/docs/src/pages/en/(pages)/features/cli.mdx index 04a8707b..34f6088b 100644 --- a/docs/src/pages/en/(pages)/features/cli.mdx +++ b/docs/src/pages/en/(pages)/features/cli.mdx @@ -140,7 +140,18 @@ When set, the development server will not validate your `react-server.config.*` ### --eval -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. +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. + +```sh +# Inline code +pnpm exec react-server --eval "export default () =>
", "evaluate code", { type: "string" })
+ .option(
+ "-e, --eval [code]",
+ "evaluate code as the server entrypoint; pass without a value to read from stdin"
+ )
.option("--outDir ", "[string] output directory", {
default: ".react-server",
})
diff --git a/packages/react-server/bin/commands/dev.mjs b/packages/react-server/bin/commands/dev.mjs
index 023eb59d..679b0990 100644
--- a/packages/react-server/bin/commands/dev.mjs
+++ b/packages/react-server/bin/commands/dev.mjs
@@ -24,7 +24,10 @@ export default (cli) =>
.option("--no-color", "disable color output", { default: false })
.option("--no-check", "skip dependency checks", { default: false })
.option("--no-validation", "skip config validation", { default: false })
- .option("-e, --eval ", "evaluate code", { type: "string" })
+ .option(
+ "-e, --eval [code]",
+ "evaluate code as the server entrypoint; pass without a value to read from stdin"
+ )
.option("-o, --outDir ", "[string] output directory", {
default: ".react-server",
})
diff --git a/packages/react-server/lib/build/server.mjs b/packages/react-server/lib/build/server.mjs
index 4634b167..b57a3d65 100644
--- a/packages/react-server/lib/build/server.mjs
+++ b/packages/react-server/lib/build/server.mjs
@@ -344,7 +344,7 @@ export default async function serverBuild(root, options, clientManifestBus) {
{ paths: [cwd] }
);
const rootModulePath =
- !root && (options.eval || (!process.stdin.isTTY && !process.env.CI))
+ !root && options.eval != null && options.eval !== false
? "virtual:react-server-eval.jsx"
: root?.startsWith("virtual:")
? root
diff --git a/packages/react-server/lib/dev/action.mjs b/packages/react-server/lib/dev/action.mjs
index 9dc05357..0162deb7 100644
--- a/packages/react-server/lib/dev/action.mjs
+++ b/packages/react-server/lib/dev/action.mjs
@@ -1,4 +1,3 @@
-import { fstatSync } from "node:fs";
import { isIPv6 } from "node:net";
import open from "open";
@@ -132,25 +131,11 @@ export default async function dev(root, options) {
await import("../../server/action-crypto.mjs");
await initSecretFromConfig(configRoot);
- // Detect whether the user is piping code via stdin.
- // Use fstat on fd 0 to distinguish a real pipe/file redirect
- // from a non-TTY background process, Docker, or CI runner
- // where stdin is /dev/null or closed.
- // Only relevant when no explicit root module was provided —
- // if the user passed `react-server ./app.jsx`, use that even
- // when stdin happens to be piped (e.g. via npm-run-all/run-p).
- let isStdinPiped = false;
- if (!root) {
- try {
- const stat = fstatSync(0);
- isStdinPiped = stat.isFIFO() || stat.isFile();
- } catch {
- // fd 0 not available — not piped
- }
- }
-
+ // Stdin is never auto-detected as the entrypoint — the user must
+ // explicitly opt in by passing `--eval` (with a string value, or
+ // bare to read the entrypoint from stdin).
server = await createServer(
- options.eval || isStdinPiped
+ options.eval != null && options.eval !== false
? "virtual:react-server-eval.jsx"
: root,
options
diff --git a/packages/react-server/lib/plugins/react-server-eval.mjs b/packages/react-server/lib/plugins/react-server-eval.mjs
index 5d5f566d..9fd1ef96 100644
--- a/packages/react-server/lib/plugins/react-server-eval.mjs
+++ b/packages/react-server/lib/plugins/react-server-eval.mjs
@@ -1,19 +1,3 @@
-import { fstatSync } from "node:fs";
-
-// Check whether stdin is an actual pipe or file redirect (i.e. the user
-// is piping code into react-server), as opposed to a TTY, /dev/null
-// (background process), or a closed fd (spawned subprocess).
-// `isFIFO()` catches `echo "code" | react-server`
-// `isFile()` catches `react-server < file.jsx`
-function isStdinPiped() {
- try {
- const stat = fstatSync(0);
- return stat.isFIFO() || stat.isFile();
- } catch {
- return false;
- }
-}
-
export default function reactServerEval(options) {
return {
name: "react-server:eval",
@@ -30,9 +14,13 @@ export default function reactServerEval(options) {
id: /^virtual:react-server-eval\.jsx$/,
},
async handler() {
- if (options.eval) {
+ // `--eval ` → use the literal code string.
+ // `--eval` (no value) → read the entrypoint from stdin.
+ // Stdin is never consulted unless `--eval` was explicitly passed.
+ if (typeof options.eval === "string") {
return options.eval;
- } else if (isStdinPiped()) {
+ }
+ if (options.eval === true) {
let code = "";
process.stdin.setEncoding("utf8");
for await (const chunk of process.stdin) {
diff --git a/test/__test__/cli-eval.spec.mjs b/test/__test__/cli-eval.spec.mjs
new file mode 100644
index 00000000..447b73d0
--- /dev/null
+++ b/test/__test__/cli-eval.spec.mjs
@@ -0,0 +1,343 @@
+// End-to-end integration test for the `--eval` CLI flag.
+//
+// The companion unit spec (`react-server-eval.spec.mjs`) tests the plugin
+// load-hook contract in isolation. This spec exercises the real CLI binary
+// as a subprocess — `node bin/cli.mjs ...` — to prove the full wiring:
+//
+// 1. `--eval ""` → dev server renders the inline code
+// 2. bare `--eval` with piped stdin → dev server renders the piped code
+// 3. positional root + piped stdin → stdin is NOT auto-consumed; the
+// positional root file wins
+// 4. `build --eval ""` → production build succeeds and
+// emits the inline entrypoint
+//
+// Case 3 is the critical regression guard: it proves stdin is untouched when
+// `--eval` was not passed, even though fd 0 is a live pipe. Previously the
+// CLI would fstat fd 0 and auto-route stdin into the virtual entrypoint.
+//
+// We run the CLI through `node` directly (not via the `server()` helper)
+// because the helper speaks to a programmatic API and bypasses the exact
+// CLI-argument parsing path we need to cover. These tests are intentionally
+// standalone — they do not use the shared `browser/page/server` harness.
+
+import { spawn } from "node:child_process";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { afterAll, beforeAll, describe, expect, test } from "vitest";
+
+// Run inside the `test/` directory so `@lazarv/react-server` (and its deps)
+// resolve cleanly from the workspace link in `test/node_modules`. A fresh
+// tmpdir would have no node_modules and the dev server would hang during
+// module resolution. We still use a unique subdir per run for the fixture
+// files so parallel runs don't stomp each other.
+const TEST_ROOT = fileURLToPath(new URL("../", import.meta.url));
+
+const CLI = fileURLToPath(
+ new URL("../../packages/react-server/bin/cli.mjs", import.meta.url)
+);
+
+// Pick a port range well above the shared harness's BASE_PORT=3000 band so
+// these tests don't collide with concurrent `server()`-driven specs.
+let portCounter = 0;
+function nextPort() {
+ return 40000 + (portCounter++ % 1000);
+}
+
+// Spawn the CLI, optionally pipe stdin, wait for a readiness marker on
+// stdout/stderr, then invoke `onReady` with the subprocess handle. Always
+// kills the subprocess on the way out.
+async function runCli({
+ args,
+ cwd,
+ stdin,
+ readyRegex,
+ timeoutMs = 60000,
+ onReady,
+}) {
+ const child = spawn(process.execPath, [CLI, ...args], {
+ cwd,
+ stdio: ["pipe", "pipe", "pipe"],
+ env: {
+ ...process.env,
+ NODE_ENV: "development",
+ CI: "true",
+ REACT_SERVER_TELEMETRY: "false",
+ // Disable ANSI color codes so our readiness regex matches the raw
+ // "Server listening on" string without wrestling escape sequences.
+ NO_COLOR: "1",
+ FORCE_COLOR: "0",
+ },
+ });
+
+ // Strip ANSI escapes defensively even with NO_COLOR set — some libraries
+ // ignore it and emit colors anyway.
+ // eslint-disable-next-line no-control-regex
+ const ANSI = /\x1B\[[0-?]*[ -/]*[@-~]/g;
+ const stripAnsi = (s) => s.replace(ANSI, "");
+
+ let stdout = "";
+ let stderr = "";
+ child.stdout.setEncoding("utf8");
+ child.stderr.setEncoding("utf8");
+ child.stdout.on("data", (c) => (stdout += stripAnsi(c)));
+ child.stderr.on("data", (c) => (stderr += stripAnsi(c)));
+
+ if (stdin !== undefined) {
+ child.stdin.write(stdin);
+ child.stdin.end();
+ } else {
+ child.stdin.end();
+ }
+
+ const ready = new Promise((resolve, reject) => {
+ const t = setTimeout(() => {
+ reject(
+ new Error(
+ `CLI did not become ready within ${timeoutMs}ms.\n` +
+ `args: ${args.join(" ")}\n` +
+ `stdout:\n${stdout}\nstderr:\n${stderr}`
+ )
+ );
+ }, timeoutMs);
+ const check = () => {
+ if (readyRegex.test(stdout) || readyRegex.test(stderr)) {
+ clearTimeout(t);
+ resolve();
+ }
+ };
+ child.stdout.on("data", check);
+ child.stderr.on("data", check);
+ child.on("exit", (code) => {
+ clearTimeout(t);
+ reject(
+ new Error(
+ `CLI exited (code ${code}) before becoming ready.\n` +
+ `args: ${args.join(" ")}\n` +
+ `stdout:\n${stdout}\nstderr:\n${stderr}`
+ )
+ );
+ });
+ });
+
+ try {
+ await ready;
+ return await onReady({ child, stdout: () => stdout, stderr: () => stderr });
+ } finally {
+ if (!child.killed) {
+ child.kill("SIGTERM");
+ await new Promise((res) => {
+ const t = setTimeout(() => {
+ try {
+ child.kill("SIGKILL");
+ } catch {}
+ res();
+ }, 3000);
+ child.once("exit", () => {
+ clearTimeout(t);
+ res();
+ });
+ });
+ }
+ }
+}
+
+// Run a build-only CLI invocation. Build exits on completion, so we wait
+// for exit rather than a readiness marker.
+function runCliToCompletion({ args, cwd, stdin, timeoutMs = 120000, env }) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, [CLI, ...args], {
+ cwd,
+ stdio: ["pipe", "pipe", "pipe"],
+ env: {
+ ...process.env,
+ NODE_ENV: "production",
+ CI: "true",
+ REACT_SERVER_TELEMETRY: "false",
+ ...env,
+ },
+ });
+ let stdout = "";
+ let stderr = "";
+ child.stdout.setEncoding("utf8");
+ child.stderr.setEncoding("utf8");
+ child.stdout.on("data", (c) => (stdout += c));
+ child.stderr.on("data", (c) => (stderr += c));
+
+ const t = setTimeout(() => {
+ child.kill("SIGKILL");
+ reject(
+ new Error(
+ `CLI did not exit within ${timeoutMs}ms.\n` +
+ `args: ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`
+ )
+ );
+ }, timeoutMs);
+
+ if (stdin !== undefined) {
+ child.stdin.write(stdin);
+ child.stdin.end();
+ } else {
+ child.stdin.end();
+ }
+
+ child.on("exit", (code) => {
+ clearTimeout(t);
+ resolve({ code, stdout, stderr });
+ });
+ child.on("error", (e) => {
+ clearTimeout(t);
+ reject(e);
+ });
+ });
+}
+
+async function fetchText(url, { timeoutMs = 10000 } = {}) {
+ const deadline = Date.now() + timeoutMs;
+ let lastErr;
+ while (Date.now() < deadline) {
+ try {
+ const res = await fetch(url);
+ return await res.text();
+ } catch (e) {
+ lastErr = e;
+ await new Promise((r) => setTimeout(r, 150));
+ }
+ }
+ throw lastErr ?? new Error(`fetch ${url} timed out`);
+}
+
+// Dev-mode readiness marker. `create-server.mjs` prints "Server listening on"
+// after the HTTP listener emits 'listening'. We also accept a bare "Local:"
+// URL line (Vite's printUrls output) as a secondary signal in case the
+// wrapper log changes.
+const DEV_READY = /(Server\s+listening\s+on|Local:\s+https?:\/\/)/i;
+
+describe.sequential("CLI --eval wiring", () => {
+ let workdir;
+
+ beforeAll(async () => {
+ // Scratch subdir inside test/ so node_modules resolution works via the
+ // workspace-linked `@lazarv/react-server`. mkdtemp needs a prefix.
+ workdir = await mkdtemp(join(TEST_ROOT, ".cli-eval-"));
+ // Positional root file used by the "no --eval, stdin piped" case.
+ await writeFile(
+ join(workdir, "root.jsx"),
+ `export default function Root() {
+ return positional-root-marker;
+}
+`
+ );
+ });
+
+ afterAll(async () => {
+ if (workdir) {
+ try {
+ await rm(workdir, { recursive: true, force: true });
+ } catch {}
+ }
+ });
+
+ test(
+ "dev: --eval renders inline code",
+ { timeout: 120000 },
+ async () => {
+ const port = nextPort();
+ const inline = `export default function Root() {
+ return inline-eval-marker;
+}`;
+ await runCli({
+ args: ["--port", String(port), "--eval", inline],
+ cwd: workdir,
+ readyRegex: DEV_READY,
+ onReady: async () => {
+ const body = await fetchText(`http://localhost:${port}/`);
+ expect(body).toContain("inline-eval-marker");
+ expect(body).not.toContain("positional-root-marker");
+ },
+ });
+ }
+ );
+
+ test(
+ "dev: bare --eval reads entrypoint from stdin",
+ { timeout: 120000 },
+ async () => {
+ const port = nextPort();
+ const stdinCode = `export default function Root() {
+ return stdin-eval-marker;
+}`;
+ await runCli({
+ args: ["--port", String(port), "--eval"],
+ cwd: workdir,
+ stdin: stdinCode,
+ readyRegex: DEV_READY,
+ onReady: async () => {
+ const body = await fetchText(`http://localhost:${port}/`);
+ expect(body).toContain("stdin-eval-marker");
+ },
+ });
+ }
+ );
+
+ test(
+ "dev: stdin is NOT auto-consumed when --eval is absent",
+ { timeout: 120000 },
+ async () => {
+ const port = nextPort();
+ // Payload that would be a SYNTAX ERROR if eval'd — if the old auto-eval
+ // path ever comes back, the server will fail to start and we'll catch
+ // it via the readiness timeout / explicit marker mismatch.
+ const bogusStdin = "this is not valid javascript <<<<< !!!\n";
+ await runCli({
+ args: ["--port", String(port), "./root.jsx"],
+ cwd: workdir,
+ stdin: bogusStdin,
+ readyRegex: DEV_READY,
+ onReady: async () => {
+ const body = await fetchText(`http://localhost:${port}/`);
+ expect(body).toContain("positional-root-marker");
+ expect(body).not.toContain("inline-eval-marker");
+ expect(body).not.toContain("stdin-eval-marker");
+ },
+ });
+ }
+ );
+
+ test(
+ "build: --eval produces a successful production build and ignores stdin",
+ { timeout: 180000 },
+ async () => {
+ const outDir = `.react-server-cli-eval-build-${Date.now()}`;
+ const inline = `export default function Root() {
+ return build-inline-eval-marker;
+}`;
+ // Pipe bogus stdin too — if the production path ever regresses to
+ // auto-reading stdin when --eval is present, the inline value would
+ // be overwritten or the build would blow up on invalid code.
+ const bogusStdin = "this is not valid javascript <<<<< !!!\n";
+ const result = await runCliToCompletion({
+ args: [
+ "build",
+ "--eval",
+ inline,
+ "--outDir",
+ outDir,
+ "--no-minify",
+ "--adapter",
+ "false",
+ ],
+ cwd: workdir,
+ stdin: bogusStdin,
+ });
+ try {
+ expect(result.code, `build failed:\n${result.stderr}`).toBe(0);
+ } finally {
+ try {
+ await rm(join(workdir, outDir), { recursive: true, force: true });
+ } catch {}
+ }
+ }
+ );
+});
diff --git a/test/__test__/react-server-eval.spec.mjs b/test/__test__/react-server-eval.spec.mjs
new file mode 100644
index 00000000..c071b8f5
--- /dev/null
+++ b/test/__test__/react-server-eval.spec.mjs
@@ -0,0 +1,114 @@
+// Unit tests for the `react-server:eval` Vite plugin.
+//
+// Guards the contract:
+// • stdin is NEVER auto-consumed — even if fd 0 is a pipe/file, the plugin
+// must not read it unless `--eval` was explicitly passed.
+// • `--eval ` (string option) → the load handler returns that code
+// verbatim as the virtual entrypoint.
+// • `--eval` bare (boolean option === true) → the load handler reads the
+// entire entrypoint from stdin.
+//
+// We exercise the plugin's `load` handler directly rather than spawning the
+// CLI because: (a) the decision lives entirely inside the plugin, (b) it's
+// synchronous to set up, and (c) we can assert the "stdin is NOT touched"
+// case by installing a process.stdin that would throw on read — something a
+// black-box HTTP test cannot observe.
+
+import { Readable } from "node:stream";
+
+import { afterEach, describe, expect, test } from "vitest";
+
+// The plugin isn't in the package `exports`, so import it via its workspace
+// file path. The `@lazarv/react-server` package is symlinked into
+// test/node_modules, so this resolves to the same source file the CLI loads.
+import reactServerEval from "@lazarv/react-server/lib/plugins/react-server-eval.mjs";
+
+// Invoke the plugin's load hook the way Vite would: the exported plugin has
+// `load` as an object with a `handler` function (filtered load hook shape).
+async function invokeLoad(plugin) {
+ const fn =
+ typeof plugin.load === "function" ? plugin.load : plugin.load.handler;
+ return fn.call({}, "virtual:react-server-eval.jsx");
+}
+
+const originalStdin = process.stdin;
+
+function installStdin(stream) {
+ Object.defineProperty(process, "stdin", {
+ value: stream,
+ configurable: true,
+ writable: true,
+ });
+}
+
+function restoreStdin() {
+ Object.defineProperty(process, "stdin", {
+ value: originalStdin,
+ configurable: true,
+ writable: true,
+ });
+}
+
+// A stdin stand-in that blows up if anything tries to read from it.
+// Used to prove that the "no --eval" path never touches stdin.
+function poisonStdin() {
+ const s = new Readable({
+ read() {
+ throw new Error("stdin was read without --eval — auto-eval regression!");
+ },
+ });
+ // `for await (chunk of stream)` calls setEncoding on the source; make
+ // sure that call alone does not count as "reading".
+ s.setEncoding = () => {};
+ return s;
+}
+
+// A stdin stand-in that yields a fixed payload and then ends — used for
+// the "bare --eval reads stdin" path.
+function fakeStdin(payload) {
+ return Readable.from([payload]);
+}
+
+describe("react-server:eval plugin", () => {
+ afterEach(() => {
+ restoreStdin();
+ });
+
+ test("no --eval: returns the throw stub and never reads stdin", async () => {
+ installStdin(poisonStdin());
+ const plugin = reactServerEval({});
+ const code = await invokeLoad(plugin);
+ expect(code).toContain("Root module not provided");
+ });
+
+ test("no --eval: `eval: false` is also treated as not passed", async () => {
+ installStdin(poisonStdin());
+ const plugin = reactServerEval({ eval: false });
+ const code = await invokeLoad(plugin);
+ expect(code).toContain("Root module not provided");
+ });
+
+ test("--eval : returns the inline string verbatim and does not read stdin", async () => {
+ installStdin(poisonStdin());
+ const inline = "export default () => 'inline-eval-marker';";
+ const plugin = reactServerEval({ eval: inline });
+ const code = await invokeLoad(plugin);
+ expect(code).toBe(inline);
+ });
+
+ test("--eval (bare): reads the full entrypoint from stdin", async () => {
+ const payload = "export default () => 'stdin-eval-marker';";
+ installStdin(fakeStdin(payload));
+ const plugin = reactServerEval({ eval: true });
+ const code = await invokeLoad(plugin);
+ expect(code).toBe(payload);
+ });
+
+ test("--eval (bare): concatenates multi-chunk stdin", async () => {
+ const chunks = ["export default ", "() => 'multi", "-chunk';"];
+ installStdin(Readable.from(chunks));
+ const plugin = reactServerEval({ eval: true });
+ const code = await invokeLoad(plugin);
+ expect(code).toBe(chunks.join(""));
+ });
+});