276 lines
7.9 KiB
TypeScript
276 lines
7.9 KiB
TypeScript
import * as plugins from './plugins.js';
|
|
const __dirname = plugins.path.dirname(plugins.url.fileURLToPath(import.meta.url));
|
|
|
|
export interface IRunOptions {
|
|
cwd?: string;
|
|
}
|
|
|
|
export interface ISpawnOptions {
|
|
/** Working directory for the child process */
|
|
cwd?: string;
|
|
|
|
/** Environment variables (merged with parent's env) */
|
|
env?: Record<string, string>;
|
|
|
|
/** Additional CLI arguments to pass to the script */
|
|
args?: string[];
|
|
|
|
/**
|
|
* Stdio configuration
|
|
* - 'pipe': Create pipes for stdin/stdout/stderr (default)
|
|
* - 'inherit': Use parent's stdio
|
|
* - Array: Custom configuration per stream
|
|
*/
|
|
stdio?: 'pipe' | 'inherit' | ['pipe' | 'inherit' | 'ignore', 'pipe' | 'inherit' | 'ignore', 'pipe' | 'inherit' | 'ignore'];
|
|
|
|
/**
|
|
* Optional timeout in milliseconds
|
|
* If provided, process is automatically killed after timeout
|
|
*/
|
|
timeout?: number;
|
|
|
|
/**
|
|
* AbortSignal for cancellation support
|
|
* Allows external cancellation of the process
|
|
*/
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
export interface ITsrunChildProcess {
|
|
/** Direct access to Node's ChildProcess object */
|
|
childProcess: plugins.ChildProcess;
|
|
|
|
/** Readable stream for stdout (null if stdio is 'inherit') */
|
|
stdout: plugins.Readable | null;
|
|
|
|
/** Readable stream for stderr (null if stdio is 'inherit') */
|
|
stderr: plugins.Readable | null;
|
|
|
|
/** Promise that resolves with the exit code when process ends */
|
|
exitCode: Promise<number>;
|
|
|
|
/**
|
|
* Send signal to process
|
|
* Returns true if signal was sent successfully
|
|
*/
|
|
kill(signal?: NodeJS.Signals): boolean;
|
|
|
|
/**
|
|
* Gracefully terminate the process
|
|
* Tries SIGTERM first, waits 5s, then SIGKILL if still running
|
|
* Returns a promise that resolves when process is terminated
|
|
*/
|
|
terminate(): Promise<void>;
|
|
}
|
|
|
|
export const runPath = async (pathArg: string, fromFileUrl?: string, options?: IRunOptions) => {
|
|
pathArg = fromFileUrl
|
|
? plugins.path.join(plugins.path.dirname(plugins.url.fileURLToPath(fromFileUrl)), pathArg)
|
|
: pathArg;
|
|
await runCli(pathArg, options);
|
|
};
|
|
|
|
export const runCli = async (pathArg?: string, options?: IRunOptions) => {
|
|
// CRITICAL: Branch BEFORE splicing argv to avoid corruption
|
|
if (options?.cwd) {
|
|
return runInChildProcess(pathArg, options.cwd);
|
|
}
|
|
|
|
// Existing in-process execution
|
|
// contents of argv array
|
|
// process.argv[0] -> node Executable
|
|
// process.argv[1] -> tsrun executable
|
|
const relativePathToTsFile = pathArg ? pathArg : process.argv[2];
|
|
const absolutePathToTsFile = plugins.path.isAbsolute(relativePathToTsFile)
|
|
? relativePathToTsFile
|
|
: plugins.path.join(process.cwd(), relativePathToTsFile);
|
|
|
|
// we want to have command line arguments available in the child process.
|
|
// when we have a path sepcified through a function there is one argeument less to pay respect to.
|
|
// thus when pathArg is specifed -> we only splice 2
|
|
pathArg ? process.argv.splice(0, 2) : process.argv.splice(0, 3); // this ensures transparent arguments for the child process
|
|
|
|
const tsx = await import('tsx/esm/api');
|
|
const unregister = tsx.register();
|
|
await import(absolutePathToTsFile);
|
|
};
|
|
|
|
const runInChildProcess = async (pathArg: string | undefined, cwd: string): Promise<void> => {
|
|
const { spawn } = await import('child_process');
|
|
|
|
// Resolve cli.js relative to this file
|
|
const cliPath = plugins.path.join(__dirname, '../cli.js');
|
|
|
|
// Build args: [Node flags, entry point, script path, script args]
|
|
const args = [
|
|
...process.execArgv, // Preserve --inspect, etc.
|
|
cliPath,
|
|
...process.argv.slice(2) // Original CLI args (not spliced)
|
|
];
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(process.execPath, args, {
|
|
cwd: cwd,
|
|
env: process.env,
|
|
stdio: 'inherit',
|
|
shell: false,
|
|
windowsHide: false
|
|
});
|
|
|
|
// Signal forwarding with cleanup
|
|
const signalHandler = (signal: NodeJS.Signals) => {
|
|
try { child.kill(signal); } catch {}
|
|
};
|
|
|
|
const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
signals.forEach(sig => process.on(sig, signalHandler));
|
|
|
|
child.on('error', (err) => {
|
|
signals.forEach(sig => process.off(sig, signalHandler));
|
|
reject(err);
|
|
});
|
|
|
|
child.on('close', (code, signal) => {
|
|
// Clean up signal handlers
|
|
signals.forEach(sig => process.off(sig, signalHandler));
|
|
|
|
if (signal) {
|
|
// Child was terminated by signal
|
|
// On POSIX: try to exit with same signal
|
|
// On Windows: exit with convention (128 + signal number)
|
|
try {
|
|
process.kill(process.pid, signal);
|
|
} catch {
|
|
// Fallback to exit code
|
|
const signalExitCode = signal === 'SIGINT' ? 130 : 128;
|
|
process.exit(signalExitCode);
|
|
}
|
|
} else if (code !== null && code !== 0) {
|
|
process.exit(code);
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
export const spawnPath = (
|
|
filePath: string,
|
|
fromFileUrl?: string | URL,
|
|
options?: ISpawnOptions
|
|
): ITsrunChildProcess => {
|
|
// 1. Resolve path (similar to runPath)
|
|
const resolvedPath = fromFileUrl
|
|
? plugins.path.join(
|
|
plugins.path.dirname(
|
|
plugins.url.fileURLToPath(
|
|
typeof fromFileUrl === 'string' ? fromFileUrl : fromFileUrl.href
|
|
)
|
|
),
|
|
filePath
|
|
)
|
|
: filePath;
|
|
|
|
// 2. Build spawn args
|
|
const cliPath = plugins.path.join(__dirname, '../cli.js');
|
|
const args = [
|
|
...process.execArgv,
|
|
cliPath,
|
|
resolvedPath,
|
|
...(options?.args || [])
|
|
];
|
|
|
|
// 3. Build spawn options
|
|
const spawnOptions = {
|
|
cwd: options?.cwd || process.cwd(),
|
|
env: { ...process.env, ...options?.env },
|
|
stdio: options?.stdio || 'pipe',
|
|
shell: false,
|
|
windowsHide: false
|
|
};
|
|
|
|
// 4. Spawn child process
|
|
const child = plugins.spawn(process.execPath, args, spawnOptions);
|
|
|
|
// 5. Set up timeout if provided
|
|
let timeoutId: NodeJS.Timeout | undefined;
|
|
let timeoutTriggered = false;
|
|
|
|
if (options?.timeout) {
|
|
timeoutId = setTimeout(() => {
|
|
timeoutTriggered = true;
|
|
child.kill('SIGTERM');
|
|
}, options.timeout);
|
|
}
|
|
|
|
// 6. Set up AbortSignal if provided
|
|
let abortHandler: (() => void) | undefined;
|
|
if (options?.signal) {
|
|
abortHandler = () => {
|
|
child.kill('SIGTERM');
|
|
};
|
|
options.signal.addEventListener('abort', abortHandler);
|
|
}
|
|
|
|
// 7. Create exitCode promise
|
|
const exitCodePromise = new Promise<number>((resolve, reject) => {
|
|
child.on('close', (code: number | null, signal: NodeJS.Signals | null) => {
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
if (abortHandler && options?.signal) {
|
|
options.signal.removeEventListener('abort', abortHandler);
|
|
}
|
|
|
|
if (timeoutTriggered) {
|
|
reject(new Error(`Process killed: timeout of ${options?.timeout}ms exceeded`));
|
|
} else if (options?.signal?.aborted) {
|
|
reject(new Error('Process killed: aborted by signal'));
|
|
} else if (signal) {
|
|
reject(new Error(`Process killed with signal ${signal}`));
|
|
} else {
|
|
resolve(code || 0);
|
|
}
|
|
});
|
|
|
|
child.on('error', (err: Error) => {
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
if (abortHandler && options?.signal) {
|
|
options.signal.removeEventListener('abort', abortHandler);
|
|
}
|
|
reject(err);
|
|
});
|
|
});
|
|
|
|
// 8. Implement terminate() method
|
|
const terminate = async (): Promise<void> => {
|
|
return new Promise((resolve) => {
|
|
if (child.killed) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
child.kill('SIGTERM');
|
|
|
|
const killTimeout = setTimeout(() => {
|
|
if (!child.killed) {
|
|
child.kill('SIGKILL');
|
|
}
|
|
}, 5000);
|
|
|
|
child.on('close', () => {
|
|
clearTimeout(killTimeout);
|
|
resolve();
|
|
});
|
|
});
|
|
};
|
|
|
|
// 9. Return ITsrunChildProcess object
|
|
return {
|
|
childProcess: child,
|
|
stdout: child.stdout,
|
|
stderr: child.stderr,
|
|
exitCode: exitCodePromise,
|
|
kill: (signal?: NodeJS.Signals) => child.kill(signal),
|
|
terminate
|
|
};
|
|
};
|