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 () =>

Hello

;" + +# From stdin (note the bare --eval, with no value) +echo 'export default () =>

Hello from stdin

;' | pnpm exec react-server --eval + +# From a file via shell redirect +pnpm exec react-server --eval < ./entry.jsx +``` ### --version @@ -259,7 +270,18 @@ This is useful when you want to override or provide adapter options without modi ### --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 build --eval "export default () =>

Hello

;" + +# From stdin +echo 'export default () =>

Hi

;' | pnpm exec react-server build --eval + +# From a file via shell redirect +pnpm exec react-server build --eval < ./entry.jsx +``` ### --outDir diff --git a/docs/src/pages/ja/(pages)/features/cli.mdx b/docs/src/pages/ja/(pages)/features/cli.mdx index 4ad3f066..f245015d 100644 --- a/docs/src/pages/ja/(pages)/features/cli.mdx +++ b/docs/src/pages/ja/(pages)/features/cli.mdx @@ -140,7 +140,18 @@ export default { ### --eval -`node -e`と同じように引数からサーバーのエントリーポイントを評価します。または標準入力も指定することが出来ます。このエントリーポイントは仮想化エントリーポイントとなり、ファイルシステムには書き込まれません。 +`node -e`と同じように引数からサーバーのエントリーポイントを評価します。値を指定せずに `--eval` だけを渡すと、エントリーポイントを _標準入力_ から読み込みます。標準入力は **自動的には消費されません** — `--eval` が明示的に指定された場合にのみ読み込まれます。このエントリーポイントは仮想化エントリーポイントとなり、ファイルシステムには書き込まれません。 + +```sh +# インラインコード +pnpm exec react-server --eval "export default () =>

Hello

;" + +# 標準入力から(値を指定しない `--eval` に注意) +echo 'export default () =>

Hello from stdin

;' | pnpm exec react-server --eval + +# シェルのリダイレクトでファイルから +pnpm exec react-server --eval < ./entry.jsx +``` ### --version @@ -259,7 +270,18 @@ pnpm react-server build --deploy '{"project": "my-project"}' ### --eval -`node -e`と同じように引数に指定したエントリーポイントを評価します。または標準入力も指定することが出来ます。このエントリーポイントはファイルシステムに書き込んだものではなく仮想的なエントリーポイントです。 +`node -e`と同じように引数に指定したエントリーポイントを評価します。値を指定せずに `--eval` だけを渡すと、エントリーポイントを _標準入力_ から読み込みます。標準入力は **自動的には消費されません** — `--eval` が明示的に指定された場合にのみ読み込まれます。このエントリーポイントはファイルシステムに書き込んだものではなく仮想的なエントリーポイントです。 + +```sh +# インラインコード +pnpm exec react-server build --eval "export default () =>

Hello

;" + +# 標準入力から +echo 'export default () =>

Hi

;' | pnpm exec react-server build --eval + +# シェルのリダイレクトでファイルから +pnpm exec react-server build --eval < ./entry.jsx +``` ### --outDir diff --git a/packages/react-server/bin/commands/build.mjs b/packages/react-server/bin/commands/build.mjs index fdba2d67..0de538c4 100644 --- a/packages/react-server/bin/commands/build.mjs +++ b/packages/react-server/bin/commands/build.mjs @@ -25,7 +25,10 @@ export default (cli) => "[boolean|json] deploy using adapter, optionally pass JSON adapter options", { 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("--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("")); + }); +});