Skip to content

Commit

Permalink
Merge pull request #1 from benjamin-wilkins/spawn-stdio
Browse files Browse the repository at this point in the history
Add stdio options to spawn
  • Loading branch information
Hexagon authored Sep 7, 2024
2 parents f6d5f8d + a77f002 commit 8335842
Show file tree
Hide file tree
Showing 10 changed files with 165 additions and 101 deletions.
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ bunx jsr add @cross/utils
- **stripAnsi(text: string): string**
- Removes all ANSI control codes from a string for cleaner logs and output.

- **spawn(command: CommandArray, extraEnvVars?: Object, cwd?: string)**
- **spawn(command: CommandArray, extraEnvVars?: Object, cwd?: string, stdio:
StdIO)**
- Spawns a sub process.

- **table(data: string[][])**
Expand All @@ -58,13 +59,23 @@ bunx jsr add @cross/utils
any modifications to the object's properties.
- If `createCopy` is `true` (default is `false`), a new frozen deep copy of
the object is returned, leaving the original unchanged.

- **deepSeal<T>(obj: T, createCopy?: boolean): T**
- Recursively seals an object and all its nested objects. Sealing prevents new
properties from being added or removed, but existing properties can still be
modified.
- If `createCopy` is `true` (default is `false`), a new sealed deep copy of
the object is returned, leaving the original unchanged.

- **stdin(): ReadableStream**
- Get the stdin as a web-standard `ReadableStream` object.

- **stdout(): WritableStream**
- Get the stdout as a web-standard `WritableStream` object.

- **stderr(): WritableStream**
- Get the stderr as a web-standard `WritableStream` object.

**Classes**

- **Colors**
Expand Down Expand Up @@ -126,8 +137,8 @@ functionality:
- **Colors Class** - Provides methods for easy console text styling.

- **@cross/utils/spawn**
- **spawn(command: CommandArray, extraEnvVars?: Object, cwd?: string):
Promise<>** - Spawns subprocesses in a cross-runtime manner.
- **spawn(command: CommandArray, extraEnvVars?: Object, cwd?: string, stdio:
StdIO): Promise<>** - Spawns subprocesses in a cross-runtime manner.

- **@cross/utils/args**
- **args(all?: boolean): string[]** - Fetches command-line arguments
Expand All @@ -139,7 +150,7 @@ functionality:
- **exit(code?: number): void** - Terminates the process with control over the
exit code.

