init project
This commit is contained in:
+201
@@ -0,0 +1,201 @@
|
||||
import {openSync, readSync, closeSync} from 'node:fs';
|
||||
import {Buffer} from 'node:buffer';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import {whichCommandSync} from 'which-command';
|
||||
|
||||
/*
|
||||
On Windows, `node:child_process` cannot natively run many kinds of files (`.cmd`, `.bat`, shebang scripts, ...): without a shell it resolves neither `PATHEXT` nor shebangs, and it does not escape arguments. We resolve the command to its full file path and escape its arguments ourselves, so those work without an explicit shell, just like on Unix, where the OS handles all of this for us.
|
||||
*/
|
||||
export const parseCommandFile = (file, commandArguments, options) => {
|
||||
// The arguments are cloned since a shebang interpreter might be prepended below, which must not mutate the caller's array
|
||||
const parsed = {file, commandArguments: [...commandArguments], options};
|
||||
|
||||
// Under a shell, or on Unix, the OS resolves the file and escapes the arguments itself
|
||||
if (options.shell || process.platform !== 'win32') {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return escapeWindowsCommand(parsed);
|
||||
};
|
||||
|
||||
// Only `.exe` and `.com` files can be spawned directly; anything else needs `cmd.exe`
|
||||
const directlyExecutableRegExp = /\.(?:com|exe)$/i;
|
||||
|
||||
// `.cmd` and `.bat` files re-expand their own arguments via `%*`/`%1`, so metacharacters must survive being interpreted by `cmd.exe` twice: once when the batch file is invoked and once when it forwards the arguments
|
||||
const batchFileRegExp = /\.(?:bat|cmd)$/i;
|
||||
|
||||
const escapeWindowsCommand = parsed => {
|
||||
// Resolve the file to an absolute path, following its shebang to the interpreter if any
|
||||
const resolvedFile = resolveWithShebang(parsed);
|
||||
|
||||
// A directly executable file is spawned by its resolved path, bypassing `cmd.exe` and its escaping
|
||||
if (resolvedFile !== undefined && directlyExecutableRegExp.test(resolvedFile)) {
|
||||
if (parsed.options.argv0 === undefined) {
|
||||
parsed.options.argv0 = parsed.file;
|
||||
}
|
||||
|
||||
parsed.file = resolvedFile;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/*
|
||||
`cmd.exe` treats CR and LF as command separators and offers no way to escape them, so allowing either would enable command injection.
|
||||
Reject them instead.
|
||||
*/
|
||||
for (const value of [parsed.file, ...parsed.commandArguments]) {
|
||||
assertNoLineBreak(value);
|
||||
}
|
||||
|
||||
const isDoubleEscape = resolvedFile !== undefined && batchFileRegExp.test(resolvedFile);
|
||||
// POSIX separators must become Windows ones (`foo/bar` -> `foo\bar`), otherwise resolution always fails with ENOENT
|
||||
const escapedFile = escapeMetaChars(path.normalize(resolvedFile ?? parsed.file));
|
||||
const escapedArguments = parsed.commandArguments.map(argument => escapeArgument(argument, isDoubleEscape));
|
||||
const commandLine = `"${[escapedFile, ...escapedArguments].join(' ')}"`;
|
||||
|
||||
// Let `node:child_process` pass the already-escaped command line through untouched
|
||||
parsed.options.windowsVerbatimArguments = true;
|
||||
return {
|
||||
file: process.env.comspec || 'cmd.exe',
|
||||
commandArguments: ['/d', '/s', '/c', commandLine],
|
||||
options: parsed.options,
|
||||
};
|
||||
};
|
||||
|
||||
// Resolve the command's absolute path, then, if it is a shebang script, resolve its interpreter instead, since Windows cannot run shebangs natively
|
||||
const resolveWithShebang = parsed => {
|
||||
const resolvedFile = resolvePath(parsed);
|
||||
const interpreter = resolvedFile !== undefined && readShebang(resolvedFile);
|
||||
if (!interpreter) {
|
||||
return resolvedFile;
|
||||
}
|
||||
|
||||
// Run the interpreter with the script as its first argument, then resolve the interpreter's own path
|
||||
parsed.commandArguments.unshift(resolvedFile);
|
||||
parsed.file = interpreter;
|
||||
return resolvePath(parsed);
|
||||
};
|
||||
|
||||
// Search `PATH` for the command, resolving its Windows executable extension via `PATHEXT`
|
||||
const resolvePath = parsed => {
|
||||
const environment = parsed.options.env || process.env;
|
||||
const cwd = parsed.options.cwd ?? process.cwd();
|
||||
const environmentPathExt = getWindowsEnvironmentValue(environment, 'PATHEXT');
|
||||
const commandExtension = path.extname(parsed.file);
|
||||
// Commands with an explicit extension must be tried verbatim so shebang scripts work even when their extension is excluded from PATHEXT.
|
||||
const pathExt = commandExtension === '' ? environmentPathExt : `${commandExtension}${path.delimiter}${environmentPathExt ?? ''}`;
|
||||
if (hasWindowsPathSeparator(parsed.file)) {
|
||||
return whichCommandSync(path.resolve(cwd, parsed.file), {cwd, pathExt});
|
||||
}
|
||||
|
||||
const searchPath = getWindowsEnvironmentValue(environment, 'PATH') ?? getWindowsEnvironmentValue(process.env, 'PATH') ?? '';
|
||||
const resolveOptions = {
|
||||
cwd,
|
||||
path: searchPath,
|
||||
pathExt,
|
||||
};
|
||||
|
||||
return shouldSearchCurrentDirectory(environment) ? whichCommandSync(parsed.file, resolveOptions) : resolvePathDirectories(parsed.file, resolveOptions);
|
||||
};
|
||||
|
||||
const hasWindowsPathSeparator = file => file.includes('/') || file.includes('\\') || file.includes(':');
|
||||
|
||||
const shouldSearchCurrentDirectory = environment => getWindowsEnvironmentValue(process.env, 'NODEFAULTCURRENTDIRECTORYINEXEPATH') === undefined && getWindowsEnvironmentValue(environment, 'NODEFAULTCURRENTDIRECTORYINEXEPATH') === undefined;
|
||||
|
||||
const resolvePathDirectories = (file, {cwd, path: searchPath, pathExt}) => {
|
||||
for (const directory of searchPath.split(path.delimiter)) {
|
||||
const unquotedDirectory = directory.length > 1 && directory.startsWith('"') && directory.endsWith('"') ? directory.slice(1, -1) : directory;
|
||||
if (unquotedDirectory === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolvedFile = whichCommandSync(path.resolve(cwd, unquotedDirectory, file), {cwd, pathExt});
|
||||
if (resolvedFile !== undefined) {
|
||||
return resolvedFile;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Node sorts Windows environment keys and uses the first case-insensitive match when spawning.
|
||||
const getWindowsEnvironmentValue = (environment, name) => {
|
||||
const environmentKey = Object.keys(environment).sort().find(key => key.toUpperCase() === name);
|
||||
return environmentKey === undefined ? undefined : environment[environmentKey];
|
||||
};
|
||||
|
||||
const SHEBANG_BYTE_LENGTH = 150;
|
||||
|
||||
// Read the file's first bytes to find its shebang interpreter, if it has one
|
||||
const readShebang = file => {
|
||||
const buffer = Buffer.alloc(SHEBANG_BYTE_LENGTH);
|
||||
|
||||
try {
|
||||
const fileDescriptor = openSync(file, 'r');
|
||||
try {
|
||||
readSync(fileDescriptor, buffer, 0, SHEBANG_BYTE_LENGTH, 0);
|
||||
} finally {
|
||||
closeSync(fileDescriptor);
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return parseShebang(buffer.toString());
|
||||
};
|
||||
|
||||
const shebangRegExp = /^#!(?<line>.*)/;
|
||||
|
||||
/*
|
||||
Extract the interpreter from a shebang line, e.g. `#!/usr/bin/env node` -> `node`.
|
||||
*/
|
||||
const parseShebang = contents => {
|
||||
const shebangLine = contents.match(shebangRegExp)?.groups.line.trim();
|
||||
if (!shebangLine) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [interpreterPath, argument] = shebangLine.split(' ');
|
||||
const interpreter = interpreterPath.split('/').at(-1);
|
||||
if (interpreter === 'env') {
|
||||
return argument;
|
||||
}
|
||||
|
||||
return argument ? `${interpreter} ${argument}` : interpreter;
|
||||
};
|
||||
|
||||
const lineBreakRegExp = /[\n\r]/;
|
||||
|
||||
const assertNoLineBreak = value => {
|
||||
if (lineBreakRegExp.test(value)) {
|
||||
throw new TypeError(`The command and its arguments cannot contain a line break on Windows without a shell.\nThis would allow a command injection with \`cmd.exe\`.\nInvalid value: ${JSON.stringify(`${value}`)}`);
|
||||
}
|
||||
};
|
||||
|
||||
// See https://web.archive.org/web/20241220221102/https://www.robvanderwoude.com/escapechars.php
|
||||
// eslint-disable-next-line regexp/sort-character-class-elements
|
||||
const metaCharsRegExp = /[()\][%!^"`<>&|;, *?]/g;
|
||||
|
||||
// Prefix every `cmd.exe` metacharacter with a caret to neutralize it
|
||||
const escapeMetaChars = value => value.replaceAll(metaCharsRegExp, '^$&');
|
||||
|
||||
const backslashRunRegExp = /\\+/g;
|
||||
|
||||
const escapeArgument = (rawArgument, doubleEscape) => {
|
||||
/*
|
||||
Escape backslashes and double quotes for `cmd.exe`, following the algorithm at https://web.archive.org/web/20240930203505/https://qntm.org/cmd.
|
||||
|
||||
A run of backslashes only needs doubling when it precedes a double quote, or the end of the argument since that becomes a double quote once the argument is wrapped below. Otherwise the backslashes would be taken as escaping that quote. Every double quote is then escaped in turn.
|
||||
|
||||
Each backslash run is matched exactly once and consumed, so a long run cannot trigger the quadratic backtracking a naive pattern would, which would be a denial-of-service risk.
|
||||
*/
|
||||
const argument = `${rawArgument}`
|
||||
.replaceAll(backslashRunRegExp, (backslashes, offset, string) => {
|
||||
const nextCharacter = string[offset + backslashes.length];
|
||||
const isPrecedesDoubleQuote = nextCharacter === '"' || nextCharacter === undefined;
|
||||
return isPrecedesDoubleQuote ? backslashes.repeat(2) : backslashes;
|
||||
})
|
||||
.replaceAll('"', '\\"');
|
||||
|
||||
// Wrap the whole argument in double quotes, then caret-escape the metacharacters, a second time when targeting a cmd-shim
|
||||
const escapedArgument = escapeMetaChars(`"${argument}"`);
|
||||
return doubleEscape ? escapeMetaChars(escapedArgument) : escapedArgument;
|
||||
};
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import {logCommand} from '../verbose/start.js';
|
||||
import {getVerboseInfo} from '../verbose/info.js';
|
||||
import {getStartTime} from '../return/duration.js';
|
||||
import {joinCommand} from './escape.js';
|
||||
import {normalizeFdSpecificOption} from './specific.js';
|
||||
|
||||
// Compute `result.command`, `result.escapedCommand` and `verbose`-related information
|
||||
export const handleCommand = (filePath, rawArguments, rawOptions) => {
|
||||
const startTime = getStartTime();
|
||||
const {command, escapedCommand} = joinCommand(filePath, rawArguments);
|
||||
const verbose = normalizeFdSpecificOption(rawOptions, 'verbose');
|
||||
const verboseInfo = getVerboseInfo(verbose, escapedCommand, {...rawOptions});
|
||||
logCommand(escapedCommand, verboseInfo);
|
||||
return {
|
||||
command,
|
||||
escapedCommand,
|
||||
startTime,
|
||||
verboseInfo,
|
||||
};
|
||||
};
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import {statSync} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import {safeNormalizeFileUrl} from './file-url.js';
|
||||
|
||||
// Normalize `cwd` option
|
||||
export const normalizeCwd = (cwd = getDefaultCwd()) => {
|
||||
const cwdString = safeNormalizeFileUrl(cwd, 'The "cwd" option');
|
||||
return path.resolve(cwdString);
|
||||
};
|
||||
|
||||
const getDefaultCwd = () => {
|
||||
try {
|
||||
return process.cwd();
|
||||
} catch (error) {
|
||||
error.message = `The current directory does not exist.\n${error.message}`;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// When `cwd` option has an invalid value, provide with a better error message
|
||||
export const fixCwdError = (originalMessage, cwd) => {
|
||||
if (cwd === getDefaultCwd()) {
|
||||
return originalMessage;
|
||||
}
|
||||
|
||||
let cwdStat;
|
||||
try {
|
||||
cwdStat = statSync(cwd);
|
||||
} catch (error) {
|
||||
return `The "cwd" option is invalid: ${cwd}.\n${error.message}\n${originalMessage}`;
|
||||
}
|
||||
|
||||
if (!cwdStat.isDirectory()) {
|
||||
return `The "cwd" option is not a directory: ${cwd}.\n${originalMessage}`;
|
||||
}
|
||||
|
||||
return originalMessage;
|
||||
};
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
// Validate `encoding` option
|
||||
export const validateEncoding = ({encoding}) => {
|
||||
if (ENCODINGS.has(encoding)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const correctEncoding = getCorrectEncoding(encoding);
|
||||
if (correctEncoding !== undefined) {
|
||||
throw new TypeError(`Invalid option \`encoding: ${serializeEncoding(encoding)}\`.
|
||||
Please rename it to ${serializeEncoding(correctEncoding)}.`);
|
||||
}
|
||||
|
||||
const correctEncodings = [...ENCODINGS].map(correctEncoding => serializeEncoding(correctEncoding)).join(', ');
|
||||
throw new TypeError(`Invalid option \`encoding: ${serializeEncoding(encoding)}\`.
|
||||
Please rename it to one of: ${correctEncodings}.`);
|
||||
};
|
||||
|
||||
const TEXT_ENCODINGS = new Set(['utf8', 'utf16le']);
|
||||
export const BINARY_ENCODINGS = new Set(['buffer', 'hex', 'base64', 'base64url', 'latin1', 'ascii']);
|
||||
const ENCODINGS = TEXT_ENCODINGS.union(BINARY_ENCODINGS);
|
||||
|
||||
const getCorrectEncoding = encoding => {
|
||||
if (encoding === null) {
|
||||
return 'buffer';
|
||||
}
|
||||
|
||||
if (typeof encoding !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
const lowerEncoding = encoding.toLowerCase();
|
||||
if (lowerEncoding in ENCODING_ALIASES) {
|
||||
return ENCODING_ALIASES[lowerEncoding];
|
||||
}
|
||||
|
||||
if (ENCODINGS.has(lowerEncoding)) {
|
||||
return lowerEncoding;
|
||||
}
|
||||
};
|
||||
|
||||
const ENCODING_ALIASES = {
|
||||
// eslint-disable-next-line unicorn/text-encoding-identifier-case
|
||||
'utf-8': 'utf8',
|
||||
'utf-16le': 'utf16le',
|
||||
'ucs-2': 'utf16le',
|
||||
ucs2: 'utf16le',
|
||||
binary: 'latin1',
|
||||
};
|
||||
|
||||
const serializeEncoding = encoding => typeof encoding === 'string' ? `"${encoding}"` : String(encoding);
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import {platform} from 'node:process';
|
||||
import {stripVTControlCharacters} from 'node:util';
|
||||
|
||||
// Compute `result.command` and `result.escapedCommand`
|
||||
export const joinCommand = (filePath, rawArguments) => {
|
||||
const fileAndArguments = [filePath, ...rawArguments];
|
||||
const command = fileAndArguments.join(' ');
|
||||
const escapedCommand = fileAndArguments
|
||||
.map(fileAndArgument => quoteString(escapeControlCharacters(fileAndArgument)))
|
||||
.join(' ');
|
||||
return {command, escapedCommand};
|
||||
};
|
||||
|
||||
// Remove ANSI sequences and escape control characters and newlines
|
||||
export const escapeLines = lines => stripVTControlCharacters(lines)
|
||||
.split('\n')
|
||||
.map(line => escapeControlCharacters(line))
|
||||
.join('\n');
|
||||
|
||||
const escapeControlCharacters = line => line.replaceAll(SPECIAL_CHAR_REGEXP, character => escapeControlCharacter(character));
|
||||
|
||||
const escapeControlCharacter = character => {
|
||||
const commonEscape = COMMON_ESCAPES[character];
|
||||
if (commonEscape !== undefined) {
|
||||
return commonEscape;
|
||||
}
|
||||
|
||||
const codepoint = character.codePointAt(0);
|
||||
const codepointHex = codepoint.toString(16);
|
||||
return codepoint <= ASTRAL_START
|
||||
? `\\u${codepointHex.padStart(4, '0')}`
|
||||
: `\\U${codepointHex}`;
|
||||
};
|
||||
|
||||
// Characters that would create issues when printed are escaped using the \u or \U notation.
|
||||
// Those include control characters and newlines.
|
||||
// The \u and \U notation is Bash specific, but there is no way to do this in a shell-agnostic way.
|
||||
// Some shells do not even have a way to print those characters in an escaped fashion.
|
||||
// Therefore, we prioritize printing those safely, instead of allowing those to be copy-pasted.
|
||||
// List of Unicode character categories: https://www.fileformat.info/info/unicode/category/index.htm
|
||||
const getSpecialCharRegExp = () => {
|
||||
try {
|
||||
// This throws when using Node.js without ICU support.
|
||||
// When using a RegExp literal, this would throw at parsing-time, instead of runtime.
|
||||
// eslint-disable-next-line prefer-regex-literals
|
||||
return new RegExp('\\p{Separator}|\\p{Other}', 'gu');
|
||||
} catch {
|
||||
// Similar to the above RegExp, but works even when Node.js has been built without ICU support.
|
||||
// Unlike the above RegExp, it only covers whitespaces and C0/C1 control characters.
|
||||
// It does not cover some edge cases, such as Unicode reserved characters.
|
||||
// See https://github.com/sindresorhus/execa/issues/1143
|
||||
// eslint-disable-next-line no-control-regex, regexp/no-control-character, unicorn/prefer-unicode-code-point-escapes
|
||||
return /[\s\u0000-\u001F\u007F-\u009F\u00AD]/g;
|
||||
}
|
||||
};
|
||||
|
||||
const SPECIAL_CHAR_REGEXP = getSpecialCharRegExp();
|
||||
|
||||
// Accepted by $'...' in Bash.
|
||||
// Exclude \a \e \v which are accepted in Bash but not in JavaScript (except \v) and JSON.
|
||||
const COMMON_ESCAPES = {
|
||||
' ': ' ',
|
||||
'\b': '\\b',
|
||||
'\f': '\\f',
|
||||
'\n': '\\n',
|
||||
'\r': '\\r',
|
||||
'\t': '\\t',
|
||||
};
|
||||
|
||||
// Up until that codepoint, \u notation can be used instead of \U
|
||||
const ASTRAL_START = 65_535;
|
||||
|
||||
// Some characters are shell-specific, i.e. need to be escaped when the command is copy-pasted then run.
|
||||
// Escaping is shell-specific. We cannot know which shell is used: `process.platform` detection is not enough.
|
||||
// For example, Windows users could be using `cmd.exe`, Powershell or Bash for Windows which all use different escaping.
|
||||
// We use '...' on Unix, which is POSIX shell compliant and escape all characters but ' so this is fairly safe.
|
||||
// On Windows, we assume cmd.exe is used and escape with "...", which also works with Powershell.
|
||||
const quoteString = escapedArgument => {
|
||||
if (NO_ESCAPE_REGEXP.test(escapedArgument)) {
|
||||
return escapedArgument;
|
||||
}
|
||||
|
||||
return platform === 'win32'
|
||||
? `"${escapedArgument.replaceAll('"', '""')}"`
|
||||
: `'${escapedArgument.replaceAll('\'', '\'\\\'\'')}'`;
|
||||
};
|
||||
|
||||
const NO_ESCAPE_REGEXP = /^[\w\-./]+$/;
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import {parseFd} from './specific.js';
|
||||
|
||||
// Retrieve stream targeted by the `to` option
|
||||
export const getToStream = (destination, to = 'stdin') => {
|
||||
const isWritable = true;
|
||||
const {options, fileDescriptors} = SUBPROCESS_OPTIONS.get(destination);
|
||||
const fdNumber = getFdNumber(fileDescriptors, to, isWritable);
|
||||
const destinationStream = destination.stdio[fdNumber];
|
||||
|
||||
if (destinationStream === null) {
|
||||
throw new TypeError(getInvalidStdioOptionMessage(fdNumber, to, options, isWritable));
|
||||
}
|
||||
|
||||
return destinationStream;
|
||||
};
|
||||
|
||||
// Retrieve stream targeted by the `from` option
|
||||
export const getFromStream = (source, from = 'stdout') => {
|
||||
const isWritable = false;
|
||||
const {options, fileDescriptors} = SUBPROCESS_OPTIONS.get(source);
|
||||
const fdNumber = getFdNumber(fileDescriptors, from, isWritable);
|
||||
const sourceStream = fdNumber === 'all' ? source.all : source.stdio[fdNumber];
|
||||
|
||||
if (sourceStream === null || sourceStream === undefined) {
|
||||
throw new TypeError(getInvalidStdioOptionMessage(fdNumber, from, options, isWritable));
|
||||
}
|
||||
|
||||
return sourceStream;
|
||||
};
|
||||
|
||||
// Keeps track of the options passed to each Execa call
|
||||
export const SUBPROCESS_OPTIONS = new WeakMap();
|
||||
|
||||
const getFdNumber = (fileDescriptors, fdName, isWritable) => {
|
||||
const fdNumber = parseFdNumber(fdName, isWritable);
|
||||
validateFdNumber(fdNumber, fdName, isWritable, fileDescriptors);
|
||||
return fdNumber;
|
||||
};
|
||||
|
||||
const parseFdNumber = (fdName, isWritable) => {
|
||||
const fdNumber = parseFd(fdName);
|
||||
if (fdNumber !== undefined) {
|
||||
return fdNumber;
|
||||
}
|
||||
|
||||
const {validOptions, defaultValue} = isWritable
|
||||
? {validOptions: '"stdin"', defaultValue: 'stdin'}
|
||||
: {validOptions: '"stdout", "stderr", "all"', defaultValue: 'stdout'};
|
||||
throw new TypeError(`"${getOptionName(isWritable)}" must not be "${fdName}".
|
||||
It must be ${validOptions} or "fd3", "fd4" (and so on).
|
||||
It is optional and defaults to "${defaultValue}".`);
|
||||
};
|
||||
|
||||
const validateFdNumber = (fdNumber, fdName, isWritable, fileDescriptors) => {
|
||||
const fileDescriptor = fileDescriptors[getUsedDescriptor(fdNumber)];
|
||||
if (fileDescriptor === undefined) {
|
||||
throw new TypeError(`"${getOptionName(isWritable)}" must not be ${fdName}. That file descriptor does not exist.
|
||||
Please set the "stdio" option to ensure that file descriptor exists.`);
|
||||
}
|
||||
|
||||
if (fileDescriptor.direction === 'input' && !isWritable) {
|
||||
throw new TypeError(`"${getOptionName(isWritable)}" must not be ${fdName}. It must be a readable stream, not writable.`);
|
||||
}
|
||||
|
||||
if (fileDescriptor.direction !== 'input' && isWritable) {
|
||||
throw new TypeError(`"${getOptionName(isWritable)}" must not be ${fdName}. It must be a writable stream, not readable.
|
||||
If you meant to use it as input, please set its "stdio" option to \`{value: 'pipe', input: true}\`.`);
|
||||
}
|
||||
};
|
||||
|
||||
const getInvalidStdioOptionMessage = (fdNumber, fdName, options, isWritable) => {
|
||||
if (fdNumber === 'all' && !options.all) {
|
||||
return 'The "all" option must be true to use "from: \'all\'".';
|
||||
}
|
||||
|
||||
const {optionName, optionValue} = getInvalidStdioOption(fdNumber, options);
|
||||
return `The "${optionName}: ${serializeOptionValue(optionValue)}" option is incompatible with using "${getOptionName(isWritable)}: ${serializeOptionValue(fdName)}".
|
||||
Please set this option with "pipe" instead.`;
|
||||
};
|
||||
|
||||
const getInvalidStdioOption = (fdNumber, {stdin, stdout, stderr, stdio}) => {
|
||||
const usedDescriptor = getUsedDescriptor(fdNumber);
|
||||
|
||||
if (usedDescriptor === 0 && stdin !== undefined) {
|
||||
return {optionName: 'stdin', optionValue: stdin};
|
||||
}
|
||||
|
||||
if (usedDescriptor === 1 && stdout !== undefined) {
|
||||
return {optionName: 'stdout', optionValue: stdout};
|
||||
}
|
||||
|
||||
if (usedDescriptor === 2 && stderr !== undefined) {
|
||||
return {optionName: 'stderr', optionValue: stderr};
|
||||
}
|
||||
|
||||
return {optionName: `stdio[${usedDescriptor}]`, optionValue: stdio[usedDescriptor]};
|
||||
};
|
||||
|
||||
const getUsedDescriptor = fdNumber => fdNumber === 'all' ? 1 : fdNumber;
|
||||
|
||||
const getOptionName = isWritable => isWritable ? 'to' : 'from';
|
||||
|
||||
export const serializeOptionValue = value => {
|
||||
if (typeof value === 'string') {
|
||||
return `'${value}'`;
|
||||
}
|
||||
|
||||
return typeof value === 'number' ? `${value}` : 'Stream';
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
// Allow some arguments/options to be either a file path string or a file URL
|
||||
export const safeNormalizeFileUrl = (file, name) => {
|
||||
const fileString = normalizeFileUrl(normalizeDenoExecPath(file));
|
||||
|
||||
if (typeof fileString !== 'string') {
|
||||
throw new TypeError(`${name} must be a string or a file URL: ${fileString}.`);
|
||||
}
|
||||
|
||||
return fileString;
|
||||
};
|
||||
|
||||
// In Deno node:process execPath is a special object, not just a string:
|
||||
// https://github.com/denoland/deno/blob/f460188e583f00144000aa0d8ade08218d47c3c1/ext/node/polyfills/process.ts#L344
|
||||
const normalizeDenoExecPath = file => isDenoExecPath(file)
|
||||
? file.toString()
|
||||
: file;
|
||||
|
||||
export const isDenoExecPath = file => typeof file !== 'string'
|
||||
&& file
|
||||
&& Object.getPrototypeOf(file) === String.prototype;
|
||||
|
||||
// Same but also allows other values, e.g. `boolean` for the `shell` option
|
||||
export const normalizeFileUrl = file => file instanceof URL ? fileURLToPath(file) : file;
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import {npmRunPathEnv} from 'npm-run-path';
|
||||
import {normalizeForceKillAfterDelay} from '../terminate/kill.js';
|
||||
import {normalizeKillSignal} from '../terminate/signal.js';
|
||||
import {validateCancelSignal} from '../terminate/cancel.js';
|
||||
import {validateGracefulCancel} from '../terminate/graceful.js';
|
||||
import {validateTimeout} from '../terminate/timeout.js';
|
||||
import {handleNodeOption} from '../methods/node.js';
|
||||
import {validateIpcInputOption} from '../ipc/ipc-input.js';
|
||||
import {validateEncoding, BINARY_ENCODINGS} from './encoding-option.js';
|
||||
import {parseCommandFile} from './command-file.js';
|
||||
import {normalizeCwd} from './cwd.js';
|
||||
import {normalizeFileUrl} from './file-url.js';
|
||||
import {normalizeFdSpecificOptions} from './specific.js';
|
||||
|
||||
const cmdExeRegExp = /^cmd(?:\.exe)?$/i;
|
||||
|
||||
// Normalize the options object, and sometimes also the file paths and arguments.
|
||||
// Applies default values, validate allowed options, normalize them.
|
||||
export const normalizeOptions = (filePath, rawArguments, rawOptions) => {
|
||||
// Prevent prototype pollution by copying only own properties to a null-prototype object
|
||||
const sanitizedOptions = {__proto__: null, ...rawOptions};
|
||||
sanitizedOptions.cwd = normalizeCwd(sanitizedOptions.cwd);
|
||||
const [processedFile, processedArguments, processedOptions] = handleNodeOption(filePath, rawArguments, sanitizedOptions);
|
||||
const fdOptions = normalizeFdSpecificOptions(processedOptions);
|
||||
const options = addDefaultOptions(fdOptions);
|
||||
options.env = getEnv(options);
|
||||
|
||||
const {file, commandArguments} = parseCommandFile(processedFile, processedArguments, options);
|
||||
|
||||
validateTimeout(options);
|
||||
validateEncoding(options);
|
||||
validateIpcInputOption(options);
|
||||
validateCancelSignal(options);
|
||||
validateGracefulCancel(options);
|
||||
options.shell = normalizeFileUrl(options.shell);
|
||||
options.killSignal = normalizeKillSignal(options.killSignal);
|
||||
options.forceKillAfterDelay = normalizeForceKillAfterDelay(options.forceKillAfterDelay);
|
||||
options.lines = options.lines.map((lines, fdNumber) => lines && !BINARY_ENCODINGS.has(options.encoding) && options.buffer[fdNumber]);
|
||||
|
||||
// The file is now an absolute path resolved via `PATHEXT`, so its extension might be uppercase (`cmd.EXE`)
|
||||
if (process.platform === 'win32' && cmdExeRegExp.test(path.basename(file))) {
|
||||
// #116
|
||||
commandArguments.unshift('/q');
|
||||
}
|
||||
|
||||
return {file, commandArguments, options};
|
||||
};
|
||||
|
||||
// Use null prototype to prevent prototype pollution from leaking through
|
||||
const addDefaultOptions = ({
|
||||
extendEnv = true,
|
||||
preferLocal = false,
|
||||
cwd,
|
||||
localDir: localDirectory = cwd,
|
||||
encoding = 'utf8',
|
||||
reject = true,
|
||||
cleanup = true,
|
||||
killDescendants = false,
|
||||
all = false,
|
||||
windowsHide = true,
|
||||
killSignal = 'SIGTERM',
|
||||
forceKillAfterDelay = true,
|
||||
gracefulCancel = false,
|
||||
ipcInput,
|
||||
ipc = ipcInput !== undefined || gracefulCancel,
|
||||
serialization = 'advanced',
|
||||
...options
|
||||
}) => ({
|
||||
__proto__: null,
|
||||
...options,
|
||||
extendEnv,
|
||||
preferLocal,
|
||||
cwd,
|
||||
localDirectory,
|
||||
encoding,
|
||||
reject,
|
||||
cleanup,
|
||||
killDescendants,
|
||||
all,
|
||||
windowsHide,
|
||||
killSignal,
|
||||
forceKillAfterDelay,
|
||||
gracefulCancel,
|
||||
ipcInput,
|
||||
ipc,
|
||||
serialization,
|
||||
});
|
||||
|
||||
const getEnv = ({env: envOption, extendEnv, preferLocal, node, localDirectory, nodePath}) => {
|
||||
const env = extendEnv ? {...process.env, ...envOption} : envOption;
|
||||
|
||||
if (preferLocal || node) {
|
||||
return npmRunPathEnv({
|
||||
env,
|
||||
cwd: localDirectory,
|
||||
execPath: nodePath,
|
||||
preferLocal,
|
||||
addExecPath: node,
|
||||
});
|
||||
}
|
||||
|
||||
return env;
|
||||
};
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
// When the `shell` option is set, any command argument is concatenated as a single string by Node.js:
|
||||
// https://github.com/nodejs/node/blob/e38ce27f3ca0a65f68a31cedd984cddb927d4002/lib/child_process.js#L614-L624
|
||||
// However, since Node 24, it also prints a deprecation warning.
|
||||
// To avoid this warning, we perform that same operation before calling `node:child_process`.
|
||||
// Shells only understand strings, which is why Node.js performs that concatenation.
|
||||
// However, we rely on users splitting command arguments as an array.
|
||||
// For example, this allows us to easily detect which arguments are passed.
|
||||
// So we do want users to pass array of arguments even with `shell: true`, but we also want to avoid any warning.
|
||||
export const concatenateShell = (file, commandArguments, options) => options.shell && commandArguments.length > 0
|
||||
? [[file, ...commandArguments].join(' '), [], options]
|
||||
: [file, commandArguments, options];
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import {debuglog} from 'node:util';
|
||||
import isPlainObject from 'is-plain-obj';
|
||||
import {STANDARD_STREAMS_ALIASES} from '../utils/standard-stream.js';
|
||||
|
||||
// Some options can have different values for `stdout`/`stderr`/`fd3`.
|
||||
// This normalizes those to array of values.
|
||||
// For example, `{verbose: {stdout: 'none', stderr: 'full'}}` becomes `{verbose: ['none', 'none', 'full']}`
|
||||
export const normalizeFdSpecificOptions = options => {
|
||||
const optionsCopy = {...options};
|
||||
|
||||
for (const optionName of FD_SPECIFIC_OPTIONS) {
|
||||
optionsCopy[optionName] = normalizeFdSpecificOption(options, optionName);
|
||||
}
|
||||
|
||||
return optionsCopy;
|
||||
};
|
||||
|
||||
export const normalizeFdSpecificOption = (options, optionName) => {
|
||||
const stdioLength = getStdioLength(options);
|
||||
const optionBaseArray = Array.from({length: stdioLength + 1});
|
||||
const optionArray = normalizeFdSpecificValue(options[optionName], optionBaseArray, optionName, stdioLength);
|
||||
return addDefaultValue(optionArray, optionName);
|
||||
};
|
||||
|
||||
const getStdioLength = ({stdio}) => Array.isArray(stdio)
|
||||
? Math.max(stdio.length, STANDARD_STREAMS_ALIASES.length)
|
||||
: STANDARD_STREAMS_ALIASES.length;
|
||||
|
||||
const normalizeFdSpecificValue = (optionValue, optionArray, optionName, stdioLength) => isPlainObject(optionValue)
|
||||
? normalizeOptionObject(optionValue, optionArray, optionName, stdioLength)
|
||||
: optionArray.fill(optionValue);
|
||||
|
||||
const normalizeOptionObject = (optionValue, optionArray, optionName, stdioLength) => {
|
||||
for (const fdName of Object.keys(optionValue).sort(compareFdName)) {
|
||||
for (const fdNumber of parseFdName(fdName, optionName, stdioLength)) {
|
||||
optionArray[fdNumber] = optionValue[fdName];
|
||||
}
|
||||
}
|
||||
|
||||
return optionArray;
|
||||
};
|
||||
|
||||
// Ensure priority order when setting both `stdout`/`stderr`, `fd1`/`fd2`, and `all`
|
||||
const compareFdName = (fdNameA, fdNameB) => getFdNameOrder(fdNameA) < getFdNameOrder(fdNameB) ? 1 : -1;
|
||||
|
||||
const getFdNameOrder = fdName => {
|
||||
if (fdName === 'stdout' || fdName === 'stderr') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return fdName === 'all' ? 2 : 1;
|
||||
};
|
||||
|
||||
const parseFdName = (fdName, optionName, stdioLength) => {
|
||||
if (fdName === 'ipc') {
|
||||
return [stdioLength];
|
||||
}
|
||||
|
||||
const fdNumber = parseFd(fdName);
|
||||
if (fdNumber === undefined || fdNumber === 0) {
|
||||
throw new TypeError(`"${optionName}.${fdName}" is invalid.
|
||||
It must be "${optionName}.stdout", "${optionName}.stderr", "${optionName}.all", "${optionName}.ipc", or "${optionName}.fd3", "${optionName}.fd4" (and so on).`);
|
||||
}
|
||||
|
||||
if (fdNumber !== 'all' && fdNumber >= stdioLength) {
|
||||
throw new TypeError(`"${optionName}.${fdName}" is invalid: that file descriptor does not exist.
|
||||
Please set the "stdio" option to ensure that file descriptor exists.`);
|
||||
}
|
||||
|
||||
return fdNumber === 'all' ? [1, 2] : [fdNumber];
|
||||
};
|
||||
|
||||
// Use the same syntax for fd-specific options and the `from`/`to` options
|
||||
export const parseFd = fdName => {
|
||||
if (fdName === 'all') {
|
||||
return fdName;
|
||||
}
|
||||
|
||||
if (STANDARD_STREAMS_ALIASES.includes(fdName)) {
|
||||
return STANDARD_STREAMS_ALIASES.indexOf(fdName);
|
||||
}
|
||||
|
||||
const regexpResult = FD_REGEXP.exec(fdName);
|
||||
if (regexpResult !== null) {
|
||||
return Number(regexpResult.groups.fdNumber);
|
||||
}
|
||||
};
|
||||
|
||||
const FD_REGEXP = /^fd(?<fdNumber>\d+)$/;
|
||||
|
||||
const addDefaultValue = (optionArray, optionName) => optionArray.map(optionValue => optionValue === undefined
|
||||
? DEFAULT_OPTIONS[optionName]
|
||||
: optionValue);
|
||||
|
||||
// Default value for the `verbose` option
|
||||
const verboseDefault = debuglog('execa').enabled ? 'full' : 'none';
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
lines: false,
|
||||
buffer: true,
|
||||
maxBuffer: 1000 * 1000 * 100,
|
||||
verbose: verboseDefault,
|
||||
stripFinalNewline: true,
|
||||
};
|
||||
|
||||
// List of options which can have different values for `stdout`/`stderr`
|
||||
export const FD_SPECIFIC_OPTIONS = ['lines', 'buffer', 'maxBuffer', 'verbose', 'stripFinalNewline'];
|
||||
|
||||
// Retrieve fd-specific option
|
||||
export const getFdSpecificValue = (optionArray, fdNumber) => fdNumber === 'ipc'
|
||||
? optionArray.at(-1)
|
||||
: optionArray[fdNumber];
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import {initializeConcurrentStreams} from './concurrent.js';
|
||||
import {createReadable} from './readable.js';
|
||||
import {createWritable} from './writable.js';
|
||||
import {createDuplex} from './duplex.js';
|
||||
import {createReadableStream, createWritableStream, createTransformStream} from './web.js';
|
||||
import {createIterable} from './iterable.js';
|
||||
|
||||
// Add methods to convert the subprocess to a stream or iterable
|
||||
export const addConvertedStreams = (subprocess, {encoding}) => {
|
||||
const concurrentStreams = initializeConcurrentStreams();
|
||||
subprocess.readable = createReadable.bind(undefined, {subprocess, concurrentStreams, encoding});
|
||||
subprocess.writable = createWritable.bind(undefined, {subprocess, concurrentStreams});
|
||||
subprocess.duplex = createDuplex.bind(undefined, {subprocess, concurrentStreams, encoding});
|
||||
subprocess.readableStream = createReadableStream.bind(undefined, subprocess);
|
||||
subprocess.writableStream = createWritableStream.bind(undefined, subprocess);
|
||||
subprocess.transformStream = createTransformStream.bind(undefined, subprocess);
|
||||
subprocess.iterable = createIterable.bind(undefined, subprocess, encoding);
|
||||
subprocess[Symbol.asyncIterator] = createIterable.bind(undefined, subprocess, encoding, {});
|
||||
};
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import {createDeferred} from '../utils/deferred.js';
|
||||
|
||||
// When using multiple `.readable()`/`.writable()`/`.duplex()`, `final` and `destroy` should wait for other streams
|
||||
export const initializeConcurrentStreams = () => ({
|
||||
readableDestroy: new WeakMap(),
|
||||
writableFinal: new WeakMap(),
|
||||
writableDestroy: new WeakMap(),
|
||||
});
|
||||
|
||||
// Each file descriptor + `waitName` has its own array of promises.
|
||||
// Each promise is a single `.readable()`/`.writable()`/`.duplex()` call.
|
||||
export const addConcurrentStream = (concurrentStreams, stream, waitName) => {
|
||||
const weakMap = concurrentStreams[waitName];
|
||||
if (!weakMap.has(stream)) {
|
||||
weakMap.set(stream, []);
|
||||
}
|
||||
|
||||
const promises = weakMap.get(stream);
|
||||
const promise = createDeferred();
|
||||
promises.push(promise);
|
||||
const resolve = promise.resolve.bind(promise);
|
||||
return {resolve, promises};
|
||||
};
|
||||
|
||||
// Wait for other streams, but stop waiting when subprocess ends
|
||||
export const waitForConcurrentStreams = async ({resolve, promises}, subprocess) => {
|
||||
resolve();
|
||||
const [isSubprocessExit] = await Promise.race([
|
||||
Promise.allSettled([true, subprocess]),
|
||||
Promise.all([false, ...promises]),
|
||||
]);
|
||||
return !isSubprocessExit;
|
||||
};
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import {Duplex} from 'node:stream';
|
||||
import {callbackify} from 'node:util';
|
||||
import {BINARY_ENCODINGS} from '../arguments/encoding-option.js';
|
||||
import {
|
||||
getSubprocessStdout,
|
||||
getReadableOptions,
|
||||
getReadableMethods,
|
||||
onStdoutFinished,
|
||||
onReadableDestroy,
|
||||
} from './readable.js';
|
||||
import {
|
||||
getSubprocessStdin,
|
||||
getWritableMethods,
|
||||
onStdinFinished,
|
||||
onWritableDestroy,
|
||||
} from './writable.js';
|
||||
|
||||
// Create a `Duplex` stream combining both `subprocess.readable()` and `subprocess.writable()`
|
||||
export const createDuplex = ({subprocess, concurrentStreams, encoding}, {from, to, binary: binaryOption = true, preserveNewlines = true} = {}) => {
|
||||
const binary = binaryOption || BINARY_ENCODINGS.has(encoding);
|
||||
const {subprocessStdout, waitReadableDestroy} = getSubprocessStdout(subprocess, from, concurrentStreams);
|
||||
const {subprocessStdin, waitWritableFinal, waitWritableDestroy} = getSubprocessStdin(subprocess, to, concurrentStreams);
|
||||
const {readableEncoding, readableObjectMode, readableHighWaterMark} = getReadableOptions(subprocessStdout, binary);
|
||||
const {read, onStdoutDataDone} = getReadableMethods({
|
||||
subprocessStdout,
|
||||
subprocess,
|
||||
binary,
|
||||
encoding,
|
||||
preserveNewlines,
|
||||
});
|
||||
const duplex = new Duplex({
|
||||
read,
|
||||
...getWritableMethods(subprocessStdin, subprocess, waitWritableFinal),
|
||||
destroy: callbackify(onDuplexDestroy.bind(undefined, {
|
||||
subprocessStdout,
|
||||
subprocessStdin,
|
||||
subprocess,
|
||||
waitReadableDestroy,
|
||||
waitWritableFinal,
|
||||
waitWritableDestroy,
|
||||
})),
|
||||
readableHighWaterMark,
|
||||
writableHighWaterMark: subprocessStdin.writableHighWaterMark,
|
||||
readableObjectMode,
|
||||
writableObjectMode: subprocessStdin.writableObjectMode,
|
||||
encoding: readableEncoding,
|
||||
});
|
||||
onStdoutFinished({
|
||||
subprocessStdout,
|
||||
onStdoutDataDone,
|
||||
readable: duplex,
|
||||
subprocess,
|
||||
subprocessStdin,
|
||||
});
|
||||
onStdinFinished(subprocessStdin, duplex, subprocessStdout, subprocess);
|
||||
return duplex;
|
||||
};
|
||||
|
||||
const onDuplexDestroy = async ({subprocessStdout, subprocessStdin, subprocess, waitReadableDestroy, waitWritableFinal, waitWritableDestroy}, error) => {
|
||||
await Promise.all([
|
||||
onReadableDestroy({subprocessStdout, subprocess, waitReadableDestroy}, error),
|
||||
onWritableDestroy({
|
||||
subprocessStdin,
|
||||
subprocess,
|
||||
waitWritableFinal,
|
||||
waitWritableDestroy,
|
||||
}, error),
|
||||
]);
|
||||
};
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import {BINARY_ENCODINGS} from '../arguments/encoding-option.js';
|
||||
import {getFromStream} from '../arguments/fd-options.js';
|
||||
import {iterateOnSubprocessStream} from '../io/iterate.js';
|
||||
|
||||
// Convert the subprocess to an async iterable
|
||||
export const createIterable = (subprocess, encoding, {
|
||||
from,
|
||||
binary: binaryOption = false,
|
||||
preserveNewlines = false,
|
||||
} = {}) => {
|
||||
const binary = binaryOption || BINARY_ENCODINGS.has(encoding);
|
||||
const subprocessStdout = getFromStream(subprocess, from);
|
||||
const onStdoutData = iterateOnSubprocessStream({
|
||||
subprocessStdout,
|
||||
subprocess,
|
||||
binary,
|
||||
shouldEncode: true,
|
||||
encoding,
|
||||
preserveNewlines,
|
||||
});
|
||||
return iterateOnStdoutData(onStdoutData, subprocessStdout, subprocess);
|
||||
};
|
||||
|
||||
const iterateOnStdoutData = async function * (onStdoutData, subprocessStdout, subprocess) {
|
||||
try {
|
||||
yield * onStdoutData;
|
||||
} finally {
|
||||
if (subprocessStdout.readable) {
|
||||
subprocessStdout.destroy();
|
||||
}
|
||||
|
||||
await subprocess;
|
||||
}
|
||||
};
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import {Readable} from 'node:stream';
|
||||
import {callbackify} from 'node:util';
|
||||
import {BINARY_ENCODINGS} from '../arguments/encoding-option.js';
|
||||
import {getFromStream} from '../arguments/fd-options.js';
|
||||
import {iterateOnSubprocessStream, DEFAULT_OBJECT_HIGH_WATER_MARK} from '../io/iterate.js';
|
||||
import {createDeferred} from '../utils/deferred.js';
|
||||
import {addConcurrentStream, waitForConcurrentStreams} from './concurrent.js';
|
||||
import {
|
||||
safeWaitForSubprocessStdin,
|
||||
waitForSubprocessStdout,
|
||||
waitForSubprocess,
|
||||
destroyOtherStream,
|
||||
} from './shared.js';
|
||||
|
||||
// Create a `Readable` stream that forwards from `stdout` and awaits the subprocess
|
||||
export const createReadable = ({subprocess, concurrentStreams, encoding}, {from, binary: binaryOption = true, preserveNewlines = true} = {}) => {
|
||||
const binary = binaryOption || BINARY_ENCODINGS.has(encoding);
|
||||
const {subprocessStdout, waitReadableDestroy} = getSubprocessStdout(subprocess, from, concurrentStreams);
|
||||
const {readableEncoding, readableObjectMode, readableHighWaterMark} = getReadableOptions(subprocessStdout, binary);
|
||||
const {read, onStdoutDataDone} = getReadableMethods({
|
||||
subprocessStdout,
|
||||
subprocess,
|
||||
binary,
|
||||
encoding,
|
||||
preserveNewlines,
|
||||
});
|
||||
const readable = new Readable({
|
||||
read,
|
||||
destroy: callbackify(onReadableDestroy.bind(undefined, {subprocessStdout, subprocess, waitReadableDestroy})),
|
||||
highWaterMark: readableHighWaterMark,
|
||||
objectMode: readableObjectMode,
|
||||
encoding: readableEncoding,
|
||||
});
|
||||
onStdoutFinished({
|
||||
subprocessStdout,
|
||||
onStdoutDataDone,
|
||||
readable,
|
||||
subprocess,
|
||||
});
|
||||
return readable;
|
||||
};
|
||||
|
||||
// Retrieve `stdout` (or other stream depending on `from`)
|
||||
export const getSubprocessStdout = (subprocess, from, concurrentStreams) => {
|
||||
const subprocessStdout = getFromStream(subprocess, from);
|
||||
const waitReadableDestroy = addConcurrentStream(concurrentStreams, subprocessStdout, 'readableDestroy');
|
||||
return {subprocessStdout, waitReadableDestroy};
|
||||
};
|
||||
|
||||
export const getReadableOptions = ({readableEncoding, readableObjectMode, readableHighWaterMark}, binary) => binary
|
||||
? {readableEncoding, readableObjectMode, readableHighWaterMark}
|
||||
: {readableEncoding, readableObjectMode: true, readableHighWaterMark: DEFAULT_OBJECT_HIGH_WATER_MARK};
|
||||
|
||||
export const getReadableMethods = ({subprocessStdout, subprocess, binary, encoding, preserveNewlines}) => {
|
||||
const onStdoutDataDone = createDeferred();
|
||||
const onStdoutData = iterateOnSubprocessStream({
|
||||
subprocessStdout,
|
||||
subprocess,
|
||||
binary,
|
||||
shouldEncode: !binary,
|
||||
encoding,
|
||||
preserveNewlines,
|
||||
});
|
||||
|
||||
return {
|
||||
read() {
|
||||
onRead(this, onStdoutData, onStdoutDataDone);
|
||||
},
|
||||
onStdoutDataDone,
|
||||
};
|
||||
};
|
||||
|
||||
// Forwards data from `stdout` to `readable`
|
||||
const onRead = async (readable, onStdoutData, onStdoutDataDone) => {
|
||||
try {
|
||||
const {value, done} = await onStdoutData.next();
|
||||
if (done) {
|
||||
onStdoutDataDone.resolve();
|
||||
} else {
|
||||
readable.push(value);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// When `subprocess.stdout` ends/aborts/errors, do the same on `readable`.
|
||||
// Await the subprocess, for the same reason as above.
|
||||
export const onStdoutFinished = async ({subprocessStdout, onStdoutDataDone, readable, subprocess, subprocessStdin}) => {
|
||||
try {
|
||||
await waitForSubprocessStdout(subprocessStdout);
|
||||
await subprocess;
|
||||
await safeWaitForSubprocessStdin(subprocessStdin);
|
||||
await onStdoutDataDone;
|
||||
|
||||
if (readable.readable) {
|
||||
readable.push(null);
|
||||
}
|
||||
} catch (error) {
|
||||
await safeWaitForSubprocessStdin(subprocessStdin);
|
||||
destroyOtherReadable(readable, await getPrematureCloseError(subprocess, error));
|
||||
}
|
||||
};
|
||||
|
||||
const getPrematureCloseError = async (subprocess, error) => {
|
||||
if (error.code !== 'ERR_STREAM_PREMATURE_CLOSE') {
|
||||
return error;
|
||||
}
|
||||
|
||||
try {
|
||||
await subprocess;
|
||||
} catch (subprocessError) {
|
||||
return subprocessError;
|
||||
}
|
||||
|
||||
return error;
|
||||
};
|
||||
|
||||
// When `readable` aborts/errors, do the same on `subprocess.stdout`
|
||||
export const onReadableDestroy = async ({subprocessStdout, subprocess, waitReadableDestroy}, error) => {
|
||||
if (!await waitForConcurrentStreams(waitReadableDestroy, subprocess)) {
|
||||
return;
|
||||
}
|
||||
|
||||
destroyOtherReadable(subprocessStdout, error);
|
||||
await waitForSubprocess(subprocess, error);
|
||||
};
|
||||
|
||||
const destroyOtherReadable = (stream, error) => {
|
||||
destroyOtherStream(stream, stream.readable, error);
|
||||
};
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import {finished} from 'node:stream/promises';
|
||||
import {isStreamAbort} from '../resolve/wait-stream.js';
|
||||
|
||||
export const safeWaitForSubprocessStdin = async subprocessStdin => {
|
||||
if (subprocessStdin === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForSubprocessStdin(subprocessStdin);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
export const safeWaitForSubprocessStdout = async subprocessStdout => {
|
||||
if (subprocessStdout === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForSubprocessStdout(subprocessStdout);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
export const waitForSubprocessStdin = async subprocessStdin => {
|
||||
await finished(subprocessStdin, {cleanup: true, readable: false, writable: true});
|
||||
};
|
||||
|
||||
export const waitForSubprocessStdout = async subprocessStdout => {
|
||||
await finished(subprocessStdout, {cleanup: true, readable: true, writable: false});
|
||||
};
|
||||
|
||||
// When `readable` or `writable` aborts/errors, awaits the subprocess, for the reason mentioned above
|
||||
export const waitForSubprocess = async (subprocess, error) => {
|
||||
await subprocess;
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const destroyOtherStream = (stream, isOpen, error) => {
|
||||
if (error && !isStreamAbort(error)) {
|
||||
stream.destroy(error);
|
||||
} else if (isOpen) {
|
||||
stream.destroy();
|
||||
}
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import {Readable, Writable, Duplex} from 'node:stream';
|
||||
|
||||
// Web stream versions of `subprocess.readable()`, `subprocess.writable()` and `subprocess.duplex()`.
|
||||
// They wrap those methods with `.toWeb()`, so all error and abort handling is reused as is.
|
||||
export const createReadableStream = (subprocess, readableOptions) => Readable.toWeb(subprocess.readable(readableOptions));
|
||||
export const createWritableStream = (subprocess, writableOptions) => Writable.toWeb(subprocess.writable(writableOptions));
|
||||
export const createTransformStream = (subprocess, duplexOptions) => Duplex.toWeb(subprocess.duplex(duplexOptions));
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import {Writable} from 'node:stream';
|
||||
import {callbackify} from 'node:util';
|
||||
import {getToStream} from '../arguments/fd-options.js';
|
||||
import {isStreamAbort} from '../resolve/wait-stream.js';
|
||||
import {addConcurrentStream, waitForConcurrentStreams} from './concurrent.js';
|
||||
import {
|
||||
safeWaitForSubprocessStdout,
|
||||
waitForSubprocessStdin,
|
||||
waitForSubprocess,
|
||||
destroyOtherStream,
|
||||
} from './shared.js';
|
||||
|
||||
// Create a `Writable` stream that forwards to `stdin` and awaits the subprocess
|
||||
export const createWritable = ({subprocess, concurrentStreams}, {to} = {}) => {
|
||||
const {subprocessStdin, waitWritableFinal, waitWritableDestroy} = getSubprocessStdin(subprocess, to, concurrentStreams);
|
||||
const writable = new Writable({
|
||||
...getWritableMethods(subprocessStdin, subprocess, waitWritableFinal),
|
||||
destroy: callbackify(onWritableDestroy.bind(undefined, {
|
||||
subprocessStdin,
|
||||
subprocess,
|
||||
waitWritableFinal,
|
||||
waitWritableDestroy,
|
||||
})),
|
||||
highWaterMark: subprocessStdin.writableHighWaterMark,
|
||||
objectMode: subprocessStdin.writableObjectMode,
|
||||
});
|
||||
onStdinFinished(subprocessStdin, writable, undefined, subprocess);
|
||||
return writable;
|
||||
};
|
||||
|
||||
// Retrieve `stdin` (or other stream depending on `to`)
|
||||
export const getSubprocessStdin = (subprocess, to, concurrentStreams) => {
|
||||
const subprocessStdin = getToStream(subprocess, to);
|
||||
const waitWritableFinal = addConcurrentStream(concurrentStreams, subprocessStdin, 'writableFinal');
|
||||
const waitWritableDestroy = addConcurrentStream(concurrentStreams, subprocessStdin, 'writableDestroy');
|
||||
return {subprocessStdin, waitWritableFinal, waitWritableDestroy};
|
||||
};
|
||||
|
||||
export const getWritableMethods = (subprocessStdin, subprocess, waitWritableFinal) => ({
|
||||
write: onWrite.bind(undefined, subprocessStdin),
|
||||
final: callbackify(onWritableFinal.bind(undefined, subprocessStdin, subprocess, waitWritableFinal)),
|
||||
});
|
||||
|
||||
// Forwards data from `writable` to `stdin`
|
||||
const onWrite = (subprocessStdin, chunk, encoding, done) => {
|
||||
if (subprocessStdin.write(chunk, encoding)) {
|
||||
done();
|
||||
} else {
|
||||
subprocessStdin.once('drain', done);
|
||||
}
|
||||
};
|
||||
|
||||
// Ensures that the writable `final` and readable `end` events awaits the subprocess.
|
||||
// Like this, any subprocess failure is propagated as a stream `error` event, instead of being lost.
|
||||
// The user does not need to `await` the subprocess anymore, but now needs to await the stream completion or error.
|
||||
// When multiple writables are targeting the same stream, they wait for each other, unless the subprocess ends first.
|
||||
const onWritableFinal = async (subprocessStdin, subprocess, waitWritableFinal) => {
|
||||
if (!await waitForConcurrentStreams(waitWritableFinal, subprocess)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (subprocessStdin.writable) {
|
||||
subprocessStdin.end();
|
||||
}
|
||||
|
||||
await subprocess;
|
||||
};
|
||||
|
||||
// When `subprocess.stdin` ends/aborts/errors, do the same on `writable`.
|
||||
export const onStdinFinished = async (subprocessStdin, writable, subprocessStdout, subprocess) => {
|
||||
try {
|
||||
await waitForSubprocessStdin(subprocessStdin);
|
||||
await subprocess;
|
||||
if (writable.writable) {
|
||||
writable.end();
|
||||
}
|
||||
} catch (error) {
|
||||
await safeWaitForSubprocessStdout(subprocessStdout);
|
||||
destroyOtherWritable(writable, await getSubprocessError(subprocess, error));
|
||||
}
|
||||
};
|
||||
|
||||
const getSubprocessError = async (subprocess, error) => {
|
||||
if (!shouldUseSubprocessError(error)) {
|
||||
return error;
|
||||
}
|
||||
|
||||
try {
|
||||
await subprocess;
|
||||
} catch (subprocessError) {
|
||||
return subprocessError;
|
||||
}
|
||||
|
||||
return error;
|
||||
};
|
||||
|
||||
const shouldUseSubprocessError = error => error === undefined
|
||||
|| isStreamAbort(error);
|
||||
|
||||
// When `writable` aborts/errors, do the same on `subprocess.stdin`
|
||||
export const onWritableDestroy = async ({subprocessStdin, subprocess, waitWritableFinal, waitWritableDestroy}, error) => {
|
||||
await waitForConcurrentStreams(waitWritableFinal, subprocess);
|
||||
if (await waitForConcurrentStreams(waitWritableDestroy, subprocess)) {
|
||||
destroyOtherWritable(subprocessStdin, error);
|
||||
await waitForSubprocess(subprocess, error);
|
||||
}
|
||||
};
|
||||
|
||||
const destroyOtherWritable = (stream, error) => {
|
||||
destroyOtherStream(stream, stream.writable, error);
|
||||
};
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import {setImmediate} from 'node:timers/promises';
|
||||
import getStream, {getStreamAsArrayBuffer, getStreamAsArray} from 'get-stream';
|
||||
import {isArrayBuffer} from '../utils/uint-array.js';
|
||||
import {shouldLogOutput, logLines} from '../verbose/output.js';
|
||||
import {iterateForResult} from './iterate.js';
|
||||
import {handleMaxBuffer} from './max-buffer.js';
|
||||
import {getStripFinalNewline} from './strip-newline.js';
|
||||
|
||||
// Retrieve `result.stdout|stderr|all|stdio[*]`
|
||||
export const getStreamOutput = async ({stream, onStreamEnd, fdNumber, encoding, buffer, maxBuffer, lines, allMixed, stripFinalNewline, verboseInfo, streamInfo}) => {
|
||||
const logPromise = logOutputAsync({
|
||||
stream,
|
||||
onStreamEnd,
|
||||
fdNumber,
|
||||
encoding,
|
||||
allMixed,
|
||||
verboseInfo,
|
||||
streamInfo,
|
||||
});
|
||||
|
||||
if (!buffer) {
|
||||
await Promise.all([resumeStream(stream), logPromise]);
|
||||
return;
|
||||
}
|
||||
|
||||
const stripFinalNewlineValue = getStripFinalNewline(stripFinalNewline, fdNumber);
|
||||
const iterable = iterateForResult({
|
||||
stream,
|
||||
onStreamEnd,
|
||||
lines,
|
||||
encoding,
|
||||
stripFinalNewline: stripFinalNewlineValue,
|
||||
allMixed,
|
||||
});
|
||||
const [output] = await Promise.all([
|
||||
getStreamContents({
|
||||
stream,
|
||||
iterable,
|
||||
fdNumber,
|
||||
encoding,
|
||||
maxBuffer,
|
||||
lines,
|
||||
}),
|
||||
logPromise,
|
||||
]);
|
||||
return output;
|
||||
};
|
||||
|
||||
const logOutputAsync = async ({stream, onStreamEnd, fdNumber, encoding, allMixed, verboseInfo, streamInfo: {fileDescriptors}}) => {
|
||||
if (!shouldLogOutput({
|
||||
stdioItems: fileDescriptors[fdNumber]?.stdioItems,
|
||||
encoding,
|
||||
verboseInfo,
|
||||
fdNumber,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
const linesIterable = iterateForResult({
|
||||
stream,
|
||||
onStreamEnd,
|
||||
lines: true,
|
||||
encoding,
|
||||
stripFinalNewline: true,
|
||||
allMixed,
|
||||
});
|
||||
await logLines(linesIterable, stream, fdNumber, verboseInfo);
|
||||
};
|
||||
|
||||
// When using `buffer: false`, users need to read `subprocess.stdout|stderr|all` right away
|
||||
// See https://github.com/sindresorhus/execa/issues/730 and https://github.com/sindresorhus/execa/pull/729#discussion_r1465496310
|
||||
const resumeStream = async stream => {
|
||||
await setImmediate();
|
||||
if (stream.readableFlowing === null) {
|
||||
stream.resume();
|
||||
}
|
||||
};
|
||||
|
||||
const getStreamContents = async ({stream, stream: {readableObjectMode}, iterable, fdNumber, encoding, maxBuffer, lines}) => {
|
||||
try {
|
||||
if (readableObjectMode || lines) {
|
||||
return await getStreamAsArray(iterable, {maxBuffer});
|
||||
}
|
||||
|
||||
if (encoding === 'buffer') {
|
||||
return new Uint8Array(await getStreamAsArrayBuffer(iterable, {maxBuffer}));
|
||||
}
|
||||
|
||||
return await getStream(iterable, {maxBuffer});
|
||||
} catch (error) {
|
||||
return handleBufferedData(handleMaxBuffer({
|
||||
error,
|
||||
stream,
|
||||
readableObjectMode,
|
||||
lines,
|
||||
encoding,
|
||||
fdNumber,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// On failure, `result.stdout|stderr|all` should contain the currently buffered stream
|
||||
// They are automatically closed and flushed by Node.js when the subprocess exits
|
||||
// When `buffer` is `false`, `streamPromise` is `undefined` and there is no buffered data to retrieve
|
||||
export const getBufferedData = async streamPromise => {
|
||||
try {
|
||||
return await streamPromise;
|
||||
} catch (error) {
|
||||
return handleBufferedData(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure we are returning Uint8Arrays when using `encoding: 'buffer'`
|
||||
const handleBufferedData = ({bufferedData}) => isArrayBuffer(bufferedData)
|
||||
? new Uint8Array(bufferedData)
|
||||
: bufferedData;
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import {runGeneratorsSync} from '../transform/generator.js';
|
||||
import {joinToUint8Array, isUint8Array} from '../utils/uint-array.js';
|
||||
import {TYPE_TO_MESSAGE} from '../stdio/type.js';
|
||||
|
||||
// Apply `stdin`/`input`/`inputFile` options, before spawning, in sync mode, by converting it to the `input` option
|
||||
export const addInputOptionsSync = (fileDescriptors, options) => {
|
||||
for (const fdNumber of getInputFdNumbers(fileDescriptors)) {
|
||||
addInputOptionSync(fileDescriptors, fdNumber, options);
|
||||
}
|
||||
};
|
||||
|
||||
const getInputFdNumbers = fileDescriptors => new Set(Object.entries(fileDescriptors)
|
||||
.filter(([, {direction}]) => direction === 'input')
|
||||
.map(([fdNumber]) => Number(fdNumber)));
|
||||
|
||||
const addInputOptionSync = (fileDescriptors, fdNumber, options) => {
|
||||
const {stdioItems} = fileDescriptors[fdNumber];
|
||||
const allStdioItems = stdioItems.filter(({contents}) => contents !== undefined);
|
||||
if (allStdioItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fdNumber !== 0) {
|
||||
const [{type, optionName}] = allStdioItems;
|
||||
throw new TypeError(`Only the \`stdin\` option, not \`${optionName}\`, can be ${TYPE_TO_MESSAGE[type]} with synchronous methods.`);
|
||||
}
|
||||
|
||||
const allContents = allStdioItems.map(({contents}) => contents);
|
||||
const transformedContents = allContents.map(contents => applySingleInputGeneratorsSync(contents, stdioItems));
|
||||
options.input = joinToUint8Array(transformedContents);
|
||||
};
|
||||
|
||||
const applySingleInputGeneratorsSync = (contents, stdioItems) => {
|
||||
const newContents = runGeneratorsSync(contents, stdioItems, 'utf8', true);
|
||||
validateSerializable(newContents);
|
||||
return joinToUint8Array(newContents);
|
||||
};
|
||||
|
||||
const validateSerializable = newContents => {
|
||||
const invalidItem = newContents.find(item => typeof item !== 'string' && !isUint8Array(item));
|
||||
if (invalidItem !== undefined) {
|
||||
throw new TypeError(`The \`stdin\` option is invalid: when passing objects as input, a transform must be used to serialize them to strings or Uint8Arrays: ${invalidItem}.`);
|
||||
}
|
||||
};
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import {on} from 'node:events';
|
||||
import {getDefaultHighWaterMark} from 'node:stream';
|
||||
import {getEncodingTransformGenerator} from '../transform/encoding-transform.js';
|
||||
import {getSplitLinesGenerator} from '../transform/split.js';
|
||||
import {transformChunkSync, finalChunksSync} from '../transform/run-sync.js';
|
||||
|
||||
// Iterate over lines of `subprocess.stdout`, used by `subprocess.readable|duplex|iterable()`
|
||||
export const iterateOnSubprocessStream = ({subprocessStdout, subprocess, binary, shouldEncode, encoding, preserveNewlines}) => {
|
||||
const controller = new AbortController();
|
||||
stopReadingOnExit(subprocess, controller);
|
||||
return iterateOnStream({
|
||||
stream: subprocessStdout,
|
||||
controller,
|
||||
binary,
|
||||
shouldEncode: !subprocessStdout.readableObjectMode && shouldEncode,
|
||||
encoding,
|
||||
shouldSplit: !subprocessStdout.readableObjectMode,
|
||||
preserveNewlines,
|
||||
});
|
||||
};
|
||||
|
||||
const stopReadingOnExit = async (subprocess, controller) => {
|
||||
try {
|
||||
await subprocess;
|
||||
} catch {} finally {
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
|
||||
// Iterate over lines of `subprocess.stdout`, used by `result.stdout` and the `verbose: 'full'` option.
|
||||
// Applies the `lines` and `encoding` options.
|
||||
export const iterateForResult = ({stream, onStreamEnd, lines, encoding, stripFinalNewline, allMixed}) => {
|
||||
const controller = new AbortController();
|
||||
stopReadingOnStreamEnd(onStreamEnd, controller, stream);
|
||||
const objectMode = stream.readableObjectMode && !allMixed;
|
||||
return iterateOnStream({
|
||||
stream,
|
||||
controller,
|
||||
binary: encoding === 'buffer',
|
||||
shouldEncode: !objectMode,
|
||||
encoding,
|
||||
shouldSplit: !objectMode && lines,
|
||||
preserveNewlines: !stripFinalNewline,
|
||||
});
|
||||
};
|
||||
|
||||
const stopReadingOnStreamEnd = async (onStreamEnd, controller, stream) => {
|
||||
try {
|
||||
await onStreamEnd;
|
||||
} catch {
|
||||
stream.destroy();
|
||||
} finally {
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
|
||||
const iterateOnStream = ({stream, controller, binary, shouldEncode, encoding, shouldSplit, preserveNewlines}) => {
|
||||
const onStdoutChunk = on(stream, 'data', {
|
||||
signal: controller.signal,
|
||||
highWaterMark: HIGH_WATER_MARK,
|
||||
// Backward compatibility with older name for this option
|
||||
// See https://github.com/nodejs/node/pull/52080#discussion_r1525227861
|
||||
// @todo Remove after removing support for Node 21
|
||||
highWatermark: HIGH_WATER_MARK,
|
||||
});
|
||||
return iterateOnData({
|
||||
onStdoutChunk,
|
||||
controller,
|
||||
binary,
|
||||
shouldEncode,
|
||||
encoding,
|
||||
shouldSplit,
|
||||
preserveNewlines,
|
||||
});
|
||||
};
|
||||
|
||||
export const DEFAULT_OBJECT_HIGH_WATER_MARK = getDefaultHighWaterMark(true);
|
||||
|
||||
// The `highWaterMark` of `events.on()` is measured in number of events, not in bytes.
|
||||
// Not knowing the average amount of bytes per `data` event, we use the same heuristic as streams in objectMode, since they have the same issue.
|
||||
// Therefore, we use the value of `getDefaultHighWaterMark(true)`.
|
||||
// Note: this option does not exist on Node 18, but this is ok since the logic works without it. It just consumes more memory.
|
||||
const HIGH_WATER_MARK = DEFAULT_OBJECT_HIGH_WATER_MARK;
|
||||
|
||||
const iterateOnData = async function * ({onStdoutChunk, controller, binary, shouldEncode, encoding, shouldSplit, preserveNewlines}) {
|
||||
const generators = getGenerators({
|
||||
binary,
|
||||
shouldEncode,
|
||||
encoding,
|
||||
shouldSplit,
|
||||
preserveNewlines,
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const [chunk] of onStdoutChunk) {
|
||||
yield * transformChunkSync(chunk, generators, 0);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
yield * finalChunksSync(generators);
|
||||
}
|
||||
};
|
||||
|
||||
const getGenerators = ({binary, shouldEncode, encoding, shouldSplit, preserveNewlines}) => [
|
||||
getEncodingTransformGenerator(binary, encoding, !shouldEncode),
|
||||
getSplitLinesGenerator(binary, preserveNewlines, !shouldSplit, {}),
|
||||
].filter(Boolean);
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import {MaxBufferError} from 'get-stream';
|
||||
import {getStreamName} from '../utils/standard-stream.js';
|
||||
import {getFdSpecificValue} from '../arguments/specific.js';
|
||||
|
||||
// When the `maxBuffer` option is hit, a MaxBufferError is thrown.
|
||||
// The stream is aborted, then specific information is kept for the error message.
|
||||
export const handleMaxBuffer = ({error, stream, readableObjectMode, lines, encoding, fdNumber}) => {
|
||||
if (!(error instanceof MaxBufferError)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (fdNumber === 'all') {
|
||||
return error;
|
||||
}
|
||||
|
||||
const unit = getMaxBufferUnit(readableObjectMode, lines, encoding);
|
||||
error.maxBufferInfo = {fdNumber, unit};
|
||||
stream.destroy();
|
||||
throw error;
|
||||
};
|
||||
|
||||
const getMaxBufferUnit = (readableObjectMode, lines, encoding) => {
|
||||
if (readableObjectMode) {
|
||||
return 'objects';
|
||||
}
|
||||
|
||||
if (lines) {
|
||||
return 'lines';
|
||||
}
|
||||
|
||||
if (encoding === 'buffer') {
|
||||
return 'bytes';
|
||||
}
|
||||
|
||||
return 'characters';
|
||||
};
|
||||
|
||||
// Check the `maxBuffer` option with `result.ipcOutput`
|
||||
export const checkIpcMaxBuffer = (subprocess, ipcOutput, maxBuffer) => {
|
||||
if (ipcOutput.length !== maxBuffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const error = new MaxBufferError();
|
||||
error.maxBufferInfo = {fdNumber: 'ipc'};
|
||||
throw error;
|
||||
};
|
||||
|
||||
// Error message when `maxBuffer` is hit
|
||||
export const getMaxBufferMessage = (error, maxBuffer) => {
|
||||
const {streamName, threshold, unit} = getMaxBufferInfo(error, maxBuffer);
|
||||
return `Command's ${streamName} was larger than ${threshold} ${unit}`;
|
||||
};
|
||||
|
||||
const getMaxBufferInfo = (error, maxBuffer) => {
|
||||
if (error?.maxBufferInfo === undefined) {
|
||||
return {streamName: 'output', threshold: maxBuffer[1], unit: 'bytes'};
|
||||
}
|
||||
|
||||
const {maxBufferInfo: {fdNumber, unit}} = error;
|
||||
delete error.maxBufferInfo;
|
||||
|
||||
const threshold = getFdSpecificValue(maxBuffer, fdNumber);
|
||||
if (fdNumber === 'ipc') {
|
||||
return {streamName: 'IPC output', threshold, unit: 'messages'};
|
||||
}
|
||||
|
||||
return {streamName: getStreamName(fdNumber), threshold, unit};
|
||||
};
|
||||
|
||||
// The only way to apply `maxBuffer` with `spawnSync()` is to use the native `maxBuffer` option Node.js provides.
|
||||
// However, this has multiple limitations, and cannot behave the exact same way as the async behavior.
|
||||
// When the `maxBuffer` is hit, a `ENOBUFS` error is thrown.
|
||||
export const isMaxBufferSync = (resultError, output, maxBuffer) => resultError?.code === 'ENOBUFS'
|
||||
&& output !== null
|
||||
&& output.some(result => result !== null && result.length > getMaxBufferSync(maxBuffer));
|
||||
|
||||
// When `maxBuffer` is hit, ensure the result is truncated
|
||||
export const truncateMaxBufferSync = (result, isMaxBuffer, maxBuffer) => {
|
||||
if (!isMaxBuffer) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const maxBufferValue = getMaxBufferSync(maxBuffer);
|
||||
return result.length > maxBufferValue ? result.slice(0, maxBufferValue) : result;
|
||||
};
|
||||
|
||||
// `spawnSync()` does not allow differentiating `maxBuffer` per file descriptor, so we always use `stdout`
|
||||
export const getMaxBufferSync = ([, stdoutMaxBuffer]) => stdoutMaxBuffer;
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import mergeStreams from '@sindresorhus/merge-streams';
|
||||
import {isStandardStream} from '../utils/standard-stream.js';
|
||||
import {incrementMaxListeners} from '../utils/max-listeners.js';
|
||||
import {TRANSFORM_TYPES} from '../stdio/type.js';
|
||||
import {pipeStreams} from './pipeline.js';
|
||||
|
||||
// Handle `input`, `inputFile`, `stdin`, `stdout` and `stderr` options, after spawning, in async mode
|
||||
// When multiple input streams are used, we merge them to ensure the output stream ends only once each input stream has ended
|
||||
export const pipeOutputAsync = (subprocess, fileDescriptors, controller) => {
|
||||
const pipeGroups = new Map();
|
||||
|
||||
for (const [fdNumber, {stdioItems, direction}] of Object.entries(fileDescriptors)) {
|
||||
const transformItems = stdioItems.filter(({type}) => TRANSFORM_TYPES.has(type));
|
||||
for (const {stream} of transformItems) {
|
||||
pipeTransform(subprocess, stream, direction, fdNumber);
|
||||
}
|
||||
|
||||
const nonTransformItems = stdioItems.filter(({type}) => !TRANSFORM_TYPES.has(type));
|
||||
for (const {stream} of nonTransformItems) {
|
||||
pipeStdioItem({
|
||||
subprocess,
|
||||
stream,
|
||||
direction,
|
||||
fdNumber,
|
||||
pipeGroups,
|
||||
controller,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [outputStream, inputStreams] of pipeGroups) {
|
||||
const inputStream = inputStreams.length === 1 ? inputStreams[0] : mergeStreams(inputStreams);
|
||||
pipeStreams(inputStream, outputStream);
|
||||
}
|
||||
};
|
||||
|
||||
// When using transforms, `subprocess.stdin|stdout|stderr|stdio` is directly mutated
|
||||
const pipeTransform = (subprocess, stream, direction, fdNumber) => {
|
||||
if (direction === 'output') {
|
||||
pipeStreams(subprocess.stdio[fdNumber], stream);
|
||||
} else {
|
||||
pipeStreams(stream, subprocess.stdio[fdNumber]);
|
||||
}
|
||||
|
||||
const streamProperty = SUBPROCESS_STREAM_PROPERTIES[fdNumber];
|
||||
if (streamProperty !== undefined) {
|
||||
subprocess[streamProperty] = stream;
|
||||
}
|
||||
|
||||
subprocess.stdio[fdNumber] = stream;
|
||||
};
|
||||
|
||||
const SUBPROCESS_STREAM_PROPERTIES = ['stdin', 'stdout', 'stderr'];
|
||||
|
||||
// Most `std*` option values involve piping `subprocess.std*` to a stream.
|
||||
// The stream is either passed by the user or created internally.
|
||||
const pipeStdioItem = ({subprocess, stream, direction, fdNumber, pipeGroups, controller}) => {
|
||||
if (stream === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStandardStreamMaxListeners(stream, controller);
|
||||
|
||||
const [inputStream, outputStream] = direction === 'output'
|
||||
? [stream, subprocess.stdio[fdNumber]]
|
||||
: [subprocess.stdio[fdNumber], stream];
|
||||
const outputStreams = pipeGroups.get(inputStream) ?? [];
|
||||
pipeGroups.set(inputStream, [...outputStreams, outputStream]);
|
||||
};
|
||||
|
||||
// Multiple subprocesses might be piping from/to `process.std*` at the same time.
|
||||
// This is not necessarily an error and should not print a `maxListeners` warning.
|
||||
const setStandardStreamMaxListeners = (stream, {signal}) => {
|
||||
if (isStandardStream(stream)) {
|
||||
incrementMaxListeners(stream, MAX_LISTENERS_INCREMENT, signal);
|
||||
}
|
||||
};
|
||||
|
||||
// `source.pipe(destination)` adds at most 1 listener for each event.
|
||||
// If `stdin` option is an array, the values might be combined with `merge-streams`.
|
||||
// That library also listens for `source` end, which adds 1 more listener.
|
||||
const MAX_LISTENERS_INCREMENT = 2;
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import {writeFileSync, appendFileSync} from 'node:fs';
|
||||
import {shouldLogOutput, logLinesSync} from '../verbose/output.js';
|
||||
import {runGeneratorsSync} from '../transform/generator.js';
|
||||
import {splitLinesSync} from '../transform/split.js';
|
||||
import {joinToString, joinToUint8Array, bufferToUint8Array} from '../utils/uint-array.js';
|
||||
import {FILE_TYPES} from '../stdio/type.js';
|
||||
import {truncateMaxBufferSync} from './max-buffer.js';
|
||||
|
||||
// Apply `stdout`/`stderr` options, after spawning, in sync mode
|
||||
export const transformOutputSync = ({fileDescriptors, syncResult: {output}, options, isMaxBuffer, verboseInfo}) => {
|
||||
if (output === null) {
|
||||
return {output: Array.from({length: 3})};
|
||||
}
|
||||
|
||||
const state = {};
|
||||
const outputFiles = new Set();
|
||||
const transformedOutput = output.map((result, fdNumber) =>
|
||||
transformOutputResultSync({
|
||||
result,
|
||||
fileDescriptors,
|
||||
fdNumber,
|
||||
state,
|
||||
outputFiles,
|
||||
isMaxBuffer,
|
||||
verboseInfo,
|
||||
}, options));
|
||||
return {output: transformedOutput, ...state};
|
||||
};
|
||||
|
||||
const transformOutputResultSync = (
|
||||
{result, fileDescriptors, fdNumber, state, outputFiles, isMaxBuffer, verboseInfo},
|
||||
{buffer, encoding, lines, stripFinalNewline, maxBuffer},
|
||||
) => {
|
||||
if (result === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const truncatedResult = truncateMaxBufferSync(result, isMaxBuffer, maxBuffer);
|
||||
const uint8ArrayResult = bufferToUint8Array(truncatedResult);
|
||||
const {stdioItems, objectMode} = fileDescriptors[fdNumber];
|
||||
const chunks = runOutputGeneratorsSync([uint8ArrayResult], stdioItems, encoding, state);
|
||||
const {serializedResult, finalResult = serializedResult} = serializeChunks({
|
||||
chunks,
|
||||
objectMode,
|
||||
encoding,
|
||||
lines,
|
||||
stripFinalNewline,
|
||||
fdNumber,
|
||||
});
|
||||
|
||||
logOutputSync({
|
||||
serializedResult,
|
||||
fdNumber,
|
||||
state,
|
||||
verboseInfo,
|
||||
encoding,
|
||||
stdioItems,
|
||||
objectMode,
|
||||
});
|
||||
|
||||
const returnedResult = buffer[fdNumber] ? finalResult : undefined;
|
||||
|
||||
try {
|
||||
if (state.error === undefined) {
|
||||
writeToFiles(serializedResult, stdioItems, outputFiles);
|
||||
}
|
||||
|
||||
return returnedResult;
|
||||
} catch (error) {
|
||||
state.error = error;
|
||||
return returnedResult;
|
||||
}
|
||||
};
|
||||
|
||||
// Applies transform generators to `stdout`/`stderr`
|
||||
const runOutputGeneratorsSync = (chunks, stdioItems, encoding, state) => {
|
||||
try {
|
||||
return runGeneratorsSync(chunks, stdioItems, encoding, false);
|
||||
} catch (error) {
|
||||
state.error = error;
|
||||
return chunks;
|
||||
}
|
||||
};
|
||||
|
||||
// The contents is converted to three stages:
|
||||
// - serializedResult: used when the target is a file path/URL or a file descriptor (including 'inherit')
|
||||
// - finalResult/returnedResult: returned as `result.std*`
|
||||
const serializeChunks = ({chunks, objectMode, encoding, lines, stripFinalNewline, fdNumber}) => {
|
||||
if (objectMode) {
|
||||
return {serializedResult: chunks};
|
||||
}
|
||||
|
||||
if (encoding === 'buffer') {
|
||||
return {serializedResult: joinToUint8Array(chunks)};
|
||||
}
|
||||
|
||||
const serializedResult = joinToString(chunks, encoding);
|
||||
if (lines[fdNumber]) {
|
||||
return {serializedResult, finalResult: splitLinesSync(serializedResult, !stripFinalNewline[fdNumber], objectMode)};
|
||||
}
|
||||
|
||||
return {serializedResult};
|
||||
};
|
||||
|
||||
const logOutputSync = ({serializedResult, fdNumber, state, verboseInfo, encoding, stdioItems, objectMode}) => {
|
||||
if (!shouldLogOutput({
|
||||
stdioItems,
|
||||
encoding,
|
||||
verboseInfo,
|
||||
fdNumber,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
const linesArray = splitLinesSync(serializedResult, false, objectMode);
|
||||
|
||||
try {
|
||||
logLinesSync(linesArray, fdNumber, verboseInfo);
|
||||
} catch (error) {
|
||||
state.error ??= error;
|
||||
}
|
||||
};
|
||||
|
||||
// When the `std*` target is a file path/URL or a file descriptor
|
||||
const writeToFiles = (serializedResult, stdioItems, outputFiles) => {
|
||||
const fileItems = stdioItems.filter(({type}) => FILE_TYPES.has(type));
|
||||
for (const {path, append} of fileItems) {
|
||||
const pathString = typeof path === 'string' ? path : path.toString();
|
||||
if (append || outputFiles.has(pathString)) {
|
||||
appendFileSync(path, serializedResult);
|
||||
} else {
|
||||
outputFiles.add(pathString);
|
||||
writeFileSync(path, serializedResult);
|
||||
}
|
||||
}
|
||||
};
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import {finished} from 'node:stream/promises';
|
||||
import {isStandardStream} from '../utils/standard-stream.js';
|
||||
|
||||
// Similar to `Stream.pipeline(source, destination)`, but does not destroy standard streams
|
||||
export const pipeStreams = (source, destination) => {
|
||||
source.pipe(destination);
|
||||
onSourceFinish(source, destination);
|
||||
onDestinationFinish(source, destination);
|
||||
};
|
||||
|
||||
// `source.pipe(destination)` makes `destination` end when `source` ends.
|
||||
// But it does not propagate aborts or errors. This function does it.
|
||||
const onSourceFinish = async (source, destination) => {
|
||||
if (isStandardStream(source) || isStandardStream(destination)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await finished(source, {cleanup: true, readable: true, writable: false});
|
||||
} catch {}
|
||||
|
||||
endDestinationStream(destination);
|
||||
};
|
||||
|
||||
export const endDestinationStream = destination => {
|
||||
if (destination.writable) {
|
||||
destination.end();
|
||||
}
|
||||
};
|
||||
|
||||
// We do the same thing in the other direction as well.
|
||||
const onDestinationFinish = async (source, destination) => {
|
||||
if (isStandardStream(source) || isStandardStream(destination)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await finished(destination, {cleanup: true, readable: false, writable: true});
|
||||
} catch {}
|
||||
|
||||
abortSourceStream(source);
|
||||
};
|
||||
|
||||
export const abortSourceStream = source => {
|
||||
if (source.readable) {
|
||||
source.destroy();
|
||||
}
|
||||
};
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import stripFinalNewlineFunction from 'strip-final-newline';
|
||||
|
||||
// Apply `stripFinalNewline` option, which applies to `result.stdout|stderr|all|stdio[*]`.
|
||||
// If the `lines` option is used, it is applied on each line, but using a different function.
|
||||
export const stripNewline = (value, {stripFinalNewline}, fdNumber) => getStripFinalNewline(stripFinalNewline, fdNumber) && value !== undefined && !Array.isArray(value)
|
||||
? stripFinalNewlineFunction(value)
|
||||
: value;
|
||||
|
||||
// Retrieve `stripFinalNewline` option value, including with `subprocess.all`
|
||||
export const getStripFinalNewline = (stripFinalNewline, fdNumber) => fdNumber === 'all'
|
||||
? stripFinalNewline[1] || stripFinalNewline[2]
|
||||
: stripFinalNewline[fdNumber];
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
// The `ipc` option adds an `ipc` item to the `stdio` option
|
||||
export const normalizeIpcStdioArray = (stdioArray, ipc) => ipc
|
||||
? [...stdioArray, 'ipc']
|
||||
: stdioArray;
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import {checkIpcMaxBuffer} from '../io/max-buffer.js';
|
||||
import {shouldLogIpc, logIpcOutput} from '../verbose/ipc.js';
|
||||
import {getFdSpecificValue} from '../arguments/specific.js';
|
||||
import {loopOnMessages} from './get-each.js';
|
||||
|
||||
// Iterate through IPC messages sent by the subprocess
|
||||
export const waitForIpcOutput = async ({
|
||||
subprocess,
|
||||
buffer: bufferArray,
|
||||
maxBuffer: maxBufferArray,
|
||||
ipc,
|
||||
ipcOutput,
|
||||
verboseInfo,
|
||||
}) => {
|
||||
if (!ipc) {
|
||||
return ipcOutput;
|
||||
}
|
||||
|
||||
const isVerbose = shouldLogIpc(verboseInfo);
|
||||
const buffer = getFdSpecificValue(bufferArray, 'ipc');
|
||||
const maxBuffer = getFdSpecificValue(maxBufferArray, 'ipc');
|
||||
|
||||
for await (const message of loopOnMessages({
|
||||
anyProcess: subprocess,
|
||||
channel: subprocess.channel,
|
||||
isSubprocess: false,
|
||||
ipc,
|
||||
shouldAwait: false,
|
||||
reference: true,
|
||||
})) {
|
||||
if (buffer) {
|
||||
checkIpcMaxBuffer(subprocess, ipcOutput, maxBuffer);
|
||||
ipcOutput.push(message);
|
||||
}
|
||||
|
||||
if (isVerbose) {
|
||||
logIpcOutput(message, verboseInfo);
|
||||
}
|
||||
}
|
||||
|
||||
return ipcOutput;
|
||||
};
|
||||
|
||||
export const getBufferedIpcOutput = async (ipcOutputPromise, ipcOutput) => {
|
||||
await Promise.allSettled([ipcOutputPromise]);
|
||||
return ipcOutput;
|
||||
};
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import {EventEmitter} from 'node:events';
|
||||
import {onMessage, onDisconnect} from './incoming.js';
|
||||
import {undoAddedReferences} from './reference.js';
|
||||
|
||||
// Forward the `message` and `disconnect` events from the process and subprocess to a proxy emitter.
|
||||
// This prevents the `error` event from stopping IPC.
|
||||
// This also allows debouncing the `message` event.
|
||||
export const getIpcEmitter = (anyProcess, channel, isSubprocess) => {
|
||||
if (IPC_EMITTERS.has(anyProcess)) {
|
||||
return IPC_EMITTERS.get(anyProcess);
|
||||
}
|
||||
|
||||
// Use an `EventEmitter`, like the `process` that is being proxied
|
||||
// eslint-disable-next-line unicorn/prefer-event-target
|
||||
const ipcEmitter = new EventEmitter();
|
||||
ipcEmitter.connected = true;
|
||||
IPC_EMITTERS.set(anyProcess, ipcEmitter);
|
||||
forwardEvents({
|
||||
ipcEmitter,
|
||||
anyProcess,
|
||||
channel,
|
||||
isSubprocess,
|
||||
});
|
||||
return ipcEmitter;
|
||||
};
|
||||
|
||||
const IPC_EMITTERS = new WeakMap();
|
||||
|
||||
// The `message` and `disconnect` events are buffered in the subprocess until the first listener is setup.
|
||||
// However, unbuffering happens after one tick, so this give enough time for the caller to setup the listener on the proxy emitter first.
|
||||
// See https://github.com/nodejs/node/blob/2aaeaa863c35befa2ebaa98fb7737ec84df4d8e9/lib/internal/child_process.js#L721
|
||||
const forwardEvents = ({ipcEmitter, anyProcess, channel, isSubprocess}) => {
|
||||
const boundOnMessage = onMessage.bind(undefined, {
|
||||
anyProcess,
|
||||
channel,
|
||||
isSubprocess,
|
||||
ipcEmitter,
|
||||
});
|
||||
anyProcess.on('message', boundOnMessage);
|
||||
anyProcess.once('disconnect', onDisconnect.bind(undefined, {
|
||||
anyProcess,
|
||||
channel,
|
||||
isSubprocess,
|
||||
ipcEmitter,
|
||||
boundOnMessage,
|
||||
}));
|
||||
undoAddedReferences(channel, isSubprocess);
|
||||
};
|
||||
|
||||
// Check whether there might still be some `message` events to receive
|
||||
export const isConnected = anyProcess => {
|
||||
const ipcEmitter = IPC_EMITTERS.get(anyProcess);
|
||||
return ipcEmitter === undefined
|
||||
? anyProcess.channel !== undefined && anyProcess.channel !== null
|
||||
: ipcEmitter.connected;
|
||||
};
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import {once, on} from 'node:events';
|
||||
import {validateIpcMethod, disconnect, getStrictResponseError} from './validation.js';
|
||||
import {getIpcEmitter, isConnected} from './forward.js';
|
||||
import {addReference, removeReference} from './reference.js';
|
||||
|
||||
export const internalGetEachMessageOptions = Symbol('internalGetEachMessageOptions');
|
||||
|
||||
// Like `[sub]process.on('message')` but promise-based
|
||||
export const getEachMessage = (subprocessInfo, options = {}) => {
|
||||
const {reference = true} = options;
|
||||
// Internal callers can stop the IPC listener without exposing cancellation or pipe-awaiting options as public API.
|
||||
const {signal, shouldAwait = !subprocessInfo.isSubprocess} = options[internalGetEachMessageOptions] ?? {};
|
||||
|
||||
return loopOnMessages({
|
||||
...subprocessInfo,
|
||||
shouldAwait,
|
||||
reference,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
// Same but used internally
|
||||
export const loopOnMessages = ({anyProcess, waitProcess = anyProcess, channel, isSubprocess, ipc, shouldAwait, reference, signal}) => {
|
||||
validateIpcMethod({
|
||||
methodName: 'getEachMessage',
|
||||
isSubprocess,
|
||||
ipc,
|
||||
isConnected: isConnected(anyProcess),
|
||||
});
|
||||
|
||||
addReference(channel, reference);
|
||||
const ipcEmitter = getIpcEmitter(anyProcess, channel, isSubprocess);
|
||||
const controller = new AbortController();
|
||||
const state = {};
|
||||
stopOnAbort(signal, controller);
|
||||
stopOnDisconnect(anyProcess, ipcEmitter, controller);
|
||||
abortOnStrictError({
|
||||
ipcEmitter,
|
||||
isSubprocess,
|
||||
controller,
|
||||
state,
|
||||
});
|
||||
return iterateOnMessages({
|
||||
anyProcess,
|
||||
waitProcess,
|
||||
channel,
|
||||
ipcEmitter,
|
||||
isSubprocess,
|
||||
shouldAwait,
|
||||
controller,
|
||||
state,
|
||||
reference,
|
||||
});
|
||||
};
|
||||
|
||||
const stopOnAbort = (signal, controller) => {
|
||||
if (signal === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
controller.abort();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reuse the iterator controller for listener cleanup when iteration ends before the internal signal aborts.
|
||||
signal.addEventListener('abort', () => {
|
||||
controller.abort();
|
||||
}, {once: true, signal: controller.signal});
|
||||
};
|
||||
|
||||
const stopOnDisconnect = async (anyProcess, ipcEmitter, controller) => {
|
||||
try {
|
||||
await once(ipcEmitter, 'disconnect', {signal: controller.signal});
|
||||
controller.abort();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const abortOnStrictError = async ({ipcEmitter, isSubprocess, controller, state}) => {
|
||||
try {
|
||||
const [error] = await once(ipcEmitter, 'strict:error', {signal: controller.signal});
|
||||
state.error = getStrictResponseError(error, isSubprocess);
|
||||
controller.abort();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const iterateOnMessages = async function * ({anyProcess, waitProcess, channel, ipcEmitter, isSubprocess, shouldAwait, controller, state, reference}) {
|
||||
try {
|
||||
for await (const [message] of on(ipcEmitter, 'message', {signal: controller.signal})) {
|
||||
throwIfStrictError(state);
|
||||
yield message;
|
||||
}
|
||||
} catch {
|
||||
throwIfStrictError(state);
|
||||
} finally {
|
||||
controller.abort();
|
||||
removeReference(channel, reference);
|
||||
|
||||
if (!isSubprocess) {
|
||||
disconnect(anyProcess);
|
||||
}
|
||||
|
||||
if (shouldAwait) {
|
||||
await waitProcess;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const throwIfStrictError = ({error}) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import {once, on} from 'node:events';
|
||||
import {
|
||||
validateIpcMethod,
|
||||
throwOnEarlyDisconnect,
|
||||
disconnect,
|
||||
getStrictResponseError,
|
||||
} from './validation.js';
|
||||
import {getIpcEmitter, isConnected} from './forward.js';
|
||||
import {addReference, removeReference} from './reference.js';
|
||||
|
||||
export const internalGetOneMessageOptions = Symbol('internalGetOneMessageOptions');
|
||||
|
||||
// Like `[sub]process.once('message')` but promise-based
|
||||
export const getOneMessage = ({anyProcess, channel, isSubprocess, ipc}, options = {}) => {
|
||||
const {reference = true, filter} = options;
|
||||
const {signal} = options[internalGetOneMessageOptions] ?? {};
|
||||
|
||||
validateIpcMethod({
|
||||
methodName: 'getOneMessage',
|
||||
isSubprocess,
|
||||
ipc,
|
||||
isConnected: isConnected(anyProcess),
|
||||
});
|
||||
|
||||
return getOneMessageAsync({
|
||||
anyProcess,
|
||||
channel,
|
||||
isSubprocess,
|
||||
filter,
|
||||
reference,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
const getOneMessageAsync = async ({anyProcess, channel, isSubprocess, filter, reference, signal}) => {
|
||||
addReference(channel, reference);
|
||||
const ipcEmitter = getIpcEmitter(anyProcess, channel, isSubprocess);
|
||||
const controller = new AbortController();
|
||||
stopOnAbort(signal, controller);
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
getMessage(ipcEmitter, filter, controller),
|
||||
throwOnDisconnect(ipcEmitter, isSubprocess, controller),
|
||||
throwOnStrictError(ipcEmitter, isSubprocess, controller),
|
||||
]);
|
||||
} catch (error) {
|
||||
disconnect(anyProcess);
|
||||
throw error;
|
||||
} finally {
|
||||
controller.abort();
|
||||
removeReference(channel, reference);
|
||||
}
|
||||
};
|
||||
|
||||
const stopOnAbort = (signal, controller) => {
|
||||
if (signal === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
controller.abort();
|
||||
return;
|
||||
}
|
||||
|
||||
signal.addEventListener('abort', () => {
|
||||
controller.abort();
|
||||
}, {once: true, signal: controller.signal});
|
||||
};
|
||||
|
||||
const getMessage = async (ipcEmitter, filter, {signal}) => {
|
||||
if (filter === undefined) {
|
||||
const [message] = await once(ipcEmitter, 'message', {signal});
|
||||
return message;
|
||||
}
|
||||
|
||||
for await (const [message] of on(ipcEmitter, 'message', {signal})) {
|
||||
if (filter(message)) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const throwOnDisconnect = async (ipcEmitter, isSubprocess, {signal}) => {
|
||||
await once(ipcEmitter, 'disconnect', {signal});
|
||||
throwOnEarlyDisconnect(isSubprocess);
|
||||
};
|
||||
|
||||
const throwOnStrictError = async (ipcEmitter, isSubprocess, {signal}) => {
|
||||
const [error] = await once(ipcEmitter, 'strict:error', {signal});
|
||||
throw getStrictResponseError(error, isSubprocess);
|
||||
};
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import {scheduler} from 'node:timers/promises';
|
||||
import {sendOneMessage} from './send.js';
|
||||
import {getIpcEmitter} from './forward.js';
|
||||
import {validateConnection, getAbortDisconnectError, throwOnMissingParent} from './validation.js';
|
||||
|
||||
// Send an IPC message so the subprocess performs a graceful termination
|
||||
export const sendAbort = (subprocess, message) => {
|
||||
const methodName = 'cancelSignal';
|
||||
validateConnection(methodName, false, subprocess.connected);
|
||||
return sendOneMessage({
|
||||
anyProcess: subprocess,
|
||||
methodName,
|
||||
isSubprocess: false,
|
||||
wrappedMessage: {type: GRACEFUL_CANCEL_TYPE, message},
|
||||
message,
|
||||
});
|
||||
};
|
||||
|
||||
// When the signal is being used, start listening for incoming messages.
|
||||
// Unbuffering messages takes one microtask to complete, so this must be async.
|
||||
export const getCancelSignal = async ({anyProcess, channel, isSubprocess, ipc}) => {
|
||||
await startIpc({
|
||||
anyProcess,
|
||||
channel,
|
||||
isSubprocess,
|
||||
ipc,
|
||||
});
|
||||
return cancelController.signal;
|
||||
};
|
||||
|
||||
const startIpc = async ({anyProcess, channel, isSubprocess, ipc}) => {
|
||||
if (isCancelListening) {
|
||||
return;
|
||||
}
|
||||
|
||||
isCancelListening = true;
|
||||
|
||||
if (!ipc) {
|
||||
throwOnMissingParent();
|
||||
return;
|
||||
}
|
||||
|
||||
if (channel === null) {
|
||||
abortOnDisconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
getIpcEmitter(anyProcess, channel, isSubprocess);
|
||||
await scheduler.yield();
|
||||
};
|
||||
|
||||
let isCancelListening = false;
|
||||
|
||||
// Reception of IPC message to perform a graceful termination
|
||||
export const handleAbort = wrappedMessage => {
|
||||
if (wrappedMessage?.type !== GRACEFUL_CANCEL_TYPE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
cancelController.abort(wrappedMessage.message);
|
||||
return true;
|
||||
};
|
||||
|
||||
const GRACEFUL_CANCEL_TYPE = 'execa:ipc:cancel';
|
||||
|
||||
// When the current process disconnects early, the subprocess `cancelSignal` is aborted.
|
||||
// Otherwise, the signal would never be able to be aborted later on.
|
||||
export const abortOnDisconnect = () => {
|
||||
cancelController.abort(getAbortDisconnectError());
|
||||
};
|
||||
|
||||
const cancelController = new AbortController();
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import {once} from 'node:events';
|
||||
import {scheduler} from 'node:timers/promises';
|
||||
import {waitForOutgoingMessages} from './outgoing.js';
|
||||
import {redoAddedReferences} from './reference.js';
|
||||
import {handleStrictRequest, handleStrictResponse} from './strict.js';
|
||||
import {handleAbort, abortOnDisconnect} from './graceful.js';
|
||||
|
||||
// By default, Node.js buffers `message` events.
|
||||
// - Buffering happens when there is a `message` event is emitted but there is no handler.
|
||||
// - As soon as a `message` event handler is set, all buffered `message` events are emitted, emptying the buffer.
|
||||
// - This happens both in the current process and the subprocess.
|
||||
// - See https://github.com/nodejs/node/blob/501546e8f37059cd577041e23941b640d0d4d406/lib/internal/child_process.js#L719
|
||||
// This is helpful. Notably, this allows sending messages to a subprocess that's still initializing.
|
||||
// However, it has several problems.
|
||||
// - This works with `events.on()` but not `events.once()` since all buffered messages are emitted at once.
|
||||
// For example, users cannot call `await getOneMessage()`/`getEachMessage()` multiple times in a row.
|
||||
// - When a user intentionally starts listening to `message` at a specific point in time, past `message` events are replayed, which might be unexpected.
|
||||
// - Buffering is unlimited, which might lead to an out-of-memory crash.
|
||||
// - This does not work well with multiple consumers.
|
||||
// For example, Execa consumes events with both `result.ipcOutput` and manual IPC calls like `getOneMessage()`.
|
||||
// Since `result.ipcOutput` reads all incoming messages, no buffering happens for manual IPC calls.
|
||||
// - Forgetting to setup a `message` listener, or setting it up too late, is a programming mistake.
|
||||
// The default behavior does not allow users to realize they made that mistake.
|
||||
// To solve those problems, instead of buffering messages, we debounce them.
|
||||
// The `message` event so it is emitted at most once per macrotask.
|
||||
export const onMessage = async ({anyProcess, channel, isSubprocess, ipcEmitter}, wrappedMessage) => {
|
||||
if (handleStrictResponse(wrappedMessage) || handleAbort(wrappedMessage)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!INCOMING_MESSAGES.has(anyProcess)) {
|
||||
INCOMING_MESSAGES.set(anyProcess, []);
|
||||
}
|
||||
|
||||
const incomingMessages = INCOMING_MESSAGES.get(anyProcess);
|
||||
incomingMessages.push(wrappedMessage);
|
||||
|
||||
if (incomingMessages.length > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (incomingMessages.length > 0) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await waitForOutgoingMessages(anyProcess, ipcEmitter, wrappedMessage);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await scheduler.yield();
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const message = await handleStrictRequest({
|
||||
wrappedMessage: incomingMessages[0],
|
||||
anyProcess,
|
||||
channel,
|
||||
isSubprocess,
|
||||
ipcEmitter,
|
||||
});
|
||||
|
||||
incomingMessages.shift();
|
||||
ipcEmitter.emit('message', message);
|
||||
ipcEmitter.emit('message:done');
|
||||
}
|
||||
};
|
||||
|
||||
// If the `message` event is currently debounced, the `disconnect` event must wait for it
|
||||
export const onDisconnect = async ({anyProcess, channel, isSubprocess, ipcEmitter, boundOnMessage}) => {
|
||||
abortOnDisconnect();
|
||||
|
||||
const incomingMessages = INCOMING_MESSAGES.get(anyProcess);
|
||||
while (incomingMessages?.length > 0) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await once(ipcEmitter, 'message:done');
|
||||
}
|
||||
|
||||
anyProcess.removeListener('message', boundOnMessage);
|
||||
redoAddedReferences(channel, isSubprocess);
|
||||
ipcEmitter.connected = false;
|
||||
ipcEmitter.emit('disconnect');
|
||||
};
|
||||
|
||||
const INCOMING_MESSAGES = new WeakMap();
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import {serialize} from 'node:v8';
|
||||
import {sendMessage} from './send.js';
|
||||
|
||||
// Validate the `ipcInput` option
|
||||
export const validateIpcInputOption = ({ipcInput, ipc, serialization}) => {
|
||||
if (ipcInput === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ipc) {
|
||||
throw new Error('The `ipcInput` option cannot be set unless the `ipc` option is `true`.');
|
||||
}
|
||||
|
||||
validateIpcInput[serialization](ipcInput);
|
||||
};
|
||||
|
||||
const validateAdvancedInput = ipcInput => {
|
||||
try {
|
||||
serialize(ipcInput);
|
||||
} catch (error) {
|
||||
throw new Error('The `ipcInput` option is not serializable with a structured clone.', {cause: error});
|
||||
}
|
||||
};
|
||||
|
||||
const validateJsonInput = ipcInput => {
|
||||
try {
|
||||
JSON.stringify(ipcInput);
|
||||
} catch (error) {
|
||||
throw new Error('The `ipcInput` option is not serializable with JSON.', {cause: error});
|
||||
}
|
||||
};
|
||||
|
||||
const validateIpcInput = {
|
||||
advanced: validateAdvancedInput,
|
||||
json: validateJsonInput,
|
||||
};
|
||||
|
||||
// When the `ipcInput` option is set, it is sent as an initial IPC message to the subprocess
|
||||
export const sendIpcInput = async (subprocess, ipcInput, ipc) => {
|
||||
if (ipcInput === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sendMessage({
|
||||
anyProcess: subprocess,
|
||||
channel: subprocess.channel,
|
||||
isSubprocess: false,
|
||||
ipc,
|
||||
}, ipcInput);
|
||||
};
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import process from 'node:process';
|
||||
import {sendMessage} from './send.js';
|
||||
import {getOneMessage} from './get-one.js';
|
||||
import {getEachMessage} from './get-each.js';
|
||||
import {getCancelSignal} from './graceful.js';
|
||||
|
||||
// Add promise-based IPC methods in current process
|
||||
export const addIpcMethods = (target, subprocess, {ipc}) => {
|
||||
Object.assign(target, getIpcMethods(subprocess, false, ipc, target));
|
||||
};
|
||||
|
||||
// Get promise-based IPC in the subprocess
|
||||
export const getIpcExport = () => {
|
||||
const anyProcess = process;
|
||||
const isSubprocess = true;
|
||||
const isIpc = process.channel !== undefined;
|
||||
|
||||
return {
|
||||
...getIpcMethods(anyProcess, isSubprocess, isIpc),
|
||||
getCancelSignal: getCancelSignal.bind(undefined, {
|
||||
anyProcess,
|
||||
channel: anyProcess.channel,
|
||||
isSubprocess,
|
||||
ipc: isIpc,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
// Retrieve the `ipc` shared by both the current process and the subprocess
|
||||
const getIpcMethods = (anyProcess, isSubprocess, ipc, waitProcess = anyProcess) => ({
|
||||
sendMessage: sendMessage.bind(undefined, {
|
||||
anyProcess,
|
||||
channel: anyProcess.channel,
|
||||
isSubprocess,
|
||||
ipc,
|
||||
}),
|
||||
getOneMessage: getOneMessage.bind(undefined, {
|
||||
anyProcess,
|
||||
channel: anyProcess.channel,
|
||||
isSubprocess,
|
||||
ipc,
|
||||
}),
|
||||
getEachMessage: getEachMessage.bind(undefined, {
|
||||
anyProcess,
|
||||
channel: anyProcess.channel,
|
||||
isSubprocess,
|
||||
ipc,
|
||||
waitProcess,
|
||||
}),
|
||||
});
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import {createDeferred} from '../utils/deferred.js';
|
||||
import {getFdSpecificValue} from '../arguments/specific.js';
|
||||
import {validateStrictDeadlock} from './strict.js';
|
||||
|
||||
// When `sendMessage()` is ongoing, any `message` being received waits before being emitted.
|
||||
// This allows calling one or multiple `await sendMessage()` followed by `await getOneMessage()`/`await getEachMessage()`.
|
||||
// Without running into a race condition when the other process sends a response too fast, before the current process set up a listener.
|
||||
export const startSendMessage = (anyProcess, wrappedMessage, strict) => {
|
||||
if (!OUTGOING_MESSAGES.has(anyProcess)) {
|
||||
OUTGOING_MESSAGES.set(anyProcess, new Set());
|
||||
}
|
||||
|
||||
const outgoingMessages = OUTGOING_MESSAGES.get(anyProcess);
|
||||
const onMessageSent = createDeferred();
|
||||
const id = strict ? wrappedMessage.id : undefined;
|
||||
const outgoingMessage = {onMessageSent, id};
|
||||
outgoingMessages.add(outgoingMessage);
|
||||
return {outgoingMessages, outgoingMessage};
|
||||
};
|
||||
|
||||
export const endSendMessage = ({outgoingMessages, outgoingMessage}) => {
|
||||
outgoingMessages.delete(outgoingMessage);
|
||||
outgoingMessage.onMessageSent.resolve();
|
||||
};
|
||||
|
||||
// Await while `sendMessage()` is ongoing, unless there is already a `message` listener
|
||||
export const waitForOutgoingMessages = async (anyProcess, ipcEmitter, wrappedMessage) => {
|
||||
while (!hasMessageListeners(anyProcess, ipcEmitter) && OUTGOING_MESSAGES.get(anyProcess)?.size > 0) {
|
||||
const outgoingMessages = [...OUTGOING_MESSAGES.get(anyProcess)];
|
||||
validateStrictDeadlock(outgoingMessages, wrappedMessage);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await Promise.all(outgoingMessages.map(({onMessageSent}) => onMessageSent));
|
||||
}
|
||||
};
|
||||
|
||||
const OUTGOING_MESSAGES = new WeakMap();
|
||||
const IPC_SUBPROCESS_OPTIONS = new WeakMap();
|
||||
|
||||
export const setIpcSubprocessOptions = (subprocess, options) => {
|
||||
IPC_SUBPROCESS_OPTIONS.set(subprocess, options);
|
||||
};
|
||||
|
||||
// Whether any `message` listener is setup
|
||||
export const hasMessageListeners = (anyProcess, ipcEmitter) => ipcEmitter.listenerCount('message') > getMinListenerCount(anyProcess);
|
||||
|
||||
// When `buffer` is `false`, we set up a `message` listener that should be ignored.
|
||||
// That listener is only meant to intercept `strict` acknowledgement responses.
|
||||
const getMinListenerCount = anyProcess => getOptions(anyProcess) !== undefined
|
||||
&& !getFdSpecificValue(getOptions(anyProcess).buffer, 'ipc')
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
const getOptions = anyProcess => IPC_SUBPROCESS_OPTIONS.get(anyProcess);
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
// By default, Node.js keeps the subprocess alive while it has a `message` or `disconnect` listener.
|
||||
// We replicate the same logic for the events that we proxy.
|
||||
// This ensures the subprocess is kept alive while `getOneMessage()` and `getEachMessage()` are ongoing.
|
||||
// This is not a problem with `sendMessage()` since Node.js handles that method automatically.
|
||||
// We do not use `anyProcess.channel.ref()` since this would prevent the automatic `.channel.refCounted()` Node.js is doing.
|
||||
// We keep a reference to `anyProcess.channel` since it might be `null` while `getOneMessage()` or `getEachMessage()` is still processing debounced messages.
|
||||
// See https://github.com/nodejs/node/blob/2aaeaa863c35befa2ebaa98fb7737ec84df4d8e9/lib/internal/child_process.js#L547
|
||||
export const addReference = (channel, reference) => {
|
||||
if (reference) {
|
||||
addReferenceCount(channel);
|
||||
}
|
||||
};
|
||||
|
||||
const addReferenceCount = channel => {
|
||||
channel.refCounted();
|
||||
};
|
||||
|
||||
export const removeReference = (channel, reference) => {
|
||||
if (reference) {
|
||||
removeReferenceCount(channel);
|
||||
}
|
||||
};
|
||||
|
||||
const removeReferenceCount = channel => {
|
||||
channel.unrefCounted();
|
||||
};
|
||||
|
||||
// To proxy events, we setup some global listeners on the `message` and `disconnect` events.
|
||||
// Those should not keep the subprocess alive, so we remove the automatic counting that Node.js is doing.
|
||||
// See https://github.com/nodejs/node/blob/1b965270a9c273d4cf70e8808e9d28b9ada7844f/lib/child_process.js#L180
|
||||
export const undoAddedReferences = (channel, isSubprocess) => {
|
||||
if (!isSubprocess) {
|
||||
return;
|
||||
}
|
||||
|
||||
removeReferenceCount(channel);
|
||||
removeReferenceCount(channel);
|
||||
};
|
||||
|
||||
// Reverse it during `disconnect`
|
||||
export const redoAddedReferences = (channel, isSubprocess) => {
|
||||
if (!isSubprocess) {
|
||||
return;
|
||||
}
|
||||
|
||||
addReferenceCount(channel);
|
||||
addReferenceCount(channel);
|
||||
};
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import {promisify} from 'node:util';
|
||||
import {
|
||||
validateIpcMethod,
|
||||
handleEpipeError,
|
||||
handleSerializationError,
|
||||
disconnect,
|
||||
} from './validation.js';
|
||||
import {startSendMessage, endSendMessage} from './outgoing.js';
|
||||
import {handleSendStrict, waitForStrictResponse} from './strict.js';
|
||||
|
||||
// Like `[sub]process.send()` but promise-based.
|
||||
// We do not `await subprocess` during `.sendMessage()` nor `.getOneMessage()` since those methods are transient.
|
||||
// Users would still need to `await subprocess` after the method is done.
|
||||
// Also, this would prevent `unhandledRejection` event from being emitted, making it silent.
|
||||
export const sendMessage = ({anyProcess, channel, isSubprocess, ipc}, message, {strict = false} = {}) => {
|
||||
const methodName = 'sendMessage';
|
||||
validateIpcMethod({
|
||||
methodName,
|
||||
isSubprocess,
|
||||
ipc,
|
||||
isConnected: anyProcess.connected,
|
||||
});
|
||||
|
||||
return sendMessageAsync({
|
||||
anyProcess,
|
||||
channel,
|
||||
methodName,
|
||||
isSubprocess,
|
||||
message,
|
||||
strict,
|
||||
});
|
||||
};
|
||||
|
||||
const sendMessageAsync = async ({anyProcess, channel, methodName, isSubprocess, message, strict}) => {
|
||||
const wrappedMessage = handleSendStrict({
|
||||
anyProcess,
|
||||
channel,
|
||||
isSubprocess,
|
||||
message,
|
||||
strict,
|
||||
});
|
||||
const outgoingMessagesState = startSendMessage(anyProcess, wrappedMessage, strict);
|
||||
try {
|
||||
await sendOneMessage({
|
||||
anyProcess,
|
||||
methodName,
|
||||
isSubprocess,
|
||||
wrappedMessage,
|
||||
message,
|
||||
});
|
||||
} catch (error) {
|
||||
disconnect(anyProcess);
|
||||
throw error;
|
||||
} finally {
|
||||
endSendMessage(outgoingMessagesState);
|
||||
}
|
||||
};
|
||||
|
||||
// Used internally by `cancelSignal`
|
||||
export const sendOneMessage = async ({anyProcess, methodName, isSubprocess, wrappedMessage, message}) => {
|
||||
const sendMethod = getSendMethod(anyProcess);
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
waitForStrictResponse(wrappedMessage, anyProcess, isSubprocess),
|
||||
sendMethod(wrappedMessage),
|
||||
]);
|
||||
} catch (error) {
|
||||
handleEpipeError({error, methodName, isSubprocess});
|
||||
handleSerializationError({
|
||||
error,
|
||||
methodName,
|
||||
isSubprocess,
|
||||
message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// [sub]process.send() promisified, memoized
|
||||
const getSendMethod = anyProcess => {
|
||||
if (PROCESS_SEND_METHODS.has(anyProcess)) {
|
||||
return PROCESS_SEND_METHODS.get(anyProcess);
|
||||
}
|
||||
|
||||
const sendMethod = promisify(anyProcess.send.bind(anyProcess));
|
||||
PROCESS_SEND_METHODS.set(anyProcess, sendMethod);
|
||||
return sendMethod;
|
||||
};
|
||||
|
||||
const PROCESS_SEND_METHODS = new WeakMap();
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import {once} from 'node:events';
|
||||
import {createDeferred} from '../utils/deferred.js';
|
||||
import {incrementMaxListeners} from '../utils/max-listeners.js';
|
||||
import {sendMessage} from './send.js';
|
||||
import {throwOnMissingStrict, throwOnStrictDisconnect, throwOnStrictDeadlockError} from './validation.js';
|
||||
import {getIpcEmitter} from './forward.js';
|
||||
import {hasMessageListeners} from './outgoing.js';
|
||||
|
||||
// When using the `strict` option, wrap the message with metadata during `sendMessage()`
|
||||
export const handleSendStrict = ({anyProcess, channel, isSubprocess, message, strict}) => {
|
||||
if (!strict) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const ipcEmitter = getIpcEmitter(anyProcess, channel, isSubprocess);
|
||||
const hasListeners = hasMessageListeners(anyProcess, ipcEmitter);
|
||||
return {
|
||||
id: count++,
|
||||
type: REQUEST_TYPE,
|
||||
message,
|
||||
hasListeners,
|
||||
};
|
||||
};
|
||||
|
||||
let count = 0n;
|
||||
|
||||
// Handles when both processes are calling `sendMessage()` with `strict` at the same time.
|
||||
// If neither process is listening, this would create a deadlock. We detect it and throw.
|
||||
export const validateStrictDeadlock = (outgoingMessages, wrappedMessage) => {
|
||||
if (wrappedMessage?.type !== REQUEST_TYPE || wrappedMessage.hasListeners) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const {id} of outgoingMessages) {
|
||||
if (id !== undefined) {
|
||||
STRICT_RESPONSES[id].resolve({isDeadlock: true, hasListeners: false});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The other process then sends the acknowledgment back as a response
|
||||
export const handleStrictRequest = async ({wrappedMessage, anyProcess, channel, isSubprocess, ipcEmitter}) => {
|
||||
if (wrappedMessage?.type !== REQUEST_TYPE || !anyProcess.connected) {
|
||||
return wrappedMessage;
|
||||
}
|
||||
|
||||
const {id, message} = wrappedMessage;
|
||||
const response = {id, type: RESPONSE_TYPE, message: hasMessageListeners(anyProcess, ipcEmitter)};
|
||||
|
||||
try {
|
||||
await sendMessage({
|
||||
anyProcess,
|
||||
channel,
|
||||
isSubprocess,
|
||||
ipc: true,
|
||||
}, response);
|
||||
} catch (error) {
|
||||
ipcEmitter.emit('strict:error', error);
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
// Reception of the acknowledgment response
|
||||
export const handleStrictResponse = wrappedMessage => {
|
||||
if (wrappedMessage?.type !== RESPONSE_TYPE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const {id, message: hasListeners} = wrappedMessage;
|
||||
STRICT_RESPONSES[id]?.resolve({isDeadlock: false, hasListeners});
|
||||
return true;
|
||||
};
|
||||
|
||||
// Wait for the other process to receive the message from `sendMessage()`
|
||||
export const waitForStrictResponse = async (wrappedMessage, anyProcess, isSubprocess) => {
|
||||
if (wrappedMessage?.type !== REQUEST_TYPE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deferred = createDeferred();
|
||||
STRICT_RESPONSES[wrappedMessage.id] = deferred;
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
const {isDeadlock, hasListeners} = await Promise.race([
|
||||
deferred,
|
||||
throwOnDisconnect(anyProcess, isSubprocess, controller),
|
||||
]);
|
||||
|
||||
if (isDeadlock) {
|
||||
throwOnStrictDeadlockError(isSubprocess);
|
||||
}
|
||||
|
||||
if (!hasListeners) {
|
||||
throwOnMissingStrict(isSubprocess);
|
||||
}
|
||||
} finally {
|
||||
controller.abort();
|
||||
delete STRICT_RESPONSES[wrappedMessage.id];
|
||||
}
|
||||
};
|
||||
|
||||
const STRICT_RESPONSES = {};
|
||||
|
||||
const throwOnDisconnect = async (anyProcess, isSubprocess, {signal}) => {
|
||||
incrementMaxListeners(anyProcess, 1, signal);
|
||||
await once(anyProcess, 'disconnect', {signal});
|
||||
throwOnStrictDisconnect(isSubprocess);
|
||||
};
|
||||
|
||||
const REQUEST_TYPE = 'execa:ipc:request';
|
||||
const RESPONSE_TYPE = 'execa:ipc:response';
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
// Validate the IPC channel is connected before receiving/sending messages
|
||||
export const validateIpcMethod = ({methodName, isSubprocess, ipc, isConnected}) => {
|
||||
validateIpcOption(methodName, isSubprocess, ipc);
|
||||
validateConnection(methodName, isSubprocess, isConnected);
|
||||
};
|
||||
|
||||
// Better error message when forgetting to set `ipc: true` and using the IPC methods
|
||||
const validateIpcOption = (methodName, isSubprocess, ipc) => {
|
||||
if (!ipc) {
|
||||
throw new Error(`${getMethodName(methodName, isSubprocess)} can only be used if the \`ipc\` option is \`true\`.`);
|
||||
}
|
||||
};
|
||||
|
||||
// Better error message when one process does not send/receive messages once the other process has disconnected.
|
||||
// This also makes it clear that any buffered messages are lost once either process has disconnected.
|
||||
// Also when aborting `cancelSignal` after disconnecting the IPC.
|
||||
export const validateConnection = (methodName, isSubprocess, isConnected) => {
|
||||
if (!isConnected) {
|
||||
throw new Error(`${getMethodName(methodName, isSubprocess)} cannot be used: the ${getOtherProcessName(isSubprocess)} has already exited or disconnected.`);
|
||||
}
|
||||
};
|
||||
|
||||
// When `getOneMessage()` could not complete due to an early disconnection
|
||||
export const throwOnEarlyDisconnect = isSubprocess => {
|
||||
throw new Error(`${getMethodName('getOneMessage', isSubprocess)} could not complete: the ${getOtherProcessName(isSubprocess)} exited or disconnected.`);
|
||||
};
|
||||
|
||||
// When both processes use `sendMessage()` with `strict` at the same time
|
||||
export const throwOnStrictDeadlockError = isSubprocess => {
|
||||
throw new Error(`${getMethodName('sendMessage', isSubprocess)} failed: the ${getOtherProcessName(isSubprocess)} is sending a message too, instead of listening to incoming messages.
|
||||
This can be fixed by both sending a message and listening to incoming messages at the same time:
|
||||
|
||||
const [receivedMessage] = await Promise.all([
|
||||
${getMethodName('getOneMessage', isSubprocess)},
|
||||
${getMethodName('sendMessage', isSubprocess, 'message, {strict: true}')},
|
||||
]);`);
|
||||
};
|
||||
|
||||
// When the other process used `strict` but the current process had I/O error calling `sendMessage()` for the response
|
||||
export const getStrictResponseError = (error, isSubprocess) => new Error(`${getMethodName('sendMessage', isSubprocess)} failed when sending an acknowledgment response to the ${getOtherProcessName(isSubprocess)}.`, {cause: error});
|
||||
|
||||
// When using `strict` but the other process was not listening for messages
|
||||
export const throwOnMissingStrict = isSubprocess => {
|
||||
throw new Error(`${getMethodName('sendMessage', isSubprocess)} failed: the ${getOtherProcessName(isSubprocess)} is not listening to incoming messages.`);
|
||||
};
|
||||
|
||||
// When using `strict` but the other process disconnected before receiving the message
|
||||
export const throwOnStrictDisconnect = isSubprocess => {
|
||||
throw new Error(`${getMethodName('sendMessage', isSubprocess)} failed: the ${getOtherProcessName(isSubprocess)} exited without listening to incoming messages.`);
|
||||
};
|
||||
|
||||
// When the current process disconnects while the subprocess is listening to `cancelSignal`
|
||||
export const getAbortDisconnectError = () => new Error(`\`cancelSignal\` aborted: the ${getOtherProcessName(true)} disconnected.`);
|
||||
|
||||
// When the subprocess uses `cancelSignal` but not the current process
|
||||
export const throwOnMissingParent = () => {
|
||||
throw new Error('`getCancelSignal()` cannot be used without setting the `cancelSignal` subprocess option.');
|
||||
};
|
||||
|
||||
// EPIPE can happen when sending a message to a subprocess that is closing but has not disconnected yet
|
||||
export const handleEpipeError = ({error, methodName, isSubprocess}) => {
|
||||
if (error.code === 'EPIPE') {
|
||||
throw new Error(`${getMethodName(methodName, isSubprocess)} cannot be used: the ${getOtherProcessName(isSubprocess)} is disconnecting.`, {cause: error});
|
||||
}
|
||||
};
|
||||
|
||||
// Better error message when sending messages which cannot be serialized.
|
||||
// Works with both `serialization: 'advanced'` and `serialization: 'json'`.
|
||||
export const handleSerializationError = ({error, methodName, isSubprocess, message}) => {
|
||||
if (isSerializationError(error)) {
|
||||
throw new Error(`${getMethodName(methodName, isSubprocess)}'s argument type is invalid: the message cannot be serialized: ${String(message)}.`, {cause: error});
|
||||
}
|
||||
};
|
||||
|
||||
const isSerializationError = ({code, message}) => SERIALIZATION_ERROR_CODES.has(code)
|
||||
|| SERIALIZATION_ERROR_MESSAGES.some(serializationErrorMessage => message.includes(serializationErrorMessage));
|
||||
|
||||
// `error.code` set by Node.js when it failed to serialize the message
|
||||
const SERIALIZATION_ERROR_CODES = new Set([
|
||||
// Message is `undefined`
|
||||
'ERR_MISSING_ARGS',
|
||||
// Message is a function, a bigint, a symbol
|
||||
'ERR_INVALID_ARG_TYPE',
|
||||
]);
|
||||
|
||||
// `error.message` set by Node.js when it failed to serialize the message
|
||||
const SERIALIZATION_ERROR_MESSAGES = [
|
||||
// Message is a promise or a proxy, with `serialization: 'advanced'`
|
||||
'could not be cloned',
|
||||
// Message has cycles, with `serialization: 'json'`
|
||||
'circular structure',
|
||||
// Message has cycles inside toJSON(), with `serialization: 'json'`
|
||||
'call stack size exceeded',
|
||||
];
|
||||
|
||||
const getMethodName = (methodName, isSubprocess, parameters = '') => methodName === 'cancelSignal'
|
||||
? '`cancelSignal`\'s `controller.abort()`'
|
||||
: `${getNamespaceName(isSubprocess)}${methodName}(${parameters})`;
|
||||
|
||||
const getNamespaceName = isSubprocess => isSubprocess ? '' : 'subprocess.';
|
||||
|
||||
const getOtherProcessName = isSubprocess => isSubprocess ? 'parent process' : 'subprocess';
|
||||
|
||||
// When any error arises, we disconnect the IPC.
|
||||
// Otherwise, it is likely that one of the processes will stop sending/receiving messages.
|
||||
// This would leave the other process hanging.
|
||||
export const disconnect = anyProcess => {
|
||||
if (anyProcess.connected) {
|
||||
anyProcess.disconnect();
|
||||
}
|
||||
};
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import isPlainObject from 'is-plain-obj';
|
||||
import {FD_SPECIFIC_OPTIONS} from '../arguments/specific.js';
|
||||
|
||||
// Deep merge specific options like `env`. Shallow merge the other ones.
|
||||
// Use spread (which only copies own properties) to safely read from boundOptions without prototype pollution
|
||||
export const mergeOptions = (boundOptions, options) => {
|
||||
const safeBoundOptions = {__proto__: null, ...boundOptions};
|
||||
const mergedOptions = Object.fromEntries(Object.entries(options).map(([optionName, optionValue]) => [
|
||||
optionName,
|
||||
mergeOption(optionName, safeBoundOptions[optionName], optionValue),
|
||||
]));
|
||||
return {...safeBoundOptions, ...mergedOptions};
|
||||
};
|
||||
|
||||
const mergeOption = (optionName, boundOptionValue, optionValue) => {
|
||||
if (DEEP_OPTIONS.has(optionName) && isPlainObject(boundOptionValue) && isPlainObject(optionValue)) {
|
||||
return {...boundOptionValue, ...optionValue};
|
||||
}
|
||||
|
||||
return optionValue;
|
||||
};
|
||||
|
||||
const DEEP_OPTIONS = new Set(['env', ...FD_SPECIFIC_OPTIONS]);
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
// Convert `command` string into an array of file or arguments to pass to $`${...fileOrCommandArguments}`
|
||||
export const parseCommandString = command => {
|
||||
if (typeof command !== 'string') {
|
||||
throw new TypeError(`The command must be a string: ${String(command)}.`);
|
||||
}
|
||||
|
||||
const trimmedCommand = command.trim();
|
||||
if (trimmedCommand === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tokens = [];
|
||||
for (const token of trimmedCommand.split(SPACES_REGEXP)) {
|
||||
// Allow spaces to be escaped by a backslash if not meant as a delimiter
|
||||
const previousToken = tokens.at(-1);
|
||||
if (previousToken && previousToken.endsWith('\\')) {
|
||||
// Merge previous token with current one
|
||||
tokens[tokens.length - 1] = `${previousToken.slice(0, -1)} ${token}`;
|
||||
} else {
|
||||
tokens.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
};
|
||||
|
||||
const SPACES_REGEXP = / +/g;
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import isPlainObject from 'is-plain-obj';
|
||||
import {normalizeParameters} from './parameters.js';
|
||||
import {isTemplateString, parseTemplates} from './template.js';
|
||||
import {execaCoreSync} from './main-sync.js';
|
||||
import {execaCoreAsync} from './main-async.js';
|
||||
import {mergeOptions} from './bind.js';
|
||||
|
||||
// Wraps every exported methods to provide the following features:
|
||||
// - template string syntax: execa`command argument`
|
||||
// - options binding: boundExeca = execa(options)
|
||||
// - optional argument/options: execa(file), execa(file, args), execa(file, options), execa(file, args, options)
|
||||
// `mapArguments()` and `setBoundExeca()` allows for method-specific logic.
|
||||
export const createExeca = (mapArguments, boundOptions, deepOptions, setBoundExeca) => {
|
||||
const createNested = (mapArguments, boundOptions, setBoundExeca) => createExeca(mapArguments, boundOptions, deepOptions, setBoundExeca);
|
||||
const boundExeca = (...execaArguments) => callBoundExeca({
|
||||
mapArguments,
|
||||
deepOptions,
|
||||
boundOptions,
|
||||
setBoundExeca,
|
||||
createNested,
|
||||
}, ...execaArguments);
|
||||
|
||||
if (setBoundExeca !== undefined) {
|
||||
setBoundExeca(boundExeca, createNested, boundOptions);
|
||||
}
|
||||
|
||||
return boundExeca;
|
||||
};
|
||||
|
||||
const callBoundExeca = ({mapArguments, deepOptions = {}, boundOptions = {}, setBoundExeca, createNested}, firstArgument, ...nextArguments) => {
|
||||
if (isPlainObject(firstArgument)) {
|
||||
return createNested(mapArguments, mergeOptions(boundOptions, firstArgument), setBoundExeca);
|
||||
}
|
||||
|
||||
const {file, commandArguments, options, isSync} = parseArguments({
|
||||
mapArguments,
|
||||
firstArgument,
|
||||
nextArguments,
|
||||
deepOptions,
|
||||
boundOptions,
|
||||
});
|
||||
return isSync
|
||||
? execaCoreSync(file, commandArguments, options)
|
||||
: execaCoreAsync(file, commandArguments, options, createNested);
|
||||
};
|
||||
|
||||
const parseArguments = ({mapArguments, firstArgument, nextArguments, deepOptions, boundOptions}) => {
|
||||
const callArguments = isTemplateString(firstArgument)
|
||||
? parseTemplates(firstArgument, nextArguments)
|
||||
: [firstArgument, ...nextArguments];
|
||||
const [initialFile, initialArguments, initialOptions] = normalizeParameters(...callArguments);
|
||||
const mergedOptions = mergeOptions(mergeOptions(deepOptions, boundOptions), initialOptions);
|
||||
const {
|
||||
options = mergedOptions,
|
||||
isSync = false,
|
||||
} = mapArguments({options: mergedOptions});
|
||||
return {
|
||||
file: initialFile,
|
||||
commandArguments: initialArguments,
|
||||
options,
|
||||
isSync,
|
||||
};
|
||||
};
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
import {setMaxListeners} from 'node:events';
|
||||
import {spawn} from 'node:child_process';
|
||||
import {MaxBufferError} from 'get-stream';
|
||||
import {handleCommand} from '../arguments/command.js';
|
||||
import {normalizeOptions} from '../arguments/options.js';
|
||||
import {SUBPROCESS_OPTIONS} from '../arguments/fd-options.js';
|
||||
import {concatenateShell} from '../arguments/shell.js';
|
||||
import {addIpcMethods} from '../ipc/methods.js';
|
||||
import {setIpcSubprocessOptions} from '../ipc/outgoing.js';
|
||||
import {makeError, makeSuccessResult} from '../return/result.js';
|
||||
import {handleResult} from '../return/reject.js';
|
||||
import {handleEarlyError} from '../return/early-error.js';
|
||||
import {handleStdioAsync} from '../stdio/handle-async.js';
|
||||
import {stripNewline} from '../io/strip-newline.js';
|
||||
import {pipeOutputAsync} from '../io/output-async.js';
|
||||
import {subprocessKill} from '../terminate/kill.js';
|
||||
import {getSpawnOptions, getKillFunction} from '../terminate/kill-descendants.js';
|
||||
import {cleanupOnExit} from '../terminate/cleanup.js';
|
||||
import {pipeToSubprocess} from '../pipe/setup.js';
|
||||
import {makeAllStream} from '../resolve/all-async.js';
|
||||
import {waitForSubprocessResult} from '../resolve/wait-subprocess.js';
|
||||
import {addConvertedStreams} from '../convert/add.js';
|
||||
import {createDeferred} from '../utils/deferred.js';
|
||||
import {mergePromise} from './promise.js';
|
||||
|
||||
// Main shared logic for all async methods: `execa()`, `$`, `execaNode()`
|
||||
export const execaCoreAsync = (rawFile, rawArguments, rawOptions, createNested) => {
|
||||
const {file, commandArguments, command, escapedCommand, startTime, verboseInfo, options, fileDescriptors} = handleAsyncArguments(rawFile, rawArguments, rawOptions);
|
||||
const {subprocess: nodeChildProcess, promise, kill, all, convertedStreams} = spawnSubprocessAsync({
|
||||
file,
|
||||
commandArguments,
|
||||
options,
|
||||
startTime,
|
||||
verboseInfo,
|
||||
command,
|
||||
escapedCommand,
|
||||
fileDescriptors,
|
||||
});
|
||||
const subprocess = getSubprocessPromise({
|
||||
promise,
|
||||
nodeChildProcess,
|
||||
kill,
|
||||
all,
|
||||
convertedStreams,
|
||||
options,
|
||||
});
|
||||
subprocess.pipe = pipeToSubprocess.bind(undefined, {
|
||||
source: subprocess,
|
||||
sourcePromise: promise,
|
||||
boundOptions: {},
|
||||
createNested,
|
||||
});
|
||||
SUBPROCESS_OPTIONS.set(subprocess, {options, fileDescriptors});
|
||||
return subprocess;
|
||||
};
|
||||
|
||||
// Compute arguments to pass to `child_process.spawn()`
|
||||
const handleAsyncArguments = (rawFile, rawArguments, rawOptions) => {
|
||||
const {command, escapedCommand, startTime, verboseInfo} = handleCommand(rawFile, rawArguments, rawOptions);
|
||||
const {file, commandArguments, options: normalizedOptions} = normalizeOptions(rawFile, rawArguments, rawOptions);
|
||||
const options = handleAsyncOptions(normalizedOptions);
|
||||
const fileDescriptors = handleStdioAsync(options, verboseInfo);
|
||||
return {
|
||||
file,
|
||||
commandArguments,
|
||||
command,
|
||||
escapedCommand,
|
||||
startTime,
|
||||
verboseInfo,
|
||||
options,
|
||||
fileDescriptors,
|
||||
};
|
||||
};
|
||||
|
||||
// Options normalization logic specific to async methods.
|
||||
// Prevent passing the `timeout` option directly to `child_process.spawn()`.
|
||||
const handleAsyncOptions = ({timeout, signal, ...options}) => {
|
||||
if (signal !== undefined) {
|
||||
throw new TypeError('The "signal" option has been renamed to "cancelSignal" instead.');
|
||||
}
|
||||
|
||||
return {...options, timeoutDuration: timeout};
|
||||
};
|
||||
|
||||
const spawnSubprocessAsync = ({file, commandArguments, options, startTime, verboseInfo, command, escapedCommand, fileDescriptors}) => {
|
||||
let subprocess;
|
||||
try {
|
||||
subprocess = spawn(...concatenateShell(file, commandArguments, getSpawnOptions(options)));
|
||||
} catch (error) {
|
||||
return handleEarlyError({
|
||||
error,
|
||||
command,
|
||||
escapedCommand,
|
||||
fileDescriptors,
|
||||
options,
|
||||
startTime,
|
||||
verboseInfo,
|
||||
});
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setMaxListeners(Infinity, controller.signal);
|
||||
|
||||
const originalStreams = [...subprocess.stdio];
|
||||
pipeOutputAsync(subprocess, fileDescriptors, controller);
|
||||
setIpcSubprocessOptions(subprocess, options);
|
||||
|
||||
const context = {};
|
||||
const onInternalError = createDeferred();
|
||||
const kill = subprocessKill.bind(undefined, {
|
||||
kill: getKillFunction(subprocess, options),
|
||||
options,
|
||||
onInternalError,
|
||||
context,
|
||||
controller,
|
||||
});
|
||||
cleanupOnExit(kill, options, controller);
|
||||
const all = makeAllStream(subprocess, options);
|
||||
|
||||
const promise = handlePromise({
|
||||
subprocess,
|
||||
kill,
|
||||
all,
|
||||
options,
|
||||
startTime,
|
||||
verboseInfo,
|
||||
fileDescriptors,
|
||||
originalStreams,
|
||||
command,
|
||||
escapedCommand,
|
||||
context,
|
||||
onInternalError,
|
||||
controller,
|
||||
});
|
||||
return {
|
||||
subprocess,
|
||||
promise,
|
||||
kill,
|
||||
all,
|
||||
};
|
||||
};
|
||||
|
||||
const getSubprocessPromise = ({promise, nodeChildProcess, kill, all, convertedStreams, options}) => {
|
||||
const subprocess = mergePromise(promise, getSubprocessProperties(nodeChildProcess, all));
|
||||
subprocess.kill = kill ?? subprocess.kill;
|
||||
if (convertedStreams === undefined) {
|
||||
addConvertedStreams(subprocess, options);
|
||||
} else {
|
||||
Object.assign(subprocess, convertedStreams);
|
||||
}
|
||||
|
||||
addIpcMethods(subprocess, nodeChildProcess, options);
|
||||
return subprocess;
|
||||
};
|
||||
|
||||
const getSubprocessProperties = (nodeChildProcess, all) => ({
|
||||
nodeChildProcess,
|
||||
pid: nodeChildProcess.pid,
|
||||
stdin: nodeChildProcess.stdin,
|
||||
stdout: nodeChildProcess.stdout,
|
||||
stderr: nodeChildProcess.stderr,
|
||||
stdio: nodeChildProcess.stdio,
|
||||
all,
|
||||
kill: nodeChildProcess.kill.bind(nodeChildProcess),
|
||||
});
|
||||
|
||||
// Asynchronous logic, as opposed to the previous logic which can be run synchronously, i.e. can be returned to user right away
|
||||
const handlePromise = async ({subprocess, kill, all: allStream, options, startTime, verboseInfo, fileDescriptors, originalStreams, command, escapedCommand, context, onInternalError, controller}) => {
|
||||
const [
|
||||
errorInfo,
|
||||
[exitCode, signal],
|
||||
stdioResults,
|
||||
allResult,
|
||||
ipcOutput,
|
||||
] = await waitForSubprocessResult({
|
||||
subprocess,
|
||||
kill,
|
||||
all: allStream,
|
||||
options,
|
||||
context,
|
||||
verboseInfo,
|
||||
fileDescriptors,
|
||||
originalStreams,
|
||||
onInternalError,
|
||||
controller,
|
||||
});
|
||||
controller.abort();
|
||||
onInternalError.resolve();
|
||||
|
||||
const stdio = stdioResults.map((stdioResult, fdNumber) => stripNewline(stdioResult, options, fdNumber));
|
||||
const all = stripNewline(allResult, options, 'all');
|
||||
const result = getAsyncResult({
|
||||
errorInfo,
|
||||
exitCode,
|
||||
signal,
|
||||
stdio,
|
||||
all,
|
||||
ipcOutput,
|
||||
context,
|
||||
options,
|
||||
command,
|
||||
escapedCommand,
|
||||
startTime,
|
||||
});
|
||||
return handleResult(result, verboseInfo, options);
|
||||
};
|
||||
|
||||
const getAsyncResult = ({errorInfo, exitCode, signal, stdio, all, ipcOutput, context, options, command, escapedCommand, startTime}) => 'error' in errorInfo
|
||||
? makeError({
|
||||
error: errorInfo.error,
|
||||
command,
|
||||
escapedCommand,
|
||||
timedOut: context.terminationReason === 'timeout',
|
||||
isCanceled: context.terminationReason === 'cancel' || context.terminationReason === 'gracefulCancel',
|
||||
isGracefullyCanceled: context.terminationReason === 'gracefulCancel',
|
||||
isMaxBuffer: errorInfo.error instanceof MaxBufferError,
|
||||
isForcefullyTerminated: context.isForcefullyTerminated,
|
||||
exitCode,
|
||||
signal,
|
||||
stdio,
|
||||
all,
|
||||
ipcOutput,
|
||||
options,
|
||||
startTime,
|
||||
isSync: false,
|
||||
})
|
||||
: makeSuccessResult({
|
||||
command,
|
||||
escapedCommand,
|
||||
stdio,
|
||||
all,
|
||||
ipcOutput,
|
||||
options,
|
||||
startTime,
|
||||
});
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {handleCommand} from '../arguments/command.js';
|
||||
import {normalizeOptions} from '../arguments/options.js';
|
||||
import {concatenateShell} from '../arguments/shell.js';
|
||||
import {makeError, makeEarlyError, makeSuccessResult} from '../return/result.js';
|
||||
import {handleResult} from '../return/reject.js';
|
||||
import {handleStdioSync} from '../stdio/handle-sync.js';
|
||||
import {stripNewline} from '../io/strip-newline.js';
|
||||
import {addInputOptionsSync} from '../io/input-sync.js';
|
||||
import {transformOutputSync} from '../io/output-sync.js';
|
||||
import {getMaxBufferSync} from '../io/max-buffer.js';
|
||||
import {getAllSync} from '../resolve/all-sync.js';
|
||||
import {getExitResultSync} from '../resolve/exit-sync.js';
|
||||
|
||||
// Main shared logic for all sync methods: `execaSync()`, `$.sync()`
|
||||
export const execaCoreSync = (rawFile, rawArguments, rawOptions) => {
|
||||
const {file, commandArguments, command, escapedCommand, startTime, verboseInfo, options, fileDescriptors} = handleSyncArguments(rawFile, rawArguments, rawOptions);
|
||||
const result = spawnSubprocessSync({
|
||||
file,
|
||||
commandArguments,
|
||||
options,
|
||||
command,
|
||||
escapedCommand,
|
||||
verboseInfo,
|
||||
fileDescriptors,
|
||||
startTime,
|
||||
});
|
||||
return handleResult(result, verboseInfo, options);
|
||||
};
|
||||
|
||||
// Compute arguments to pass to `child_process.spawnSync()`
|
||||
const handleSyncArguments = (rawFile, rawArguments, rawOptions) => {
|
||||
const {command, escapedCommand, startTime, verboseInfo} = handleCommand(rawFile, rawArguments, rawOptions);
|
||||
const syncOptions = normalizeSyncOptions(rawOptions);
|
||||
const {file, commandArguments, options} = normalizeOptions(rawFile, rawArguments, syncOptions);
|
||||
validateSyncOptions(options);
|
||||
const fileDescriptors = handleStdioSync(options, verboseInfo);
|
||||
return {
|
||||
file,
|
||||
commandArguments,
|
||||
command,
|
||||
escapedCommand,
|
||||
startTime,
|
||||
verboseInfo,
|
||||
options,
|
||||
fileDescriptors,
|
||||
};
|
||||
};
|
||||
|
||||
// Options normalization logic specific to sync methods
|
||||
const normalizeSyncOptions = options => options.node && !options.ipc ? {...options, ipc: false} : options;
|
||||
|
||||
// Options validation logic specific to sync methods
|
||||
const validateSyncOptions = ({ipc, ipcInput, detached, cancelSignal, killDescendants}) => {
|
||||
if (ipcInput) {
|
||||
throwInvalidSyncOption('ipcInput');
|
||||
}
|
||||
|
||||
if (ipc) {
|
||||
throwInvalidSyncOption('ipc: true');
|
||||
}
|
||||
|
||||
if (detached) {
|
||||
throwInvalidSyncOption('detached: true');
|
||||
}
|
||||
|
||||
if (killDescendants) {
|
||||
throwInvalidSyncOption('killDescendants: true');
|
||||
}
|
||||
|
||||
if (cancelSignal) {
|
||||
throwInvalidSyncOption('cancelSignal');
|
||||
}
|
||||
};
|
||||
|
||||
const throwInvalidSyncOption = value => {
|
||||
throw new TypeError(`The "${value}" option cannot be used with synchronous methods.`);
|
||||
};
|
||||
|
||||
const spawnSubprocessSync = ({file, commandArguments, options, command, escapedCommand, verboseInfo, fileDescriptors, startTime}) => {
|
||||
const syncResult = runSubprocessSync({
|
||||
file,
|
||||
commandArguments,
|
||||
options,
|
||||
command,
|
||||
escapedCommand,
|
||||
fileDescriptors,
|
||||
startTime,
|
||||
});
|
||||
if (syncResult.failed) {
|
||||
return syncResult;
|
||||
}
|
||||
|
||||
const {resultError, exitCode, signal, timedOut, isMaxBuffer} = getExitResultSync(syncResult, options);
|
||||
const {output, error = resultError} = transformOutputSync({
|
||||
fileDescriptors,
|
||||
syncResult,
|
||||
options,
|
||||
isMaxBuffer,
|
||||
verboseInfo,
|
||||
});
|
||||
const stdio = output.map((stdioOutput, fdNumber) => stripNewline(stdioOutput, options, fdNumber));
|
||||
const all = stripNewline(getAllSync(output, options), options, 'all');
|
||||
return getSyncResult({
|
||||
error,
|
||||
exitCode,
|
||||
signal,
|
||||
timedOut,
|
||||
isMaxBuffer,
|
||||
stdio,
|
||||
all,
|
||||
options,
|
||||
command,
|
||||
escapedCommand,
|
||||
startTime,
|
||||
});
|
||||
};
|
||||
|
||||
const runSubprocessSync = ({file, commandArguments, options, command, escapedCommand, fileDescriptors, startTime}) => {
|
||||
try {
|
||||
addInputOptionsSync(fileDescriptors, options);
|
||||
const normalizedOptions = normalizeSpawnSyncOptions(options);
|
||||
return spawnSync(...concatenateShell(file, commandArguments, normalizedOptions));
|
||||
} catch (error) {
|
||||
return makeEarlyError({
|
||||
error,
|
||||
command,
|
||||
escapedCommand,
|
||||
fileDescriptors,
|
||||
options,
|
||||
startTime,
|
||||
isSync: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// The `encoding` option is handled by Execa, not by `child_process.spawnSync()`
|
||||
const normalizeSpawnSyncOptions = ({encoding, maxBuffer, ...options}) => ({...options, encoding: 'buffer', maxBuffer: getMaxBufferSync(maxBuffer)});
|
||||
|
||||
const getSyncResult = ({error, exitCode, signal, timedOut, isMaxBuffer, stdio, all, options, command, escapedCommand, startTime}) => error === undefined
|
||||
? makeSuccessResult({
|
||||
command,
|
||||
escapedCommand,
|
||||
stdio,
|
||||
all,
|
||||
ipcOutput: [],
|
||||
options,
|
||||
startTime,
|
||||
})
|
||||
: makeError({
|
||||
error,
|
||||
command,
|
||||
escapedCommand,
|
||||
timedOut,
|
||||
isCanceled: false,
|
||||
isGracefullyCanceled: false,
|
||||
isMaxBuffer,
|
||||
isForcefullyTerminated: false,
|
||||
exitCode,
|
||||
signal,
|
||||
stdio,
|
||||
all,
|
||||
ipcOutput: [],
|
||||
options,
|
||||
startTime,
|
||||
isSync: true,
|
||||
});
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import {execPath, execArgv} from 'node:process';
|
||||
import path from 'node:path';
|
||||
import {safeNormalizeFileUrl} from '../arguments/file-url.js';
|
||||
|
||||
// `execaNode()` is a shortcut for `execa(..., {node: true})`
|
||||
export const mapNode = ({options}) => {
|
||||
if (options.node === false) {
|
||||
throw new TypeError('The "node" option cannot be false with `execaNode()`.');
|
||||
}
|
||||
|
||||
return {options: {...options, node: true}};
|
||||
};
|
||||
|
||||
// Applies the `node: true` option, and the related `nodePath`/`nodeOptions` options.
|
||||
// Modifies the file commands/arguments to ensure the same Node binary and flags are re-used.
|
||||
// Also adds `ipc: true` and `shell: false`.
|
||||
export const handleNodeOption = (file, commandArguments, {
|
||||
node: shouldHandleNode = false,
|
||||
nodePath = execPath,
|
||||
nodeOptions = execArgv.filter(nodeOption => !nodeOption.startsWith('--inspect')),
|
||||
cwd,
|
||||
execPath: formerNodePath,
|
||||
...options
|
||||
}) => {
|
||||
if (formerNodePath !== undefined) {
|
||||
throw new TypeError('The "execPath" option has been removed. Please use the "nodePath" option instead.');
|
||||
}
|
||||
|
||||
const normalizedNodePath = safeNormalizeFileUrl(nodePath, 'The "nodePath" option');
|
||||
const resolvedNodePath = path.resolve(cwd, normalizedNodePath);
|
||||
// Use spread (which only copies own properties) to safely get shell without reading polluted prototype
|
||||
const newOptions = {
|
||||
__proto__: null,
|
||||
shell: false,
|
||||
...options,
|
||||
nodePath: resolvedNodePath,
|
||||
node: shouldHandleNode,
|
||||
cwd,
|
||||
};
|
||||
|
||||
if (!shouldHandleNode) {
|
||||
return [file, commandArguments, newOptions];
|
||||
}
|
||||
|
||||
if (path.basename(file, '.exe') === 'node') {
|
||||
throw new TypeError('When the "node" option is true, the first argument does not need to be "node".');
|
||||
}
|
||||
|
||||
return [
|
||||
resolvedNodePath,
|
||||
[
|
||||
...nodeOptions,
|
||||
file,
|
||||
...commandArguments,
|
||||
],
|
||||
{
|
||||
__proto__: null,
|
||||
ipc: true,
|
||||
...newOptions,
|
||||
shell: false,
|
||||
},
|
||||
];
|
||||
};
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import isPlainObject from 'is-plain-obj';
|
||||
import {safeNormalizeFileUrl} from '../arguments/file-url.js';
|
||||
|
||||
// The command `arguments` and `options` are both optional.
|
||||
// This also does basic validation on them and on the command file.
|
||||
export const normalizeParameters = (rawFile, rawArguments = [], rawOptions = {}) => {
|
||||
const filePath = safeNormalizeFileUrl(rawFile, 'First argument');
|
||||
const [commandArguments, options] = isPlainObject(rawArguments)
|
||||
? [[], rawArguments]
|
||||
: [rawArguments, rawOptions];
|
||||
|
||||
if (!Array.isArray(commandArguments)) {
|
||||
throw new TypeError(`Second argument must be either an array of arguments or an options object: ${commandArguments}`);
|
||||
}
|
||||
|
||||
if (commandArguments.some(commandArgument => typeof commandArgument === 'object' && commandArgument !== null)) {
|
||||
throw new TypeError(`Second argument must be an array of strings: ${commandArguments}`);
|
||||
}
|
||||
|
||||
const normalizedArguments = commandArguments.map(String);
|
||||
const nullByteArgument = normalizedArguments.find(normalizedArgument => normalizedArgument.includes('\0'));
|
||||
if (nullByteArgument !== undefined) {
|
||||
throw new TypeError(`Arguments cannot contain null bytes ("\\0"): ${nullByteArgument}`);
|
||||
}
|
||||
|
||||
if (!isPlainObject(options)) {
|
||||
throw new TypeError(`Last argument must be an options object: ${options}`);
|
||||
}
|
||||
|
||||
// Prevent prototype pollution by copying only own properties to a null-prototype object
|
||||
return [filePath, normalizedArguments, {__proto__: null, ...options}];
|
||||
};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
// The return value is a native Promise with Execa-specific properties.
|
||||
export const mergePromise = (promise, properties) => Object.assign(promise, properties);
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
// Sets `$.sync` and `$.s`
|
||||
export const setScriptSync = (boundExeca, createNested, boundOptions) => {
|
||||
boundExeca.sync = createNested(mapScriptSync, boundOptions);
|
||||
boundExeca.s = boundExeca.sync;
|
||||
};
|
||||
|
||||
// Main logic for `$`
|
||||
export const mapScriptAsync = ({options}) => getScriptOptions(options);
|
||||
|
||||
// Main logic for `$.sync`
|
||||
const mapScriptSync = ({options}) => ({...getScriptOptions(options), isSync: true});
|
||||
|
||||
// `$` is like `execa` but with script-friendly options: `{stdin: 'inherit', preferLocal: true}`
|
||||
const getScriptOptions = options => ({options: {...getScriptStdinOption(options), ...options}});
|
||||
|
||||
const getScriptStdinOption = ({input, inputFile, stdio}) => input === undefined && inputFile === undefined && stdio === undefined
|
||||
? {stdin: 'inherit'}
|
||||
: {};
|
||||
|
||||
// When using $(...).pipe(...), most script-friendly options should apply to both commands.
|
||||
// However, some options (like `stdin: 'inherit'`) would create issues with piping, i.e. cannot be deep.
|
||||
export const deepScriptOptions = {preferLocal: true};
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
import {ChildProcess} from 'node:child_process';
|
||||
import isPlainObject from 'is-plain-obj';
|
||||
import {isUint8Array, uint8ArrayToString} from '../utils/uint-array.js';
|
||||
|
||||
// Check whether the template string syntax is being used
|
||||
export const isTemplateString = templates => Array.isArray(templates) && Array.isArray(templates.raw);
|
||||
|
||||
// Convert execa`file ...commandArguments` to execa(file, commandArguments)
|
||||
export const parseTemplates = (templates, expressions) => {
|
||||
let tokens = [];
|
||||
|
||||
for (const [index, template] of templates.entries()) {
|
||||
tokens = parseTemplate({
|
||||
templates,
|
||||
expressions,
|
||||
tokens,
|
||||
index,
|
||||
template,
|
||||
});
|
||||
}
|
||||
|
||||
if (tokens.length === 0) {
|
||||
throw new TypeError('Template script must not be empty');
|
||||
}
|
||||
|
||||
const [file, ...commandArguments] = tokens;
|
||||
return [file, commandArguments, {}];
|
||||
};
|
||||
|
||||
const parseTemplate = ({templates, expressions, tokens, index, template}) => {
|
||||
if (template === undefined) {
|
||||
throw new TypeError(`Invalid backslash sequence: ${templates.raw[index]}`);
|
||||
}
|
||||
|
||||
const {nextTokens, leadingWhitespaces, trailingWhitespaces} = splitByWhitespaces(template, templates.raw[index]);
|
||||
const newTokens = concatTokens(tokens, nextTokens, leadingWhitespaces);
|
||||
|
||||
if (index === expressions.length) {
|
||||
return newTokens;
|
||||
}
|
||||
|
||||
const expression = expressions[index];
|
||||
const expressionTokens = Array.isArray(expression)
|
||||
? expression.map(expression => parseExpression(expression))
|
||||
: [parseExpression(expression)];
|
||||
return concatTokens(newTokens, expressionTokens, trailingWhitespaces);
|
||||
};
|
||||
|
||||
// Like `string.split(/[ \t\r\n]+/)` except newlines and tabs are:
|
||||
// - ignored when input as a backslash sequence like: `echo foo\n bar`
|
||||
// - not ignored when input directly
|
||||
// The only way to distinguish those in JavaScript is to use a tagged template and compare:
|
||||
// - the first array argument, which does not escape backslash sequences
|
||||
// - its `raw` property, which escapes them
|
||||
const splitByWhitespaces = (template, rawTemplate) => {
|
||||
if (rawTemplate.length === 0) {
|
||||
return {nextTokens: [], leadingWhitespaces: false, trailingWhitespaces: false};
|
||||
}
|
||||
|
||||
const nextTokens = [];
|
||||
let templateStart = 0;
|
||||
const isLeadingWhitespaces = DELIMITERS.has(rawTemplate[0]);
|
||||
|
||||
for (
|
||||
let templateIndex = 0, rawIndex = 0;
|
||||
templateIndex < template.length;
|
||||
templateIndex += 1, rawIndex += 1
|
||||
) {
|
||||
const rawCharacter = rawTemplate[rawIndex];
|
||||
if (DELIMITERS.has(rawCharacter)) {
|
||||
if (templateStart !== templateIndex) {
|
||||
nextTokens.push(template.slice(templateStart, templateIndex));
|
||||
}
|
||||
|
||||
templateStart = templateIndex + 1;
|
||||
} else if (rawCharacter === '\\') {
|
||||
const nextRawCharacter = rawTemplate[rawIndex + 1];
|
||||
if (nextRawCharacter === '\n') {
|
||||
// Handles escaped newlines in templates
|
||||
templateIndex -= 1;
|
||||
rawIndex += 1;
|
||||
} else if (nextRawCharacter === 'u' && rawTemplate[rawIndex + 2] === '{') {
|
||||
rawIndex = rawTemplate.indexOf('}', rawIndex + 3);
|
||||
} else {
|
||||
rawIndex += ESCAPE_LENGTH[nextRawCharacter] ?? 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isTrailingWhitespaces = templateStart === template.length;
|
||||
if (!isTrailingWhitespaces) {
|
||||
nextTokens.push(template.slice(templateStart));
|
||||
}
|
||||
|
||||
return {nextTokens, leadingWhitespaces: isLeadingWhitespaces, trailingWhitespaces: isTrailingWhitespaces};
|
||||
};
|
||||
|
||||
const DELIMITERS = new Set([' ', '\t', '\r', '\n']);
|
||||
|
||||
// Number of characters in backslash escape sequences: \0 \xXX or \uXXXX
|
||||
// \cX is allowed in RegExps but not in strings
|
||||
// Octal sequences are not allowed in strict mode
|
||||
const ESCAPE_LENGTH = {x: 3, u: 5};
|
||||
|
||||
const concatTokens = (tokens, nextTokens, isSeparated) => isSeparated
|
||||
|| tokens.length === 0
|
||||
|| nextTokens.length === 0
|
||||
? [...tokens, ...nextTokens]
|
||||
: [
|
||||
...tokens.slice(0, -1),
|
||||
`${tokens.at(-1)}${nextTokens[0]}`,
|
||||
...nextTokens.slice(1),
|
||||
];
|
||||
|
||||
// Handle `${expression}` inside the template string syntax
|
||||
const parseExpression = expression => {
|
||||
const typeOfExpression = typeof expression;
|
||||
|
||||
if (typeOfExpression === 'string') {
|
||||
return expression;
|
||||
}
|
||||
|
||||
if (typeOfExpression === 'number') {
|
||||
return String(expression);
|
||||
}
|
||||
|
||||
if (isPlainObject(expression) && ('stdout' in expression || 'isMaxBuffer' in expression)) {
|
||||
return getSubprocessResult(expression);
|
||||
}
|
||||
|
||||
if (expression instanceof ChildProcess || Object.prototype.toString.call(expression) === '[object Promise]') {
|
||||
// eslint-disable-next-line no-template-curly-in-string
|
||||
throw new TypeError('Unexpected subprocess in template expression. Please use ${await subprocess} instead of ${subprocess}.');
|
||||
}
|
||||
|
||||
throw new TypeError(`Unexpected "${typeOfExpression}" in template expression`);
|
||||
};
|
||||
|
||||
const getSubprocessResult = ({stdout}) => {
|
||||
if (typeof stdout === 'string') {
|
||||
return stdout;
|
||||
}
|
||||
|
||||
if (isUint8Array(stdout)) {
|
||||
return uint8ArrayToString(stdout);
|
||||
}
|
||||
|
||||
if (stdout === undefined) {
|
||||
throw new TypeError('Missing result.stdout in template expression. This is probably due to the previous subprocess\' "stdout" option.');
|
||||
}
|
||||
|
||||
throw new TypeError(`Unexpected "${typeof stdout}" stdout in template expression`);
|
||||
};
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import {aborted} from 'node:util';
|
||||
import {createNonCommandError} from './throw.js';
|
||||
|
||||
// When passing an `unpipeSignal` option, abort piping when the signal is aborted.
|
||||
// However, do not terminate the subprocesses.
|
||||
export const unpipeOnAbort = (unpipeSignal, unpipeContext) => unpipeSignal === undefined
|
||||
? []
|
||||
: [unpipeOnSignalAbort(unpipeSignal, unpipeContext)];
|
||||
|
||||
const unpipeOnSignalAbort = async (unpipeSignal, {sourceStream, mergedStream, fileDescriptors, sourceOptions, startTime}) => {
|
||||
await aborted(unpipeSignal, sourceStream);
|
||||
await mergedStream.remove(sourceStream);
|
||||
const error = new Error('Pipe canceled by `unpipeSignal` option.');
|
||||
throw createNonCommandError({
|
||||
error,
|
||||
fileDescriptors,
|
||||
sourceOptions,
|
||||
startTime,
|
||||
});
|
||||
};
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import {normalizeParameters} from '../methods/parameters.js';
|
||||
import {getStartTime} from '../return/duration.js';
|
||||
import {SUBPROCESS_OPTIONS, getToStream, getFromStream} from '../arguments/fd-options.js';
|
||||
import {isDenoExecPath} from '../arguments/file-url.js';
|
||||
|
||||
// Normalize and validate arguments passed to `source.pipe(destination)`
|
||||
export const normalizePipeArguments = ({source, sourcePromise, boundOptions, createNested}, ...pipeArguments) => {
|
||||
const startTime = getStartTime();
|
||||
const {
|
||||
destination,
|
||||
destinationStream,
|
||||
destinationError,
|
||||
from,
|
||||
unpipeSignal,
|
||||
} = getDestinationStream(boundOptions, createNested, pipeArguments);
|
||||
const {sourceStream, sourceError} = getSourceStream(source, from);
|
||||
const {options: sourceOptions, fileDescriptors} = SUBPROCESS_OPTIONS.get(source);
|
||||
return {
|
||||
sourcePromise,
|
||||
sourceStream,
|
||||
sourceOptions,
|
||||
sourceError,
|
||||
destination,
|
||||
destinationStream,
|
||||
destinationError,
|
||||
unpipeSignal,
|
||||
fileDescriptors,
|
||||
startTime,
|
||||
};
|
||||
};
|
||||
|
||||
const getDestinationStream = (boundOptions, createNested, pipeArguments) => {
|
||||
try {
|
||||
const {
|
||||
destination,
|
||||
pipeOptions: {from, to, unpipeSignal} = {},
|
||||
} = getDestination(boundOptions, createNested, ...pipeArguments);
|
||||
const destinationStream = getToStream(destination, to);
|
||||
return {
|
||||
destination,
|
||||
destinationStream,
|
||||
from,
|
||||
unpipeSignal,
|
||||
};
|
||||
} catch (error) {
|
||||
return {destinationError: error};
|
||||
}
|
||||
};
|
||||
|
||||
// Piping subprocesses can use three syntaxes:
|
||||
// - source.pipe('command', commandArguments, pipeOptionsOrDestinationOptions)
|
||||
// - source.pipe`command commandArgument` or source.pipe(pipeOptionsOrDestinationOptions)`command commandArgument`
|
||||
// - source.pipe(execa(...), pipeOptions)
|
||||
const getDestination = (boundOptions, createNested, firstArgument, ...pipeArguments) => {
|
||||
if (Array.isArray(firstArgument)) {
|
||||
const destination = createNested(mapDestinationArguments, boundOptions)(firstArgument, ...pipeArguments);
|
||||
return {destination, pipeOptions: boundOptions};
|
||||
}
|
||||
|
||||
if (typeof firstArgument === 'string' || firstArgument instanceof URL || isDenoExecPath(firstArgument)) {
|
||||
if (Object.keys(boundOptions).length > 0) {
|
||||
throw new TypeError('Please use .pipe("file", ..., options) or .pipe(execa("file", ..., options)) instead of .pipe(options)("file", ...).');
|
||||
}
|
||||
|
||||
const [rawFile, rawArguments, rawOptions] = normalizeParameters(firstArgument, ...pipeArguments);
|
||||
const destination = createNested(mapDestinationArguments)(rawFile, rawArguments, rawOptions);
|
||||
return {destination, pipeOptions: rawOptions};
|
||||
}
|
||||
|
||||
if (SUBPROCESS_OPTIONS.has(firstArgument)) {
|
||||
if (Object.keys(boundOptions).length > 0) {
|
||||
throw new TypeError('Please use .pipe(options)`command` or .pipe($(options)`command`) instead of .pipe(options)($`command`).');
|
||||
}
|
||||
|
||||
return {destination: firstArgument, pipeOptions: pipeArguments[0]};
|
||||
}
|
||||
|
||||
throw new TypeError(`The first argument must be a template string, an options object, or an Execa subprocess: ${firstArgument}`);
|
||||
};
|
||||
|
||||
// Force `stdin: 'pipe'` with the destination subprocess
|
||||
const mapDestinationArguments = ({options}) => ({options: {...options, stdin: 'pipe', piped: true}});
|
||||
|
||||
const getSourceStream = (source, from) => {
|
||||
try {
|
||||
const sourceStream = getFromStream(source, from);
|
||||
return {sourceStream};
|
||||
} catch (error) {
|
||||
return {sourceError: error};
|
||||
}
|
||||
};
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
// Like Bash, we await both subprocesses. This is unlike some other shells which only await the destination subprocess.
|
||||
// Like Bash with the `pipefail` option, if either subprocess fails, the whole pipe fails.
|
||||
// Like Bash, if both subprocesses fail, we return the failure of the destination.
|
||||
// This ensures both subprocesses' errors are present, using `error.pipedFrom`.
|
||||
export const waitForBothSubprocesses = async subprocessPromises => {
|
||||
const [
|
||||
{status: sourceStatus, reason: sourceReason, value: sourceResult = sourceReason},
|
||||
{status: destinationStatus, reason: destinationReason, value: destinationResult = destinationReason},
|
||||
] = await subprocessPromises;
|
||||
|
||||
if (!destinationResult.pipedFrom.includes(sourceResult)) {
|
||||
destinationResult.pipedFrom.push(sourceResult);
|
||||
}
|
||||
|
||||
if (destinationStatus === 'rejected') {
|
||||
throw destinationResult;
|
||||
}
|
||||
|
||||
if (sourceStatus === 'rejected') {
|
||||
throw sourceResult;
|
||||
}
|
||||
|
||||
return destinationResult;
|
||||
};
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
import isPlainObject from 'is-plain-obj';
|
||||
import {SUBPROCESS_OPTIONS} from '../arguments/fd-options.js';
|
||||
import {initializeConcurrentStreams} from '../convert/concurrent.js';
|
||||
import {createIterable} from '../convert/iterable.js';
|
||||
import {createReadable} from '../convert/readable.js';
|
||||
import {createReadableStream} from '../convert/web.js';
|
||||
import {internalGetOneMessageOptions} from '../ipc/get-one.js';
|
||||
import {internalGetEachMessageOptions} from '../ipc/get-each.js';
|
||||
import {normalizePipeArguments} from './pipe-arguments.js';
|
||||
import {handlePipeArgumentsError} from './throw.js';
|
||||
import {waitForBothSubprocesses} from './sequence.js';
|
||||
import {pipeSubprocessStream} from './streaming.js';
|
||||
import {unpipeOnAbort} from './abort.js';
|
||||
|
||||
// Pipe a subprocess' `stdout`/`stderr`/`stdio` into another subprocess' `stdin`
|
||||
export const pipeToSubprocess = (sourceInfo, ...pipeArguments) => {
|
||||
if (isPlainObject(pipeArguments[0])) {
|
||||
return pipeToSubprocess.bind(undefined, {
|
||||
...sourceInfo,
|
||||
boundOptions: {...sourceInfo.boundOptions, ...pipeArguments[0]},
|
||||
});
|
||||
}
|
||||
|
||||
const {destination, ...normalizedInfo} = normalizePipeArguments(sourceInfo, ...pipeArguments);
|
||||
const pipeFailureController = new AbortController();
|
||||
const promise = handlePipePromise({...normalizedInfo, destination, pipeFailureController});
|
||||
promise.pipe = pipeToSubprocess.bind(undefined, {
|
||||
...sourceInfo,
|
||||
source: destination,
|
||||
sourcePromise: promise,
|
||||
boundOptions: {},
|
||||
});
|
||||
forwardDestinationMethods(promise, destination, pipeFailureController.signal);
|
||||
return promise;
|
||||
};
|
||||
|
||||
/*
|
||||
The return value of `.pipe()` exposes the destination subprocess' output, but its iteration and stream conversion methods must await the whole pipe so source failures are propagated too. The destination is `undefined` when `.pipe()` was passed invalid arguments, in which case the promise rejects and there is nothing to forward.
|
||||
*/
|
||||
const forwardDestinationMethods = (promise, destination, pipeFailureSignal) => {
|
||||
if (destination === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
forwardReadableMethods(promise, destination);
|
||||
forwardIpcMethods(promise, destination, pipeFailureSignal);
|
||||
};
|
||||
|
||||
const forwardReadableMethods = (promise, destination) => {
|
||||
const subprocessOptions = SUBPROCESS_OPTIONS.get(destination);
|
||||
SUBPROCESS_OPTIONS.set(promise, subprocessOptions);
|
||||
promise.stdio = destination.stdio;
|
||||
promise.all = destination.all;
|
||||
|
||||
const {options: {encoding}} = subprocessOptions;
|
||||
const concurrentStreams = initializeConcurrentStreams();
|
||||
promise[Symbol.asyncIterator] = createIterable.bind(undefined, promise, encoding, {});
|
||||
promise.iterable = createIterable.bind(undefined, promise, encoding);
|
||||
promise.readable = createPipeReadable.bind(undefined, promise, {
|
||||
subprocess: promise,
|
||||
concurrentStreams,
|
||||
encoding,
|
||||
});
|
||||
promise.readableStream = createReadableStream.bind(undefined, promise);
|
||||
forwardAll(promise, destination);
|
||||
};
|
||||
|
||||
const forwardAll = (promise, destination) => {
|
||||
if (destination.all === undefined) {
|
||||
promise.all = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
Object.defineProperty(promise, 'all', {
|
||||
get() {
|
||||
setAllProperty(promise, destination.all);
|
||||
const all = promise.readable({from: 'all'});
|
||||
setAllProperty(promise, all);
|
||||
return all;
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const setAllProperty = (promise, value) => {
|
||||
Object.defineProperty(promise, 'all', {
|
||||
value,
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const createPipeReadable = (promise, readableOptions, ...arguments_) => {
|
||||
const readable = createReadable(readableOptions, ...arguments_);
|
||||
destroyOnPipeFailure(promise, readable);
|
||||
return readable;
|
||||
};
|
||||
|
||||
const destroyOnPipeFailure = async (promise, readable) => {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
readable.destroy(error);
|
||||
}
|
||||
};
|
||||
|
||||
const forwardIpcMethods = (promise, destination, pipeFailureSignal) => {
|
||||
promise.sendMessage = destination.sendMessage;
|
||||
promise.getOneMessage = getOnePipeMessage.bind(undefined, destination, pipeFailureSignal);
|
||||
promise.getEachMessage = getEachPipeMessage.bind(undefined, promise, destination, pipeFailureSignal);
|
||||
};
|
||||
|
||||
const getOnePipeMessage = (destination, pipeFailureSignal, ...arguments_) => {
|
||||
const controller = new AbortController();
|
||||
const messagePromise = destination.getOneMessage(...addPipeOptions(arguments_, controller.signal, internalGetOneMessageOptions));
|
||||
return waitForOnePipeMessage(pipeFailureSignal, messagePromise, controller);
|
||||
};
|
||||
|
||||
const waitForOnePipeMessage = async (pipeFailureSignal, messagePromise, controller) => {
|
||||
try {
|
||||
return await Promise.race([messagePromise, getSignalRejection(pipeFailureSignal, controller.signal)]);
|
||||
} finally {
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
|
||||
const getSignalRejection = (signal, listenerSignal) => new Promise((_, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(signal.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(signal.reason);
|
||||
}, {once: true, signal: listenerSignal});
|
||||
});
|
||||
|
||||
const getEachPipeMessage = (promise, destination, pipeFailureSignal, ...arguments_) => {
|
||||
const controller = new AbortController();
|
||||
// Create the destination iterator before awaiting the pipe so option validation stays synchronous.
|
||||
const iterator = destination.getEachMessage(...addPipeOptions(arguments_, controller.signal, internalGetEachMessageOptions));
|
||||
abortOnSignal(pipeFailureSignal, controller);
|
||||
return iterateOnPipeMessages(promise, iterator, controller);
|
||||
};
|
||||
|
||||
const iterateOnPipeMessages = async function * (promise, iterator, controller) {
|
||||
try {
|
||||
yield * iterator;
|
||||
} finally {
|
||||
controller.abort();
|
||||
await promise;
|
||||
}
|
||||
};
|
||||
|
||||
const addPipeOptions = (arguments_, signal, internalOptionsSymbol) => {
|
||||
if (arguments_[0] === null) {
|
||||
// Preserve the public validation error from `getEachMessage(null)` instead of masking it with a pipe failure.
|
||||
return arguments_;
|
||||
}
|
||||
|
||||
const [options] = arguments_;
|
||||
// The returned pipe promise is awaited by the forwarded IPC iterator, so the destination IPC iterator must not also await the destination subprocess on close.
|
||||
return [{...options, [internalOptionsSymbol]: {signal, shouldAwait: false}}];
|
||||
};
|
||||
|
||||
const abortOnSignal = (signal, controller) => {
|
||||
if (signal.aborted) {
|
||||
controller.abort();
|
||||
return;
|
||||
}
|
||||
|
||||
// Interrupt pending IPC reads when the pipe rejects, including `unpipeSignal` cancellation while the destination keeps running.
|
||||
signal.addEventListener('abort', () => {
|
||||
controller.abort();
|
||||
}, {once: true, signal: controller.signal});
|
||||
};
|
||||
|
||||
// `writable()`, `duplex()`, `writableStream()` and `transformStream()` are intentionally not forwarded: they write to the destination's `stdin`, which is already being piped from the source.
|
||||
|
||||
// Asynchronous logic when piping subprocesses
|
||||
const handlePipePromise = async ({
|
||||
sourcePromise,
|
||||
sourceStream,
|
||||
sourceOptions,
|
||||
sourceError,
|
||||
destination,
|
||||
destinationStream,
|
||||
destinationError,
|
||||
unpipeSignal,
|
||||
fileDescriptors,
|
||||
startTime,
|
||||
pipeFailureController,
|
||||
}) => {
|
||||
const maxListenersController = new AbortController();
|
||||
try {
|
||||
const subprocessPromises = getSubprocessPromises(sourcePromise, destination);
|
||||
handlePipeArgumentsError({
|
||||
sourceStream,
|
||||
sourceError,
|
||||
destinationStream,
|
||||
destinationError,
|
||||
fileDescriptors,
|
||||
sourceOptions,
|
||||
startTime,
|
||||
});
|
||||
const mergedStream = pipeSubprocessStream(sourceStream, destinationStream, maxListenersController);
|
||||
return await Promise.race([
|
||||
waitForBothSubprocesses(subprocessPromises),
|
||||
...unpipeOnAbort(unpipeSignal, {
|
||||
sourceStream,
|
||||
mergedStream,
|
||||
sourceOptions,
|
||||
fileDescriptors,
|
||||
startTime,
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
pipeFailureController.abort(error);
|
||||
throw error;
|
||||
} finally {
|
||||
maxListenersController.abort();
|
||||
}
|
||||
};
|
||||
|
||||
// `.pipe()` awaits the subprocess promises.
|
||||
// When invalid arguments are passed to `.pipe()`, we throw an error, which prevents awaiting them.
|
||||
// We need to ensure this does not create unhandled rejections.
|
||||
const getSubprocessPromises = (sourcePromise, destination) => Promise.allSettled([sourcePromise, destination]);
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import {finished} from 'node:stream/promises';
|
||||
import mergeStreams from '@sindresorhus/merge-streams';
|
||||
import {incrementMaxListeners} from '../utils/max-listeners.js';
|
||||
import {pipeStreams} from '../io/pipeline.js';
|
||||
|
||||
// The piping behavior is like Bash.
|
||||
// In particular, when one subprocess exits, the other is not terminated by a signal.
|
||||
// Instead, its stdout (for the source) or stdin (for the destination) closes.
|
||||
// If the subprocess uses it, it will make it error with SIGPIPE or EPIPE (for the source) or end (for the destination).
|
||||
// If it does not use it, it will continue running.
|
||||
// This allows for subprocesses to gracefully exit and lower the coupling between subprocesses.
|
||||
export const pipeSubprocessStream = (sourceStream, destinationStream, maxListenersController) => {
|
||||
const mergedStream = MERGED_STREAMS.has(destinationStream)
|
||||
? pipeMoreSubprocessStream(sourceStream, destinationStream)
|
||||
: pipeFirstSubprocessStream(sourceStream, destinationStream);
|
||||
incrementMaxListeners(sourceStream, SOURCE_LISTENERS_PER_PIPE, maxListenersController.signal);
|
||||
incrementMaxListeners(destinationStream, DESTINATION_LISTENERS_PER_PIPE, maxListenersController.signal);
|
||||
cleanupMergedStreamsMap(destinationStream);
|
||||
return mergedStream;
|
||||
};
|
||||
|
||||
// We use `merge-streams` to allow for multiple sources to pipe to the same destination.
|
||||
const pipeFirstSubprocessStream = (sourceStream, destinationStream) => {
|
||||
const mergedStream = mergeStreams([sourceStream]);
|
||||
pipeStreams(mergedStream, destinationStream);
|
||||
MERGED_STREAMS.set(destinationStream, mergedStream);
|
||||
return mergedStream;
|
||||
};
|
||||
|
||||
const pipeMoreSubprocessStream = (sourceStream, destinationStream) => {
|
||||
const mergedStream = MERGED_STREAMS.get(destinationStream);
|
||||
mergedStream.add(sourceStream);
|
||||
return mergedStream;
|
||||
};
|
||||
|
||||
const cleanupMergedStreamsMap = async destinationStream => {
|
||||
try {
|
||||
await finished(destinationStream, {cleanup: true, readable: false, writable: true});
|
||||
} catch {}
|
||||
|
||||
MERGED_STREAMS.delete(destinationStream);
|
||||
};
|
||||
|
||||
const MERGED_STREAMS = new WeakMap();
|
||||
|
||||
// Number of listeners set up on `sourceStream` by each `sourceStream.pipe(destinationStream)`
|
||||
// Those are added by `merge-streams`
|
||||
const SOURCE_LISTENERS_PER_PIPE = 2;
|
||||
// Number of listeners set up on `destinationStream` by each `sourceStream.pipe(destinationStream)`
|
||||
// Those are added by `finished()` in `cleanupMergedStreamsMap()`
|
||||
const DESTINATION_LISTENERS_PER_PIPE = 1;
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import {makeEarlyError} from '../return/result.js';
|
||||
import {abortSourceStream, endDestinationStream} from '../io/pipeline.js';
|
||||
|
||||
// When passing invalid arguments to `source.pipe()`, throw asynchronously.
|
||||
// We also abort both subprocesses.
|
||||
export const handlePipeArgumentsError = ({
|
||||
sourceStream,
|
||||
sourceError,
|
||||
destinationStream,
|
||||
destinationError,
|
||||
fileDescriptors,
|
||||
sourceOptions,
|
||||
startTime,
|
||||
}) => {
|
||||
const error = getPipeArgumentsError({
|
||||
sourceStream,
|
||||
sourceError,
|
||||
destinationStream,
|
||||
destinationError,
|
||||
});
|
||||
if (error !== undefined) {
|
||||
throw createNonCommandError({
|
||||
error,
|
||||
fileDescriptors,
|
||||
sourceOptions,
|
||||
startTime,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getPipeArgumentsError = ({sourceStream, sourceError, destinationStream, destinationError}) => {
|
||||
if (sourceError !== undefined && destinationError !== undefined) {
|
||||
return destinationError;
|
||||
}
|
||||
|
||||
if (destinationError !== undefined) {
|
||||
abortSourceStream(sourceStream);
|
||||
return destinationError;
|
||||
}
|
||||
|
||||
if (sourceError !== undefined) {
|
||||
endDestinationStream(destinationStream);
|
||||
return sourceError;
|
||||
}
|
||||
};
|
||||
|
||||
// Specific error return value when passing invalid arguments to `subprocess.pipe()` or when using `unpipeSignal`
|
||||
export const createNonCommandError = ({error, fileDescriptors, sourceOptions, startTime}) => makeEarlyError({
|
||||
error,
|
||||
command: PIPE_COMMAND_MESSAGE,
|
||||
escapedCommand: PIPE_COMMAND_MESSAGE,
|
||||
fileDescriptors,
|
||||
options: sourceOptions,
|
||||
startTime,
|
||||
isSync: false,
|
||||
});
|
||||
|
||||
const PIPE_COMMAND_MESSAGE = 'source.pipe(destination)';
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import mergeStreams from '@sindresorhus/merge-streams';
|
||||
import {waitForSubprocessStream} from './stdio.js';
|
||||
|
||||
// `all` interleaves `stdout` and `stderr`
|
||||
export const makeAllStream = ({stdout, stderr}, {all}) => all && (stdout || stderr)
|
||||
? mergeStreams([stdout, stderr].filter(Boolean))
|
||||
: undefined;
|
||||
|
||||
// Read the contents of `subprocess.all` and|or wait for its completion
|
||||
export const waitForAllStream = ({subprocess, all, encoding, buffer, maxBuffer, lines, stripFinalNewline, verboseInfo, streamInfo}) => waitForSubprocessStream({
|
||||
...getAllStream(subprocess, all, buffer),
|
||||
fdNumber: 'all',
|
||||
encoding,
|
||||
maxBuffer: maxBuffer[1] + maxBuffer[2],
|
||||
lines: lines[1] || lines[2],
|
||||
allMixed: getAllMixed(subprocess, all),
|
||||
stripFinalNewline,
|
||||
verboseInfo,
|
||||
streamInfo,
|
||||
});
|
||||
|
||||
const getAllStream = ({stdout, stderr}, all, [, bufferStdout, bufferStderr]) => {
|
||||
const buffer = bufferStdout || bufferStderr;
|
||||
if (!buffer) {
|
||||
return {stream: all, buffer};
|
||||
}
|
||||
|
||||
if (!bufferStdout) {
|
||||
return {stream: stderr, buffer};
|
||||
}
|
||||
|
||||
if (!bufferStderr) {
|
||||
return {stream: stdout, buffer};
|
||||
}
|
||||
|
||||
return {stream: all, buffer};
|
||||
};
|
||||
|
||||
// When `subprocess.stdout` is in objectMode but not `subprocess.stderr` (or the opposite), we need to use both:
|
||||
// - `getStreamAsArray()` for the chunks in objectMode, to return as an array without changing each chunk
|
||||
// - `getStreamAsArrayBuffer()` or `getStream()` for the chunks not in objectMode, to convert them from Buffers to string or Uint8Array
|
||||
// We do this by emulating the Buffer -> string|Uint8Array conversion performed by `get-stream` with our own, which is identical.
|
||||
const getAllMixed = ({stdout, stderr}, all) => all
|
||||
&& stdout
|
||||
&& stderr
|
||||
&& stdout.readableObjectMode !== stderr.readableObjectMode;
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import {isUint8Array, concatUint8Arrays} from '../utils/uint-array.js';
|
||||
import {stripNewline} from '../io/strip-newline.js';
|
||||
|
||||
// Retrieve `result.all` with synchronous methods
|
||||
export const getAllSync = ([, stdout, stderr], options) => {
|
||||
if (!options.all) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (stdout === undefined) {
|
||||
return stderr;
|
||||
}
|
||||
|
||||
if (stderr === undefined) {
|
||||
return stdout;
|
||||
}
|
||||
|
||||
if (Array.isArray(stdout)) {
|
||||
return Array.isArray(stderr)
|
||||
? [...stdout, ...stderr]
|
||||
: [...stdout, stripNewline(stderr, options, 'all')];
|
||||
}
|
||||
|
||||
if (Array.isArray(stderr)) {
|
||||
return [stripNewline(stdout, options, 'all'), ...stderr];
|
||||
}
|
||||
|
||||
if (isUint8Array(stdout) && isUint8Array(stderr)) {
|
||||
return concatUint8Arrays([stdout, stderr]);
|
||||
}
|
||||
|
||||
return `${stdout}${stderr}`;
|
||||
};
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import {once} from 'node:events';
|
||||
import {DiscardedError} from '../return/final-error.js';
|
||||
|
||||
// If `error` is emitted before `spawn`, `exit` will never be emitted.
|
||||
// However, `error` might be emitted after `spawn`.
|
||||
// In that case, `exit` will still be emitted.
|
||||
// Since the `exit` event contains the signal name, we want to make sure we are listening for it.
|
||||
// This function also takes into account the following unlikely cases:
|
||||
// - `exit` being emitted in the same microtask as `spawn`
|
||||
// - `error` being emitted multiple times
|
||||
export const waitForExit = async (subprocess, context) => {
|
||||
const [exitCode, signal] = await waitForExitOrError(subprocess);
|
||||
context.isForcefullyTerminated ??= false;
|
||||
return [exitCode, signal];
|
||||
};
|
||||
|
||||
const waitForExitOrError = async subprocess => {
|
||||
const [spawnPayload, exitPayload] = await Promise.allSettled([
|
||||
once(subprocess, 'spawn'),
|
||||
once(subprocess, 'exit'),
|
||||
]);
|
||||
|
||||
if (spawnPayload.status === 'rejected') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return exitPayload.status === 'rejected'
|
||||
? waitForSubprocessExit(subprocess)
|
||||
: exitPayload.value;
|
||||
};
|
||||
|
||||
const waitForSubprocessExit = async subprocess => {
|
||||
try {
|
||||
return await once(subprocess, 'exit');
|
||||
} catch {
|
||||
return waitForSubprocessExit(subprocess);
|
||||
}
|
||||
};
|
||||
|
||||
// Retrieve the final exit code and|or signal name
|
||||
export const waitForSuccessfulExit = async exitPromise => {
|
||||
const [exitCode, signal] = await exitPromise;
|
||||
|
||||
if (!isSubprocessErrorExit(exitCode, signal) && isFailedExit(exitCode, signal)) {
|
||||
throw new DiscardedError();
|
||||
}
|
||||
|
||||
return [exitCode, signal];
|
||||
};
|
||||
|
||||
// When the subprocess fails due to an `error` event
|
||||
const isSubprocessErrorExit = (exitCode, signal) => exitCode === undefined && signal === undefined;
|
||||
// When the subprocess fails due to a non-0 exit code or to a signal termination
|
||||
export const isFailedExit = (exitCode, signal) => exitCode !== 0 || signal !== null;
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import {DiscardedError} from '../return/final-error.js';
|
||||
import {isMaxBufferSync} from '../io/max-buffer.js';
|
||||
import {isFailedExit} from './exit-async.js';
|
||||
|
||||
// Retrieve exit code, signal name and error information, with synchronous methods
|
||||
export const getExitResultSync = ({error, status: exitCode, signal, output}, {maxBuffer}) => {
|
||||
const resultError = getResultError(error, exitCode, signal);
|
||||
const isTimedOut = resultError?.code === 'ETIMEDOUT';
|
||||
const isMaxBuffer = isMaxBufferSync(resultError, output, maxBuffer);
|
||||
return {
|
||||
resultError,
|
||||
exitCode,
|
||||
signal,
|
||||
timedOut: isTimedOut,
|
||||
isMaxBuffer,
|
||||
};
|
||||
};
|
||||
|
||||
const getResultError = (error, exitCode, signal) => {
|
||||
if (error !== undefined) {
|
||||
return error;
|
||||
}
|
||||
|
||||
return isFailedExit(exitCode, signal) ? new DiscardedError() : undefined;
|
||||
};
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import {getStreamOutput} from '../io/contents.js';
|
||||
import {waitForStream, isInputFileDescriptor} from './wait-stream.js';
|
||||
|
||||
// Read the contents of `subprocess.std*` and|or wait for its completion
|
||||
export const waitForStdioStreams = ({subprocess, encoding, buffer, maxBuffer, lines, stripFinalNewline, verboseInfo, streamInfo}) => subprocess.stdio.map((stream, fdNumber) => waitForSubprocessStream({
|
||||
stream,
|
||||
fdNumber,
|
||||
encoding,
|
||||
buffer: buffer[fdNumber],
|
||||
maxBuffer: maxBuffer[fdNumber],
|
||||
lines: lines[fdNumber],
|
||||
allMixed: false,
|
||||
stripFinalNewline,
|
||||
verboseInfo,
|
||||
streamInfo,
|
||||
}));
|
||||
|
||||
// Read the contents of `subprocess.std*` or `subprocess.all` and|or wait for its completion
|
||||
export const waitForSubprocessStream = async ({stream, fdNumber, encoding, buffer, maxBuffer, lines, allMixed, stripFinalNewline, verboseInfo, streamInfo}) => {
|
||||
if (!stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onStreamEnd = waitForStream(stream, fdNumber, streamInfo);
|
||||
if (isInputFileDescriptor(streamInfo, fdNumber)) {
|
||||
await onStreamEnd;
|
||||
return;
|
||||
}
|
||||
|
||||
const [output] = await Promise.all([
|
||||
getStreamOutput({
|
||||
stream,
|
||||
onStreamEnd,
|
||||
fdNumber,
|
||||
encoding,
|
||||
buffer,
|
||||
maxBuffer,
|
||||
lines,
|
||||
allMixed,
|
||||
stripFinalNewline,
|
||||
verboseInfo,
|
||||
streamInfo,
|
||||
}),
|
||||
onStreamEnd,
|
||||
]);
|
||||
return output;
|
||||
};
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import {finished} from 'node:stream/promises';
|
||||
|
||||
// Wraps `finished(stream)` to handle the following case:
|
||||
// - When the subprocess exits, Node.js automatically calls `subprocess.stdin.destroy()`, which we need to ignore.
|
||||
// - However, we still need to throw if `subprocess.stdin.destroy()` is called before subprocess exit.
|
||||
export const waitForStream = async (stream, fdNumber, streamInfo, {isSameDirection, stopOnExit = false} = {}) => {
|
||||
const state = handleStdinDestroy(stream, streamInfo);
|
||||
const abortController = new AbortController();
|
||||
try {
|
||||
await Promise.race([
|
||||
...(stopOnExit ? [streamInfo.exitPromise] : []),
|
||||
finished(stream, {cleanup: true, signal: abortController.signal}),
|
||||
]);
|
||||
} catch (error) {
|
||||
if (!state.stdinCleanedUp) {
|
||||
handleStreamError(error, fdNumber, streamInfo, isSameDirection);
|
||||
}
|
||||
} finally {
|
||||
abortController.abort();
|
||||
}
|
||||
};
|
||||
|
||||
// If `subprocess.stdin` is destroyed before being fully written to, it is considered aborted and should throw an error.
|
||||
// This can happen for example when user called `subprocess.stdin.destroy()` before `subprocess.stdin.end()`.
|
||||
// However, Node.js calls `subprocess.stdin.destroy()` on exit for cleanup purposes.
|
||||
// https://github.com/nodejs/node/blob/0b4cdb4b42956cbd7019058e409e06700a199e11/lib/internal/child_process.js#L278
|
||||
// This is normal and should not throw an error.
|
||||
// Therefore, we need to differentiate between both situations to know whether to throw an error.
|
||||
// Unfortunately, events (`close`, `error`, `end`, `exit`) cannot be used because `.destroy()` can take an arbitrary amount of time.
|
||||
// For example, `stdin: 'pipe'` is implemented as a TCP socket, and its `.destroy()` method waits for TCP disconnection.
|
||||
// Therefore `.destroy()` might end before or after subprocess exit, based on OS speed and load.
|
||||
// The only way to detect this is to spy on `subprocess.stdin._destroy()` by wrapping it.
|
||||
// If `subprocess.exitCode` or `subprocess.signalCode` is set, it means `.destroy()` is being called by Node.js itself.
|
||||
const handleStdinDestroy = (stream, {originalStreams, subprocess}) => {
|
||||
const [originalStdin] = originalStreams;
|
||||
const state = {stdinCleanedUp: false};
|
||||
if (stream === originalStdin) {
|
||||
spyOnStdinDestroy(stream, subprocess, state);
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
const spyOnStdinDestroy = (subprocessStdin, subprocess, state) => {
|
||||
const {_destroy} = subprocessStdin;
|
||||
subprocessStdin._destroy = (...destroyArguments) => {
|
||||
setStdinCleanedUp(subprocess, state);
|
||||
_destroy.call(subprocessStdin, ...destroyArguments);
|
||||
};
|
||||
};
|
||||
|
||||
const setStdinCleanedUp = ({exitCode, signalCode}, state) => {
|
||||
if (exitCode !== null || signalCode !== null) {
|
||||
state.stdinCleanedUp = true;
|
||||
}
|
||||
};
|
||||
|
||||
// We ignore EPIPEs on writable streams and aborts on readable streams since those can happen normally.
|
||||
// When one stream errors, the error is propagated to the other streams on the same file descriptor.
|
||||
// Those other streams might have a different direction due to the above.
|
||||
// When this happens, the direction of both the initial stream and the others should then be taken into account.
|
||||
// Therefore, we keep track of whether a stream error is currently propagating.
|
||||
const handleStreamError = (error, fdNumber, streamInfo, isSameDirection) => {
|
||||
if (!shouldIgnoreStreamError(error, fdNumber, streamInfo, isSameDirection)) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const shouldIgnoreStreamError = (error, fdNumber, streamInfo, isSameDirection = true) => {
|
||||
if (streamInfo.propagating) {
|
||||
return isStreamEpipe(error) || isStreamAbort(error);
|
||||
}
|
||||
|
||||
streamInfo.propagating = true;
|
||||
return isInputFileDescriptor(streamInfo, fdNumber) === isSameDirection
|
||||
? isStreamEpipe(error)
|
||||
: isStreamAbort(error);
|
||||
};
|
||||
|
||||
// Unfortunately, we cannot use the stream's class or properties to know whether it is readable or writable.
|
||||
// For example, `subprocess.stdin` is technically a Duplex, but can only be used as a writable.
|
||||
// Therefore, we need to use the file descriptor's direction (`stdin` is input, `stdout` is output, etc.).
|
||||
// However, while `subprocess.std*` and transforms follow that direction, any stream passed the `std*` option has the opposite direction.
|
||||
// For example, `subprocess.stdin` is a writable, but the `stdin` option is a readable.
|
||||
export const isInputFileDescriptor = ({fileDescriptors}, fdNumber) => fdNumber !== 'all' && fileDescriptors[fdNumber].direction === 'input';
|
||||
|
||||
// When `stream.destroy()` is called without an `error` argument, stream is aborted.
|
||||
// This is the only way to abort a readable stream, which can be useful in some instances.
|
||||
// Therefore, we ignore this error on readable streams.
|
||||
export const isStreamAbort = error => error?.code === 'ERR_STREAM_PREMATURE_CLOSE';
|
||||
|
||||
// When `stream.write()` is called but the underlying source has been closed, `EPIPE` is emitted.
|
||||
// When piping subprocesses, the source subprocess usually decides when to stop piping.
|
||||
// However, there are some instances when the destination does instead, such as `... | head -n1`.
|
||||
// It notifies the source by using `EPIPE`.
|
||||
// Therefore, we ignore this error on writable streams.
|
||||
const isStreamEpipe = error => error?.code === 'EPIPE';
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import {once} from 'node:events';
|
||||
import {isStream as isNodeStream} from 'is-stream';
|
||||
import {throwOnTimeout} from '../terminate/timeout.js';
|
||||
import {throwOnCancel} from '../terminate/cancel.js';
|
||||
import {throwOnGracefulCancel} from '../terminate/graceful.js';
|
||||
import {isStandardStream} from '../utils/standard-stream.js';
|
||||
import {TRANSFORM_TYPES} from '../stdio/type.js';
|
||||
import {getBufferedData} from '../io/contents.js';
|
||||
import {waitForIpcOutput, getBufferedIpcOutput} from '../ipc/buffer-messages.js';
|
||||
import {sendIpcInput} from '../ipc/ipc-input.js';
|
||||
import {waitForAllStream} from './all-async.js';
|
||||
import {waitForStdioStreams} from './stdio.js';
|
||||
import {waitForExit, waitForSuccessfulExit} from './exit-async.js';
|
||||
import {waitForStream} from './wait-stream.js';
|
||||
|
||||
// Retrieve result of subprocess: exit code, signal, error, streams (stdout/stderr/all)
|
||||
export const waitForSubprocessResult = async ({
|
||||
subprocess,
|
||||
kill,
|
||||
all,
|
||||
options: {
|
||||
encoding,
|
||||
buffer,
|
||||
maxBuffer,
|
||||
lines,
|
||||
timeoutDuration: timeout,
|
||||
cancelSignal,
|
||||
gracefulCancel,
|
||||
forceKillAfterDelay,
|
||||
stripFinalNewline,
|
||||
ipc,
|
||||
ipcInput,
|
||||
},
|
||||
context,
|
||||
verboseInfo,
|
||||
fileDescriptors,
|
||||
originalStreams,
|
||||
onInternalError,
|
||||
controller,
|
||||
}) => {
|
||||
const exitPromise = waitForExit(subprocess, context);
|
||||
const streamInfo = {
|
||||
originalStreams,
|
||||
fileDescriptors,
|
||||
subprocess,
|
||||
exitPromise,
|
||||
propagating: false,
|
||||
};
|
||||
|
||||
const stdioPromises = waitForStdioStreams({
|
||||
subprocess,
|
||||
encoding,
|
||||
buffer,
|
||||
maxBuffer,
|
||||
lines,
|
||||
stripFinalNewline,
|
||||
verboseInfo,
|
||||
streamInfo,
|
||||
});
|
||||
const allPromise = waitForAllStream({
|
||||
subprocess,
|
||||
all,
|
||||
encoding,
|
||||
buffer,
|
||||
maxBuffer,
|
||||
lines,
|
||||
stripFinalNewline,
|
||||
verboseInfo,
|
||||
streamInfo,
|
||||
});
|
||||
const ipcOutput = [];
|
||||
const ipcOutputPromise = waitForIpcOutput({
|
||||
subprocess,
|
||||
buffer,
|
||||
maxBuffer,
|
||||
ipc,
|
||||
ipcOutput,
|
||||
verboseInfo,
|
||||
});
|
||||
const originalPromises = waitForOriginalStreams(originalStreams, subprocess, streamInfo);
|
||||
const customStreamsEndPromises = waitForCustomStreamsEnd(fileDescriptors, streamInfo);
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.all([
|
||||
{},
|
||||
waitForSuccessfulExit(exitPromise),
|
||||
Promise.all(stdioPromises),
|
||||
allPromise,
|
||||
ipcOutputPromise,
|
||||
sendIpcInput(subprocess, ipcInput, ipc),
|
||||
...originalPromises,
|
||||
...customStreamsEndPromises,
|
||||
]),
|
||||
onInternalError,
|
||||
throwOnSubprocessError(subprocess, controller),
|
||||
...throwOnTimeout(kill, timeout, context, controller),
|
||||
...throwOnCancel({
|
||||
kill,
|
||||
cancelSignal,
|
||||
gracefulCancel,
|
||||
context,
|
||||
controller,
|
||||
}),
|
||||
...throwOnGracefulCancel({
|
||||
subprocess,
|
||||
kill,
|
||||
cancelSignal,
|
||||
gracefulCancel,
|
||||
forceKillAfterDelay,
|
||||
context,
|
||||
controller,
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
context.terminationReason ??= 'other';
|
||||
return Promise.all([
|
||||
{error},
|
||||
exitPromise,
|
||||
Promise.all(stdioPromises.map(stdioPromise => getBufferedData(stdioPromise))),
|
||||
getBufferedData(allPromise),
|
||||
getBufferedIpcOutput(ipcOutputPromise, ipcOutput),
|
||||
Promise.allSettled(originalPromises),
|
||||
Promise.allSettled(customStreamsEndPromises),
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
// Transforms replace `subprocess.std*`, which means they are not exposed to users.
|
||||
// However, we still want to wait for their completion.
|
||||
const waitForOriginalStreams = (originalStreams, subprocess, streamInfo) =>
|
||||
originalStreams.map((stream, fdNumber) => stream === subprocess.stdio[fdNumber]
|
||||
? undefined
|
||||
: waitForStream(stream, fdNumber, streamInfo));
|
||||
|
||||
// Some `stdin`/`stdout`/`stderr` options create a stream, e.g. when passing a file path.
|
||||
// The `.pipe()` method automatically ends that stream when `subprocess` ends.
|
||||
// This makes sure we wait for the completion of those streams, in order to catch any error.
|
||||
const waitForCustomStreamsEnd = (fileDescriptors, streamInfo) => fileDescriptors.flatMap(({stdioItems}, fdNumber) => stdioItems
|
||||
.filter(({value, stream = value}) => isNodeStream(stream, {checkOpen: false}) && !isStandardStream(stream))
|
||||
.map(({type, value, stream = value}) => waitForStream(stream, fdNumber, streamInfo, {
|
||||
isSameDirection: TRANSFORM_TYPES.has(type),
|
||||
stopOnExit: type === 'native',
|
||||
})));
|
||||
|
||||
// Fails when the subprocess emits an `error` event
|
||||
const throwOnSubprocessError = async (subprocess, {signal}) => {
|
||||
const [error] = await once(subprocess, 'error', {signal});
|
||||
throw error;
|
||||
};
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import {hrtime} from 'node:process';
|
||||
|
||||
// Start counting time before spawning the subprocess
|
||||
export const getStartTime = () => hrtime.bigint();
|
||||
|
||||
// Compute duration after the subprocess ended.
|
||||
// Printed by the `verbose` option.
|
||||
export const getDurationMs = startTime => Number(hrtime.bigint() - startTime) / 1e6;
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import {ChildProcess} from 'node:child_process';
|
||||
import {
|
||||
PassThrough,
|
||||
Readable,
|
||||
Writable,
|
||||
Duplex,
|
||||
} from 'node:stream';
|
||||
import {cleanupCustomStreams} from '../stdio/handle.js';
|
||||
import {makeEarlyError} from './result.js';
|
||||
import {handleResult} from './reject.js';
|
||||
|
||||
// When the subprocess fails to spawn.
|
||||
// We ensure the returned error is always both a promise and a subprocess.
|
||||
export const handleEarlyError = ({error, command, escapedCommand, fileDescriptors, options, startTime, verboseInfo}) => {
|
||||
cleanupCustomStreams(fileDescriptors);
|
||||
|
||||
const subprocess = new ChildProcess();
|
||||
const all = createDummyStreams(subprocess, fileDescriptors);
|
||||
|
||||
const earlyError = makeEarlyError({
|
||||
error,
|
||||
command,
|
||||
escapedCommand,
|
||||
fileDescriptors,
|
||||
options,
|
||||
startTime,
|
||||
isSync: false,
|
||||
});
|
||||
const promise = handleDummyPromise(earlyError, verboseInfo, options);
|
||||
return {
|
||||
subprocess,
|
||||
promise,
|
||||
all: options.all ? all : undefined,
|
||||
convertedStreams: {
|
||||
readable,
|
||||
writable,
|
||||
duplex,
|
||||
readableStream,
|
||||
writableStream,
|
||||
transformStream,
|
||||
iterable,
|
||||
[Symbol.asyncIterator]: iterable,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createDummyStreams = (subprocess, fileDescriptors) => {
|
||||
const stdin = createDummyStream();
|
||||
const stdout = createDummyStream();
|
||||
const stderr = createDummyStream();
|
||||
const extraStdio = Array.from({length: fileDescriptors.length - 3}, createDummyStream);
|
||||
const all = createDummyStream();
|
||||
const stdio = [stdin, stdout, stderr, ...extraStdio];
|
||||
Object.assign(subprocess, {
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
stdio,
|
||||
});
|
||||
return all;
|
||||
};
|
||||
|
||||
const createDummyStream = () => {
|
||||
const stream = new PassThrough();
|
||||
stream.end();
|
||||
return stream;
|
||||
};
|
||||
|
||||
const readable = () => new Readable({read() {}});
|
||||
const writable = () => new Writable({write() {}});
|
||||
const duplex = () => new Duplex({read() {}, write() {}});
|
||||
const readableStream = () => Readable.toWeb(readable());
|
||||
const writableStream = () => Writable.toWeb(writable());
|
||||
const transformStream = () => Duplex.toWeb(duplex());
|
||||
const iterable = async function * () {};
|
||||
|
||||
const handleDummyPromise = async (error, verboseInfo, options) => handleResult(error, verboseInfo, options);
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
// When the subprocess fails, this is the error instance being returned.
|
||||
// If another error instance is being thrown, it is kept as `error.cause`.
|
||||
export const getFinalError = (originalError, message, isSync) => {
|
||||
const ErrorClass = isSync ? ExecaSyncError : ExecaError;
|
||||
const options = originalError instanceof DiscardedError ? {} : {cause: originalError};
|
||||
return new ErrorClass(message, options);
|
||||
};
|
||||
|
||||
// Indicates that the error is used only to interrupt control flow, but not in the return value
|
||||
export class DiscardedError extends Error {}
|
||||
|
||||
// Proper way to set `error.name`: it should be inherited and non-enumerable
|
||||
const setErrorName = (ErrorClass, value) => {
|
||||
Object.defineProperties(ErrorClass.prototype, {
|
||||
name: {
|
||||
value,
|
||||
writable: true,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
},
|
||||
[execaErrorSymbol]: {
|
||||
value: true,
|
||||
writable: false,
|
||||
enumerable: false,
|
||||
configurable: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Unlike `instanceof`, this works across realms
|
||||
export const isExecaError = error => isErrorInstance(error) && execaErrorSymbol in error;
|
||||
|
||||
const execaErrorSymbol = Symbol('isExecaError');
|
||||
|
||||
export const isErrorInstance = value => Object.prototype.toString.call(value) === '[object Error]';
|
||||
|
||||
// We use two different Error classes for async/sync methods since they have slightly different shape and types
|
||||
export class ExecaError extends Error {}
|
||||
// eslint-disable-next-line unicorn/no-top-level-side-effects -- `error.name` must be set as soon as the class is defined
|
||||
setErrorName(ExecaError, ExecaError.name);
|
||||
|
||||
export class ExecaSyncError extends Error {}
|
||||
// eslint-disable-next-line unicorn/no-top-level-side-effects -- `error.name` must be set as soon as the class is defined
|
||||
setErrorName(ExecaSyncError, ExecaSyncError.name);
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
import {inspect} from 'node:util';
|
||||
import stripFinalNewline from 'strip-final-newline';
|
||||
import {isUint8Array, uint8ArrayToString} from '../utils/uint-array.js';
|
||||
import {fixCwdError} from '../arguments/cwd.js';
|
||||
import {escapeLines} from '../arguments/escape.js';
|
||||
import {getMaxBufferMessage} from '../io/max-buffer.js';
|
||||
import {getSignalDescription} from '../terminate/signal.js';
|
||||
import {DiscardedError, isExecaError} from './final-error.js';
|
||||
|
||||
// Computes `error.message`, `error.shortMessage` and `error.originalMessage`
|
||||
export const createMessages = ({
|
||||
stdio,
|
||||
all,
|
||||
ipcOutput,
|
||||
originalError,
|
||||
signal,
|
||||
signalDescription,
|
||||
exitCode,
|
||||
escapedCommand,
|
||||
timedOut,
|
||||
isCanceled,
|
||||
isGracefullyCanceled,
|
||||
isMaxBuffer,
|
||||
isForcefullyTerminated,
|
||||
forceKillAfterDelay,
|
||||
killSignal,
|
||||
maxBuffer,
|
||||
timeout,
|
||||
cwd,
|
||||
}) => {
|
||||
const errorCode = originalError?.code;
|
||||
const prefix = getErrorPrefix({
|
||||
originalError,
|
||||
timedOut,
|
||||
timeout,
|
||||
isMaxBuffer,
|
||||
maxBuffer,
|
||||
errorCode,
|
||||
signal,
|
||||
signalDescription,
|
||||
exitCode,
|
||||
isCanceled,
|
||||
isGracefullyCanceled,
|
||||
isForcefullyTerminated,
|
||||
forceKillAfterDelay,
|
||||
killSignal,
|
||||
});
|
||||
const originalMessage = getOriginalMessage(originalError, cwd);
|
||||
const suffix = originalMessage === undefined ? '' : `\n${originalMessage}`;
|
||||
const shortMessage = `${prefix}: ${escapedCommand}${suffix}`;
|
||||
const messageStdio = all === undefined ? [stdio[2], stdio[1]] : [all];
|
||||
const message = [
|
||||
shortMessage,
|
||||
...messageStdio,
|
||||
...stdio.slice(3),
|
||||
ipcOutput.map(ipcMessage => serializeIpcMessage(ipcMessage)).join('\n'),
|
||||
]
|
||||
.map(messagePart => escapeLines(stripFinalNewline(serializeMessagePart(messagePart))))
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
return {originalMessage, shortMessage, message};
|
||||
};
|
||||
|
||||
const getErrorPrefix = ({
|
||||
originalError,
|
||||
timedOut,
|
||||
timeout,
|
||||
isMaxBuffer,
|
||||
maxBuffer,
|
||||
errorCode,
|
||||
signal,
|
||||
signalDescription,
|
||||
exitCode,
|
||||
isCanceled,
|
||||
isGracefullyCanceled,
|
||||
isForcefullyTerminated,
|
||||
forceKillAfterDelay,
|
||||
killSignal,
|
||||
}) => {
|
||||
const forcefulSuffix = getForcefulSuffix(isForcefullyTerminated, forceKillAfterDelay);
|
||||
|
||||
if (timedOut) {
|
||||
return `Command timed out after ${timeout} milliseconds${forcefulSuffix}`;
|
||||
}
|
||||
|
||||
if (isGracefullyCanceled) {
|
||||
if (signal === undefined) {
|
||||
return `Command was gracefully canceled with exit code ${exitCode}`;
|
||||
}
|
||||
|
||||
return isForcefullyTerminated
|
||||
? `Command was gracefully canceled${forcefulSuffix}`
|
||||
: `Command was gracefully canceled with ${signal} (${signalDescription})`;
|
||||
}
|
||||
|
||||
if (isCanceled) {
|
||||
return `Command was canceled${forcefulSuffix}`;
|
||||
}
|
||||
|
||||
if (isMaxBuffer) {
|
||||
return `${getMaxBufferMessage(originalError, maxBuffer)}${forcefulSuffix}`;
|
||||
}
|
||||
|
||||
if (errorCode !== undefined) {
|
||||
return `Command failed with ${errorCode}${forcefulSuffix}`;
|
||||
}
|
||||
|
||||
if (isForcefullyTerminated) {
|
||||
return `Command was killed with ${killSignal} (${getSignalDescription(killSignal)})${forcefulSuffix}`;
|
||||
}
|
||||
|
||||
if (signal !== undefined) {
|
||||
return `Command was killed with ${signal} (${signalDescription})`;
|
||||
}
|
||||
|
||||
if (exitCode !== undefined) {
|
||||
return `Command failed with exit code ${exitCode}`;
|
||||
}
|
||||
|
||||
return 'Command failed';
|
||||
};
|
||||
|
||||
const getForcefulSuffix = (isForcefullyTerminated, forceKillAfterDelay) => isForcefullyTerminated
|
||||
? ` and was forcefully terminated after ${forceKillAfterDelay} milliseconds`
|
||||
: '';
|
||||
|
||||
const getOriginalMessage = (originalError, cwd) => {
|
||||
if (originalError instanceof DiscardedError) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalMessage = isExecaError(originalError)
|
||||
? originalError.originalMessage
|
||||
: String(originalError?.message ?? originalError);
|
||||
const escapedOriginalMessage = escapeLines(fixCwdError(originalMessage, cwd));
|
||||
return escapedOriginalMessage === '' ? undefined : escapedOriginalMessage;
|
||||
};
|
||||
|
||||
const serializeIpcMessage = ipcMessage => typeof ipcMessage === 'string'
|
||||
? ipcMessage
|
||||
: inspect(ipcMessage);
|
||||
|
||||
const serializeMessagePart = messagePart => Array.isArray(messagePart)
|
||||
? messagePart.map(messageItem => stripFinalNewline(serializeMessageItem(messageItem))).filter(Boolean).join('\n')
|
||||
: serializeMessageItem(messagePart);
|
||||
|
||||
const serializeMessageItem = messageItem => {
|
||||
if (typeof messageItem === 'string') {
|
||||
return messageItem;
|
||||
}
|
||||
|
||||
if (isUint8Array(messageItem)) {
|
||||
return uint8ArrayToString(messageItem);
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import {logResult} from '../verbose/complete.js';
|
||||
|
||||
// Applies the `reject` option.
|
||||
// Also print the final log line with `verbose`.
|
||||
export const handleResult = (result, verboseInfo, {reject}) => {
|
||||
logResult(result, verboseInfo);
|
||||
|
||||
if (result.failed && reject) {
|
||||
throw result;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
import {getSignalDescription} from '../terminate/signal.js';
|
||||
import {getDurationMs} from './duration.js';
|
||||
import {getFinalError} from './final-error.js';
|
||||
import {createMessages} from './message.js';
|
||||
|
||||
// Object returned on subprocess success
|
||||
export const makeSuccessResult = ({
|
||||
command,
|
||||
escapedCommand,
|
||||
stdio,
|
||||
all,
|
||||
ipcOutput,
|
||||
options: {cwd},
|
||||
startTime,
|
||||
}) => omitUndefinedProperties({
|
||||
command,
|
||||
escapedCommand,
|
||||
cwd,
|
||||
durationMs: getDurationMs(startTime),
|
||||
failed: false,
|
||||
timedOut: false,
|
||||
isCanceled: false,
|
||||
isGracefullyCanceled: false,
|
||||
isTerminated: false,
|
||||
isMaxBuffer: false,
|
||||
isForcefullyTerminated: false,
|
||||
exitCode: 0,
|
||||
stdout: stdio[1],
|
||||
stderr: stdio[2],
|
||||
all,
|
||||
stdio,
|
||||
ipcOutput,
|
||||
pipedFrom: [],
|
||||
});
|
||||
|
||||
// Object returned on subprocess failure before spawning
|
||||
export const makeEarlyError = ({
|
||||
error,
|
||||
command,
|
||||
escapedCommand,
|
||||
fileDescriptors,
|
||||
options,
|
||||
startTime,
|
||||
isSync,
|
||||
}) => makeError({
|
||||
error,
|
||||
command,
|
||||
escapedCommand,
|
||||
startTime,
|
||||
timedOut: false,
|
||||
isCanceled: false,
|
||||
isGracefullyCanceled: false,
|
||||
isMaxBuffer: false,
|
||||
isForcefullyTerminated: false,
|
||||
stdio: Array.from({length: fileDescriptors.length}),
|
||||
ipcOutput: [],
|
||||
options,
|
||||
isSync,
|
||||
});
|
||||
|
||||
// Object returned on subprocess failure
|
||||
export const makeError = ({
|
||||
error: originalError,
|
||||
command,
|
||||
escapedCommand,
|
||||
startTime,
|
||||
timedOut,
|
||||
isCanceled,
|
||||
isGracefullyCanceled,
|
||||
isMaxBuffer,
|
||||
isForcefullyTerminated,
|
||||
exitCode: rawExitCode,
|
||||
signal: rawSignal,
|
||||
stdio,
|
||||
all,
|
||||
ipcOutput,
|
||||
options: {
|
||||
timeoutDuration,
|
||||
timeout = timeoutDuration,
|
||||
forceKillAfterDelay,
|
||||
killSignal,
|
||||
cwd,
|
||||
maxBuffer,
|
||||
},
|
||||
isSync,
|
||||
}) => {
|
||||
const {exitCode, signal, signalDescription} = normalizeExitPayload(rawExitCode, rawSignal);
|
||||
const {originalMessage, shortMessage, message} = createMessages({
|
||||
stdio,
|
||||
all,
|
||||
ipcOutput,
|
||||
originalError,
|
||||
signal,
|
||||
signalDescription,
|
||||
exitCode,
|
||||
escapedCommand,
|
||||
timedOut,
|
||||
isCanceled,
|
||||
isGracefullyCanceled,
|
||||
isMaxBuffer,
|
||||
isForcefullyTerminated,
|
||||
forceKillAfterDelay,
|
||||
killSignal,
|
||||
maxBuffer,
|
||||
timeout,
|
||||
cwd,
|
||||
});
|
||||
const error = getFinalError(originalError, message, isSync);
|
||||
Object.assign(error, getErrorProperties({
|
||||
error,
|
||||
command,
|
||||
escapedCommand,
|
||||
startTime,
|
||||
timedOut,
|
||||
isCanceled,
|
||||
isGracefullyCanceled,
|
||||
isMaxBuffer,
|
||||
isForcefullyTerminated,
|
||||
exitCode,
|
||||
signal,
|
||||
signalDescription,
|
||||
stdio,
|
||||
all,
|
||||
ipcOutput,
|
||||
cwd,
|
||||
originalMessage,
|
||||
shortMessage,
|
||||
}));
|
||||
return error;
|
||||
};
|
||||
|
||||
const getErrorProperties = ({
|
||||
error,
|
||||
command,
|
||||
escapedCommand,
|
||||
startTime,
|
||||
timedOut,
|
||||
isCanceled,
|
||||
isGracefullyCanceled,
|
||||
isMaxBuffer,
|
||||
isForcefullyTerminated,
|
||||
exitCode,
|
||||
signal,
|
||||
signalDescription,
|
||||
stdio,
|
||||
all,
|
||||
ipcOutput,
|
||||
cwd,
|
||||
originalMessage,
|
||||
shortMessage,
|
||||
}) => omitUndefinedProperties({
|
||||
shortMessage,
|
||||
originalMessage,
|
||||
command,
|
||||
escapedCommand,
|
||||
cwd,
|
||||
durationMs: getDurationMs(startTime),
|
||||
failed: true,
|
||||
timedOut,
|
||||
isCanceled,
|
||||
isGracefullyCanceled,
|
||||
isTerminated: signal !== undefined,
|
||||
isMaxBuffer,
|
||||
isForcefullyTerminated,
|
||||
exitCode,
|
||||
signal,
|
||||
signalDescription,
|
||||
code: error.cause?.code,
|
||||
stdout: stdio[1],
|
||||
stderr: stdio[2],
|
||||
all,
|
||||
stdio,
|
||||
ipcOutput,
|
||||
pipedFrom: [],
|
||||
});
|
||||
|
||||
const omitUndefinedProperties = result => Object.fromEntries(Object.entries(result).filter(([, value]) => value !== undefined));
|
||||
|
||||
// `signal` and `exitCode` emitted on `subprocess.on('exit')` event can be `null`.
|
||||
// We normalize them to `undefined`
|
||||
const normalizeExitPayload = (rawExitCode, rawSignal) => {
|
||||
const exitCode = rawExitCode === null ? undefined : rawExitCode;
|
||||
const signal = rawSignal === null ? undefined : rawSignal;
|
||||
const signalDescription = signal === undefined ? undefined : getSignalDescription(rawSignal);
|
||||
return {exitCode, signal, signalDescription};
|
||||
};
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import process from 'node:process';
|
||||
import {
|
||||
isStream as isNodeStream,
|
||||
isReadableStream as isNodeReadableStream,
|
||||
isWritableStream as isNodeWritableStream,
|
||||
} from 'is-stream';
|
||||
import {isWritableStream} from './type.js';
|
||||
|
||||
// For `stdio[fdNumber]` beyond stdin/stdout/stderr, we need to guess whether the value passed is intended for inputs or outputs.
|
||||
// This allows us to know whether to pipe _into_ or _from_ the stream.
|
||||
// When `stdio[fdNumber]` is a single value, this guess is fairly straightforward.
|
||||
// However, when it is an array instead, we also need to make sure the different values are not incompatible with each other.
|
||||
export const getStreamDirection = (stdioItems, fdNumber, optionName) => {
|
||||
const directions = stdioItems.map(stdioItem => getStdioItemDirection(stdioItem, fdNumber));
|
||||
|
||||
if (directions.includes('input') && directions.includes('output')) {
|
||||
throw new TypeError(`The \`${optionName}\` option must not be an array of both readable and writable values.`);
|
||||
}
|
||||
|
||||
return directions.find(Boolean) ?? DEFAULT_DIRECTION;
|
||||
};
|
||||
|
||||
// `direction` is set when the user explicitly requests it, e.g. `{value: 'pipe', input: true}`.
|
||||
// It takes precedence over guessing, but not over the fixed direction of `stdin`/`stdout`/`stderr`.
|
||||
const getStdioItemDirection = (stdioItem, fdNumber) => KNOWN_DIRECTIONS[fdNumber] ?? getRequestedDirection(stdioItem);
|
||||
|
||||
// The explicitly requested `direction` must not contradict a value that is intrinsically the other direction.
|
||||
// For example, `{value: writableStream, input: true}` is invalid since a writable value can only be an output.
|
||||
const getRequestedDirection = ({type, value, direction, optionName}) => {
|
||||
const guessedDirection = guessStreamDirection[type](value);
|
||||
if (direction === 'input' && guessedDirection === 'output') {
|
||||
throw new TypeError(`The \`${optionName}\` option is invalid: \`input: true\` cannot be used with a writable value, which is always an output.`);
|
||||
}
|
||||
|
||||
return direction ?? guessedDirection;
|
||||
};
|
||||
|
||||
// `stdin`/`stdout`/`stderr` have a known direction
|
||||
const KNOWN_DIRECTIONS = ['input', 'output', 'output'];
|
||||
|
||||
const anyDirection = () => undefined;
|
||||
const alwaysInput = () => 'input';
|
||||
|
||||
// `string` can only be added through the `input` option, i.e. does not need to be handled here
|
||||
const guessStreamDirection = {
|
||||
generator: anyDirection,
|
||||
asyncGenerator: anyDirection,
|
||||
fileUrl: anyDirection,
|
||||
filePath: anyDirection,
|
||||
iterable: alwaysInput,
|
||||
asyncIterable: alwaysInput,
|
||||
uint8Array: alwaysInput,
|
||||
webStream: value => isWritableStream(value) ? 'output' : 'input',
|
||||
nodeStream(value) {
|
||||
if (!isNodeReadableStream(value, {checkOpen: false})) {
|
||||
return 'output';
|
||||
}
|
||||
|
||||
return isNodeWritableStream(value, {checkOpen: false}) ? undefined : 'input';
|
||||
},
|
||||
webTransform: anyDirection,
|
||||
duplex: anyDirection,
|
||||
native(value) {
|
||||
const standardStreamDirection = getStandardStreamDirection(value);
|
||||
if (standardStreamDirection !== undefined) {
|
||||
return standardStreamDirection;
|
||||
}
|
||||
|
||||
if (isNodeStream(value, {checkOpen: false})) {
|
||||
return guessStreamDirection.nodeStream(value);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const getStandardStreamDirection = value => {
|
||||
if ([0, process.stdin].includes(value)) {
|
||||
return 'input';
|
||||
}
|
||||
|
||||
if ([1, 2, process.stdout, process.stderr].includes(value)) {
|
||||
return 'output';
|
||||
}
|
||||
};
|
||||
|
||||
// When ambiguous, we initially keep the direction as `undefined`.
|
||||
// This allows arrays of `stdio` values to resolve the ambiguity.
|
||||
// For example, `stdio[3]: DuplexStream` is ambiguous, but `stdio[3]: [DuplexStream, WritableStream]` is not.
|
||||
// When the ambiguity remains, we default to `output` since it is the most common use case for additional file descriptors.
|
||||
const DEFAULT_DIRECTION = 'output';
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
SPECIAL_DUPLICATE_TYPES_SYNC,
|
||||
SPECIAL_DUPLICATE_TYPES,
|
||||
FORBID_DUPLICATE_TYPES,
|
||||
TYPE_TO_MESSAGE,
|
||||
} from './type.js';
|
||||
|
||||
// Duplicates in the same file descriptor is most likely an error.
|
||||
// However, this can be useful with generators.
|
||||
export const filterDuplicates = stdioItems => stdioItems.filter((stdioItemOne, indexOne) =>
|
||||
stdioItems.every((stdioItemTwo, indexTwo) => !hasSameValueAndDirection(stdioItemOne, stdioItemTwo)
|
||||
|| indexOne >= indexTwo
|
||||
|| stdioItemOne.type === 'generator'
|
||||
|| stdioItemOne.type === 'asyncGenerator'));
|
||||
|
||||
const hasSameValueAndDirection = (stdioItemOne, stdioItemTwo) => stdioItemOne.value === stdioItemTwo.value
|
||||
&& stdioItemOne.direction === stdioItemTwo.direction;
|
||||
|
||||
// Check if two file descriptors are sharing the same target.
|
||||
// For example `{stdout: {file: './output.txt'}, stderr: {file: './output.txt'}}`.
|
||||
export const getDuplicateStream = ({stdioItem: {type, value, optionName}, direction, fileDescriptors, isSync}) => {
|
||||
const otherStdioItems = getOtherStdioItems(fileDescriptors, type);
|
||||
if (otherStdioItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSync) {
|
||||
validateDuplicateStreamSync({
|
||||
otherStdioItems,
|
||||
type,
|
||||
value,
|
||||
optionName,
|
||||
direction,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (SPECIAL_DUPLICATE_TYPES.has(type)) {
|
||||
return getDuplicateStreamInstance({
|
||||
otherStdioItems,
|
||||
type,
|
||||
value,
|
||||
optionName,
|
||||
direction,
|
||||
});
|
||||
}
|
||||
|
||||
if (FORBID_DUPLICATE_TYPES.has(type)) {
|
||||
validateDuplicateTransform({
|
||||
otherStdioItems,
|
||||
type,
|
||||
value,
|
||||
optionName,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Values shared by multiple file descriptors
|
||||
const getOtherStdioItems = (fileDescriptors, type) => fileDescriptors
|
||||
.flatMap(({direction, stdioItems}) => stdioItems
|
||||
.filter(stdioItem => stdioItem.type === type)
|
||||
.map((stdioItem => ({...stdioItem, direction}))));
|
||||
|
||||
// With `execaSync()`, do not allow setting a file path both in input and output
|
||||
const validateDuplicateStreamSync = ({otherStdioItems, type, value, optionName, direction}) => {
|
||||
if (SPECIAL_DUPLICATE_TYPES_SYNC.has(type)) {
|
||||
getDuplicateStreamInstance({
|
||||
otherStdioItems,
|
||||
type,
|
||||
value,
|
||||
optionName,
|
||||
direction,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// When two file descriptors share the file or stream, we need to re-use the same underlying stream.
|
||||
// Otherwise, the stream would be closed twice when piping ends.
|
||||
// This is only an issue with output file descriptors.
|
||||
// This is not a problem with generator functions since those create a new instance for each file descriptor.
|
||||
// We also forbid input and output file descriptors sharing the same file or stream, since that does not make sense.
|
||||
const getDuplicateStreamInstance = ({otherStdioItems, type, value, optionName, direction}) => {
|
||||
const duplicateStdioItems = otherStdioItems.filter(stdioItem => hasSameValue(stdioItem, value));
|
||||
if (duplicateStdioItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const differentStdioItem = duplicateStdioItems.find(stdioItem => stdioItem.direction !== direction);
|
||||
throwOnDuplicateStream(differentStdioItem, optionName, type);
|
||||
|
||||
return direction === 'output' ? duplicateStdioItems[0].stream : undefined;
|
||||
};
|
||||
|
||||
const hasSameValue = ({type, value}, secondValue) => {
|
||||
if (type === 'filePath') {
|
||||
return value.file === secondValue.file;
|
||||
}
|
||||
|
||||
if (type === 'fileUrl') {
|
||||
return value.href === secondValue.href;
|
||||
}
|
||||
|
||||
return value === secondValue;
|
||||
};
|
||||
|
||||
// We do not allow two file descriptors to share the same Duplex or TransformStream.
|
||||
// This is because those are set directly to `subprocess.std*`.
|
||||
// For example, this could result in `subprocess.stdout` and `subprocess.stderr` being the same value.
|
||||
// This means reading from either would get data from both stdout and stderr.
|
||||
const validateDuplicateTransform = ({otherStdioItems, type, value, optionName}) => {
|
||||
const duplicateStdioItem = otherStdioItems.find(({value: {transform}}) => transform === value.transform);
|
||||
throwOnDuplicateStream(duplicateStdioItem, optionName, type);
|
||||
};
|
||||
|
||||
const throwOnDuplicateStream = (stdioItem, optionName, type) => {
|
||||
if (stdioItem !== undefined) {
|
||||
throw new TypeError(`The \`${stdioItem.optionName}\` and \`${optionName}\` options must not target ${TYPE_TO_MESSAGE[type]} that is the same.`);
|
||||
}
|
||||
};
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import {createReadStream, createWriteStream} from 'node:fs';
|
||||
import {Buffer} from 'node:buffer';
|
||||
import {Readable, Writable, Duplex} from 'node:stream';
|
||||
import {generatorToStream} from '../transform/generator.js';
|
||||
import {handleStdio} from './handle.js';
|
||||
import {TYPE_TO_MESSAGE} from './type.js';
|
||||
|
||||
// Handle `input`, `inputFile`, `stdin`, `stdout` and `stderr` options, before spawning, in async mode
|
||||
export const handleStdioAsync = (options, verboseInfo) => handleStdio(addPropertiesAsync, options, verboseInfo, false);
|
||||
|
||||
const forbiddenIfAsync = ({type, optionName}) => {
|
||||
throw new TypeError(`The \`${optionName}\` option cannot be ${TYPE_TO_MESSAGE[type]}.`);
|
||||
};
|
||||
|
||||
// Create streams used internally for piping when using specific values for the `std*` options, in async mode.
|
||||
// For example, `stdout: {file}` creates a file stream, which is piped from/to.
|
||||
const addProperties = {
|
||||
fileNumber: forbiddenIfAsync,
|
||||
generator: generatorToStream,
|
||||
asyncGenerator: generatorToStream,
|
||||
nodeStream: ({value}) => ({stream: value}),
|
||||
webTransform({value: {transform, writableObjectMode, readableObjectMode}}) {
|
||||
const objectMode = writableObjectMode || readableObjectMode;
|
||||
const stream = Duplex.fromWeb(transform, {objectMode});
|
||||
return {stream};
|
||||
},
|
||||
duplex: ({value: {transform}}) => ({stream: transform}),
|
||||
native() {},
|
||||
};
|
||||
|
||||
const addPropertiesAsync = {
|
||||
input: {
|
||||
...addProperties,
|
||||
fileUrl: ({value}) => ({stream: createReadStream(value)}),
|
||||
filePath: ({value: {file}}) => ({stream: createReadStream(file)}),
|
||||
webStream: ({value}) => ({stream: Readable.fromWeb(value)}),
|
||||
iterable: ({value}) => ({stream: Readable.from(value)}),
|
||||
asyncIterable: ({value}) => ({stream: Readable.from(value)}),
|
||||
string: ({value}) => ({stream: Readable.from(value)}),
|
||||
uint8Array: ({value}) => ({stream: Readable.from(Buffer.from(value))}),
|
||||
},
|
||||
output: {
|
||||
...addProperties,
|
||||
fileUrl: ({value}) => ({stream: createWriteStream(value)}),
|
||||
filePath: ({value: {file, append}}) => ({stream: createWriteStream(file, append ? {flags: 'a'} : {})}),
|
||||
webStream: ({value}) => ({stream: Writable.fromWeb(value)}),
|
||||
iterable: forbiddenIfAsync,
|
||||
asyncIterable: forbiddenIfAsync,
|
||||
string: forbiddenIfAsync,
|
||||
uint8Array: forbiddenIfAsync,
|
||||
},
|
||||
};
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {bufferToUint8Array} from '../utils/uint-array.js';
|
||||
import {handleStdio} from './handle.js';
|
||||
import {TYPE_TO_MESSAGE} from './type.js';
|
||||
|
||||
// Normalize `input`, `inputFile`, `stdin`, `stdout` and `stderr` options, before spawning, in sync mode
|
||||
export const handleStdioSync = (options, verboseInfo) => handleStdio(addPropertiesSync, options, verboseInfo, true);
|
||||
|
||||
const forbiddenIfSync = ({type, optionName}) => {
|
||||
throwInvalidSyncValue(optionName, TYPE_TO_MESSAGE[type]);
|
||||
};
|
||||
|
||||
const forbiddenNativeIfSync = ({optionName, value}) => {
|
||||
if (value === 'overlapped') {
|
||||
throwInvalidSyncValue(optionName, `"${value}"`);
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
// A native input pipe on an additional file descriptor cannot be fed in sync mode, since there is no way to write to it after spawning, so it would hang.
|
||||
const forbiddenNativeInputIfSync = stdioItem => {
|
||||
const {optionName, value} = stdioItem;
|
||||
if (value === 'pipe' && optionName !== 'stdin') {
|
||||
throw new TypeError(`Only the \`stdin\` option, not \`${optionName}\`, can be an input pipe with synchronous methods.`);
|
||||
}
|
||||
|
||||
return forbiddenNativeIfSync(stdioItem);
|
||||
};
|
||||
|
||||
const throwInvalidSyncValue = (optionName, value) => {
|
||||
throw new TypeError(`The \`${optionName}\` option cannot be ${value} with synchronous methods.`);
|
||||
};
|
||||
|
||||
// Create streams used internally for redirecting when using specific values for the `std*` options, in sync mode.
|
||||
// For example, `stdin: {file}` reads the file synchronously, then passes it as the `input` option.
|
||||
const addProperties = {
|
||||
generator() {},
|
||||
asyncGenerator: forbiddenIfSync,
|
||||
webStream: forbiddenIfSync,
|
||||
nodeStream: forbiddenIfSync,
|
||||
webTransform: forbiddenIfSync,
|
||||
duplex: forbiddenIfSync,
|
||||
asyncIterable: forbiddenIfSync,
|
||||
native: forbiddenNativeIfSync,
|
||||
};
|
||||
|
||||
const addPropertiesSync = {
|
||||
input: {
|
||||
...addProperties,
|
||||
native: forbiddenNativeInputIfSync,
|
||||
fileUrl: ({value}) => ({contents: [bufferToUint8Array(readFileSync(value))]}),
|
||||
filePath: ({value: {file}}) => ({contents: [bufferToUint8Array(readFileSync(file))]}),
|
||||
fileNumber: forbiddenIfSync,
|
||||
iterable: ({value}) => ({contents: [...value]}),
|
||||
string: ({value}) => ({contents: [value]}),
|
||||
uint8Array: ({value}) => ({contents: [value]}),
|
||||
},
|
||||
output: {
|
||||
...addProperties,
|
||||
fileUrl: ({value}) => ({path: value}),
|
||||
filePath: ({value: {file, append}}) => ({path: file, append}),
|
||||
fileNumber: ({value}) => ({path: value}),
|
||||
iterable: forbiddenIfSync,
|
||||
string: forbiddenIfSync,
|
||||
uint8Array: forbiddenIfSync,
|
||||
},
|
||||
};
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
import {getStreamName, isStandardStream} from '../utils/standard-stream.js';
|
||||
import {normalizeTransforms} from '../transform/normalize.js';
|
||||
import {getFdObjectMode} from '../transform/object-mode.js';
|
||||
import {
|
||||
getStdioItemType,
|
||||
isRegularUrl,
|
||||
isUnknownStdioString,
|
||||
isStdioValueObject,
|
||||
checkBooleanOption,
|
||||
FILE_TYPES,
|
||||
} from './type.js';
|
||||
import {getStreamDirection} from './direction.js';
|
||||
import {normalizeStdioOption} from './stdio-option.js';
|
||||
import {handleNativeStream} from './native.js';
|
||||
import {handleInputOptions} from './input-option.js';
|
||||
import {filterDuplicates, getDuplicateStream} from './duplicate.js';
|
||||
|
||||
// Handle `input`, `inputFile`, `stdin`, `stdout` and `stderr` options, before spawning, in async/sync mode
|
||||
// They are converted into an array of `fileDescriptors`.
|
||||
// Each `fileDescriptor` is normalized, validated and contains all information necessary for further handling.
|
||||
export const handleStdio = (addProperties, options, verboseInfo, isSync) => {
|
||||
const stdio = normalizeStdioOption(options, verboseInfo, isSync);
|
||||
const initialFileDescriptors = stdio.map((stdioOption, fdNumber) => getFileDescriptor({
|
||||
stdioOption,
|
||||
fdNumber,
|
||||
options,
|
||||
isSync,
|
||||
}));
|
||||
const fileDescriptors = getFinalFileDescriptors({
|
||||
initialFileDescriptors,
|
||||
addProperties,
|
||||
options,
|
||||
isSync,
|
||||
});
|
||||
options.stdio = fileDescriptors.map(({stdioItems}) => forwardStdio(stdioItems));
|
||||
return fileDescriptors;
|
||||
};
|
||||
|
||||
const getFileDescriptor = ({stdioOption, fdNumber, options, isSync}) => {
|
||||
const optionName = getStreamName(fdNumber);
|
||||
const {stdioItems: initialStdioItems, isStdioArray} = initializeStdioItems({
|
||||
stdioOption,
|
||||
fdNumber,
|
||||
options,
|
||||
optionName,
|
||||
});
|
||||
const direction = getStreamDirection(initialStdioItems, fdNumber, optionName);
|
||||
const stdioItems = initialStdioItems.map(stdioItem => handleNativeStream({
|
||||
stdioItem,
|
||||
isStdioArray,
|
||||
fdNumber,
|
||||
direction,
|
||||
isSync,
|
||||
}));
|
||||
const normalizedStdioItems = normalizeTransforms(stdioItems, optionName, direction, options);
|
||||
const objectMode = getFdObjectMode(normalizedStdioItems, direction);
|
||||
validateFileObjectMode(normalizedStdioItems, objectMode);
|
||||
return {direction, objectMode, stdioItems: normalizedStdioItems};
|
||||
};
|
||||
|
||||
// We make sure passing an array with a single item behaves the same as passing that item without an array.
|
||||
// This is what users would expect.
|
||||
// For example, `stdout: ['ignore']` behaves the same as `stdout: 'ignore'`.
|
||||
const initializeStdioItems = ({stdioOption, fdNumber, options, optionName}) => {
|
||||
const values = Array.isArray(stdioOption) ? stdioOption : [stdioOption];
|
||||
const inputStdioItems = handleInputOptions(options, fdNumber);
|
||||
const initialStdioItems = [
|
||||
...omitInheritedStdin(values.map(value => initializeStdioItem(value, optionName)), inputStdioItems),
|
||||
...inputStdioItems,
|
||||
];
|
||||
|
||||
const stdioItems = filterDuplicates(initialStdioItems);
|
||||
const isStdioArray = stdioItems.length > 1;
|
||||
validateStdioArray(stdioItems, isStdioArray, optionName);
|
||||
validateStreams(stdioItems);
|
||||
return {stdioItems, isStdioArray};
|
||||
};
|
||||
|
||||
const omitInheritedStdin = (stdioItems, inputStdioItems) => inputStdioItems.length > 0 && isInheritedStdinOnly(stdioItems)
|
||||
? []
|
||||
: stdioItems;
|
||||
|
||||
const isInheritedStdinOnly = stdioItems => stdioItems.length === 1
|
||||
&& stdioItems[0].type === 'native'
|
||||
&& stdioItems[0].value === 'inherit';
|
||||
|
||||
const initializeStdioItem = (value, optionName) => {
|
||||
if (isStdioValueObject(value)) {
|
||||
return initializeStdioValueObject(value, optionName);
|
||||
}
|
||||
|
||||
return {
|
||||
type: getStdioItemType(value, optionName),
|
||||
value,
|
||||
optionName,
|
||||
};
|
||||
};
|
||||
|
||||
// The `{value, input}` object form wraps any stdio value with an explicit direction.
|
||||
const initializeStdioValueObject = ({value, input}, optionName) => {
|
||||
checkBooleanOption(input, `${optionName}.input`);
|
||||
|
||||
return {
|
||||
type: getStdioItemType(value, optionName),
|
||||
value,
|
||||
direction: input ? 'input' : undefined,
|
||||
optionName,
|
||||
};
|
||||
};
|
||||
|
||||
const validateStdioArray = (stdioItems, isStdioArray, optionName) => {
|
||||
if (stdioItems.length === 0) {
|
||||
throw new TypeError(`The \`${optionName}\` option must not be an empty array.`);
|
||||
}
|
||||
|
||||
if (!isStdioArray) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const {value, optionName} of stdioItems) {
|
||||
if (INVALID_STDIO_ARRAY_OPTIONS.has(value)) {
|
||||
throw new Error(`The \`${optionName}\` option must not include \`${value}\`.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Using the `ignore` value together with others for the same stream does not make sense, so we make it fail.
|
||||
// However, we do allow it if the array has a single item.
|
||||
const INVALID_STDIO_ARRAY_OPTIONS = new Set(['ignore']);
|
||||
|
||||
const validateStreams = stdioItems => {
|
||||
for (const stdioItem of stdioItems) {
|
||||
validateFileStdio(stdioItem);
|
||||
}
|
||||
};
|
||||
|
||||
const validateFileStdio = ({type, value, optionName}) => {
|
||||
if (isRegularUrl(value)) {
|
||||
throw new TypeError(`The \`${optionName}: URL\` option must use the \`file:\` scheme.
|
||||
For example, you can use the \`pathToFileURL()\` method of the \`url\` core module.`);
|
||||
}
|
||||
|
||||
if (isUnknownStdioString(type, value)) {
|
||||
throw new TypeError(`The \`${optionName}: { file: '...' }\` option must be used instead of \`${optionName}: '...'\`.`);
|
||||
}
|
||||
};
|
||||
|
||||
const validateFileObjectMode = (stdioItems, objectMode) => {
|
||||
if (!objectMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileStdioItem = stdioItems.find(({type}) => FILE_TYPES.has(type));
|
||||
if (fileStdioItem !== undefined) {
|
||||
throw new TypeError(`The \`${fileStdioItem.optionName}\` option cannot use both files and transforms in objectMode.`);
|
||||
}
|
||||
};
|
||||
|
||||
// Some `stdio` values require Execa to create streams.
|
||||
// For example, file paths create file read/write streams.
|
||||
// Those transformations are specified in `addProperties`, which is both direction-specific and type-specific.
|
||||
const getFinalFileDescriptors = ({initialFileDescriptors, addProperties, options, isSync}) => {
|
||||
const fileDescriptors = [];
|
||||
|
||||
try {
|
||||
for (const fileDescriptor of initialFileDescriptors) {
|
||||
fileDescriptors.push(getFinalFileDescriptor({
|
||||
fileDescriptor,
|
||||
fileDescriptors,
|
||||
addProperties,
|
||||
options,
|
||||
isSync,
|
||||
}));
|
||||
}
|
||||
|
||||
return fileDescriptors;
|
||||
} catch (error) {
|
||||
cleanupCustomStreams(fileDescriptors);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const getFinalFileDescriptor = ({
|
||||
fileDescriptor: {direction, objectMode, stdioItems},
|
||||
fileDescriptors,
|
||||
addProperties,
|
||||
options,
|
||||
isSync,
|
||||
}) => {
|
||||
const finalStdioItems = stdioItems.map(stdioItem => addStreamProperties({
|
||||
stdioItem,
|
||||
addProperties,
|
||||
direction,
|
||||
options,
|
||||
fileDescriptors,
|
||||
isSync,
|
||||
}));
|
||||
return {direction, objectMode, stdioItems: finalStdioItems};
|
||||
};
|
||||
|
||||
const addStreamProperties = ({stdioItem, addProperties, direction, options, fileDescriptors, isSync}) => {
|
||||
const duplicateStream = getDuplicateStream({
|
||||
stdioItem,
|
||||
direction,
|
||||
fileDescriptors,
|
||||
isSync,
|
||||
});
|
||||
|
||||
if (duplicateStream !== undefined) {
|
||||
return {...stdioItem, stream: duplicateStream};
|
||||
}
|
||||
|
||||
return {
|
||||
...stdioItem,
|
||||
...addProperties[direction][stdioItem.type](stdioItem, options),
|
||||
};
|
||||
};
|
||||
|
||||
// The stream error handling is performed by the piping logic above, which cannot be performed before subprocess spawning.
|
||||
// If the subprocess spawning fails (e.g. due to an invalid command), the streams need to be manually destroyed.
|
||||
// We need to create those streams before subprocess spawning, in case their creation fails, e.g. when passing an invalid generator as argument.
|
||||
// Like this, an exception would be thrown, which would prevent spawning a subprocess.
|
||||
export const cleanupCustomStreams = fileDescriptors => {
|
||||
for (const {stdioItems} of fileDescriptors) {
|
||||
for (const {stream} of stdioItems) {
|
||||
if (stream !== undefined && !isStandardStream(stream)) {
|
||||
stream.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// When the `std*: Iterable | WebStream | URL | filePath`, `input` or `inputFile` option is used, we pipe to `subprocess.std*`.
|
||||
// When the `std*: Array` option is used, we emulate some of the native values ('inherit', Node.js stream and file descriptor integer). To do so, we also need to pipe to `subprocess.std*`.
|
||||
// Therefore the `std*` options must be either `pipe` or `overlapped`. Other values do not set `subprocess.std*`.
|
||||
const forwardStdio = stdioItems => {
|
||||
if (stdioItems.length > 1) {
|
||||
return stdioItems.some(({value}) => value === 'overlapped') ? 'overlapped' : 'pipe';
|
||||
}
|
||||
|
||||
const [{type, value}] = stdioItems;
|
||||
return type === 'native' ? value : 'pipe';
|
||||
};
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import {isReadableStream} from 'is-stream';
|
||||
import {isUint8Array} from '../utils/uint-array.js';
|
||||
import {isUrl, isFilePathString} from './type.js';
|
||||
|
||||
// Append the `stdin` option with the `input` and `inputFile` options
|
||||
export const handleInputOptions = ({input, inputFile}, fdNumber) => fdNumber === 0
|
||||
? [
|
||||
...handleInputOption(input),
|
||||
...handleInputFileOption(inputFile),
|
||||
]
|
||||
: [];
|
||||
|
||||
const handleInputOption = input => input === undefined
|
||||
? []
|
||||
: [{
|
||||
type: getInputType(input),
|
||||
value: input,
|
||||
optionName: 'input',
|
||||
}];
|
||||
|
||||
const getInputType = input => {
|
||||
if (isReadableStream(input, {checkOpen: false})) {
|
||||
return 'nodeStream';
|
||||
}
|
||||
|
||||
if (typeof input === 'string') {
|
||||
return 'string';
|
||||
}
|
||||
|
||||
if (isUint8Array(input)) {
|
||||
return 'uint8Array';
|
||||
}
|
||||
|
||||
throw new Error('The `input` option must be a string, a Uint8Array or a Node.js Readable stream.');
|
||||
};
|
||||
|
||||
const handleInputFileOption = inputFile => inputFile === undefined
|
||||
? []
|
||||
: [{
|
||||
...getInputFileType(inputFile),
|
||||
optionName: 'inputFile',
|
||||
}];
|
||||
|
||||
const getInputFileType = inputFile => {
|
||||
if (isUrl(inputFile)) {
|
||||
return {type: 'fileUrl', value: inputFile};
|
||||
}
|
||||
|
||||
if (isFilePathString(inputFile)) {
|
||||
return {type: 'filePath', value: {file: inputFile}};
|
||||
}
|
||||
|
||||
throw new Error('The `inputFile` option must be a file path string or a file URL.');
|
||||
};
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import {readFileSync} from 'node:fs';
|
||||
import tty from 'node:tty';
|
||||
import {isStream as isNodeStream} from 'is-stream';
|
||||
import {STANDARD_STREAMS} from '../utils/standard-stream.js';
|
||||
import {bufferToUint8Array} from '../utils/uint-array.js';
|
||||
import {serializeOptionValue} from '../arguments/fd-options.js';
|
||||
|
||||
// When we use multiple `stdio` values for the same streams, we pass 'pipe' to `child_process.spawn()`.
|
||||
// We then emulate the piping done by core Node.js.
|
||||
// To do so, we transform the following values:
|
||||
// - Node.js streams are marked as `type: nodeStream`
|
||||
// - 'inherit' becomes `process.stdin|stdout|stderr`
|
||||
// - any file descriptor integer becomes `process.stdio[fdNumber]`
|
||||
// All of the above transformations tell Execa to perform manual piping.
|
||||
export const handleNativeStream = ({stdioItem, stdioItem: {type}, isStdioArray, fdNumber, direction, isSync}) => {
|
||||
if (!isStdioArray || type !== 'native') {
|
||||
return stdioItem;
|
||||
}
|
||||
|
||||
return isSync
|
||||
? handleNativeStreamSync({stdioItem, fdNumber, direction})
|
||||
: handleNativeStreamAsync({stdioItem, fdNumber});
|
||||
};
|
||||
|
||||
// Synchronous methods use a different logic.
|
||||
// 'inherit', file descriptors and process.std* are handled by readFileSync()/writeFileSync().
|
||||
const handleNativeStreamSync = ({stdioItem, stdioItem: {value, optionName}, fdNumber, direction}) => {
|
||||
const targetFd = getTargetFd({
|
||||
value,
|
||||
optionName,
|
||||
fdNumber,
|
||||
direction,
|
||||
});
|
||||
if (targetFd !== undefined) {
|
||||
return targetFd;
|
||||
}
|
||||
|
||||
if (isNodeStream(value, {checkOpen: false})) {
|
||||
throw new TypeError(`The \`${optionName}: Stream\` option cannot both be an array and include a stream with synchronous methods.`);
|
||||
}
|
||||
|
||||
return stdioItem;
|
||||
};
|
||||
|
||||
const getTargetFd = ({value, optionName, fdNumber, direction}) => {
|
||||
const targetFdNumber = getTargetFdNumber(value, fdNumber);
|
||||
if (targetFdNumber === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (direction === 'output') {
|
||||
return {type: 'fileNumber', value: targetFdNumber, optionName};
|
||||
}
|
||||
|
||||
if (tty.isatty(targetFdNumber)) {
|
||||
throw new TypeError(`The \`${optionName}: ${serializeOptionValue(value)}\` option is invalid: it cannot be a TTY with synchronous methods.`);
|
||||
}
|
||||
|
||||
return {type: 'uint8Array', value: bufferToUint8Array(readFileSync(targetFdNumber)), optionName};
|
||||
};
|
||||
|
||||
const getTargetFdNumber = (value, fdNumber) => {
|
||||
if (value === 'inherit') {
|
||||
return fdNumber;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const standardStreamIndex = STANDARD_STREAMS.indexOf(value);
|
||||
if (standardStreamIndex !== -1) {
|
||||
return standardStreamIndex;
|
||||
}
|
||||
};
|
||||
|
||||
const handleNativeStreamAsync = ({stdioItem, stdioItem: {value, optionName}, fdNumber}) => {
|
||||
if (value === 'inherit') {
|
||||
return {type: 'nodeStream', value: getStandardStream(fdNumber, value, optionName), optionName};
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return {type: 'nodeStream', value: getStandardStream(value, value, optionName), optionName};
|
||||
}
|
||||
|
||||
if (isNodeStream(value, {checkOpen: false})) {
|
||||
return {type: 'nodeStream', value, optionName};
|
||||
}
|
||||
|
||||
return stdioItem;
|
||||
};
|
||||
|
||||
// Node.js does not allow to easily retrieve file descriptors beyond stdin/stdout/stderr as streams.
|
||||
// - `fs.createReadStream()`/`fs.createWriteStream()` with the `fd` option do not work with character devices that use blocking reads/writes (such as interactive TTYs).
|
||||
// - Using a TCP `Socket` would work but be rather complex to implement.
|
||||
// Since this is an edge case, we simply throw an error message.
|
||||
// See https://github.com/sindresorhus/execa/pull/643#discussion_r1435905707
|
||||
const getStandardStream = (fdNumber, value, optionName) => {
|
||||
const standardStream = STANDARD_STREAMS[fdNumber];
|
||||
|
||||
if (standardStream === undefined) {
|
||||
throw new TypeError(`The \`${optionName}: ${value}\` option is invalid: no such standard stream.`);
|
||||
}
|
||||
|
||||
return standardStream;
|
||||
};
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import {STANDARD_STREAMS_ALIASES} from '../utils/standard-stream.js';
|
||||
import {normalizeIpcStdioArray} from '../ipc/array.js';
|
||||
import {isFullVerbose} from '../verbose/values.js';
|
||||
import {isStdioValueObject} from './type.js';
|
||||
|
||||
// Add support for `stdin`/`stdout`/`stderr` as an alias for `stdio`.
|
||||
// Also normalize the `stdio` option.
|
||||
export const normalizeStdioOption = ({stdio, ipc, buffer, ...options}, verboseInfo, isSync) => {
|
||||
const stdioArray = getStdioArray(stdio, options).map((stdioOption, fdNumber) => addDefaultValue(stdioOption, fdNumber));
|
||||
validateIpcStdioOption(stdioArray);
|
||||
return isSync
|
||||
? normalizeStdioSync(stdioArray, buffer, verboseInfo)
|
||||
: normalizeIpcStdioArray(stdioArray, ipc);
|
||||
};
|
||||
|
||||
// The `stdio: 'ipc'` value (raw `child_process` syntax) was replaced by the `ipc: true` option
|
||||
const validateIpcStdioOption = stdioArray => {
|
||||
if (stdioArray.some(stdioOption => hasIpcStdioOption(stdioOption))) {
|
||||
throw new Error('The `ipc: true` option must be used instead of `stdio: \'ipc\'`.');
|
||||
}
|
||||
};
|
||||
|
||||
const hasIpcStdioOption = stdioOption => {
|
||||
if (Array.isArray(stdioOption)) {
|
||||
return stdioOption.some(item => hasIpcStdioItem(item));
|
||||
}
|
||||
|
||||
return hasIpcStdioItem(stdioOption);
|
||||
};
|
||||
|
||||
const hasIpcStdioItem = stdioOption => {
|
||||
if (isStdioValueObject(stdioOption)) {
|
||||
return stdioOption.value === 'ipc';
|
||||
}
|
||||
|
||||
return stdioOption === 'ipc';
|
||||
};
|
||||
|
||||
const getStdioArray = (stdio, options) => {
|
||||
if (stdio === undefined) {
|
||||
return STANDARD_STREAMS_ALIASES.map(alias => options[alias]);
|
||||
}
|
||||
|
||||
if (hasAlias(options)) {
|
||||
throw new Error(`It's not possible to provide \`stdio\` in combination with one of ${STANDARD_STREAMS_ALIASES.map(alias => `\`${alias}\``).join(', ')}`);
|
||||
}
|
||||
|
||||
if (typeof stdio === 'string') {
|
||||
return [stdio, stdio, stdio];
|
||||
}
|
||||
|
||||
if (!Array.isArray(stdio)) {
|
||||
throw new TypeError(`Expected \`stdio\` to be of type \`string\` or \`Array\`, got \`${typeof stdio}\``);
|
||||
}
|
||||
|
||||
const length = Math.max(stdio.length, STANDARD_STREAMS_ALIASES.length);
|
||||
return Array.from({length}, (_, fdNumber) => stdio[fdNumber]);
|
||||
};
|
||||
|
||||
const hasAlias = options => STANDARD_STREAMS_ALIASES.some(alias => options[alias] !== undefined);
|
||||
|
||||
const addDefaultValue = (stdioOption, fdNumber) => {
|
||||
if (Array.isArray(stdioOption)) {
|
||||
return stdioOption.map(item => addDefaultValue(item, fdNumber));
|
||||
}
|
||||
|
||||
if (stdioOption === null || stdioOption === undefined) {
|
||||
return fdNumber >= STANDARD_STREAMS_ALIASES.length ? 'ignore' : 'pipe';
|
||||
}
|
||||
|
||||
return stdioOption;
|
||||
};
|
||||
|
||||
// Using `buffer: false` with synchronous methods implies `stdout`/`stderr`: `ignore`.
|
||||
// Unless the output is needed, e.g. due to `verbose: 'full'` or to redirecting to a file.
|
||||
const normalizeStdioSync = (stdioArray, buffer, verboseInfo) => stdioArray.map((stdioOption, fdNumber) =>
|
||||
!buffer[fdNumber]
|
||||
&& fdNumber !== 0
|
||||
&& !isFullVerbose(verboseInfo, fdNumber)
|
||||
&& isOutputPipeOnly(stdioOption, fdNumber)
|
||||
? 'ignore'
|
||||
: stdioOption);
|
||||
|
||||
const isOutputPipeOnly = (stdioOption, fdNumber) => isOutputPipe(stdioOption, fdNumber)
|
||||
|| (Array.isArray(stdioOption) && stdioOption.every(item => isOutputPipe(item, fdNumber)));
|
||||
|
||||
const isOutputPipe = (stdioOption, fdNumber) => stdioOption === 'pipe'
|
||||
|| isOutputPipeObject(stdioOption, fdNumber);
|
||||
|
||||
const isOutputPipeObject = (stdioOption, fdNumber) => isStdioValueObject(stdioOption)
|
||||
&& stdioOption.value === 'pipe'
|
||||
&& (stdioOption.input === undefined || stdioOption.input === false || isFixedOutputPipe(fdNumber, stdioOption.input));
|
||||
|
||||
// On `stdout`/`stderr`, the direction is always output, so `input: true` is ignored and the pipe stays output.
|
||||
const isFixedOutputPipe = (fdNumber, input) => input === true && (fdNumber === 1 || fdNumber === 2);
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
import {isStream as isNodeStream, isDuplexStream} from 'is-stream';
|
||||
import isPlainObj from 'is-plain-obj';
|
||||
import {isUint8Array} from '../utils/uint-array.js';
|
||||
|
||||
// The `stdin`/`stdout`/`stderr` option can be of many types. This detects it.
|
||||
export const getStdioItemType = (value, optionName) => {
|
||||
if (isAsyncGenerator(value)) {
|
||||
return 'asyncGenerator';
|
||||
}
|
||||
|
||||
if (isSyncGenerator(value)) {
|
||||
return 'generator';
|
||||
}
|
||||
|
||||
if (isUrl(value)) {
|
||||
return 'fileUrl';
|
||||
}
|
||||
|
||||
if (isFilePathObject(value)) {
|
||||
return 'filePath';
|
||||
}
|
||||
|
||||
if (isWebStream(value)) {
|
||||
return 'webStream';
|
||||
}
|
||||
|
||||
if (isNodeStream(value, {checkOpen: false})) {
|
||||
return 'native';
|
||||
}
|
||||
|
||||
if (isUint8Array(value)) {
|
||||
return 'uint8Array';
|
||||
}
|
||||
|
||||
if (isAsyncIterableObject(value)) {
|
||||
return 'asyncIterable';
|
||||
}
|
||||
|
||||
if (isIterableObject(value)) {
|
||||
return 'iterable';
|
||||
}
|
||||
|
||||
if (isTransformStream(value)) {
|
||||
return getTransformStreamType({transform: value}, optionName);
|
||||
}
|
||||
|
||||
if (isTransformOptions(value)) {
|
||||
return getTransformObjectType(value, optionName);
|
||||
}
|
||||
|
||||
return 'native';
|
||||
};
|
||||
|
||||
const getTransformObjectType = (value, optionName) => {
|
||||
if (isDuplexStream(value.transform, {checkOpen: false})) {
|
||||
return getDuplexType(value, optionName);
|
||||
}
|
||||
|
||||
if (isTransformStream(value.transform)) {
|
||||
return getTransformStreamType(value, optionName);
|
||||
}
|
||||
|
||||
return getGeneratorObjectType(value, optionName);
|
||||
};
|
||||
|
||||
const getDuplexType = (value, optionName) => {
|
||||
validateNonGeneratorType(value, optionName, 'Duplex stream');
|
||||
return 'duplex';
|
||||
};
|
||||
|
||||
const getTransformStreamType = (value, optionName) => {
|
||||
validateNonGeneratorType(value, optionName, 'web TransformStream');
|
||||
return 'webTransform';
|
||||
};
|
||||
|
||||
const validateNonGeneratorType = ({final, binary, objectMode}, optionName, typeName) => {
|
||||
checkUndefinedOption(final, `${optionName}.final`, typeName);
|
||||
checkUndefinedOption(binary, `${optionName}.binary`, typeName);
|
||||
checkBooleanOption(objectMode, `${optionName}.objectMode`);
|
||||
};
|
||||
|
||||
const checkUndefinedOption = (value, optionName, typeName) => {
|
||||
if (value !== undefined) {
|
||||
throw new TypeError(`The \`${optionName}\` option can only be defined when using a generator, not a ${typeName}.`);
|
||||
}
|
||||
};
|
||||
|
||||
const getGeneratorObjectType = ({transform, final, binary, objectMode}, optionName) => {
|
||||
if (transform !== undefined && !isGenerator(transform)) {
|
||||
throw new TypeError(`The \`${optionName}.transform\` option must be a generator, a Duplex stream or a web TransformStream.`);
|
||||
}
|
||||
|
||||
if (isDuplexStream(final, {checkOpen: false})) {
|
||||
throw new TypeError(`The \`${optionName}.final\` option must not be a Duplex stream.`);
|
||||
}
|
||||
|
||||
if (isTransformStream(final)) {
|
||||
throw new TypeError(`The \`${optionName}.final\` option must not be a web TransformStream.`);
|
||||
}
|
||||
|
||||
if (final !== undefined && !isGenerator(final)) {
|
||||
throw new TypeError(`The \`${optionName}.final\` option must be a generator.`);
|
||||
}
|
||||
|
||||
checkBooleanOption(binary, `${optionName}.binary`);
|
||||
checkBooleanOption(objectMode, `${optionName}.objectMode`);
|
||||
|
||||
return isAsyncGenerator(transform) || isAsyncGenerator(final) ? 'asyncGenerator' : 'generator';
|
||||
};
|
||||
|
||||
export const checkBooleanOption = (value, optionName) => {
|
||||
if (value !== undefined && typeof value !== 'boolean') {
|
||||
throw new TypeError(`The \`${optionName}\` option must use a boolean.`);
|
||||
}
|
||||
};
|
||||
|
||||
const isGenerator = value => isAsyncGenerator(value) || isSyncGenerator(value);
|
||||
export const isAsyncGenerator = value => Object.prototype.toString.call(value) === '[object AsyncGeneratorFunction]';
|
||||
const isSyncGenerator = value => Object.prototype.toString.call(value) === '[object GeneratorFunction]';
|
||||
const isTransformOptions = value => isPlainObj(value)
|
||||
&& (value.transform !== undefined || value.final !== undefined);
|
||||
|
||||
export const isUrl = value => Object.prototype.toString.call(value) === '[object URL]';
|
||||
export const isRegularUrl = value => isUrl(value) && value.protocol !== 'file:';
|
||||
|
||||
const isFilePathObject = value => isPlainObj(value)
|
||||
&& Object.keys(value).length > 0
|
||||
&& Object.keys(value).every(key => FILE_PATH_KEYS.has(key))
|
||||
&& isFilePathString(value.file);
|
||||
const FILE_PATH_KEYS = new Set(['file', 'append']);
|
||||
export const isFilePathString = file => typeof file === 'string';
|
||||
|
||||
// The `{value, input}` object form marks an additional file descriptor's direction explicitly.
|
||||
// This is needed for direction-ambiguous values (like `'pipe'`, `'inherit'`, a file or a transform) on fd3+, which otherwise default to output.
|
||||
export const isStdioValueObject = value => isPlainObj(value)
|
||||
&& Object.keys(value).length > 0
|
||||
&& Object.keys(value).every(key => STDIO_VALUE_KEYS.has(key))
|
||||
&& 'value' in value;
|
||||
const STDIO_VALUE_KEYS = new Set(['value', 'input']);
|
||||
|
||||
export const isUnknownStdioString = (type, value) => type === 'native'
|
||||
&& typeof value === 'string'
|
||||
&& !KNOWN_STDIO_STRINGS.has(value);
|
||||
const KNOWN_STDIO_STRINGS = new Set(['ipc', 'ignore', 'inherit', 'overlapped', 'pipe']);
|
||||
|
||||
const isReadableStream = value => Object.prototype.toString.call(value) === '[object ReadableStream]';
|
||||
export const isWritableStream = value => Object.prototype.toString.call(value) === '[object WritableStream]';
|
||||
const isWebStream = value => isReadableStream(value) || isWritableStream(value);
|
||||
const isTransformStream = value => isReadableStream(value?.readable) && isWritableStream(value?.writable);
|
||||
|
||||
const isAsyncIterableObject = value => isObject(value) && typeof value[Symbol.asyncIterator] === 'function';
|
||||
const isIterableObject = value => isObject(value) && typeof value[Symbol.iterator] === 'function';
|
||||
const isObject = value => typeof value === 'object' && value !== null;
|
||||
|
||||
// Types which modify `subprocess.std*`
|
||||
export const TRANSFORM_TYPES = new Set(['generator', 'asyncGenerator', 'duplex', 'webTransform']);
|
||||
// Types which write to a file or a file descriptor
|
||||
export const FILE_TYPES = new Set(['fileUrl', 'filePath', 'fileNumber']);
|
||||
// When two file descriptors of this type share the same target, we need to do some special logic
|
||||
export const SPECIAL_DUPLICATE_TYPES_SYNC = new Set(['fileUrl', 'filePath']);
|
||||
export const SPECIAL_DUPLICATE_TYPES = new Set([...SPECIAL_DUPLICATE_TYPES_SYNC, 'webStream', 'nodeStream']);
|
||||
// Do not allow two file descriptors of this type sharing the same target
|
||||
export const FORBID_DUPLICATE_TYPES = new Set(['webTransform', 'duplex']);
|
||||
|
||||
// Convert types to human-friendly strings for error messages
|
||||
export const TYPE_TO_MESSAGE = {
|
||||
generator: 'a generator',
|
||||
asyncGenerator: 'an async generator',
|
||||
fileUrl: 'a file URL',
|
||||
filePath: 'a file path string',
|
||||
fileNumber: 'a file descriptor number',
|
||||
webStream: 'a web stream',
|
||||
nodeStream: 'a Node.js stream',
|
||||
webTransform: 'a web TransformStream',
|
||||
duplex: 'a Duplex stream',
|
||||
native: 'any value',
|
||||
iterable: 'an iterable',
|
||||
asyncIterable: 'an async iterable',
|
||||
string: 'a string',
|
||||
uint8Array: 'a Uint8Array',
|
||||
};
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import {onAbortedSignal} from '../utils/abort-signal.js';
|
||||
|
||||
// Validate the `cancelSignal` option
|
||||
export const validateCancelSignal = ({cancelSignal}) => {
|
||||
if (cancelSignal !== undefined && Object.prototype.toString.call(cancelSignal) !== '[object AbortSignal]') {
|
||||
throw new Error(`The \`cancelSignal\` option must be an AbortSignal: ${String(cancelSignal)}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Terminate the subprocess when aborting the `cancelSignal` option and `gracefulSignal` is `false`
|
||||
export const throwOnCancel = ({kill, cancelSignal, gracefulCancel, context, controller}) => cancelSignal === undefined || gracefulCancel
|
||||
? []
|
||||
: [terminateOnCancel(kill, cancelSignal, context, controller)];
|
||||
|
||||
const terminateOnCancel = async (kill, cancelSignal, context, {signal}) => {
|
||||
await onAbortedSignal(cancelSignal, signal);
|
||||
context.terminationReason ??= 'cancel';
|
||||
kill();
|
||||
throw cancelSignal.reason;
|
||||
};
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import {addAbortListener} from 'node:events';
|
||||
import {onExit} from 'signal-exit';
|
||||
|
||||
// If the `cleanup` option is used, call `subprocess.kill()` when the parent process exits
|
||||
export const cleanupOnExit = (kill, {cleanup, detached}, {signal}) => {
|
||||
if (!cleanup || detached) {
|
||||
return;
|
||||
}
|
||||
|
||||
const removeExitHandler = onExit(() => {
|
||||
kill();
|
||||
});
|
||||
addAbortListener(signal, () => {
|
||||
removeExitHandler();
|
||||
});
|
||||
};
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import {onAbortedSignal} from '../utils/abort-signal.js';
|
||||
import {sendAbort} from '../ipc/graceful.js';
|
||||
import {killOnTimeout} from './kill.js';
|
||||
|
||||
// Validate the `gracefulCancel` option
|
||||
export const validateGracefulCancel = ({gracefulCancel, cancelSignal, ipc, serialization}) => {
|
||||
if (!gracefulCancel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cancelSignal === undefined) {
|
||||
throw new Error('The `cancelSignal` option must be defined when setting the `gracefulCancel` option.');
|
||||
}
|
||||
|
||||
if (!ipc) {
|
||||
throw new Error('The `ipc` option cannot be false when setting the `gracefulCancel` option.');
|
||||
}
|
||||
|
||||
if (serialization === 'json') {
|
||||
throw new Error('The `serialization` option cannot be \'json\' when setting the `gracefulCancel` option.');
|
||||
}
|
||||
};
|
||||
|
||||
// Send abort reason to the subprocess when aborting the `cancelSignal` option and `gracefulCancel` is `true`
|
||||
export const throwOnGracefulCancel = ({
|
||||
subprocess,
|
||||
kill,
|
||||
cancelSignal,
|
||||
gracefulCancel,
|
||||
forceKillAfterDelay,
|
||||
context,
|
||||
controller,
|
||||
}) => gracefulCancel
|
||||
? [sendOnAbort({
|
||||
subprocess,
|
||||
kill,
|
||||
cancelSignal,
|
||||
forceKillAfterDelay,
|
||||
context,
|
||||
controller,
|
||||
})]
|
||||
: [];
|
||||
|
||||
const sendOnAbort = async ({subprocess, kill, cancelSignal, forceKillAfterDelay, context, controller: {signal}}) => {
|
||||
await onAbortedSignal(cancelSignal, signal);
|
||||
const reason = getReason(cancelSignal);
|
||||
await sendAbort(subprocess, reason);
|
||||
killOnTimeout({
|
||||
kill,
|
||||
forceKillAfterDelay,
|
||||
context,
|
||||
controllerSignal: signal,
|
||||
});
|
||||
context.terminationReason ??= 'gracefulCancel';
|
||||
throw cancelSignal.reason;
|
||||
};
|
||||
|
||||
// The default `reason` is a DOMException, which is not serializable with V8
|
||||
// See https://github.com/nodejs/node/issues/53225
|
||||
const getReason = ({reason}) => {
|
||||
if (!(reason instanceof DOMException)) {
|
||||
return reason;
|
||||
}
|
||||
|
||||
const error = new Error(reason.message);
|
||||
Object.defineProperty(error, 'stack', {
|
||||
value: reason.stack,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
return error;
|
||||
};
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import process from 'node:process';
|
||||
import {execFile} from 'node:child_process';
|
||||
import path from 'node:path/win32';
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
// The `killDescendants` option terminates the whole process tree, not just the direct child.
|
||||
// On Unix, this requires spawning the subprocess in its own process group, so we override the
|
||||
// `detached` argument passed to `child_process.spawn()`.
|
||||
// This is kept separate from the user-facing `detached` option, which must keep its own value,
|
||||
// so the `cleanup` behavior is not affected.
|
||||
export const getSpawnOptions = options => options.killDescendants && !isWindows
|
||||
? {...options, detached: true}
|
||||
: options;
|
||||
|
||||
// Returns the low-level function used to send a signal to the subprocess.
|
||||
// With the `killDescendants` option, the signal is sent to the whole process tree.
|
||||
export const getKillFunction = (subprocess, {killDescendants}) => {
|
||||
if (!killDescendants) {
|
||||
return subprocess.kill.bind(subprocess);
|
||||
}
|
||||
|
||||
const killDescendantsFunction = isWindows ? killDescendantsWindows : killDescendantsUnix;
|
||||
return killDescendantsFunction.bind(undefined, subprocess);
|
||||
};
|
||||
|
||||
// On Unix, the subprocess is its own process group leader (its PGID equals its PID), since it
|
||||
// was spawned with `detached: true`. Sending the signal to `-pid` targets the whole group.
|
||||
const killDescendantsUnix = (subprocess, signal) => {
|
||||
if (subprocess.pid === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return process.kill(-subprocess.pid, signal);
|
||||
} catch {
|
||||
// The process group might already be gone, or signaling it might not be permitted, so we
|
||||
// fall back to the direct child. Like `ChildProcess.kill()`, this returns `false` instead of throwing.
|
||||
return subprocess.kill(signal);
|
||||
}
|
||||
};
|
||||
|
||||
// Windows has no process groups. Instead, `taskkill /T` recursively terminates the process tree.
|
||||
// It must run while the tree is still intact, so direct subprocess termination is only used as a
|
||||
// fallback: killing the direct subprocess first would orphan its descendants before `taskkill` could enumerate them.
|
||||
// If `taskkill` is unavailable or fails, the fallback only terminates the direct subprocess.
|
||||
// `taskkill` ignores the signal (`/F` terminates the tree).
|
||||
const killDescendantsWindows = (subprocess, signal) => {
|
||||
if (subprocess.pid === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const taskkillFile = getTaskkillFile();
|
||||
if (taskkillFile === undefined) {
|
||||
return subprocess.kill(signal);
|
||||
}
|
||||
|
||||
// This is best-effort: if `taskkill` fails, still try the direct subprocess.
|
||||
execFile(taskkillFile, ['/pid', `${subprocess.pid}`, '/T', '/F'], error => {
|
||||
if (error) {
|
||||
subprocess.kill(signal);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getTaskkillFile = () => {
|
||||
const windowsDirectory = [process.env.SystemRoot, process.env.windir]
|
||||
.find(directory => directory && isWindowsDriveAbsolutePath(directory));
|
||||
|
||||
return windowsDirectory === undefined
|
||||
? undefined
|
||||
: path.join(windowsDirectory, 'System32', 'taskkill.exe');
|
||||
};
|
||||
|
||||
const isWindowsDriveAbsolutePath = directory => {
|
||||
const {root} = path.parse(directory);
|
||||
return /^[a-z]:[/\\]/i.test(root);
|
||||
};
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import {setTimeout} from 'node:timers/promises';
|
||||
import {isErrorInstance} from '../return/final-error.js';
|
||||
import {normalizeSignalArgument} from './signal.js';
|
||||
|
||||
// Normalize the `forceKillAfterDelay` option
|
||||
export const normalizeForceKillAfterDelay = forceKillAfterDelay => {
|
||||
if (forceKillAfterDelay === false) {
|
||||
return forceKillAfterDelay;
|
||||
}
|
||||
|
||||
if (forceKillAfterDelay === true) {
|
||||
return DEFAULT_FORCE_KILL_TIMEOUT;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(forceKillAfterDelay) || forceKillAfterDelay < 0) {
|
||||
throw new TypeError(`Expected the \`forceKillAfterDelay\` option to be a non-negative integer, got \`${forceKillAfterDelay}\` (${typeof forceKillAfterDelay})`);
|
||||
}
|
||||
|
||||
return forceKillAfterDelay;
|
||||
};
|
||||
|
||||
const DEFAULT_FORCE_KILL_TIMEOUT = 1000 * 5;
|
||||
|
||||
// Monkey-patches `subprocess.kill()` to add `forceKillAfterDelay` behavior and `.kill(error)`
|
||||
export const subprocessKill = (
|
||||
{kill, options: {forceKillAfterDelay, killSignal}, onInternalError, context, controller},
|
||||
signalOrError,
|
||||
errorArgument,
|
||||
) => {
|
||||
const {signal, error} = parseKillArguments(signalOrError, errorArgument, killSignal);
|
||||
emitKillError(error, onInternalError);
|
||||
const killResult = kill(signal);
|
||||
setKillTimeout({
|
||||
kill,
|
||||
signal,
|
||||
forceKillAfterDelay,
|
||||
killSignal,
|
||||
killResult,
|
||||
context,
|
||||
controller,
|
||||
});
|
||||
return killResult;
|
||||
};
|
||||
|
||||
const parseKillArguments = (signalOrError, errorArgument, killSignal) => {
|
||||
const [signal = killSignal, error] = isErrorInstance(signalOrError)
|
||||
? [undefined, signalOrError]
|
||||
: [signalOrError, errorArgument];
|
||||
|
||||
if (typeof signal !== 'string' && !Number.isInteger(signal)) {
|
||||
throw new TypeError(`The first argument must be an error instance or a signal name string/integer: ${String(signal)}`);
|
||||
}
|
||||
|
||||
if (error !== undefined && !isErrorInstance(error)) {
|
||||
throw new TypeError(`The second argument is optional. If specified, it must be an error instance: ${error}`);
|
||||
}
|
||||
|
||||
return {signal: normalizeSignalArgument(signal), error};
|
||||
};
|
||||
|
||||
// Fails right away when calling `subprocess.kill(error)`.
|
||||
// Does not wait for actual signal termination.
|
||||
// Uses a deferred promise instead of the `error` event on the subprocess, as this is less intrusive.
|
||||
const emitKillError = (error, onInternalError) => {
|
||||
if (error !== undefined) {
|
||||
onInternalError.reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
const setKillTimeout = async ({kill, signal, forceKillAfterDelay, killSignal, killResult, context, controller}) => {
|
||||
if (signal === killSignal && killResult) {
|
||||
killOnTimeout({
|
||||
kill,
|
||||
forceKillAfterDelay,
|
||||
context,
|
||||
controllerSignal: controller.signal,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Forcefully terminate a subprocess after a timeout
|
||||
export const killOnTimeout = async ({kill, forceKillAfterDelay, context, controllerSignal}) => {
|
||||
if (forceKillAfterDelay === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await setTimeout(forceKillAfterDelay, undefined, {signal: controllerSignal});
|
||||
if (kill('SIGKILL')) {
|
||||
context.isForcefullyTerminated ??= true;
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import {constants} from 'node:os';
|
||||
import {signalsByName} from 'human-signals';
|
||||
|
||||
// Normalize signals for comparison purpose.
|
||||
// Also validate the signal exists.
|
||||
export const normalizeKillSignal = killSignal => {
|
||||
const optionName = 'option `killSignal`';
|
||||
if (killSignal === 0) {
|
||||
throw new TypeError(`Invalid ${optionName}: 0 cannot be used.`);
|
||||
}
|
||||
|
||||
return normalizeSignal(killSignal, optionName);
|
||||
};
|
||||
|
||||
export const normalizeSignalArgument = signal => signal === 0
|
||||
? signal
|
||||
: normalizeSignal(signal, '`subprocess.kill()`\'s argument');
|
||||
|
||||
const normalizeSignal = (signalNameOrInteger, optionName) => {
|
||||
if (Number.isInteger(signalNameOrInteger)) {
|
||||
return normalizeSignalInteger(signalNameOrInteger, optionName);
|
||||
}
|
||||
|
||||
if (typeof signalNameOrInteger === 'string') {
|
||||
return normalizeSignalName(signalNameOrInteger, optionName);
|
||||
}
|
||||
|
||||
throw new TypeError(`Invalid ${optionName} ${String(signalNameOrInteger)}: it must be a string or an integer.\n${getAvailableSignals()}`);
|
||||
};
|
||||
|
||||
const normalizeSignalInteger = (signalInteger, optionName) => {
|
||||
if (signalsIntegerToName.has(signalInteger)) {
|
||||
return signalsIntegerToName.get(signalInteger);
|
||||
}
|
||||
|
||||
throw new TypeError(`Invalid ${optionName} ${signalInteger}: this signal integer does not exist.\n${getAvailableSignals()}`);
|
||||
};
|
||||
|
||||
const getSignalsIntegerToName = () => new Map(Object.entries(constants.signals)
|
||||
.reverse()
|
||||
.map(([signalName, signalInteger]) => [signalInteger, signalName]));
|
||||
|
||||
const signalsIntegerToName = getSignalsIntegerToName();
|
||||
|
||||
const normalizeSignalName = (signalName, optionName) => {
|
||||
if (signalName in constants.signals) {
|
||||
return signalName;
|
||||
}
|
||||
|
||||
if (signalName.toUpperCase() in constants.signals) {
|
||||
throw new TypeError(`Invalid ${optionName} '${signalName}': please rename it to '${signalName.toUpperCase()}'.`);
|
||||
}
|
||||
|
||||
throw new TypeError(`Invalid ${optionName} '${signalName}': this signal name does not exist.\n${getAvailableSignals()}`);
|
||||
};
|
||||
|
||||
const getAvailableSignals = () => `Available signal names: ${getAvailableSignalNames()}.
|
||||
Available signal numbers: ${getAvailableSignalIntegers()}.`;
|
||||
|
||||
const getAvailableSignalNames = () => Object.keys(constants.signals)
|
||||
.sort()
|
||||
.map(signalName => `'${signalName}'`)
|
||||
.join(', ');
|
||||
|
||||
const getAvailableSignalIntegers = () => [...new Set(Object.values(constants.signals)
|
||||
.sort((signalInteger, signalIntegerTwo) => signalInteger - signalIntegerTwo))]
|
||||
.join(', ');
|
||||
|
||||
// Human-friendly description of a signal
|
||||
export const getSignalDescription = signal => signalsByName[signal].description;
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import {setTimeout} from 'node:timers/promises';
|
||||
import {DiscardedError} from '../return/final-error.js';
|
||||
|
||||
// Validate `timeout` option
|
||||
export const validateTimeout = ({timeout}) => {
|
||||
if (timeout !== undefined && (!Number.isFinite(timeout) || timeout < 0)) {
|
||||
throw new TypeError(`Expected the \`timeout\` option to be a non-negative integer, got \`${timeout}\` (${typeof timeout})`);
|
||||
}
|
||||
};
|
||||
|
||||
// Fails when the `timeout` option is exceeded
|
||||
export const throwOnTimeout = (kill, timeout, context, controller) => timeout === 0 || timeout === undefined
|
||||
? []
|
||||
: [killAfterTimeout(kill, timeout, context, controller)];
|
||||
|
||||
const killAfterTimeout = async (kill, timeout, context, {signal}) => {
|
||||
await setTimeout(timeout, undefined, {signal});
|
||||
context.terminationReason ??= 'timeout';
|
||||
kill();
|
||||
throw new DiscardedError();
|
||||
};
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import {Buffer} from 'node:buffer';
|
||||
import {StringDecoder} from 'node:string_decoder';
|
||||
import {isUint8Array, bufferToUint8Array} from '../utils/uint-array.js';
|
||||
|
||||
/*
|
||||
When using binary encodings, add an internal generator that converts chunks from `Buffer` to `string` or `Uint8Array`.
|
||||
Chunks might be Buffer, Uint8Array or strings since:
|
||||
- `subprocess.stdout|stderr` emits Buffers
|
||||
- `subprocess.stdin.write()` accepts Buffer, Uint8Array or string
|
||||
- Previous generators might return Uint8Array or string
|
||||
|
||||
However, those are converted to Buffer:
|
||||
- on writes: `Duplex.writable` `decodeStrings: true` default option
|
||||
- on reads: `Duplex.readable` `readableEncoding: null` default option
|
||||
*/
|
||||
export const getEncodingTransformGenerator = (binary, encoding, skipped) => {
|
||||
if (skipped) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (binary) {
|
||||
return {transform: encodingUint8ArrayGenerator.bind(undefined, new TextEncoder())};
|
||||
}
|
||||
|
||||
const stringDecoder = new StringDecoder(encoding);
|
||||
return {
|
||||
transform: encodingStringGenerator.bind(undefined, stringDecoder),
|
||||
final: encodingStringFinal.bind(undefined, stringDecoder),
|
||||
};
|
||||
};
|
||||
|
||||
const encodingUint8ArrayGenerator = function * (textEncoder, chunk) {
|
||||
if (Buffer.isBuffer(chunk)) {
|
||||
yield bufferToUint8Array(chunk);
|
||||
} else if (typeof chunk === 'string') {
|
||||
yield textEncoder.encode(chunk);
|
||||
} else {
|
||||
yield chunk;
|
||||
}
|
||||
};
|
||||
|
||||
const encodingStringGenerator = function * (stringDecoder, chunk) {
|
||||
yield isUint8Array(chunk) ? stringDecoder.write(chunk) : chunk;
|
||||
};
|
||||
|
||||
const encodingStringFinal = function * (stringDecoder) {
|
||||
const lastChunk = stringDecoder.end();
|
||||
if (lastChunk !== '') {
|
||||
yield lastChunk;
|
||||
}
|
||||
};
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import {Transform, getDefaultHighWaterMark} from 'node:stream';
|
||||
import {isAsyncGenerator} from '../stdio/type.js';
|
||||
import {getSplitLinesGenerator, getAppendNewlineGenerator} from './split.js';
|
||||
import {getValidateTransformInput, getValidateTransformReturn} from './validate.js';
|
||||
import {getEncodingTransformGenerator} from './encoding-transform.js';
|
||||
import {
|
||||
pushChunks,
|
||||
transformChunk,
|
||||
finalChunks,
|
||||
destroyTransform,
|
||||
} from './run-async.js';
|
||||
import {
|
||||
pushChunksSync,
|
||||
transformChunkSync,
|
||||
finalChunksSync,
|
||||
runTransformSync,
|
||||
} from './run-sync.js';
|
||||
|
||||
/*
|
||||
Generators can be used to transform/filter standard streams.
|
||||
|
||||
Generators have a simple syntax, yet allows all of the following:
|
||||
- Sharing `state` between chunks
|
||||
- Flushing logic, by using a `final` function
|
||||
- Asynchronous logic
|
||||
- Emitting multiple chunks from a single source chunk, even if spaced in time, by using multiple `yield`
|
||||
- Filtering, by using no `yield`
|
||||
|
||||
Therefore, there is no need to allow Node.js or web transform streams.
|
||||
|
||||
The `highWaterMark` is kept as the default value, since this is what `subprocess.std*` uses.
|
||||
|
||||
Chunks are currently processed serially. We could add a `concurrency` option to parallelize in the future.
|
||||
|
||||
Transform an array of generator functions into a `Transform` stream.
|
||||
`Duplex.from(generator)` cannot be used because it does not allow setting the `objectMode` and `highWaterMark`.
|
||||
*/
|
||||
export const generatorToStream = ({
|
||||
value,
|
||||
value: {transform, final, writableObjectMode, readableObjectMode},
|
||||
optionName,
|
||||
}, {encoding}) => {
|
||||
const state = {};
|
||||
const generators = addInternalGenerators(value, encoding, optionName);
|
||||
|
||||
const transformAsync = isAsyncGenerator(transform);
|
||||
const finalAsync = isAsyncGenerator(final);
|
||||
const transformMethod = transformAsync
|
||||
? pushChunks.bind(undefined, transformChunk, state)
|
||||
: pushChunksSync.bind(undefined, transformChunkSync);
|
||||
const finalMethod = transformAsync || finalAsync
|
||||
? pushChunks.bind(undefined, finalChunks, state)
|
||||
: pushChunksSync.bind(undefined, finalChunksSync);
|
||||
const destroyMethod = transformAsync || finalAsync
|
||||
? destroyTransform.bind(undefined, state)
|
||||
: undefined;
|
||||
|
||||
const stream = new Transform({
|
||||
writableObjectMode,
|
||||
writableHighWaterMark: getDefaultHighWaterMark(writableObjectMode),
|
||||
readableObjectMode,
|
||||
readableHighWaterMark: getDefaultHighWaterMark(readableObjectMode),
|
||||
transform(chunk, encoding, done) {
|
||||
transformMethod([chunk, generators, 0], this, done);
|
||||
},
|
||||
flush(done) {
|
||||
finalMethod([generators], this, done);
|
||||
},
|
||||
destroy: destroyMethod,
|
||||
});
|
||||
return {stream};
|
||||
};
|
||||
|
||||
// Applies transform generators in sync mode
|
||||
export const runGeneratorsSync = (chunks, stdioItems, encoding, isInput) => {
|
||||
const generators = stdioItems.filter(({type}) => type === 'generator');
|
||||
const reversedGenerators = isInput ? generators.reverse() : generators;
|
||||
|
||||
for (const {value, optionName} of reversedGenerators) {
|
||||
const generators = addInternalGenerators(value, encoding, optionName);
|
||||
chunks = runTransformSync(generators, chunks);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
};
|
||||
|
||||
// Generators used internally to convert the chunk type, validate it, and split into lines
|
||||
const addInternalGenerators = (
|
||||
{transform, final, binary, writableObjectMode, readableObjectMode, preserveNewlines},
|
||||
encoding,
|
||||
optionName,
|
||||
) => {
|
||||
const state = {};
|
||||
return [
|
||||
{transform: getValidateTransformInput(writableObjectMode, optionName)},
|
||||
getEncodingTransformGenerator(binary, encoding, writableObjectMode),
|
||||
getSplitLinesGenerator(binary, preserveNewlines, writableObjectMode, state),
|
||||
{transform, final},
|
||||
{transform: getValidateTransformReturn(readableObjectMode, optionName)},
|
||||
getAppendNewlineGenerator({
|
||||
binary,
|
||||
preserveNewlines,
|
||||
readableObjectMode,
|
||||
state,
|
||||
}),
|
||||
].filter(Boolean);
|
||||
};
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import isPlainObj from 'is-plain-obj';
|
||||
import {BINARY_ENCODINGS} from '../arguments/encoding-option.js';
|
||||
import {TRANSFORM_TYPES} from '../stdio/type.js';
|
||||
import {getTransformObjectModes} from './object-mode.js';
|
||||
|
||||
// Transforms generators/duplex/TransformStream can have multiple shapes.
|
||||
// This normalizes it and applies default values.
|
||||
export const normalizeTransforms = (stdioItems, optionName, direction, options) => [
|
||||
...stdioItems.filter(({type}) => !TRANSFORM_TYPES.has(type)),
|
||||
...getTransforms(stdioItems, optionName, direction, options),
|
||||
];
|
||||
|
||||
const getTransforms = (stdioItems, optionName, direction, {encoding}) => {
|
||||
const transforms = stdioItems.filter(({type}) => TRANSFORM_TYPES.has(type));
|
||||
const newTransforms = Array.from({length: transforms.length});
|
||||
|
||||
for (const [index, stdioItem] of Object.entries(transforms)) {
|
||||
newTransforms[index] = normalizeTransform({
|
||||
stdioItem,
|
||||
index: Number(index),
|
||||
newTransforms,
|
||||
optionName,
|
||||
direction,
|
||||
encoding,
|
||||
});
|
||||
}
|
||||
|
||||
return sortTransforms(newTransforms, direction);
|
||||
};
|
||||
|
||||
const normalizeTransform = ({stdioItem, stdioItem: {type}, index, newTransforms, optionName, direction, encoding}) => {
|
||||
if (type === 'duplex') {
|
||||
return normalizeDuplex({stdioItem, optionName});
|
||||
}
|
||||
|
||||
if (type === 'webTransform') {
|
||||
return normalizeTransformStream({
|
||||
stdioItem,
|
||||
index,
|
||||
newTransforms,
|
||||
direction,
|
||||
});
|
||||
}
|
||||
|
||||
return normalizeGenerator({
|
||||
stdioItem,
|
||||
index,
|
||||
newTransforms,
|
||||
direction,
|
||||
encoding,
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeDuplex = ({stdioItem, optionName}) => {
|
||||
const {value} = stdioItem;
|
||||
const {transform} = value;
|
||||
const {writableObjectMode, readableObjectMode} = transform;
|
||||
const {objectMode = readableObjectMode} = value;
|
||||
|
||||
if (objectMode && !readableObjectMode) {
|
||||
throw new TypeError(`The \`${optionName}.objectMode\` option can only be \`true\` if \`new Duplex({objectMode: true})\` is used.`);
|
||||
}
|
||||
|
||||
if (!objectMode && readableObjectMode) {
|
||||
throw new TypeError(`The \`${optionName}.objectMode\` option cannot be \`false\` if \`new Duplex({objectMode: true})\` is used.`);
|
||||
}
|
||||
|
||||
return {
|
||||
...stdioItem,
|
||||
value: {transform, writableObjectMode, readableObjectMode},
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeTransformStream = ({stdioItem, stdioItem: {value}, index, newTransforms, direction}) => {
|
||||
const {transform, objectMode} = isPlainObj(value) ? value : {transform: value};
|
||||
const {writableObjectMode, readableObjectMode} = getTransformObjectModes(objectMode, index, newTransforms, direction);
|
||||
return ({
|
||||
...stdioItem,
|
||||
value: {transform, writableObjectMode, readableObjectMode},
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeGenerator = ({stdioItem, stdioItem: {value}, index, newTransforms, direction, encoding}) => {
|
||||
const {
|
||||
transform,
|
||||
final,
|
||||
binary: binaryOption = false,
|
||||
preserveNewlines = false,
|
||||
objectMode,
|
||||
} = isPlainObj(value) ? value : {transform: value};
|
||||
const binary = binaryOption || BINARY_ENCODINGS.has(encoding);
|
||||
const {writableObjectMode, readableObjectMode} = getTransformObjectModes(objectMode, index, newTransforms, direction);
|
||||
return {
|
||||
...stdioItem,
|
||||
value: {
|
||||
transform,
|
||||
final,
|
||||
binary,
|
||||
preserveNewlines,
|
||||
writableObjectMode,
|
||||
readableObjectMode,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const sortTransforms = (newTransforms, direction) => direction === 'input' ? newTransforms.reverse() : newTransforms;
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import {TRANSFORM_TYPES} from '../stdio/type.js';
|
||||
|
||||
/*
|
||||
Retrieve the `objectMode`s of a single transform.
|
||||
`objectMode` determines the return value's type, i.e. the `readableObjectMode`.
|
||||
The chunk argument's type is based on the previous generator's return value, i.e. the `writableObjectMode` is based on the previous `readableObjectMode`.
|
||||
The last input's generator is read by `subprocess.stdin` which:
|
||||
- should not be in `objectMode` for performance reasons.
|
||||
- can only be strings, Buffers and Uint8Arrays.
|
||||
Therefore its `readableObjectMode` must be `false`.
|
||||
The same applies to the first output's generator's `writableObjectMode`.
|
||||
*/
|
||||
export const getTransformObjectModes = (objectMode, index, newTransforms, direction) => direction === 'output'
|
||||
? getOutputObjectModes(objectMode, index, newTransforms)
|
||||
: getInputObjectModes(objectMode, index, newTransforms);
|
||||
|
||||
const getOutputObjectModes = (objectMode, index, newTransforms) => {
|
||||
const writableObjectMode = index !== 0 && newTransforms[index - 1].value.readableObjectMode;
|
||||
const readableObjectMode = objectMode ?? writableObjectMode;
|
||||
return {writableObjectMode, readableObjectMode};
|
||||
};
|
||||
|
||||
const getInputObjectModes = (objectMode, index, newTransforms) => {
|
||||
const writableObjectMode = index === 0
|
||||
? objectMode === true
|
||||
: newTransforms[index - 1].value.readableObjectMode;
|
||||
const readableObjectMode = index !== newTransforms.length - 1 && (objectMode ?? writableObjectMode);
|
||||
return {writableObjectMode, readableObjectMode};
|
||||
};
|
||||
|
||||
// Retrieve the `objectMode` of a file descriptor, e.g. `stdout` or `stderr`
|
||||
export const getFdObjectMode = (stdioItems, direction) => {
|
||||
const lastTransform = stdioItems.findLast(({type}) => TRANSFORM_TYPES.has(type));
|
||||
if (lastTransform === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return direction === 'input'
|
||||
? lastTransform.value.writableObjectMode
|
||||
: lastTransform.value.readableObjectMode;
|
||||
};
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import {callbackify} from 'node:util';
|
||||
|
||||
// Applies a series of generator functions asynchronously
|
||||
export const pushChunks = callbackify(async (getChunks, state, getChunksArguments, transformStream) => {
|
||||
state.currentIterable = getChunks(...getChunksArguments);
|
||||
|
||||
try {
|
||||
for await (const chunk of state.currentIterable) {
|
||||
transformStream.push(chunk);
|
||||
}
|
||||
} finally {
|
||||
delete state.currentIterable;
|
||||
}
|
||||
});
|
||||
|
||||
// For each new chunk, apply each `transform()` method
|
||||
export const transformChunk = async function * (chunk, generators, index) {
|
||||
if (index === generators.length) {
|
||||
yield chunk;
|
||||
return;
|
||||
}
|
||||
|
||||
const {transform = identityGenerator} = generators[index];
|
||||
for await (const transformedChunk of transform(chunk)) {
|
||||
yield * transformChunk(transformedChunk, generators, index + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// At the end, apply each `final()` method, followed by the `transform()` method of the next transforms
|
||||
export const finalChunks = async function * (generators) {
|
||||
for (const [index, {final}] of Object.entries(generators)) {
|
||||
yield * generatorFinalChunks(final, Number(index), generators);
|
||||
}
|
||||
};
|
||||
|
||||
const generatorFinalChunks = async function * (final, index, generators) {
|
||||
if (final === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
for await (const finalChunk of final()) {
|
||||
yield * transformChunk(finalChunk, generators, index + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel any ongoing async generator when the Transform is destroyed, e.g. when the subprocess errors
|
||||
export const destroyTransform = callbackify(async ({currentIterable}, error) => {
|
||||
if (currentIterable !== undefined) {
|
||||
await (error ? currentIterable.throw(error) : currentIterable.return());
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
const identityGenerator = function * (chunk) {
|
||||
yield chunk;
|
||||
};
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
// Duplicate the code from `run-async.js` but as synchronous functions
|
||||
export const pushChunksSync = (getChunksSync, getChunksArguments, transformStream, done) => {
|
||||
try {
|
||||
for (const chunk of getChunksSync(...getChunksArguments)) {
|
||||
transformStream.push(chunk);
|
||||
}
|
||||
|
||||
done();
|
||||
} catch (error) {
|
||||
done(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Run synchronous generators with `execaSync()`
|
||||
export const runTransformSync = (generators, chunks) => [
|
||||
...chunks.flatMap(chunk => [...transformChunkSync(chunk, generators, 0)]),
|
||||
...finalChunksSync(generators),
|
||||
];
|
||||
|
||||
export const transformChunkSync = function * (chunk, generators, index) {
|
||||
if (index === generators.length) {
|
||||
yield chunk;
|
||||
return;
|
||||
}
|
||||
|
||||
const {transform = identityGenerator} = generators[index];
|
||||
for (const transformedChunk of transform(chunk)) {
|
||||
yield * transformChunkSync(transformedChunk, generators, index + 1);
|
||||
}
|
||||
};
|
||||
|
||||
export const finalChunksSync = function * (generators) {
|
||||
for (const [index, {final}] of Object.entries(generators)) {
|
||||
yield * generatorFinalChunksSync(final, Number(index), generators);
|
||||
}
|
||||
};
|
||||
|
||||
const generatorFinalChunksSync = function * (final, index, generators) {
|
||||
if (final === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const finalChunk of final()) {
|
||||
yield * transformChunkSync(finalChunk, generators, index + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const identityGenerator = function * (chunk) {
|
||||
yield chunk;
|
||||
};
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
// Split chunks line-wise for generators passed to the `std*` options
|
||||
export const getSplitLinesGenerator = (binary, preserveNewlines, skipped, state) => binary || skipped
|
||||
? undefined
|
||||
: initializeSplitLines(preserveNewlines, state);
|
||||
|
||||
// Same but for synchronous methods
|
||||
export const splitLinesSync = (chunk, preserveNewlines, objectMode) => objectMode
|
||||
? chunk.flatMap(item => splitLinesItemSync(item, preserveNewlines))
|
||||
: splitLinesItemSync(chunk, preserveNewlines);
|
||||
|
||||
const splitLinesItemSync = (chunk, preserveNewlines) => {
|
||||
const {transform, final} = initializeSplitLines(preserveNewlines, {});
|
||||
return [...transform(chunk), ...final()];
|
||||
};
|
||||
|
||||
const initializeSplitLines = (preserveNewlines, state) => {
|
||||
state.previousChunks = '';
|
||||
return {
|
||||
transform: splitGenerator.bind(undefined, state, preserveNewlines),
|
||||
final: linesFinal.bind(undefined, state),
|
||||
};
|
||||
};
|
||||
|
||||
// This imperative logic is much faster than using `String.split()` and uses very low memory.
|
||||
const splitGenerator = function * (state, preserveNewlines, chunk) {
|
||||
if (typeof chunk !== 'string') {
|
||||
yield chunk;
|
||||
return;
|
||||
}
|
||||
|
||||
let {previousChunks} = state;
|
||||
let start = -1;
|
||||
|
||||
for (let end = 0; end < chunk.length; end += 1) {
|
||||
if (chunk[end] !== '\n') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newlineLength = getNewlineLength(chunk, end, preserveNewlines, state);
|
||||
let line = chunk.slice(start + 1, end + 1 - newlineLength);
|
||||
|
||||
if (previousChunks.length > 0) {
|
||||
line = concatString(previousChunks, line);
|
||||
previousChunks = '';
|
||||
}
|
||||
|
||||
yield line;
|
||||
start = end;
|
||||
}
|
||||
|
||||
if (start !== chunk.length - 1) {
|
||||
previousChunks = concatString(previousChunks, chunk.slice(start + 1));
|
||||
}
|
||||
|
||||
state.previousChunks = previousChunks;
|
||||
};
|
||||
|
||||
const getNewlineLength = (chunk, end, preserveNewlines, state) => {
|
||||
if (preserveNewlines) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
state.isWindowsNewline = end !== 0 && chunk[end - 1] === '\r';
|
||||
return state.isWindowsNewline ? 2 : 1;
|
||||
};
|
||||
|
||||
const linesFinal = function * ({previousChunks}) {
|
||||
if (previousChunks.length > 0) {
|
||||
yield previousChunks;
|
||||
}
|
||||
};
|
||||
|
||||
// Unless `preserveNewlines: true` is used, we strip the newline of each line.
|
||||
// This re-adds them after the user `transform` code has run.
|
||||
export const getAppendNewlineGenerator = ({binary, preserveNewlines, readableObjectMode, state}) => binary || preserveNewlines || readableObjectMode
|
||||
? undefined
|
||||
: {transform: appendNewlineGenerator.bind(undefined, state)};
|
||||
|
||||
const appendNewlineGenerator = function * ({isWindowsNewline = false}, chunk) {
|
||||
const {unixNewline, windowsNewline, LF, concatBytes} = typeof chunk === 'string' ? linesStringInfo : linesUint8ArrayInfo;
|
||||
|
||||
if (chunk.at(-1) === LF) {
|
||||
yield chunk;
|
||||
return;
|
||||
}
|
||||
|
||||
const newline = isWindowsNewline ? windowsNewline : unixNewline;
|
||||
yield concatBytes(chunk, newline);
|
||||
};
|
||||
|
||||
const concatString = (firstChunk, secondChunk) => `${firstChunk}${secondChunk}`;
|
||||
|
||||
const linesStringInfo = {
|
||||
windowsNewline: '\r\n',
|
||||
unixNewline: '\n',
|
||||
LF: '\n',
|
||||
concatBytes: concatString,
|
||||
};
|
||||
|
||||
const concatUint8Array = (firstChunk, secondChunk) => {
|
||||
const chunk = new Uint8Array(firstChunk.length + secondChunk.length);
|
||||
chunk.set(firstChunk, 0);
|
||||
chunk.set(secondChunk, firstChunk.length);
|
||||
return chunk;
|
||||
};
|
||||
|
||||
const linesUint8ArrayInfo = {
|
||||
windowsNewline: new Uint8Array([0x0D, 0x0A]),
|
||||
unixNewline: new Uint8Array([0x0A]),
|
||||
LF: 0x0A,
|
||||
concatBytes: concatUint8Array,
|
||||
};
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import {Buffer} from 'node:buffer';
|
||||
import {isUint8Array} from '../utils/uint-array.js';
|
||||
|
||||
// Validate the type of chunk argument passed to transform generators
|
||||
export const getValidateTransformInput = (writableObjectMode, optionName) => writableObjectMode
|
||||
? undefined
|
||||
: validateStringTransformInput.bind(undefined, optionName);
|
||||
|
||||
const validateStringTransformInput = function * (optionName, chunk) {
|
||||
if (typeof chunk !== 'string' && !isUint8Array(chunk) && !Buffer.isBuffer(chunk)) {
|
||||
throw new TypeError(`The \`${optionName}\` option's transform must use "objectMode: true" to receive as input: ${typeof chunk}.`);
|
||||
}
|
||||
|
||||
yield chunk;
|
||||
};
|
||||
|
||||
// Validate the type of the value returned by transform generators
|
||||
export const getValidateTransformReturn = (readableObjectMode, optionName) => readableObjectMode
|
||||
? validateObjectTransformReturn.bind(undefined, optionName)
|
||||
: validateStringTransformReturn.bind(undefined, optionName);
|
||||
|
||||
const validateObjectTransformReturn = function * (optionName, chunk) {
|
||||
validateEmptyReturn(optionName, chunk);
|
||||
yield chunk;
|
||||
};
|
||||
|
||||
const validateStringTransformReturn = function * (optionName, chunk) {
|
||||
validateEmptyReturn(optionName, chunk);
|
||||
|
||||
if (typeof chunk !== 'string' && !isUint8Array(chunk)) {
|
||||
throw new TypeError(`The \`${optionName}\` option's function must yield a string or an Uint8Array, not ${typeof chunk}.`);
|
||||
}
|
||||
|
||||
yield chunk;
|
||||
};
|
||||
|
||||
const validateEmptyReturn = (optionName, chunk) => {
|
||||
if (chunk === null || chunk === undefined) {
|
||||
throw new TypeError(`The \`${optionName}\` option's function must not call \`yield ${chunk}\`.
|
||||
Instead, \`yield\` should either be called with a value, or not be called at all. For example:
|
||||
if (condition) { yield value; }`);
|
||||
}
|
||||
};
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import {once} from 'node:events';
|
||||
|
||||
// Combines `util.aborted()` and `events.addAbortListener()`: promise-based and cleaned up with a stop signal
|
||||
export const onAbortedSignal = async (mainSignal, stopSignal) => {
|
||||
if (!mainSignal.aborted) {
|
||||
await once(mainSignal, 'abort', {signal: stopSignal});
|
||||
}
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export const createDeferred = () => {
|
||||
const methods = {};
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
Object.assign(methods, {resolve, reject});
|
||||
});
|
||||
return Object.assign(promise, methods);
|
||||
};
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import {addAbortListener} from 'node:events';
|
||||
|
||||
// Temporarily increase the maximum number of listeners on an eventEmitter
|
||||
export const incrementMaxListeners = (eventEmitter, maxListenersIncrement, signal) => {
|
||||
const maxListeners = eventEmitter.getMaxListeners();
|
||||
if (maxListeners === 0 || maxListeners === Infinity) {
|
||||
return;
|
||||
}
|
||||
|
||||
eventEmitter.setMaxListeners(maxListeners + maxListenersIncrement);
|
||||
addAbortListener(signal, () => {
|
||||
eventEmitter.setMaxListeners(eventEmitter.getMaxListeners() - maxListenersIncrement);
|
||||
});
|
||||
};
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import process from 'node:process';
|
||||
|
||||
export const isStandardStream = stream => STANDARD_STREAMS.includes(stream);
|
||||
export const STANDARD_STREAMS = [process.stdin, process.stdout, process.stderr];
|
||||
export const STANDARD_STREAMS_ALIASES = ['stdin', 'stdout', 'stderr'];
|
||||
export const getStreamName = fdNumber => STANDARD_STREAMS_ALIASES[fdNumber] ?? `stdio[${fdNumber}]`;
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import {StringDecoder} from 'node:string_decoder';
|
||||
|
||||
const {toString: objectToString} = Object.prototype;
|
||||
|
||||
export const isArrayBuffer = value => objectToString.call(value) === '[object ArrayBuffer]';
|
||||
|
||||
// Is either Uint8Array or Buffer
|
||||
export const isUint8Array = value => objectToString.call(value) === '[object Uint8Array]';
|
||||
|
||||
export const bufferToUint8Array = buffer => new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const stringToUint8Array = string => textEncoder.encode(string);
|
||||
|
||||
const textDecoder = new TextDecoder();
|
||||
export const uint8ArrayToString = uint8Array => textDecoder.decode(uint8Array);
|
||||
|
||||
export const joinToString = (uint8ArraysOrStrings, encoding) => {
|
||||
const strings = uint8ArraysToStrings(uint8ArraysOrStrings, encoding);
|
||||
return strings.join('');
|
||||
};
|
||||
|
||||
const uint8ArraysToStrings = (uint8ArraysOrStrings, encoding) => {
|
||||
if (encoding === 'utf8' && uint8ArraysOrStrings.every(uint8ArrayOrString => typeof uint8ArrayOrString === 'string')) {
|
||||
return uint8ArraysOrStrings;
|
||||
}
|
||||
|
||||
const decoder = new StringDecoder(encoding);
|
||||
const strings = uint8ArraysOrStrings
|
||||
.map(uint8ArrayOrString => typeof uint8ArrayOrString === 'string'
|
||||
? stringToUint8Array(uint8ArrayOrString)
|
||||
: uint8ArrayOrString)
|
||||
.map(uint8Array => decoder.write(uint8Array));
|
||||
const finalString = decoder.end();
|
||||
return finalString === '' ? strings : [...strings, finalString];
|
||||
};
|
||||
|
||||
export const joinToUint8Array = uint8ArraysOrStrings => {
|
||||
if (uint8ArraysOrStrings.length === 1 && isUint8Array(uint8ArraysOrStrings[0])) {
|
||||
return uint8ArraysOrStrings[0];
|
||||
}
|
||||
|
||||
return concatUint8Arrays(stringsToUint8Arrays(uint8ArraysOrStrings));
|
||||
};
|
||||
|
||||
const stringsToUint8Arrays = uint8ArraysOrStrings => uint8ArraysOrStrings.map(uint8ArrayOrString => typeof uint8ArrayOrString === 'string'
|
||||
? stringToUint8Array(uint8ArrayOrString)
|
||||
: uint8ArrayOrString);
|
||||
|
||||
export const concatUint8Arrays = uint8Arrays => {
|
||||
const result = new Uint8Array(getJoinLength(uint8Arrays));
|
||||
|
||||
let index = 0;
|
||||
for (const uint8Array of uint8Arrays) {
|
||||
result.set(uint8Array, index);
|
||||
index += uint8Array.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const getJoinLength = uint8Arrays => {
|
||||
let joinLength = 0;
|
||||
for (const uint8Array of uint8Arrays) {
|
||||
joinLength += uint8Array.length;
|
||||
}
|
||||
|
||||
return joinLength;
|
||||
};
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import prettyMs from 'pretty-ms';
|
||||
import {isVerbose} from './values.js';
|
||||
import {verboseLog} from './log.js';
|
||||
import {logError} from './error.js';
|
||||
|
||||
// When `verbose` is `short|full|custom`, print each command's completion, duration and error
|
||||
export const logResult = (result, verboseInfo) => {
|
||||
if (!isVerbose(verboseInfo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
logError(result, verboseInfo);
|
||||
logDuration(result, verboseInfo);
|
||||
};
|
||||
|
||||
const logDuration = (result, verboseInfo) => {
|
||||
const verboseMessage = `(done in ${prettyMs(result.durationMs)})`;
|
||||
verboseLog({
|
||||
type: 'duration',
|
||||
verboseMessage,
|
||||
verboseInfo,
|
||||
result,
|
||||
});
|
||||
};
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import {getVerboseFunction} from './values.js';
|
||||
|
||||
// Apply the `verbose` function on each line
|
||||
export const applyVerboseOnLines = (printedLines, verboseInfo, fdNumber) => {
|
||||
const verboseFunction = getVerboseFunction(verboseInfo, fdNumber);
|
||||
return printedLines
|
||||
.map(({verboseLine, verboseObject}) => applyVerboseFunction(verboseLine, verboseObject, verboseFunction))
|
||||
.filter(printedLine => printedLine !== undefined)
|
||||
.map(printedLine => appendNewline(printedLine))
|
||||
.join('');
|
||||
};
|
||||
|
||||
const applyVerboseFunction = (verboseLine, verboseObject, verboseFunction) => {
|
||||
if (verboseFunction === undefined) {
|
||||
return verboseLine;
|
||||
}
|
||||
|
||||
const printedLine = verboseFunction(verboseLine, verboseObject);
|
||||
if (typeof printedLine === 'string') {
|
||||
return printedLine;
|
||||
}
|
||||
};
|
||||
|
||||
const appendNewline = printedLine => printedLine.endsWith('\n')
|
||||
? printedLine
|
||||
: `${printedLine}\n`;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user