Skip to content

Commit

Permalink
Fix --cwd to actually set the working directory and work with ESM c…
Browse files Browse the repository at this point in the history
…hild process

Currently the `--esm` option does not necessarily do what the
documentation suggests. i.e. the script does not run as if the working
directory is the specified directory.

This commit fixes this, so that the option is useful for TSConfig
resolution, as well as for controlling the script working directory.

Also fixes that the CWD encoded in the bootstrap brotli state for the
ESM child process messes with the entry-point resolution, if e.g. the
entry-point in `child_process.fork` is relative to a specified `cwd`.
  • Loading branch information
devversion committed Jul 3, 2022
1 parent 796b593 commit d78e437
Show file tree
Hide file tree
Showing 17 changed files with 235 additions and 31 deletions.
89 changes: 60 additions & 29 deletions src/bin.ts
Expand Up @@ -73,13 +73,17 @@ export function bootstrap(state: BootstrapState) {
if (!state.phase2Result) {
state.phase2Result = phase2(state);
if (state.shouldUseChildProcess && !state.isInChildProcess) {
return callInChild(state);
// Note: When transitioning into the child-process after `phase2`,
// the updated working directory needs to be preserved.
return callInChild(state, state.phase2Result.targetCwd);
}
}
if (!state.phase3Result) {
state.phase3Result = phase3(state);
if (state.shouldUseChildProcess && !state.isInChildProcess) {
return callInChild(state);
// Note: When transitioning into the child-process after `phase2`,
// the updated working directory needs to be preserved.
return callInChild(state, state.phase2Result.targetCwd);
}
}
return phase4(state);
Expand Down Expand Up @@ -293,7 +297,8 @@ Options:
-D, --ignoreDiagnostics [code] Ignore TypeScript warnings by diagnostic code
-O, --compilerOptions [opts] JSON object to merge with compiler options
--cwd Behave as if invoked within this working directory.
--cwd Sets the working directory of the spawned script. Also useful for the
automatic discovering of the \`tsconfig.json\` project.
--files Load \`files\`, \`include\` and \`exclude\` from \`tsconfig.json\` on startup
--pretty Use pretty diagnostic formatter (usually enabled by default)
--cwdMode Use current directory instead of <script.ts> for config resolution
Expand All @@ -318,7 +323,15 @@ Options:
process.exit(0);
}

const cwd = cwdArg || process.cwd();
let targetCwd: string;

// Switch to the target `--cwd` if specified.
if (cwdArg !== undefined) {
targetCwd = resolve(cwdArg);
process.chdir(targetCwd);
} else {
targetCwd = process.cwd();
}

// If ESM is explicitly enabled through the flag, stage3 should be run in a child process
// with the ESM loaders configured.
Expand All @@ -327,7 +340,7 @@ Options:
}

return {
cwd,
targetCwd,
};
}

Expand Down Expand Up @@ -359,21 +372,18 @@ function phase3(payload: BootstrapState) {
esm,
experimentalSpecifierResolution,
} = payload.parseArgvResult;
const { cwd } = payload.phase2Result!;
const { targetCwd } = 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
// original entry-point but then the process forks itself using e.g. `child_process.fork`.
// 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.parseArgvResult!,
payload.phase2Result!
);
const { entryPointPath } = getEntryPointInfo(payload.parseArgvResult!);

const preloadedConfig = findAndReadConfig({
cwd,
cwd: targetCwd,
emit,
files,
pretty,
Expand All @@ -386,7 +396,7 @@ function phase3(payload: BootstrapState) {
ignore,
logError,
projectSearchDir: getProjectSearchDir(
cwd,
targetCwd,
scriptMode,
cwdMode,
entryPointPath
Expand Down Expand Up @@ -431,11 +441,9 @@ function phase3(payload: BootstrapState) {
* details can be found in here: https://github.com/TypeStrong/ts-node/issues/1812.
*/
function getEntryPointInfo(
argvResult: NonNullable<BootstrapState['parseArgvResult']>,
phase2Result: NonNullable<BootstrapState['phase2Result']>
argvResult: NonNullable<BootstrapState['parseArgvResult']>
) {
const { code, interactive, restArgs } = argvResult;
const { cwd } = phase2Result;

// Figure out which we are executing: piped stdin, --eval, REPL, and/or entrypoint
// This is complicated because node's behavior is complicated
Expand All @@ -447,10 +455,8 @@ function getEntryPointInfo(
(interactive || (process.stdin.isTTY && !executeEval));
const executeStdin = !executeEval && !executeRepl && !executeEntrypoint;

/** Unresolved. May point to a symlink, not realpath. May be missing file extension */
const entryPointPath = executeEntrypoint
? resolve(cwd, restArgs[0])
: undefined;
/** Unresolved. May point to a symlink, not realpath. May be missing file extension */
const entryPointPath = executeEntrypoint ? resolve(restArgs[0]) : undefined;

return {
executeEval,
Expand All @@ -465,7 +471,7 @@ function phase4(payload: BootstrapState) {
const { isInChildProcess, tsNodeScript } = payload;
const { version, showConfig, restArgs, code, print, argv } =
payload.parseArgvResult;
const { cwd } = payload.phase2Result!;
const { targetCwd } = payload.phase2Result!;
const { preloadedConfig } = payload.phase3Result!;

const {
Expand All @@ -474,7 +480,7 @@ function phase4(payload: BootstrapState) {
executeEval,
executeRepl,
executeStdin,
} = getEntryPointInfo(payload.parseArgvResult!, payload.phase2Result!);
} = getEntryPointInfo(payload.parseArgvResult!);

/**
* <repl>, [stdin], and [eval] are all essentially virtual files that do not exist on disc and are backed by a REPL
Expand All @@ -490,7 +496,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(targetCwd, EVAL_FILENAME));
evalStuff = {
state,
repl: createRepl({
Expand All @@ -503,10 +509,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(targetCwd);
}
if (executeStdin) {
const state = new EvalState(join(cwd, STDIN_FILENAME));
const state = new EvalState(join(targetCwd, STDIN_FILENAME));
stdinStuff = {
state,
repl: createRepl({
Expand All @@ -519,10 +525,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(targetCwd);
}
if (executeRepl) {
const state = new EvalState(join(cwd, REPL_FILENAME));
const state = new EvalState(join(targetCwd, REPL_FILENAME));
replStuff = {
state,
repl: createRepl({
Expand Down Expand Up @@ -607,7 +613,8 @@ function phase4(payload: BootstrapState) {
},
...ts.convertToTSConfig(
service.config,
service.configFilePath ?? join(cwd, 'ts-node-implicit-tsconfig.json'),
service.configFilePath ??
join(targetCwd, 'ts-node-implicit-tsconfig.json'),
service.ts.sys
),
};
Expand All @@ -623,10 +630,10 @@ function phase4(payload: BootstrapState) {
// Prepend `ts-node` arguments to CLI for child processes.
process.execArgv.push(
tsNodeScript,
...argv.slice(2, argv.length - restArgs.length)
...sanitizeArgvForChildForking(argv.slice(2, argv.length - restArgs.length))
);

// TODO this comes from BoostrapState
// TODO this comes from BootstrapState
process.argv = [process.argv[1]]
.concat(executeEntrypoint ? ([entryPointPath] as string[]) : [])
.concat(restArgs.slice(executeEntrypoint ? 1 : 0));
Expand Down Expand Up @@ -749,6 +756,30 @@ function requireResolveNonCached(absoluteModuleSpecifier: string) {
});
}

/**
* Sanitizes the specified argv string array to be useful for child processes
* which may be created using `child_process.fork`. Some initial `ts-node` options
* should not be preserved and forwarded to child process forks.
*
* * `--cwd` should not override the working directory in forked processes.
*/
function sanitizeArgvForChildForking(argv: string[]): string[] {
let result: string[] = [];
let omitNext = false;

for (const value of argv) {
if (value === '--cwd' || value === '--dir') {
omitNext = true;
} else if (!omitNext) {
result.push(value);
} else {
omitNext = false;
}
}

return result;
}

/**
* Evaluate an [eval] or [stdin] script
*/
Expand Down
10 changes: 8 additions & 2 deletions src/child/spawn-child.ts
Expand Up @@ -6,8 +6,13 @@ import { versionGteLt } from '../util';

const argPrefix = '--brotli-base64-config=';

/** @internal */
export function callInChild(state: BootstrapState) {
/**
* @internal
* @param state Bootstrap state to be transferred into the child process.
* @param targetCwd Working directory to be preserved when transitioning to
* the child process.
*/
export function callInChild(state: BootstrapState, targetCwd: string) {
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.'
Expand All @@ -29,6 +34,7 @@ export function callInChild(state: BootstrapState) {
],
{
stdio: 'inherit',
cwd: targetCwd,
argv0: process.argv0,
}
);
Expand Down
43 changes: 43 additions & 0 deletions src/test/esm-loader.spec.ts
Expand Up @@ -359,6 +359,35 @@ 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 ./working-dir/esm/ index.ts`
);