* **@cross/utils/format**
- **@cross/utils/format**
- **table(data: string[][]): void** - Generates a neatly formatted table
representation of a 2D array and prints it to the console.
- **Parameter:**
Expand Down Expand Up @@ -211,3 +222,11 @@ functionality:
sealedCopy.w = 7; // Throws an error in strict mode
original.w = 20; // Succeeds, original is unchanged
```

- **@cross/utils/stdio**
- **stdin(): ReadableStream**
- Get the stdin as a web-standard `ReadableStream` object.
- **stdout(): WritableStream**
- Get the stdout as a web-standard `WritableStream` object.
- **stderr(): WritableStream**
- Get the stderr as a web-standard `WritableStream` object.
3 changes: 2 additions & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"./table": "./utils/table.ts",
"./execpath": "./utils/execpath.ts",
"./sysinfo": "./utils/sysinfo.ts",
"./objectManip": "./utils/objectManip.ts"
"./objectManip": "./utils/objectManip.ts",
"./stdio": "./utils/stdio.ts"
},
"imports": {
"@cross/fs": "jsr:@cross/fs@^0.1.11",
Expand Down
3 changes: 2 additions & 1 deletion mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export { pid } from "./utils/pid.ts";
export { args, ArgsParser } from "./utils/args.ts";
export { Colors, Cursor, stripAnsi } from "./utils/ansi.ts";
export { spawn } from "./utils/spawn.ts";
export type { SpawnResult } from "./utils/spawn.ts";
export type { SpawnResult, SpawnStdIO } from "./utils/spawn.ts";
export { execPath, resolvedExecPath } from "./utils/execpath.ts";
export {
loadAvg,
Expand All @@ -14,3 +14,4 @@ export {
uptime,
} from "./utils/sysinfo.ts";
export { deepFreeze, deepSeal } from "./utils/objectManip.ts";
export { stderr, stdin, stdout } from "./utils/stdio.ts";
1 change: 1 addition & 0 deletions utils/args.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CurrentRuntime, Runtime } from "@cross/runtime";
import process from "node:process";

/**
* Retrieves command-line arguments in a cross-runtime compatible manner.
Expand Down
1 change: 1 addition & 0 deletions utils/execpath.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { CurrentRuntime, Runtime } from "@cross/runtime";
import { which } from "@cross/fs/stat";
import process from "node:process";

/**
* Cross-runtime compatible way to return the current executable path in a manner, regardless of Node, Deno or Bun.
Expand Down
1 change: 1 addition & 0 deletions utils/exit.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CurrentRuntime, Runtime } from "@cross/runtime";
import process from "node:process";

/**
* Terminates the current process in a cross-runtime compatible manner.
Expand Down
1 change: 1 addition & 0 deletions utils/pid.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CurrentRuntime, Runtime } from "@cross/runtime";
import process from "node:process";

/**
* Return the current process pid in a cross-runtime compatible manner.
Expand Down
194 changes: 99 additions & 95 deletions utils/spawn.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
import { CurrentRuntime, Runtime } from "@cross/runtime";

import { spawn as spawnChild } from "node:child_process";
import { Readable, Writable } from "node:stream";

import process from "node:process";

if (CurrentRuntime === Runtime.Bun) {
// Bun has a bug in Writable.fromWeb, so polyfill
Writable.fromWeb = (writableStream) => {
const writer = writableStream.getWriter();

return new Writable({
write(chunk) {
writer.write(chunk);
},
});
};
}

/**
* Represents the results of a spawned child process.
*/
Expand All @@ -21,101 +38,38 @@ export interface SpawnResult {
stderr: string;
}

// Runtime-specific execution functions (also using async/await)
async function spawnNodeChildProcess(
command: string[],
env: Record<string, string> = {},
cwd?: string,
): Promise<SpawnResult> {
const { spawn } = await import("node:child_process");
//@ts-ignore Node specific
const options: SpawnOptionsWithoutStdio = {
//@ts-ignore Cross Runtime
env: { ...process.env, ...env },
cwd: cwd,
shell: false,
};
const childProcess = spawn(
command[0],
command.length > 1 ? command.slice(1) : [],
options,
);

let stdout = "";
let stderr = "";

childProcess.stdout.on("data", (data: string) => stdout += data.toString());
childProcess.stderr.on("data", (data: string) => stderr += data.toString());

return new Promise((resolve, reject) => { // Still need Promise here due to event listeners
childProcess.on("error", (error: Error) => reject(error));
childProcess.on(
"close",
(code: number) => resolve({ code, stdout, stderr }),
);
});
}

async function spawnDenoChildProcess(
command: string[],
env: Record<string, string> = {},
cwd?: string,
): Promise<SpawnResult> {
// @ts-ignore Deno is specific to Deno
const options: Deno.CommandOptions = {
args: command.length > 1 ? command.slice(1) : [],
env: { ...env },
cwd,
};
const cmd = new Deno.Command(command[0], options);
const output = await cmd.output();
return {
code: output.code,
stdout: new TextDecoder().decode(output.stdout),
stderr: new TextDecoder().decode(output.stderr),
};
}

async function spawnBunChildProcess(
command: string[],
extraEnvVars: Record<string, string> = {},
cwd?: string,
): Promise<SpawnResult> {
// @ts-ignore Bun is runtime specific
const results = await Bun.spawn({
cmd: command,
// @ts-ignore process is runtime specific
env: { ...process.env, ...extraEnvVars }, // Merge environment variables
stdout: "pipe",
stderr: "pipe",
cwd,
});

// Convert ReadableStreams to strings
await results.exited;
/**
* Use to pass stdin, stdout and stderr to spawn() instead of getting all the
* text at the end.
*/
export interface SpawnStdIO {
/**
* The input into the spawned process.
*/
stdin: ReadableStream | "inherit" | null;

// @ts-ignore Bun is runtime specific
const stdout = await Bun.readableStreamToText(results.stdout);
// @ts-ignore Bun is runtime specific
const stderr = await Bun.readableStreamToText(results.stderr);
/**
* The output from the spawned process.
*/
stdout: WritableStream | "inherit" | null;

return {
code: results.exitCode,
stdout,
stderr,
};
/**
* The errors from the spawned process.
*/
stderr: WritableStream | "inherit" | null;
}

/**
* Starts a child processes.
*
* @param {string[]} command - An array of strings representing the command and its arguments.
* @param {Record<string, string>} [extraEnvVars] - An optional object containing additional environment variables to set for the command.
* @param {Record<string, string>} [env] - An optional object containing additional environment variables to set for the command.
* @param {string} [cwd] - An optional path specifying the current working directory for the command.
* @param {StdIO} [stdio] - An optional object specifying streams to use instead of buffering the output.
* @returns {Promise<SpawnResult>} A Promise resolving with an object containing:
* * code: The exit code of the executed command.
* * stdout: The standard output of the command.
* * stderr: The standard error of the command.
* * stdout: The standard output of the command (empty if stdio argument supplied).
* * stderr: The standard error of the command (empty if stdio argument supplied).
*
* @throws {Error} If the current runtime is not supported.
*
Expand All @@ -131,19 +85,69 @@ async function spawnBunChildProcess(
* console.error(error);
* }
*/
export async function spawn(
export function spawn(
command: string[],
extraEnvVars: Record<string, string> = {},
env: Record<string, string> = {},
cwd?: string,
stdio?: SpawnStdIO,
): Promise<SpawnResult> {
switch (CurrentRuntime) {
case Runtime.Node:
return await spawnNodeChildProcess(command, extraEnvVars, cwd);
case Runtime.Deno:
return await spawnDenoChildProcess(command, extraEnvVars, cwd);
case Runtime.Bun:
return await spawnBunChildProcess(command, extraEnvVars, cwd);
default:
throw new Error(`Unsupported runtime: ${CurrentRuntime}`);
let stdoutBuffer = "";
let stderrBuffer = "";

if (!stdio) {
stdio = {
stdin: null,
stdout: new WritableStream({
write(chunk) {
stdoutBuffer += chunk.toString();
},
}),
stderr: new WritableStream({
write(chunk) {
stderrBuffer += chunk.toString();
},
}),
};
}

const stdio_node: ("pipe" | "inherit" | "ignore")[] = [
stdio.stdin === "inherit" ? "inherit" : stdio.stdin ? "pipe" : "ignore",
stdio.stdout === "inherit" ? "inherit" : stdio.stdout ? "pipe" : "ignore",
stdio.stderr === "inherit" ? "inherit" : stdio.stderr ? "pipe" : "ignore",
];

const childProcess = spawnChild(
command[0],
command.length > 1 ? command.slice(1) : [],
{
env: { ...process.env, ...env },
cwd: cwd,
shell: false,
stdio: stdio_node,
},
);

if (stdio.stdin instanceof ReadableStream) {
// @ts-ignore Node's types here are weird
Readable.fromWeb(stdio.stdin).pipe(childProcess.stdin, { end: false });
}

if (stdio.stdout instanceof WritableStream) {
// @ts-ignore Node's types here are weird
childProcess.stdout.pipe(Writable.fromWeb(stdio.stdout), { end: false });
}

if (stdio.stderr instanceof WritableStream) {
// @ts-ignore Node's types here are weird
childProcess.stderr.pipe(Writable.fromWeb(stdio.stderr), { end: false });
}

return new Promise((resolve, reject) => { // Still need Promise here due to event listeners
childProcess.on("error", (error: Error) => reject(error));
childProcess.on(
"close",
(code: number) =>
resolve({ code, stdout: stdoutBuffer, stderr: stderrBuffer }),
);
});
}
34 changes: 34 additions & 0 deletions utils/stdio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { CurrentRuntime, Runtime } from "@cross/runtime";

import process from "node:process";
import { Readable, Writable } from "node:stream";

if (CurrentRuntime === Runtime.Bun) {
// Bun has a bug in Writable.toWeb, so polyfill
Writable.toWeb = (streamWritable) => {
return new WritableStream({
write(chunk) {
streamWritable.write(chunk);
},
});
};
}

/**
* Get the stdin as a web-standard `ReadableStream` object.
* @returns the stdin stream
*/
// @ts-ignore Node has strange typings on Stream objects
export const stdin = (): ReadableStream => Readable.toWeb(process.stdin);

/**
* Get the stdout as a web-standard `WritableStream` object.
* @returns the stdout stream
*/
export const stdout = (): WritableStream => Writable.toWeb(process.stdout);

/**
* Get the stderr as a web-standard `WritableStream` object.
* @returns the stderr stream
*/
export const stderr = (): WritableStream => Writable.toWeb(process.stderr);
1 change: 1 addition & 0 deletions utils/sysinfo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { CurrentRuntime, Runtime } from "@cross/runtime";
import { freemem, loadavg, totalmem, uptime as nodeUptime } from "node:os";
import process from "node:process";

/**
* Provides information about the memory usage of the current process.
Expand Down

0 comments on commit 8335842

Please sign in to comment.