From 4c3390aa4ca14e05b4b57385a8a52430e1ca6903 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Fri, 24 Jun 2022 13:51:25 +0000 Subject: [PATCH] Fix ESM node processes being unable to fork into other scripts Currently, Node processes instantiated through the `--esm` flag result in a child process being created so that the ESM loader can be registered. This works fine and is reasonable. The child process approach to register ESM hooks currently prevents the NodeJS `fork` method from being used because the `execArgv` propagated into forked processes causes `ts-node` (which is also propagated as child exec script -- this is good because it allows nested type resolution to work) to always execute the original entry-point, causing potential infinite loops because the designated fork module script is not executed as expected. This commit fixes this by not encoding the entry-point information into the state that is captured as part of the `execArgv`. Instead the entry-point information is always retrieved from the parsed rest command line arguments in the final stage (`phase4`). Additionally, this PR streamlines the boostrap mechanism to always call into the child script, resulting in reduced complexity, and also improved caching for user-initiated forked processes. i.e. the tsconfig resolution is not repeated multiple-times because forked processes are expected to preserve the existing ts-node project. More details can be found here #1831. Fixes #1812. --- src/bin.ts | 230 ++++++++++++------ src/child/child-entrypoint.ts | 19 +- src/child/child-exec-args.ts | 41 ++++ src/child/spawn-child.ts | 48 ++-- src/test/esm-loader.spec.ts | 52 ++-- src/test/helpers.ts | 4 + src/test/index.spec.ts | 53 ++-- src/test/repl/repl-environment.spec.ts | 4 +- .../index.ts | 4 - .../package.json | 0 .../tsconfig.json | 3 - .../worker.js | 0 .../index.mts} | 5 +- .../tsconfig.json | 8 + .../worker.mts} | 0 .../index.ts | 12 +- .../package.json | 0 .../tsconfig.json | 2 +- .../worker.ts | 0 .../process-forking-nested-relative/index.ts | 22 ++ .../package.json | 0 .../subfolder/worker.ts | 3 + .../tsconfig.json | 2 +- tests/project-resolution/a/index.ts | 9 + tests/project-resolution/a/tsconfig.json | 12 + tests/project-resolution/b/index.ts | 9 + tests/project-resolution/b/tsconfig.json | 12 + tests/working-dir/cjs/index.ts | 6 +- tests/working-dir/esm-node-next/index.ts | 11 + tests/working-dir/esm-node-next/package.json | 3 + tests/working-dir/esm-node-next/tsconfig.json | 5 + tests/working-dir/esm/index.ts | 13 +- tests/working-dir/esm/tsconfig.json | 2 +- 33 files changed, 390 insertions(+), 204 deletions(-) create mode 100644 src/child/child-exec-args.ts rename tests/esm-child-process/{process-forking-js => process-forking-js-worker}/index.ts (83%) rename tests/esm-child-process/{process-forking-js => process-forking-js-worker}/package.json (100%) rename tests/esm-child-process/{process-forking-ts-abs => process-forking-js-worker}/tsconfig.json (60%) rename tests/esm-child-process/{process-forking-js => process-forking-js-worker}/worker.js (100%) rename tests/esm-child-process/{process-forking-ts/index.ts => process-forking-nested-esm-node-next/index.mts} (84%) create mode 100644 tests/esm-child-process/process-forking-nested-esm-node-next/tsconfig.json rename tests/esm-child-process/{process-forking-ts-abs/subfolder/worker.ts => process-forking-nested-esm-node-next/worker.mts} (100%) rename tests/esm-child-process/{process-forking-ts-abs => process-forking-nested-esm}/index.ts (72%) rename tests/esm-child-process/{process-forking-ts-abs => process-forking-nested-esm}/package.json (100%) rename tests/esm-child-process/{process-forking-ts => process-forking-nested-esm}/tsconfig.json (74%) rename tests/esm-child-process/{process-forking-ts/subfolder => process-forking-nested-esm}/worker.ts (100%) create mode 100644 tests/esm-child-process/process-forking-nested-relative/index.ts rename tests/esm-child-process/{process-forking-ts => process-forking-nested-relative}/package.json (100%) create mode 100644 tests/esm-child-process/process-forking-nested-relative/subfolder/worker.ts rename tests/esm-child-process/{process-forking-js => process-forking-nested-relative}/tsconfig.json (74%) create mode 100644 tests/project-resolution/a/index.ts create mode 100644 tests/project-resolution/a/tsconfig.json create mode 100644 tests/project-resolution/b/index.ts create mode 100644 tests/project-resolution/b/tsconfig.json create mode 100644 tests/working-dir/esm-node-next/index.ts create mode 100644 tests/working-dir/esm-node-next/package.json create mode 100644 tests/working-dir/esm-node-next/tsconfig.json diff --git a/src/bin.ts b/src/bin.ts index fb3208c48..f78359020 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -30,6 +30,7 @@ import type { TSInternal } from './ts-compiler-types'; import { addBuiltinLibsToObject } from '../dist-raw/node-internal-modules-cjs-helpers'; import { callInChild } from './child/spawn-child'; import { findAndReadConfig } from './configuration'; +import { getChildProcessArguments } from './child/child-exec-args'; /** * Main `bin` functionality. @@ -46,10 +47,6 @@ export function main( ) { const args = parseArgv(argv, entrypointArgs); const state: BootstrapState = { - shouldUseChildProcess: false, - isInChildProcess: false, - isCli: true, - tsNodeScript: __filename, parseArgvResult: args, }; return bootstrap(state); @@ -61,37 +58,77 @@ export function main( * Can be marshalled when necessary to resume bootstrapping in a child process. */ export interface BootstrapState { - isInChildProcess: boolean; - shouldUseChildProcess: boolean; - /** - * True if bootstrapping the ts-node CLI process or the direct child necessitated by `--esm`. - * false if bootstrapping a subsequently `fork()`ed child. - */ - isCli: boolean; - tsNodeScript: string; parseArgvResult: ReturnType; phase2Result?: ReturnType; phase3Result?: ReturnType; } +/** + * Bootstrap state that is passed to the child process used to execute + * the final bootstrap phase. + * + * This state may be encoded in process command line arguments and should + * only capture information that should be persisted to e.g. forked child processes. + */ +export interface BootstrapStateForForkedProcesses { + // For the final bootstrap we are only interested in the user arguments + // that should be passed to the entry-point script (or eval script). + // We don't want to encode any options that would break child forking. e.g. + // persisting the `--eval` option would break `child_process.fork` in scripts. + parseArgvResult: Pick, 'restArgs'>; + phase3Result: Pick< + ReturnType, + 'enableEsmLoader' | 'preloadedConfig' + >; +} + +export interface BootstrapStateInitialProcessChild + extends Omit { + initialProcessOptions: { resolutionCwd: string } & Pick< + ReturnType, + // These are options which should not persist into forked child processes, + // but can be passed-through in the initial child process creation -- but should + // not be encoded in the Brotli state for child process forks (through `execArgv`.) + 'version' | 'showConfig' | 'code' | 'print' | 'interactive' + >; +} + +export type BootstrapStateForChild = Omit< + BootstrapStateForForkedProcesses, + 'initialProcessOptions' +> & + Partial; + /** @internal */ export function bootstrap(state: BootstrapState) { - if (!state.phase2Result) { - state.phase2Result = phase2(state); - if (state.shouldUseChildProcess && !state.isInChildProcess) { - // Note: When transitioning into the child-process after `phase2`, - // the updated working directory needs to be preserved. - return callInChild(state); - } - } - if (!state.phase3Result) { - state.phase3Result = phase3(state); - if (state.shouldUseChildProcess && !state.isInChildProcess) { - // Note: When transitioning into the child-process after `phase2`, - // the updated working directory needs to be preserved. - return callInChild(state); - } - } + state.phase2Result = phase2(state); + state.phase3Result = phase3(state); + + const initialChildState: BootstrapStateInitialProcessChild = { + ...createBootstrapStateForChildProcess(state as Required), + // Aside with the default child process state, we attach the initial process + // options since this `callInChild` invocation is from the initial process. + // Later when forking, the initial process options are omitted / not persisted. + initialProcessOptions: { + code: state.parseArgvResult.code, + interactive: state.parseArgvResult.interactive, + print: state.parseArgvResult.print, + showConfig: state.parseArgvResult.showConfig, + version: state.parseArgvResult.version, + resolutionCwd: state.phase2Result.resolutionCwd, + }, + }; + + // Note: When transitioning into the child process for the final phase, + // we want to preserve the initial user working directory. + callInChild( + initialChildState, + state.phase3Result.enableEsmLoader, + process.cwd() + ); +} +/** Final phase of the bootstrap. */ +export function completeBootstrap(state: BootstrapStateForChild) { return phase4(state); } @@ -274,7 +311,7 @@ function parseArgv(argv: string[], entrypointArgs: Record) { } function phase2(payload: BootstrapState) { - const { help, version, cwdArg, esm } = payload.parseArgvResult; + const { help, version, cwdArg } = payload.parseArgvResult; if (help) { console.log(` @@ -328,14 +365,15 @@ Options: process.exit(0); } - const cwd = cwdArg ? resolve(cwdArg) : process.cwd(); - - // If ESM is explicitly enabled through the flag, stage3 should be run in a child process - // with the ESM loaders configured. - if (esm) payload.shouldUseChildProcess = true; + let resolutionCwd: string; + if (cwdArg !== undefined) { + resolutionCwd = resolve(cwdArg); + } else { + resolutionCwd = process.cwd(); + } return { - cwd, + resolutionCwd, }; } @@ -367,7 +405,7 @@ function phase3(payload: BootstrapState) { esm, experimentalSpecifierResolution, } = payload.parseArgvResult; - const { cwd } = payload.phase2Result!; + const { resolutionCwd } = payload.phase2Result!; // NOTE: When we transition to a child process for ESM, the entry-point script determined // here might not be the one used later in `phase4`. This can happen when we execute the @@ -375,10 +413,13 @@ function phase3(payload: BootstrapState) { // We will always use the original TS project in forked processes anyway, so it is // expected and acceptable to retrieve the entry-point information here in `phase2`. // See: https://github.com/TypeStrong/ts-node/issues/1812. - const { entryPointPath } = getEntryPointInfo(payload); + const { entryPointPath } = getEntryPointInfo( + resolutionCwd, + payload.parseArgvResult! + ); const preloadedConfig = findAndReadConfig({ - cwd, + cwd: resolutionCwd, emit, files, pretty, @@ -391,7 +432,7 @@ function phase3(payload: BootstrapState) { ignore, logError, projectSearchDir: getProjectSearchDir( - cwd, + resolutionCwd, scriptMode, cwdMode, entryPointPath @@ -411,11 +452,10 @@ function phase3(payload: BootstrapState) { experimentalSpecifierResolution as ExperimentalSpecifierResolution, }); - // If ESM is enabled through the parsed tsconfig, stage4 should be run in a child - // process with the ESM loaders configured. - if (preloadedConfig.options.esm) payload.shouldUseChildProcess = true; - - return { preloadedConfig }; + return { + preloadedConfig, + enableEsmLoader: !!(preloadedConfig.options.esm || esm), + }; } /** @@ -433,10 +473,15 @@ function phase3(payload: BootstrapState) { * configuration and entry-point information is only reliable in the final phase. More * details can be found in here: https://github.com/TypeStrong/ts-node/issues/1812. */ -function getEntryPointInfo(state: BootstrapState) { - const { code, interactive, restArgs } = state.parseArgvResult!; - const { cwd } = state.phase2Result!; - const { isCli } = state; +function getEntryPointInfo( + resolutionCwd: string, + argvResult: { + code: string | undefined; + interactive: boolean | undefined; + restArgs: string[]; + } +) { + const { code, interactive, restArgs } = argvResult; // Figure out which we are executing: piped stdin, --eval, REPL, and/or entrypoint // This is complicated because node's behavior is complicated @@ -448,14 +493,9 @@ function getEntryPointInfo(state: BootstrapState) { (interactive || (process.stdin.isTTY && !executeEval)); const executeStdin = !executeEval && !executeRepl && !executeEntrypoint; - /** - * Unresolved. May point to a symlink, not realpath. May be missing file extension - * NOTE: resolution relative to cwd option (not `process.cwd()`) is legacy backwards-compat; should be changed in next major: https://github.com/TypeStrong/ts-node/issues/1834 - */ + /** Unresolved. May point to a symlink, not realpath. May be missing file extension */ const entryPointPath = executeEntrypoint - ? isCli - ? resolve(cwd, restArgs[0]) - : resolve(restArgs[0]) + ? resolve(resolutionCwd, restArgs[0]) : undefined; return { @@ -467,12 +507,11 @@ function getEntryPointInfo(state: BootstrapState) { }; } -function phase4(payload: BootstrapState) { - const { isInChildProcess, tsNodeScript } = payload; - const { version, showConfig, restArgs, code, print, argv } = - payload.parseArgvResult; - const { cwd } = payload.phase2Result!; - const { preloadedConfig } = payload.phase3Result!; +function phase4(payload: BootstrapStateForChild) { + const { restArgs } = payload.parseArgvResult; + const { preloadedConfig } = payload.phase3Result; + const resolutionCwd = + payload.initialProcessOptions?.resolutionCwd ?? process.cwd(); const { entryPointPath, @@ -480,7 +519,11 @@ function phase4(payload: BootstrapState) { executeEval, executeRepl, executeStdin, - } = getEntryPointInfo(payload); + } = getEntryPointInfo(resolutionCwd, { + code: payload.initialProcessOptions?.code, + interactive: payload.initialProcessOptions?.interactive, + restArgs: payload.parseArgvResult.restArgs, + }); /** * , [stdin], and [eval] are all essentially virtual files that do not exist on disc and are backed by a REPL @@ -496,7 +539,7 @@ function phase4(payload: BootstrapState) { let stdinStuff: VirtualFileState | undefined; let evalAwarePartialHost: EvalAwarePartialHost | undefined = undefined; if (executeEval) { - const state = new EvalState(join(cwd, EVAL_FILENAME)); + const state = new EvalState(join(resolutionCwd, EVAL_FILENAME)); evalStuff = { state, repl: createRepl({ @@ -509,10 +552,10 @@ function phase4(payload: BootstrapState) { // Create a local module instance based on `cwd`. const module = (evalStuff.module = new Module(EVAL_NAME)); module.filename = evalStuff.state.path; - module.paths = (Module as any)._nodeModulePaths(cwd); + module.paths = (Module as any)._nodeModulePaths(resolutionCwd); } if (executeStdin) { - const state = new EvalState(join(cwd, STDIN_FILENAME)); + const state = new EvalState(join(resolutionCwd, STDIN_FILENAME)); stdinStuff = { state, repl: createRepl({ @@ -525,10 +568,10 @@ function phase4(payload: BootstrapState) { // Create a local module instance based on `cwd`. const module = (stdinStuff.module = new Module(STDIN_NAME)); module.filename = stdinStuff.state.path; - module.paths = (Module as any)._nodeModulePaths(cwd); + module.paths = (Module as any)._nodeModulePaths(resolutionCwd); } if (executeRepl) { - const state = new EvalState(join(cwd, REPL_FILENAME)); + const state = new EvalState(join(resolutionCwd, REPL_FILENAME)); replStuff = { state, repl: createRepl({ @@ -552,7 +595,8 @@ function phase4(payload: BootstrapState) { }, }); register(service); - if (isInChildProcess) + + if (payload.phase3Result.enableEsmLoader) ( require('./child/child-loader') as typeof import('./child/child-loader') ).lateBindHooks(createEsmHooks(service)); @@ -563,13 +607,13 @@ function phase4(payload: BootstrapState) { stdinStuff?.repl.setService(service); // Output project information. - if (version === 2) { + if (payload.initialProcessOptions?.version === 2) { console.log(`ts-node v${VERSION}`); console.log(`node ${process.version}`); console.log(`compiler v${service.ts.version}`); process.exit(0); } - if (version >= 3) { + if ((payload.initialProcessOptions?.version ?? 0) >= 3) { console.log(`ts-node v${VERSION} ${dirname(__dirname)}`); console.log(`node ${process.version}`); console.log( @@ -578,7 +622,7 @@ function phase4(payload: BootstrapState) { process.exit(0); } - if (showConfig) { + if (payload.initialProcessOptions?.showConfig) { const ts = service.ts as any as TSInternal; if (typeof ts.convertToTSConfig !== 'function') { console.error( @@ -613,7 +657,8 @@ function phase4(payload: BootstrapState) { }, ...ts.convertToTSConfig( service.config, - service.configFilePath ?? join(cwd, 'ts-node-implicit-tsconfig.json'), + service.configFilePath ?? + join(resolutionCwd, 'ts-node-implicit-tsconfig.json'), service.ts.sys ), }; @@ -626,12 +671,21 @@ function phase4(payload: BootstrapState) { process.exit(0); } - // Prepend `ts-node` arguments to CLI for child processes. - process.execArgv.push( - tsNodeScript, - ...argv.slice(2, argv.length - restArgs.length) + const forkPersistentBootstrapState: BootstrapStateForForkedProcesses = + createBootstrapStateForChildProcess(payload); + + const { childScriptPath, childScriptArgs } = getChildProcessArguments( + payload.phase3Result.enableEsmLoader, + forkPersistentBootstrapState ); + // Append the child script path and arguments to the process `execArgv`. + // The final phase is always invoked with Node directly, but subsequent + // forked instances (of the user entry-point) should directly jump into + // the final phase by landing directly in the child script with the Brotli + // encoded bootstrap state (as computed above with `forkPersistentBootstrapState`). + process.execArgv.push(childScriptPath, ...childScriptArgs); + // TODO this comes from BootstrapState process.argv = [process.argv[1]] .concat(executeEntrypoint ? ([entryPointPath] as string[]) : []) @@ -648,8 +702,8 @@ function phase4(payload: BootstrapState) { evalAndExitOnTsError( evalStuff!.repl, evalStuff!.module!, - code!, - print, + payload.initialProcessOptions!.code!, + payload.initialProcessOptions!.print, 'eval' ); } @@ -659,7 +713,7 @@ function phase4(payload: BootstrapState) { } if (executeStdin) { - let buffer = code || ''; + let buffer = payload.initialProcessOptions?.code ?? ''; process.stdin.on('data', (chunk: Buffer) => (buffer += chunk)); process.stdin.on('end', () => { evalAndExitOnTsError( @@ -667,7 +721,7 @@ function phase4(payload: BootstrapState) { stdinStuff!.module!, buffer, // `echo 123 | node -p` still prints 123 - print, + payload.initialProcessOptions?.print ?? false, 'stdin' ); }); @@ -675,6 +729,22 @@ function phase4(payload: BootstrapState) { } } +function createBootstrapStateForChildProcess( + state: BootstrapStateInitialProcessChild | BootstrapStateForForkedProcesses +): BootstrapStateForForkedProcesses { + // NOTE: Build up the child process fork bootstrap state manually so that we do + // not encode unnecessary properties into the bootstrap state that is persisted + return { + parseArgvResult: { + restArgs: state.parseArgvResult.restArgs, + }, + phase3Result: { + enableEsmLoader: state.phase3Result!.enableEsmLoader, + preloadedConfig: state.phase3Result!.preloadedConfig, + }, + }; +} + /** * Get project search path from args. */ diff --git a/src/child/child-entrypoint.ts b/src/child/child-entrypoint.ts index 0550170bf..1e5e874e9 100644 --- a/src/child/child-entrypoint.ts +++ b/src/child/child-entrypoint.ts @@ -1,24 +1,11 @@ -import { BootstrapState, bootstrap } from '../bin'; +import { completeBootstrap, BootstrapStateForChild } from '../bin'; import { argPrefix, compress, decompress } from './argv-payload'; const base64ConfigArg = process.argv[2]; if (!base64ConfigArg.startsWith(argPrefix)) throw new Error('unexpected argv'); const base64Payload = base64ConfigArg.slice(argPrefix.length); -const state = decompress(base64Payload) as BootstrapState; +const state = decompress(base64Payload) as BootstrapStateForChild; -state.isInChildProcess = true; -state.tsNodeScript = __filename; -state.parseArgvResult.argv = process.argv; state.parseArgvResult.restArgs = process.argv.slice(3); -// Modify and re-compress the payload delivered to subsequent child processes. -// This logic may be refactored into bin.ts by https://github.com/TypeStrong/ts-node/issues/1831 -if (state.isCli) { - const stateForChildren: BootstrapState = { - ...state, - isCli: false, - }; - state.parseArgvResult.argv[2] = `${argPrefix}${compress(stateForChildren)}`; -} - -bootstrap(state); +completeBootstrap(state); diff --git a/src/child/child-exec-args.ts b/src/child/child-exec-args.ts new file mode 100644 index 000000000..3a25aa505 --- /dev/null +++ b/src/child/child-exec-args.ts @@ -0,0 +1,41 @@ +import { pathToFileURL } from 'url'; +import { brotliCompressSync } from 'zlib'; +import type { BootstrapStateForChild } from '../bin'; +import { versionGteLt } from '../util'; + +const argPrefix = '--brotli-base64-config='; + +export function getChildProcessArguments( + enableEsmLoader: boolean, + state: BootstrapStateForChild +) { + if (enableEsmLoader && !versionGteLt(process.versions.node, '12.17.0')) { + throw new Error( + '`ts-node-esm` and `ts-node --esm` require node version 12.17.0 or newer.' + ); + } + + const nodeExecArgs = []; + + if (enableEsmLoader) { + nodeExecArgs.push( + '--require', + require.resolve('./child-require.js'), + '--loader', + // Node on Windows doesn't like `c:\` absolute paths here; must be `file:///c:/` + pathToFileURL(require.resolve('../../child-loader.mjs')).toString() + ); + } + + const childScriptArgs = [ + `${argPrefix}${brotliCompressSync( + Buffer.from(JSON.stringify(state), 'utf8') + ).toString('base64')}`, + ]; + + return { + nodeExecArgs, + childScriptArgs, + childScriptPath: require.resolve('./child-entrypoint.js'), + }; +} diff --git a/src/child/spawn-child.ts b/src/child/spawn-child.ts index 618b8190a..446be06b0 100644 --- a/src/child/spawn-child.ts +++ b/src/child/spawn-child.ts @@ -1,38 +1,30 @@ -import type { BootstrapState } from '../bin'; -import { spawn } from 'child_process'; -import { pathToFileURL } from 'url'; -import { versionGteLt } from '../util'; -import { argPrefix, compress } from './argv-payload'; +import { fork } from 'child_process'; +import type { BootstrapStateForChild } from '../bin'; +import { getChildProcessArguments } from './child-exec-args'; /** * @internal * @param state Bootstrap state to be transferred into the child process. + * @param enableEsmLoader Whether to enable the ESM loader or not. This option may + * be removed in the future when `--esm` is no longer a choice. * @param targetCwd Working directory to be preserved when transitioning to * the child process. */ -export function callInChild(state: BootstrapState) { - if (!versionGteLt(process.versions.node, '12.17.0')) { - throw new Error( - '`ts-node-esm` and `ts-node --esm` require node version 12.17.0 or newer.' - ); - } - const child = spawn( - process.execPath, - [ - '--require', - require.resolve('./child-require.js'), - '--loader', - // Node on Windows doesn't like `c:\` absolute paths here; must be `file:///c:/` - pathToFileURL(require.resolve('../../child-loader.mjs')).toString(), - require.resolve('./child-entrypoint.js'), - `${argPrefix}${compress(state)}`, - ...state.parseArgvResult.restArgs, - ], - { - stdio: 'inherit', - argv0: process.argv0, - } - ); +export function callInChild( + state: BootstrapStateForChild, + enableEsmLoader: boolean, + targetCwd: string +) { + const { childScriptArgs, childScriptPath, nodeExecArgs } = + getChildProcessArguments(enableEsmLoader, state); + + childScriptArgs.push(...state.parseArgvResult.restArgs); + + const child = fork(childScriptPath, childScriptArgs, { + stdio: 'inherit', + execArgv: [...process.execArgv, ...nodeExecArgs], + cwd: targetCwd, + }); child.on('error', (error) => { console.error(error); process.exit(1); diff --git a/src/test/esm-loader.spec.ts b/src/test/esm-loader.spec.ts index 375012a76..e53906e68 100644 --- a/src/test/esm-loader.spec.ts +++ b/src/test/esm-loader.spec.ts @@ -362,10 +362,8 @@ test.suite('esm', (test) => { test.suite('esm child process working directory', (test) => { test('should have the correct working directory in the user entry-point', async () => { const { err, stdout, stderr } = await exec( - `${BIN_PATH} --esm --cwd ./esm/ index.ts`, - { - cwd: resolve(TEST_DIR, 'working-dir'), - } + `${BIN_PATH} --esm index.ts`, + { cwd: './working-dir/esm/' } ); expect(err).toBe(null); @@ -377,7 +375,8 @@ test.suite('esm', (test) => { test.suite('esm child process and forking', (test) => { test('should be able to fork vanilla NodeJS script', async () => { const { err, stdout, stderr } = await exec( - `${BIN_PATH} --esm --cwd ./esm-child-process/ ./process-forking-js/index.ts` + `${BIN_PATH} --esm index.ts`, + { cwd: './esm-child-process/process-forking-js-worker/' } ); expect(err).toBe(null); @@ -385,9 +384,10 @@ test.suite('esm', (test) => { expect(stderr).toBe(''); }); - test('should be able to fork TypeScript script', async () => { + test('should be able to fork into a nested TypeScript ESM script', async () => { const { err, stdout, stderr } = await exec( - `${BIN_PATH} --esm --cwd ./esm-child-process/ ./process-forking-ts/index.ts` + `${BIN_PATH} --esm index.ts`, + { cwd: './esm-child-process/process-forking-nested-esm/' } ); expect(err).toBe(null); @@ -395,15 +395,37 @@ test.suite('esm', (test) => { expect(stderr).toBe(''); }); - test('should be able to fork TypeScript script by absolute path', async () => { - const { err, stdout, stderr } = await exec( - `${BIN_PATH} --esm --cwd ./esm-child-process/ ./process-forking-ts-abs/index.ts` - ); + test( + 'should be possible to fork into a nested TypeScript script with respect to ' + + 'the working directory', + async () => { + const { err, stdout, stderr } = await exec( + `${BIN_PATH} --esm index.ts`, + { cwd: './esm-child-process/process-forking-nested-relative/' } + ); - expect(err).toBe(null); - expect(stdout.trim()).toBe('Passing: from main'); - expect(stderr).toBe(''); - }); + expect(err).toBe(null); + expect(stdout.trim()).toBe('Passing: from main'); + expect(stderr).toBe(''); + } + ); + + test.suite( + 'with NodeNext TypeScript resolution and `.mts` extension', + (test) => { + test.runIf(tsSupportsStableNodeNextNode16); + + test('should be able to fork into a nested TypeScript ESM script', async () => { + const { err, stdout, stderr } = await exec( + `${BIN_PATH} --esm ./esm-child-process/process-forking-nested-esm-node-next/index.mts` + ); + + expect(err).toBe(null); + expect(stdout.trim()).toBe('Passing: from main'); + expect(stderr).toBe(''); + }); + } + ); }); test.suite('parent passes signals to child', (test) => { diff --git a/src/test/helpers.ts b/src/test/helpers.ts index da86bddc2..20916a9f7 100644 --- a/src/test/helpers.ts +++ b/src/test/helpers.ts @@ -33,6 +33,10 @@ export const BIN_SCRIPT_PATH = join( TEST_DIR, 'node_modules/.bin/ts-node-script' ); +export const CHILD_ENTRY_POINT_SCRIPT = join( + TEST_DIR, + 'node_modules/ts-node/dist/child/child-entrypoint.js' +); export const BIN_CWD_PATH = join(TEST_DIR, 'node_modules/.bin/ts-node-cwd'); export const BIN_ESM_PATH = join(TEST_DIR, 'node_modules/.bin/ts-node-esm'); diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index f085a3639..5c4a865c7 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'os'; import semver = require('semver'); import { BIN_PATH_JS, + CHILD_ENTRY_POINT_SCRIPT, CMD_TS_NODE_WITH_PROJECT_TRANSPILE_ONLY_FLAG, nodeSupportsEsmHooks, nodeSupportsSpawningChildProcess, @@ -619,10 +620,7 @@ test.suite('ts-node', (test) => { test('should have the correct working directory in the user entry-point', async () => { const { err, stdout, stderr } = await exec( - `${BIN_PATH} --cwd ./cjs index.ts`, - { - cwd: resolve(TEST_DIR, 'working-dir'), - } + `${BIN_PATH} --cwd ./working-dir/cjs/ index.ts` ); expect(err).toBe(null); @@ -630,19 +628,19 @@ test.suite('ts-node', (test) => { expect(stderr).toBe(''); }); - // Disabled due to bug: - // --cwd is passed to forked children when not using --esm, erroneously affects their entrypoint resolution. - // tracked/fixed by either https://github.com/TypeStrong/ts-node/issues/1834 - // or https://github.com/TypeStrong/ts-node/issues/1831 - test.skip('should be able to fork into a nested TypeScript script with a modified working directory', async () => { - const { err, stdout, stderr } = await exec( - `${BIN_PATH} --cwd ./working-dir/forking/ index.ts` - ); + test( + 'should be able to fork into a nested TypeScript script with a modified ' + + 'working directory', + async () => { + const { err, stdout, stderr } = await exec( + `${BIN_PATH} --cwd ./working-dir/forking/ index.ts` + ); - expect(err).toBe(null); - expect(stdout.trim()).toBe('Passing: from main'); - expect(stderr).toBe(''); - }); + expect(err).toBe(null); + expect(stdout.trim()).toBe('Passing: from main'); + expect(stderr).toBe(''); + } + ); test.suite('should read ts-node options from tsconfig.json', (test) => { const BIN_EXEC = `"${BIN_PATH}" --project tsconfig-options/tsconfig.json`; @@ -1132,17 +1130,10 @@ test('Falls back to transpileOnly when ts compiler returns emitSkipped', async ( test.suite('node environment', (test) => { test.suite('Sets argv and execArgv correctly in forked processes', (test) => { - forkTest(`node --no-warnings ${BIN_PATH_JS}`, BIN_PATH_JS, '--no-warnings'); - forkTest( - `${BIN_PATH}`, - process.platform === 'win32' ? BIN_PATH_JS : BIN_PATH - ); + forkTest(`node --no-warnings ${BIN_PATH_JS}`, '--no-warnings'); + forkTest(`${BIN_PATH}`); - function forkTest( - command: string, - expectParentArgv0: string, - nodeFlag?: string - ) { + function forkTest(command: string, nodeFlag?: string) { test(command, async (t) => { const { err, stderr, stdout } = await exec( `${command} --skipIgnore ./recursive-fork/index.ts argv2` @@ -1151,16 +1142,18 @@ test.suite('node environment', (test) => { expect(stderr).toBe(''); const generations = stdout.split('\n'); const expectation = { - execArgv: [nodeFlag, BIN_PATH_JS, '--skipIgnore'].filter((v) => v), + execArgv: [ + nodeFlag, + CHILD_ENTRY_POINT_SCRIPT, + expect.stringMatching(/^--brotli-base64-config=.*/), + ].filter((v) => v), argv: [ - // Note: argv[0] is *always* BIN_PATH_JS in child & grandchild - expectParentArgv0, + CHILD_ENTRY_POINT_SCRIPT, resolve(TEST_DIR, 'recursive-fork/index.ts'), 'argv2', ], }; expect(JSON.parse(generations[0])).toMatchObject(expectation); - expectation.argv[0] = BIN_PATH_JS; expect(JSON.parse(generations[1])).toMatchObject(expectation); expect(JSON.parse(generations[2])).toMatchObject(expectation); }); diff --git a/src/test/repl/repl-environment.spec.ts b/src/test/repl/repl-environment.spec.ts index d02341f1e..981e05567 100644 --- a/src/test/repl/repl-environment.spec.ts +++ b/src/test/repl/repl-environment.spec.ts @@ -6,6 +6,7 @@ import { context, expect } from '../testlib'; import * as getStream from 'get-stream'; import { + CHILD_ENTRY_POINT_SCRIPT, CMD_TS_NODE_WITH_PROJECT_FLAG, ctxTsNode, delay, @@ -145,8 +146,7 @@ test.suite( return modulePaths; } - // Executable is `ts-node` on Posix, `bin.js` on Windows due to Windows shimming limitations (this is determined by package manager) - const tsNodeExe = expect.stringMatching(/\b(ts-node|bin.js)$/); + const tsNodeExe = CHILD_ENTRY_POINT_SCRIPT; test('stdin', async (t) => { const { stdout } = await execTester({ diff --git a/tests/esm-child-process/process-forking-js/index.ts b/tests/esm-child-process/process-forking-js-worker/index.ts similarity index 83% rename from tests/esm-child-process/process-forking-js/index.ts rename to tests/esm-child-process/process-forking-js-worker/index.ts index 88a3bd61a..2e3e05ec8 100644 --- a/tests/esm-child-process/process-forking-js/index.ts +++ b/tests/esm-child-process/process-forking-js-worker/index.ts @@ -1,13 +1,9 @@ import { fork } from 'child_process'; -import { dirname } from 'path'; -import { fileURLToPath } from 'url'; // Initially set the exit code to non-zero. We only set it to `0` when the // worker process finishes properly with the expected stdout message. process.exitCode = 1; -process.chdir(dirname(fileURLToPath(import.meta.url))); - const workerProcess = fork('./worker.js', [], { stdio: 'pipe', }); diff --git a/tests/esm-child-process/process-forking-js/package.json b/tests/esm-child-process/process-forking-js-worker/package.json similarity index 100% rename from tests/esm-child-process/process-forking-js/package.json rename to tests/esm-child-process/process-forking-js-worker/package.json diff --git a/tests/esm-child-process/process-forking-ts-abs/tsconfig.json b/tests/esm-child-process/process-forking-js-worker/tsconfig.json similarity index 60% rename from tests/esm-child-process/process-forking-ts-abs/tsconfig.json rename to tests/esm-child-process/process-forking-js-worker/tsconfig.json index 04e93e5c7..1ac61592b 100644 --- a/tests/esm-child-process/process-forking-ts-abs/tsconfig.json +++ b/tests/esm-child-process/process-forking-js-worker/tsconfig.json @@ -1,8 +1,5 @@ { "compilerOptions": { "module": "ESNext" - }, - "ts-node": { - "swc": true } } diff --git a/tests/esm-child-process/process-forking-js/worker.js b/tests/esm-child-process/process-forking-js-worker/worker.js similarity index 100% rename from tests/esm-child-process/process-forking-js/worker.js rename to tests/esm-child-process/process-forking-js-worker/worker.js diff --git a/tests/esm-child-process/process-forking-ts/index.ts b/tests/esm-child-process/process-forking-nested-esm-node-next/index.mts similarity index 84% rename from tests/esm-child-process/process-forking-ts/index.ts rename to tests/esm-child-process/process-forking-nested-esm-node-next/index.mts index 2d59e0aab..e76286d8e 100644 --- a/tests/esm-child-process/process-forking-ts/index.ts +++ b/tests/esm-child-process/process-forking-nested-esm-node-next/index.mts @@ -6,9 +6,8 @@ import { fileURLToPath } from 'url'; // worker process finishes properly with the expected stdout message. process.exitCode = 1; -process.chdir(join(dirname(fileURLToPath(import.meta.url)), 'subfolder')); - -const workerProcess = fork('./worker.ts', [], { +const projectDir = dirname(fileURLToPath(import.meta.url)); +const workerProcess = fork(join(projectDir, 'worker.mts'), [], { stdio: 'pipe', }); diff --git a/tests/esm-child-process/process-forking-nested-esm-node-next/tsconfig.json b/tests/esm-child-process/process-forking-nested-esm-node-next/tsconfig.json new file mode 100644 index 000000000..0b0d8971d --- /dev/null +++ b/tests/esm-child-process/process-forking-nested-esm-node-next/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "module": "NodeNext" + }, + "ts-node": { + "transpileOnly": true + } +} diff --git a/tests/esm-child-process/process-forking-ts-abs/subfolder/worker.ts b/tests/esm-child-process/process-forking-nested-esm-node-next/worker.mts similarity index 100% rename from tests/esm-child-process/process-forking-ts-abs/subfolder/worker.ts rename to tests/esm-child-process/process-forking-nested-esm-node-next/worker.mts diff --git a/tests/esm-child-process/process-forking-ts-abs/index.ts b/tests/esm-child-process/process-forking-nested-esm/index.ts similarity index 72% rename from tests/esm-child-process/process-forking-ts-abs/index.ts rename to tests/esm-child-process/process-forking-nested-esm/index.ts index ec94e846d..ff0f2e61b 100644 --- a/tests/esm-child-process/process-forking-ts-abs/index.ts +++ b/tests/esm-child-process/process-forking-nested-esm/index.ts @@ -1,18 +1,12 @@ import { fork } from 'child_process'; -import { dirname, join } from 'path'; -import { fileURLToPath } from 'url'; // Initially set the exit code to non-zero. We only set it to `0` when the // worker process finishes properly with the expected stdout message. process.exitCode = 1; -const workerProcess = fork( - join(dirname(fileURLToPath(import.meta.url)), 'subfolder/worker.ts'), - [], - { - stdio: 'pipe', - } -); +const workerProcess = fork('./worker.ts', [], { + stdio: 'pipe', +}); let stdout = ''; diff --git a/tests/esm-child-process/process-forking-ts-abs/package.json b/tests/esm-child-process/process-forking-nested-esm/package.json similarity index 100% rename from tests/esm-child-process/process-forking-ts-abs/package.json rename to tests/esm-child-process/process-forking-nested-esm/package.json diff --git a/tests/esm-child-process/process-forking-ts/tsconfig.json b/tests/esm-child-process/process-forking-nested-esm/tsconfig.json similarity index 74% rename from tests/esm-child-process/process-forking-ts/tsconfig.json rename to tests/esm-child-process/process-forking-nested-esm/tsconfig.json index 04e93e5c7..f16e691d2 100644 --- a/tests/esm-child-process/process-forking-ts/tsconfig.json +++ b/tests/esm-child-process/process-forking-nested-esm/tsconfig.json @@ -3,6 +3,6 @@ "module": "ESNext" }, "ts-node": { - "swc": true + "transpileOnly": true } } diff --git a/tests/esm-child-process/process-forking-ts/subfolder/worker.ts b/tests/esm-child-process/process-forking-nested-esm/worker.ts similarity index 100% rename from tests/esm-child-process/process-forking-ts/subfolder/worker.ts rename to tests/esm-child-process/process-forking-nested-esm/worker.ts diff --git a/tests/esm-child-process/process-forking-nested-relative/index.ts b/tests/esm-child-process/process-forking-nested-relative/index.ts new file mode 100644 index 000000000..e0b27f3cf --- /dev/null +++ b/tests/esm-child-process/process-forking-nested-relative/index.ts @@ -0,0 +1,22 @@ +import { fork } from 'child_process'; +import { join } from 'path'; + +// Initially set the exit code to non-zero. We only set it to `0` when the +// worker process finishes properly with the expected stdout message. +process.exitCode = 1; + +const workerProcess = fork('./worker.ts', [], { + stdio: 'pipe', + cwd: join(process.cwd(), 'subfolder/'), +}); + +let stdout = ''; + +workerProcess.stdout.on('data', (chunk) => (stdout += chunk.toString('utf8'))); +workerProcess.on('error', () => (process.exitCode = 1)); +workerProcess.on('close', (status, signal) => { + if (status === 0 && signal === null && stdout.trim() === 'Works') { + console.log('Passing: from main'); + process.exitCode = 0; + } +}); diff --git a/tests/esm-child-process/process-forking-ts/package.json b/tests/esm-child-process/process-forking-nested-relative/package.json similarity index 100% rename from tests/esm-child-process/process-forking-ts/package.json rename to tests/esm-child-process/process-forking-nested-relative/package.json diff --git a/tests/esm-child-process/process-forking-nested-relative/subfolder/worker.ts b/tests/esm-child-process/process-forking-nested-relative/subfolder/worker.ts new file mode 100644 index 000000000..4114d5ab0 --- /dev/null +++ b/tests/esm-child-process/process-forking-nested-relative/subfolder/worker.ts @@ -0,0 +1,3 @@ +const message: string = 'Works'; + +console.log(message); diff --git a/tests/esm-child-process/process-forking-js/tsconfig.json b/tests/esm-child-process/process-forking-nested-relative/tsconfig.json similarity index 74% rename from tests/esm-child-process/process-forking-js/tsconfig.json rename to tests/esm-child-process/process-forking-nested-relative/tsconfig.json index 04e93e5c7..f16e691d2 100644 --- a/tests/esm-child-process/process-forking-js/tsconfig.json +++ b/tests/esm-child-process/process-forking-nested-relative/tsconfig.json @@ -3,6 +3,6 @@ "module": "ESNext" }, "ts-node": { - "swc": true + "transpileOnly": true } } diff --git a/tests/project-resolution/a/index.ts b/tests/project-resolution/a/index.ts new file mode 100644 index 000000000..230f5ea09 --- /dev/null +++ b/tests/project-resolution/a/index.ts @@ -0,0 +1,9 @@ +export {}; +// Type assertion to please TS 2.7 +const register = process[(Symbol as any).for('ts-node.register.instance')]; +console.log( + JSON.stringify({ + options: register.options, + config: register.config, + }) +); diff --git a/tests/project-resolution/a/tsconfig.json b/tests/project-resolution/a/tsconfig.json new file mode 100644 index 000000000..377a86016 --- /dev/null +++ b/tests/project-resolution/a/tsconfig.json @@ -0,0 +1,12 @@ +{ + "ts-node": { + "transpileOnly": true + }, + "compilerOptions": { + "plugins": [ + { + "name": "plugin-a" + } + ] + } +} diff --git a/tests/project-resolution/b/index.ts b/tests/project-resolution/b/index.ts new file mode 100644 index 000000000..230f5ea09 --- /dev/null +++ b/tests/project-resolution/b/index.ts @@ -0,0 +1,9 @@ +export {}; +// Type assertion to please TS 2.7 +const register = process[(Symbol as any).for('ts-node.register.instance')]; +console.log( + JSON.stringify({ + options: register.options, + config: register.config, + }) +); diff --git a/tests/project-resolution/b/tsconfig.json b/tests/project-resolution/b/tsconfig.json new file mode 100644 index 000000000..dd98865c1 --- /dev/null +++ b/tests/project-resolution/b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "ts-node": { + "transpileOnly": true + }, + "compilerOptions": { + "plugins": [ + { + "name": "plugin-b" + } + ] + } +} diff --git a/tests/working-dir/cjs/index.ts b/tests/working-dir/cjs/index.ts index f3ba1b30a..597b779e6 100644 --- a/tests/working-dir/cjs/index.ts +++ b/tests/working-dir/cjs/index.ts @@ -1,7 +1,7 @@ import { strictEqual } from 'assert'; -import { normalize, dirname } from 'path'; +import { join, normalize } from 'path'; -// Expect the working directory to be the parent directory. -strictEqual(normalize(process.cwd()), normalize(dirname(__dirname))); +// Expect the working directory to be the current directory. +strictEqual(normalize(process.cwd()), normalize(join(__dirname, '../..'))); console.log('Passing'); diff --git a/tests/working-dir/esm-node-next/index.ts b/tests/working-dir/esm-node-next/index.ts new file mode 100644 index 000000000..f290171a9 --- /dev/null +++ b/tests/working-dir/esm-node-next/index.ts @@ -0,0 +1,11 @@ +import { strictEqual } from 'assert'; +import { normalize, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +// Expect the working directory to be the current directory. +strictEqual( + normalize(process.cwd()), + normalize(dirname(fileURLToPath(import.meta.url))) +); + +console.log('Passing'); diff --git a/tests/working-dir/esm-node-next/package.json b/tests/working-dir/esm-node-next/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/tests/working-dir/esm-node-next/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/working-dir/esm-node-next/tsconfig.json b/tests/working-dir/esm-node-next/tsconfig.json new file mode 100644 index 000000000..3998b5074 --- /dev/null +++ b/tests/working-dir/esm-node-next/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "module": "NodeNext" + } +} diff --git a/tests/working-dir/esm/index.ts b/tests/working-dir/esm/index.ts index 21230f9d8..0f6220303 100644 --- a/tests/working-dir/esm/index.ts +++ b/tests/working-dir/esm/index.ts @@ -1,11 +1,8 @@ -import { strictEqual } from 'assert'; -import { normalize, dirname } from 'path'; -import { fileURLToPath } from 'url'; +import { ok } from 'assert'; -// Expect the working directory to be the parent directory. -strictEqual( - normalize(process.cwd()), - normalize(dirname(dirname(fileURLToPath(import.meta.url)))) -); +// Expect the working directory to be the current directory. +// Note: Cannot use `import.meta.url` in this variant of the test +// because older TypeScript versions do not know about this syntax. +ok(/working-dir[\/\\]esm[\/\\]?/.test(process.cwd())); console.log('Passing'); diff --git a/tests/working-dir/esm/tsconfig.json b/tests/working-dir/esm/tsconfig.json index 04e93e5c7..f16e691d2 100644 --- a/tests/working-dir/esm/tsconfig.json +++ b/tests/working-dir/esm/tsconfig.json @@ -3,6 +3,6 @@ "module": "ESNext" }, "ts-node": { - "swc": true + "transpileOnly": true } }