Repository: denoland/deno
Pull request: fix(ext/node): support input option in spawnSync

closes #28784 

This PR adds support of `input` option in `spawnSync` API in `node:child_process`, which passes the given input as stdin of the spawned process.

Changed files:
filename: ext/node/polyfills/internal/child_process.ts
patch: @@ -59,6 +59,7 @@ import { Socket } from "node:net";
 import {
   kDetached,
   kExtraStdio,
+  kInputOption,
   kIpc,
   kNeedsNpmProcessState,
 } from "ext:deno_process/40_process.js";
@@ -985,13 +986,35 @@ function parseSpawnSyncOutputStreams(
   }
 }
 
+function normalizeInput(input: unknown) {
+  if (input == null) {
+    return null;
+  }
+  if (typeof input === "string") {
+    return Buffer.from(input);
+  }
+  if (input instanceof Uint8Array) {
+    return input;
+  }
+  if (input instanceof DataView) {
+    return Buffer.from(input.buffer, input.byteOffset, input.byteLength);
+  }
+  throw new ERR_INVALID_ARG_TYPE("input", [
+    "string",
+    "Buffer",
+    "TypedArray",
+    "DataView",
+  ], input);
+}
+
 export function spawnSync(
   command: string,
   args: string[],
   options: SpawnSyncOptions,
 ): SpawnSyncResult {
   const {
     env = Deno.env.toObject(),
+    input,
     stdio = ["pipe", "pipe", "pipe"],
     shell = false,
     cwd,
@@ -1008,6 +1031,7 @@ export function spawnSync(
     _channel, // TODO(kt3k): handle this correctly
   ] = normalizeStdioOption(stdio);
   [command, args] = buildCommand(command, args ?? [], shell);
+  const input_ = normalizeInput(input);
 
   const result: SpawnSyncResult = {};
   try {
@@ -1021,6 +1045,7 @@ export function spawnSync(
       uid,
       gid,
       windowsRawArguments: windowsVerbatimArguments,
+      [kInputOption]: input_,
     }).outputSync();
 
     const status = output.signal ? null : output.code;

filename: ext/process/40_process.js
patch: @@ -41,6 +41,9 @@ import {
   writableStreamForRid,
 } from "ext:deno_web/06_streams.js";
 
+// The key for private `input` option for `Deno.Command`
+const kInputOption = Symbol("kInputOption");
+
 function opKill(pid, signo, apiName) {
   op_kill(pid, signo, apiName);
 }
@@ -404,6 +407,7 @@ function spawnSync(command, {
   stdout = "piped",
   stderr = "piped",
   windowsRawArguments = false,
+  [kInputOption]: input,
 } = { __proto__: null }) {
   if (stdin === "piped") {
     throw new TypeError(
@@ -425,6 +429,7 @@ function spawnSync(command, {
     extraStdio: [],
     detached: false,
     needsNpmProcessState: false,
+    input,
   });
   return {
     success: result.status.success,
@@ -484,4 +489,4 @@ class Command {
   }
 }
 
-export { ChildProcess, Command, kill, Process, run };
+export { ChildProcess, Command, kill, kInputOption, Process, run };

filename: ext/process/lib.rs
patch: @@ -20,6 +20,7 @@ use deno_core::op2;
 use deno_core::serde_json;
 use deno_core::AsyncMutFuture;
 use deno_core::AsyncRefCell;
+use deno_core::JsBuffer;
 use deno_core::OpState;
 use deno_core::RcRef;
 use deno_core::Resource;
@@ -193,6 +194,8 @@ pub struct SpawnArgs {
   #[serde(flatten)]
   stdio: ChildStdio,
 
+  input: Option<JsBuffer>,
+
   extra_stdio: Vec<Stdio>,
   detached: bool,
   needs_npm_process_state: bool,
@@ -437,6 +440,8 @@ fn create_command(
 
   if args.stdio.stdin.is_ipc() {
     args.ipc = Some(0);
+  } else if args.input.is_some() {
+    command.stdin(std::process::Stdio::piped());
   } else {
     command.stdin(args.stdio.stdin.as_stdio(state)?);
   }
@@ -936,13 +941,31 @@ fn op_spawn_sync(
 ) -> Result<SpawnOutput, ProcessError> {
   let stdout = matches!(args.stdio.stdout, StdioOrRid::Stdio(Stdio::Piped));
   let stderr = matches!(args.stdio.stderr, StdioOrRid::Stdio(Stdio::Piped));
+  let input = args.input.clone();
   let (mut command, _, _, _) =
     create_command(state, args, "Deno.Command().outputSync()")?;
-  let output = command.output().map_err(|e| ProcessError::SpawnFailed {
+
+  let mut child = command.spawn().map_err(|e| ProcessError::SpawnFailed {
     command: command.get_program().to_string_lossy().to_string(),
     error: Box::new(e.into()),
   })?;
-
+  if let Some(input) = input {
+    let mut stdin = child.stdin.take().ok_or_else(|| {
+      ProcessError::Io(std::io::Error::new(
+        std::io::ErrorKind::Other,
+        "stdin is not available",
+      ))
+    })?;
+    stdin.write_all(&input)?;
+    stdin.flush()?;
+  }
+  let output =
+    child
+      .wait_with_output()
+      .map_err(|e| ProcessError::SpawnFailed {
+        command: command.get_program().to_string_lossy().to_string(),
+        error: Box::new(e.into()),
+      })?;
   Ok(SpawnOutput {
     status: output.status.try_into()?,
     stdout: if stdout {

filename: tests/unit_node/child_process_test.ts
patch: @@ -1128,3 +1128,46 @@ Deno.test(async function noWarningsFlag() {
 
   await timeout.promise;
 });
+
+Deno.test({
+  name: "[node/child_process] spawnSync supports input option",
+  fn() {
+    const text = "  console.log('hello')";
+    const expected = `console.log("hello");\n`;
+    {
+      const { stdout } = spawnSync(Deno.execPath(), ["fmt", "-"], {
+        input: text,
+      });
+      assertEquals(stdout.toString(), expected);
+    }
+    {
+      const { stdout } = spawnSync(Deno.execPath(), ["fmt", "-"], {
+        input: Buffer.from(text),
+      });
+      assertEquals(stdout.toString(), expected);
+    }
+    {
+      const { stdout } = spawnSync(Deno.execPath(), ["fmt", "-"], {
+        input: new TextEncoder().encode(text),
+      });
+      assertEquals(stdout.toString(), expected);
+    }
+    {
+      const { stdout } = spawnSync(Deno.execPath(), ["fmt", "-"], {
+        input: new DataView(Buffer.from(text).buffer),
+      });
+      assertEquals(stdout.toString(), expected);
+    }
+
+    assertThrows(
+      () => {
+        spawnSync(Deno.execPath(), ["fmt", "-"], {
+          // deno-lint-ignore no-explicit-any
+          input: {} as any,
+        });
+      },
+      Error,
+      'The "input" argument must be of type string or an instance of Buffer, TypedArray, or DataView. Received an instance of Object',
+    );
+  },
+});