diff --git a/source/context.js b/source/context.js index d185def..0e69cb7 100644 --- a/source/context.js +++ b/source/context.js @@ -1,19 +1,15 @@ import process from 'node:process'; import {stripVTControlCharacters} from 'node:util'; -export const getContext = (previous, rawFile, rawArguments) => { - const start = previous.start ?? process.hrtime.bigint(); - const command = [previous.command, getCommand(rawFile, rawArguments)].filter(Boolean).join(' | '); - return {start, command, state: {stdout: '', stderr: '', output: ''}}; -}; +export const getContext = ({start, command}, raw) => ({ + start: start ?? process.hrtime.bigint(), + command: [ + command, + raw.map(part => getCommandPart(stripVTControlCharacters(part))).join(' '), + ].filter(Boolean).join(' | '), + state: {stdout: '', stderr: '', output: ''}, +}); -const getCommand = (rawFile, rawArguments) => [rawFile, ...rawArguments] - .map(part => getCommandPart(part)) - .join(' '); - -const getCommandPart = part => { - part = stripVTControlCharacters(part); - return /[^\w./-]/.test(part) - ? `'${part.replaceAll('\'', '\'\\\'\'')}'` - : part; -}; +const getCommandPart = part => /[^\w./-]/.test(part) + ? `'${part.replaceAll('\'', '\'\\\'\'')}'` + : part; diff --git a/source/index.js b/source/index.js index f009f16..1fb9269 100644 --- a/source/index.js +++ b/source/index.js @@ -6,12 +6,12 @@ import {handlePipe} from './pipe.js'; import {lineIterator, combineAsyncIterators} from './iterable.js'; export default function nanoSpawn(first, second = [], third = {}) { - const [rawFile, previous] = Array.isArray(first) ? first : [first, {}]; - const [rawArguments, options] = Array.isArray(second) ? [second, third] : [[], second]; + const [file, previous] = Array.isArray(first) ? first : [first, {}]; + const [commandArguments, options] = Array.isArray(second) ? [second, third] : [[], second]; - const context = getContext(previous, rawFile, rawArguments); + const context = getContext(previous, [file, ...commandArguments]); const spawnOptions = getOptions(options); - const nodeChildProcess = spawnSubprocess(rawFile, rawArguments, spawnOptions, context); + const nodeChildProcess = spawnSubprocess(file, commandArguments, spawnOptions, context); const resultPromise = getResult(nodeChildProcess, spawnOptions, context); Object.assign(resultPromise, {nodeChildProcess}); const finalPromise = previous.resultPromise === undefined ? resultPromise : handlePipe(previous, resultPromise); diff --git a/source/iterable.js b/source/iterable.js index 383da5c..fa46476 100644 --- a/source/iterable.js +++ b/source/iterable.js @@ -9,8 +9,7 @@ export const lineIterator = async function * (resultPromise, {state}, streamName state.isIterating = true; try { - const instance = await resultPromise.nodeChildProcess; - const stream = instance[streamName]; + const {[streamName]: stream} = await resultPromise.nodeChildProcess; if (!stream) { return; } diff --git a/source/options.js b/source/options.js index d860667..2bfd3f1 100644 --- a/source/options.js +++ b/source/options.js @@ -14,13 +14,11 @@ export const getOptions = ({ }) => { const cwd = cwdOption instanceof URL ? fileURLToPath(cwdOption) : path.resolve(cwdOption); const env = envOption === undefined ? undefined : {...process.env, ...envOption}; - const [stdioOption, input] = stdio[0]?.string === undefined - ? [stdio] - : [['pipe', ...stdio.slice(1)], stdio[0].string]; + const input = stdio[0]?.string; return { ...options, - stdio: stdioOption, input, + stdio: input === undefined ? stdio : ['pipe', ...stdio.slice(1)], env: preferLocal ? addLocalPath(env ?? process.env, cwd) : env, cwd, }; diff --git a/source/result.js b/source/result.js index 3fdbe1e..9716418 100644 --- a/source/result.js +++ b/source/result.js @@ -1,13 +1,19 @@ import {once, on} from 'node:events'; import process from 'node:process'; -export const getResult = async (nodeChildProcess, options, context) => { +export const getResult = async (nodeChildProcess, {input}, context) => { const instance = await nodeChildProcess; - useInput(instance, options); + if (input !== undefined) { + instance.stdin.end(input); + } + const onClose = once(instance, 'close'); try { - await Promise.race([onClose, ...onStreamErrors(instance)]); + await Promise.race([ + onClose, + ...instance.stdio.filter(Boolean).map(stream => onStreamError(stream)), + ]); checkFailure(context, getErrorOutput(instance)); return getOutputs(context); } catch (error) { @@ -16,25 +22,15 @@ export const getResult = async (nodeChildProcess, options, context) => { } }; -const useInput = (instance, {input}) => { - if (input !== undefined) { - instance.stdin.end(input); - } -}; - -const onStreamErrors = ({stdio}) => stdio.filter(Boolean).map(stream => onStreamError(stream)); - const onStreamError = async stream => { for await (const [error] of on(stream, 'error')) { - if (!IGNORED_CODES.has(error?.code)) { + // Ignore errors that are due to closing errors when the subprocesses exit normally, or due to piping + if (!['ERR_STREAM_PREMATURE_CLOSE', 'EPIPE'].includes(error?.code)) { throw error; } } }; -// Ignore errors that are due to closing errors when the subprocesses exit normally, or due to piping -const IGNORED_CODES = new Set(['ERR_STREAM_PREMATURE_CLOSE', 'EPIPE']); - const checkFailure = ({command}, {exitCode, signalName}) => { if (signalName !== undefined) { throw new Error(`Command was terminated with ${signalName}: ${command}`); @@ -57,7 +53,7 @@ const getErrorInstance = (error, {command}) => error?.message.startsWith('Comman const getErrorOutput = ({exitCode, signalCode}) => ({ // `exitCode` can be a negative number (`errno`) when the `error` event is emitted on the `instance` - ...(exitCode === null || exitCode < 1 ? {} : {exitCode}), + ...(exitCode < 1 ? {} : {exitCode}), ...(signalCode === null ? {} : {signalName: signalCode}), }); @@ -69,6 +65,6 @@ const getOutputs = ({state: {stdout, stderr, output}, command, start}) => ({ durationMs: Number(process.hrtime.bigint() - start) / 1e6, }); -const getOutput = input => input?.at(-1) === '\n' - ? input.slice(0, input.at(-2) === '\r' ? -2 : -1) - : input; +const getOutput = output => output.at(-1) === '\n' + ? output.slice(0, output.at(-2) === '\r' ? -2 : -1) + : output; diff --git a/source/spawn.js b/source/spawn.js index 08aa845..3772895 100644 --- a/source/spawn.js +++ b/source/spawn.js @@ -4,9 +4,15 @@ import process from 'node:process'; import {getForcedShell, escapeArguments} from './windows.js'; import {getResultError} from './result.js'; -export const spawnSubprocess = async (rawFile, rawArguments, options, context) => { +export const spawnSubprocess = async (file, commandArguments, options, context) => { try { - const [file, commandArguments] = handleNode(rawFile, rawArguments); + // When running `node`, keep the current Node version and CLI flags. + // Not applied with file paths to `.../node` since those indicate a clear intent to use a specific Node version. + // Does not work with shebangs, but those don't work cross-platform anyway. + [file, commandArguments] = ['node', 'node.exe'].includes(file.toLowerCase()) + ? [process.execPath, [...process.execArgv.filter(flag => !flag.startsWith('--inspect')), ...commandArguments]] + : [file, commandArguments]; + const forcedShell = await getForcedShell(file, options); const instance = spawn(...escapeArguments(file, commandArguments, forcedShell), { ...options, @@ -27,27 +33,15 @@ export const spawnSubprocess = async (rawFile, rawArguments, options, context) = } }; -// When running `node`, keep the current Node version and CLI flags. -// Not applied with file paths to `.../node` since those indicate a clear intent to use a specific Node version. -// Does not work with shebangs, but those don't work cross-platform anyway. -const handleNode = (rawFile, rawArguments) => rawFile.toLowerCase().replace(/\.exe$/, '') === 'node' - ? [process.execPath, [...process.execArgv.filter(flag => !flag.startsWith('--inspect')), ...rawArguments]] - : [rawFile, rawArguments]; - const bufferOutput = (stream, {state}, streamName) => { - if (!stream) { - return; - } - - stream.setEncoding('utf8'); - if (state.isIterating) { - return; - } - - state.isIterating = false; - stream.on('data', chunk => { - for (const outputName of [streamName, 'output']) { - state[outputName] += chunk; + if (stream) { + stream.setEncoding('utf8'); + if (!state.isIterating) { + state.isIterating = false; + stream.on('data', chunk => { + state[streamName] += chunk; + state.output += chunk; + }); } - }); + } }; diff --git a/source/windows.js b/source/windows.js index 0367648..eeb5a98 100644 --- a/source/windows.js +++ b/source/windows.js @@ -68,12 +68,9 @@ export const escapeArguments = (file, commandArguments, forcedShell) => forcedSh // `cmd.exe` escaping for arguments. // Taken from https://github.com/moxystudio/node-cross-spawn -const escapeArgument = argument => { - const escapedArgument = argument - .replaceAll(/(\\*)"/g, '$1$1\\"') - .replace(/(\\*)$/, '$1$1'); - return escapeFile(escapeFile(`"${escapedArgument}"`)); -}; +const escapeArgument = argument => escapeFile(escapeFile(`"${argument + .replaceAll(/(\\*)"/g, '$1$1\\"') + .replace(/(\\*)$/, '$1$1')}"`)); // `cmd.exe` escaping for file and arguments. const escapeFile = file => file.replaceAll(/([()\][%!^"`<>&|;, *?])/g, '^$1');