expect(err).toBe(null);
expect(stdout.trim()).toBe('Passing');
expect(stderr).toBe('');
});

test.suite(
'with NodeNext TypeScript resolution and `.mts` extension',
(test) => {
test.runIf(tsSupportsStableNodeNextNode16);

test('should have the correct working directory in the user entry-point', async () => {
const { err, stdout, stderr } = await exec(
`${BIN_PATH} --esm --cwd ./working-dir/esm-node-next/ index.ts`
);

expect(err).toBe(null);
expect(stdout.trim()).toBe('Passing');
expect(stderr).toBe('');
});
}
);
});

test.suite('esm child process and forking', (test) => {
test('should be able to fork vanilla NodeJS script', async () => {
const { err, stdout, stderr } = await exec(
Expand All @@ -380,6 +409,20 @@ test.suite('esm', (test) => {
expect(stderr).toBe('');
});

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 --cwd ./esm-child-process/process-forking-nested-relative/ index.ts`
);

expect(err).toBe(null);
expect(stdout.trim()).toBe('Passing: from main');
expect(stderr).toBe('');
}
);

test.suite(
'with NodeNext TypeScript resolution and `.mts` extension',
(test) => {
Expand Down
24 changes: 24 additions & 0 deletions src/test/index.spec.ts
Expand Up @@ -617,6 +617,30 @@ 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 ./working-dir/cjs/ index.ts`
);

expect(err).toBe(null);
expect(stdout.trim()).toBe('Passing');
expect(stderr).toBe('');
});

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('');
}
);

test.suite('should read ts-node options from tsconfig.json', (test) => {
const BIN_EXEC = `"${BIN_PATH}" --project tsconfig-options/tsconfig.json`;

Expand Down
22 changes: 22 additions & 0 deletions 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;
}
});
@@ -0,0 +1,3 @@
{
"type": "module"
}
@@ -0,0 +1,3 @@
const message: string = 'Works';

console.log(message);
@@ -0,0 +1,5 @@
{
"compilerOptions": {
"module": "ESNext"
}
}
7 changes: 7 additions & 0 deletions tests/working-dir/cjs/index.ts
@@ -0,0 +1,7 @@
import { strictEqual } from 'assert';
import { normalize } from 'path';

// Expect the working directory to be the current directory.
strictEqual(normalize(process.cwd()), normalize(__dirname));

console.log('Passing');
11 changes: 11 additions & 0 deletions 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');
3 changes: 3 additions & 0 deletions tests/working-dir/esm-node-next/package.json
@@ -0,0 +1,3 @@
{
"type": "module"
}

0 comments on commit d78e437

Please sign in to comment.