From 2960c543a14f288bb3c2f873223106d45f4f5a3f Mon Sep 17 00:00:00 2001 From: ehmicky Date: Sun, 1 Sep 2024 20:13:09 +0100 Subject: [PATCH] Separate smaller core --- source/arguments.js | 14 +++++++ source/context.js | 1 - source/index.js | 24 +++++++++--- source/iterable.js | 6 +-- source/pico-spawn.js | 91 ++++++++++++++++++++++++++++++++++++++++++++ source/result.js | 67 +++++++------------------------- source/spawn.js | 47 ----------------------- source/windows.js | 6 +-- 8 files changed, 144 insertions(+), 112 deletions(-) create mode 100644 source/arguments.js create mode 100644 source/pico-spawn.js delete mode 100644 source/spawn.js diff --git a/source/arguments.js b/source/arguments.js new file mode 100644 index 0000000..4c20f5a --- /dev/null +++ b/source/arguments.js @@ -0,0 +1,14 @@ +import process from 'node:process'; +import {getForcedShell, escapeArguments} from './windows.js'; + +export const handleArguments = async (file, commandArguments, options) => { + // 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. + // This also provides a way to opting out, e.g. using `process.execPath` instead of `node` to discard current CLI flags. + // 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]; + + return escapeArguments(file, commandArguments, options, await getForcedShell(file, options)); +}; diff --git a/source/context.js b/source/context.js index 75c9a50..a462d60 100644 --- a/source/context.js +++ b/source/context.js @@ -4,7 +4,6 @@ import {stripVTControlCharacters} from 'node:util'; export const getContext = raw => ({ start: process.hrtime.bigint(), command: raw.map(part => getCommandPart(stripVTControlCharacters(part))).join(' '), - state: {stdout: '', stderr: '', output: ''}, }); const getCommandPart = part => /[^\w./-]/.test(part) diff --git a/source/index.js b/source/index.js index 296790f..5e132e3 100644 --- a/source/index.js +++ b/source/index.js @@ -1,6 +1,7 @@ import {getContext} from './context.js'; import {getOptions} from './options.js'; -import {spawnSubprocess} from './spawn.js'; +import {handleArguments} from './arguments.js'; +import picoSpawn from './pico-spawn.js'; import {getResult} from './result.js'; import {handlePipe} from './pipe.js'; import {lineIterator, combineAsyncIterators} from './iterable.js'; @@ -9,13 +10,15 @@ export default function nanoSpawn(file, second, third, previous) { const [commandArguments = [], options = {}] = Array.isArray(second) ? [second, third] : [[], second]; const context = getContext([file, ...commandArguments]); const spawnOptions = getOptions(options); - const nodeChildProcess = spawnSubprocess(file, commandArguments, spawnOptions, context); - let subprocess = getResult(nodeChildProcess, spawnOptions, context); + spawnOptions.internal = {}; + const picoPromise = getPicoSubprocess(file, commandArguments, spawnOptions); + const nodeChildProcess = getNodeChildProcess(picoPromise); + let subprocess = getResult(picoPromise, nodeChildProcess, context, spawnOptions); Object.assign(subprocess, {nodeChildProcess}); subprocess = previous ? handlePipe([previous, subprocess]) : subprocess; - const stdout = lineIterator(subprocess, context, 'stdout'); - const stderr = lineIterator(subprocess, context, 'stderr'); + const stdout = lineIterator(subprocess, spawnOptions, 'stdout'); + const stderr = lineIterator(subprocess, spawnOptions, 'stderr'); return Object.assign(subprocess, { nodeChildProcess, stdout, @@ -24,3 +27,14 @@ export default function nanoSpawn(file, second, third, previous) { pipe: (file, second, third) => nanoSpawn(file, second, third, subprocess), }); } + +const getPicoSubprocess = async (file, commandArguments, spawnOptions) => { + const spawnArguments = await handleArguments(file, commandArguments, spawnOptions); + const picoSubprocess = picoSpawn(...spawnArguments); + return {picoSubprocess}; +}; + +const getNodeChildProcess = async picoPromise => { + const {picoSubprocess} = await picoPromise; + return picoSubprocess.nodeChildProcess; +}; diff --git a/source/iterable.js b/source/iterable.js index 4baf0a7..e9e29ec 100644 --- a/source/iterable.js +++ b/source/iterable.js @@ -1,12 +1,12 @@ -export const lineIterator = async function * (subprocess, {state}, streamName) { +export const lineIterator = async function * (subprocess, {internal}, streamName) { // Prevent buffering when iterating. // This would defeat one of the main goals of iterating: low memory consumption. - if (state.isIterating === false) { + if (internal.isIterating === false) { throw new Error(`The subprocess must be iterated right away, for example: for await (const line of nanoSpawn(...)) { ... }`); } - state.isIterating = true; + internal.isIterating = true; try { const {[streamName]: stream} = await subprocess.nodeChildProcess; diff --git a/source/pico-spawn.js b/source/pico-spawn.js new file mode 100644 index 0000000..0e33bf8 --- /dev/null +++ b/source/pico-spawn.js @@ -0,0 +1,91 @@ +import {spawn} from 'node:child_process'; +import {once, on} from 'node:events'; + +export default function picoSpawn(file, second, third) { + const [commandArguments = [], options = {}] = Array.isArray(second) ? [second, third] : [[], second]; + const state = { + stdout: '', + stderr: '', + output: '', + command: [file, ...commandArguments].join(' '), + }; + const nodeChildProcess = spawnSubprocess(file, commandArguments, options, state); + return Object.assign(getResult(nodeChildProcess, state), {nodeChildProcess}); +} + +const spawnSubprocess = async (file, commandArguments, options, state) => { + try { + const instance = spawn(file, commandArguments, options); + bufferOutput(instance.stdout, 'stdout', options, state); + bufferOutput(instance.stderr, 'stderr', options, state); + + // The `error` event is caught by `once(instance, 'spawn')` and `once(instance, 'close')`. + // But it creates an uncaught exception if it happens exactly one tick after 'spawn'. + // This prevents that. + instance.once('error', () => {}); + + await once(instance, 'spawn'); + return instance; + } catch (error) { + throw getSubprocessError(error, {}, state); + } +}; + +const bufferOutput = (stream, streamName, {internal}, state) => { + if (stream) { + stream.setEncoding('utf8'); + if (!internal.isIterating) { + internal.isIterating = false; + stream.on('data', chunk => { + state[streamName] += chunk; + state.output += chunk; + }); + } + } +}; + +const getResult = async (nodeChildProcess, state) => { + const instance = await nodeChildProcess; + const onClose = once(instance, 'close'); + + try { + await Promise.race([ + onClose, + ...instance.stdio.filter(Boolean).map(stream => onStreamError(stream)), + ]); + checkFailure(instance, state); + return state; + } catch (error) { + await Promise.allSettled([onClose]); + throw getSubprocessError(error, instance, state); + } +}; + +const onStreamError = async stream => { + for await (const [error] of on(stream, 'error')) { + // 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; + } + } +}; + +const checkFailure = ({exitCode, signalCode}, {command}) => { + if (signalCode) { + throw new Error(`Command was terminated with ${signalCode}: ${command}`); + } + + if (exitCode >= 1) { + throw new Error(`Command failed with exit code ${exitCode}: ${command}`); + } +}; + +const getSubprocessError = (error, {exitCode, signalCode}, state) => Object.assign( + error?.message?.startsWith('Command ') + ? error + : new Error(`Command failed: ${state.command}`, {cause: error}), + // `exitCode` can be a negative number (`errno`) when the `error` event is emitted on the `instance` + exitCode >= 1 ? {exitCode} : {}, + signalCode ? {signalName: signalCode} : {}, + state, +); diff --git a/source/result.js b/source/result.js index 9716418..d3d6d26 100644 --- a/source/result.js +++ b/source/result.js @@ -1,66 +1,27 @@ -import {once, on} from 'node:events'; import process from 'node:process'; -export const getResult = async (nodeChildProcess, {input}, context) => { - const instance = await nodeChildProcess; - if (input !== undefined) { - instance.stdin.end(input); - } - - const onClose = once(instance, 'close'); - +export const getResult = async (picoPromise, nodeChildProcess, context, options) => { try { - await Promise.race([ - onClose, - ...instance.stdio.filter(Boolean).map(stream => onStreamError(stream)), - ]); - checkFailure(context, getErrorOutput(instance)); - return getOutputs(context); + const {picoSubprocess} = await picoPromise; + const [result] = await Promise.all([picoSubprocess, nodeChildProcess, handleInput(picoSubprocess, options)]); + return updateResult(result, context); } catch (error) { - await Promise.allSettled([onClose]); - throw getResultError(error, instance, context); - } -}; - -const onStreamError = async stream => { - for await (const [error] of on(stream, 'error')) { - // 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; - } + error.message = error.message.replaceAll(error.command, context.command); + throw updateResult(error, context); } }; -const checkFailure = ({command}, {exitCode, signalName}) => { - if (signalName !== undefined) { - throw new Error(`Command was terminated with ${signalName}: ${command}`); - } - - if (exitCode !== undefined) { - throw new Error(`Command failed with exit code ${exitCode}: ${command}`); +const handleInput = async (picoSubprocess, {input}) => { + if (input !== undefined) { + const {stdin} = await picoSubprocess.nodeChildProcess; + stdin.end(input); } }; -export const getResultError = (error, instance, context) => Object.assign( - getErrorInstance(error, context), - getErrorOutput(instance), - getOutputs(context), -); - -const getErrorInstance = (error, {command}) => error?.message.startsWith('Command ') - ? error - : new Error(`Command failed: ${command}`, {cause: error}); - -const getErrorOutput = ({exitCode, signalCode}) => ({ - // `exitCode` can be a negative number (`errno`) when the `error` event is emitted on the `instance` - ...(exitCode < 1 ? {} : {exitCode}), - ...(signalCode === null ? {} : {signalName: signalCode}), -}); - -const getOutputs = ({state: {stdout, stderr, output}, command, start}) => ({ - stdout: getOutput(stdout), - stderr: getOutput(stderr), - output: getOutput(output), +const updateResult = (result, {command, start}) => Object.assign(result, { + stdout: getOutput(result.stdout), + stderr: getOutput(result.stderr), + output: getOutput(result.output), command, durationMs: Number(process.hrtime.bigint() - start) / 1e6, }); diff --git a/source/spawn.js b/source/spawn.js deleted file mode 100644 index 3772895..0000000 --- a/source/spawn.js +++ /dev/null @@ -1,47 +0,0 @@ -import {spawn} from 'node:child_process'; -import {once} from 'node:events'; -import process from 'node:process'; -import {getForcedShell, escapeArguments} from './windows.js'; -import {getResultError} from './result.js'; - -export const spawnSubprocess = async (file, commandArguments, options, context) => { - try { - // 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, - shell: options.shell || forcedShell, - }); - bufferOutput(instance.stdout, context, 'stdout'); - bufferOutput(instance.stderr, context, 'stderr'); - - // The `error` event is caught by `once(instance, 'spawn')` and `once(instance, 'close')`. - // But it creates an uncaught exception if it happens exactly one tick after 'spawn'. - // This prevents that. - instance.once('error', () => {}); - - await once(instance, 'spawn'); - return instance; - } catch (error) { - throw getResultError(error, {}, context); - } -}; - -const bufferOutput = (stream, {state}, streamName) => { - 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 eeb5a98..f751ae0 100644 --- a/source/windows.js +++ b/source/windows.js @@ -62,9 +62,9 @@ const exeExtensions = ['.exe', '.com']; // When setting `shell: true` under-the-hood, we must manually escape the file and arguments. // This ensures arguments are properly split, and prevents command injection. -export const escapeArguments = (file, commandArguments, forcedShell) => forcedShell - ? [escapeFile(file), commandArguments.map(argument => escapeArgument(argument))] - : [file, commandArguments]; +export const escapeArguments = (file, commandArguments, options, forcedShell) => forcedShell + ? [escapeFile(file), commandArguments.map(argument => escapeArgument(argument)), {...options, shell: true}] + : [file, commandArguments, options]; // `cmd.exe` escaping for arguments. // Taken from https://github.com/moxystudio/node-cross-spawn