Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Add globalPreload to ts-node/esm for node 20 #2009

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Expand Up @@ -230,7 +230,7 @@ To test your version of `env` for compatibility with `-S`:

## node flags and other tools

You can register ts-node without using our CLI: `node -r ts-node/register` and `node --loader ts-node/esm`
You can register ts-node without using our CLI: `node -r ts-node/register`, `node --loader ts-node/esm`, or `node --import ts-node/import` in node 20.6 and above.

In many cases, setting [`NODE_OPTIONS`](https://nodejs.org/api/cli.html#cli_node_options_options) will enable `ts-node` within other node tools, child processes, and worker threads. This can be combined with other node flags.

Expand Down
2 changes: 1 addition & 1 deletion ava.config.cjs
Expand Up @@ -16,7 +16,7 @@ module.exports = {
CONCURRENT_TESTS: '4'
},
require: ['./src/test/remove-env-var-force-color.js'],
nodeArguments: ['--loader', './src/test/test-loader.mjs', '--no-warnings'],
nodeArguments: ['--loader', './src/test/test-loader/loader.mjs', '--no-warnings'],
timeout: '300s',
concurrency: 4,
// We do chdir -- maybe other things -- that you can't do in worker_threads.
Expand Down
7 changes: 5 additions & 2 deletions child-loader.mjs
@@ -1,8 +1,11 @@
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
import { fileURLToPath } from 'url';

const require = createRequire(fileURLToPath(import.meta.url));

// TODO why use require() here? I think we can just `import`
/** @type {import('./dist/child-loader')} */
const childLoader = require('./dist/child/child-loader');
export const { resolve, load, getFormat, transformSource } = childLoader;
export const { resolve, load, getFormat, transformSource, bindFromLoaderThread } = childLoader;

bindFromLoaderThread(import.meta.url);
2 changes: 1 addition & 1 deletion esm.mjs
Expand Up @@ -4,4 +4,4 @@ const require = createRequire(fileURLToPath(import.meta.url));

/** @type {import('./dist/esm')} */
const esm = require('./dist/esm');
export const { resolve, load, getFormat, transformSource } = esm.registerAndCreateEsmHooks();
export const { initialize, resolve, load, getFormat, transformSource, globalPreload } = esm.registerAndCreateEsmHooks();
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -113,7 +113,7 @@
"homepage": "https://typestrong.org/ts-node",
"devDependencies": {
"@TypeStrong/fs-fixture-builder": "https://github.com/Typestrong/fs-fixture-builder.git#3099e53621daf99db971af29c96145dc115693cd",
"@cspotcode/ava-lib": "https://github.com/cspotcode/ava-lib#bbbed83f393342b51dc6caf2ddf775a3e89371d8",
"@cspotcode/ava-lib": "https://github.com/cspotcode/ava-lib#805aab17b2b89c388596b6dc2b4eece403c5fb87",
"@cspotcode/expect-stream": "https://github.com/cspotcode/node-expect-stream#4e425ff1eef240003af8716291e80fbaf3e3ae8f",
"@microsoft/api-extractor": "^7.19.4",
"@swc/core": "1.3.32",
Expand Down
9 changes: 7 additions & 2 deletions src/bin.ts
Expand Up @@ -71,6 +71,7 @@ export interface BootstrapState {
parseArgvResult: ReturnType<typeof parseArgv>;
phase2Result?: ReturnType<typeof phase2>;
phase3Result?: ReturnType<typeof phase3>;
isLoaderThread?: boolean;
}

/** @internal */
Expand Down Expand Up @@ -441,7 +442,7 @@ function getEntryPointInfo(state: BootstrapState) {
}

function phase4(payload: BootstrapState) {
const { isInChildProcess, tsNodeScript } = payload;
const { isInChildProcess, tsNodeScript, isLoaderThread } = payload;
const { version, showConfig, restArgs, code, print, argv } = payload.parseArgvResult;
const { cwd } = payload.phase2Result!;
const { preloadedConfig } = payload.phase3Result!;
Expand Down Expand Up @@ -522,8 +523,12 @@ function phase4(payload: BootstrapState) {

if (replStuff) replStuff.state.path = join(cwd, REPL_FILENAME(service.ts.version));

if (isInChildProcess)
if (isInChildProcess) {
(require('./child/child-loader') as typeof import('./child/child-loader')).lateBindHooks(createEsmHooks(service));
// we should not do anything else at this point in the loader thread,
// let the entrypoint run the actual program.
if (isLoaderThread) return;
}

// Bind REPL service to ts-node compiler service (chicken-and-egg problem)
replStuff?.repl.setService(service);
Expand Down
19 changes: 19 additions & 0 deletions src/child/child-loader.ts
@@ -1,5 +1,24 @@
import type { NodeLoaderHooksAPI1, NodeLoaderHooksAPI2 } from '..';
import { filterHooksByAPIVersion } from '../esm';
import { URL } from 'url';
import { bootstrap } from '../bin';
import { versionGteLt } from '../util';
import { argPrefix, decompress } from './argv-payload';

// On node v20, we cannot lateBind the hooks from outside the loader thread
// so it has to be done in the loader thread.
export function bindFromLoaderThread(loaderURL: string) {
// If we aren't in a loader thread, then skip this step.
if (!versionGteLt(process.versions.node, '20.0.0')) return;

const url = new URL(loaderURL);
const base64Payload = url.searchParams.get(argPrefix);
if (!base64Payload) throw new Error('unexpected loader url');
const state = decompress(base64Payload);
state.isInChildProcess = true;
state.isLoaderThread = true;
bootstrap(state);
}

let hooks: NodeLoaderHooksAPI1 & NodeLoaderHooksAPI2;

Expand Down
8 changes: 6 additions & 2 deletions src/child/spawn-child.ts
Expand Up @@ -10,16 +10,20 @@ import { argPrefix, compress } from './argv-payload';
* the child process.
*/
export function callInChild(state: BootstrapState) {
const loaderURL = pathToFileURL(require.resolve('../../child-loader.mjs'));
const compressedState = compress(state);
loaderURL.searchParams.set(argPrefix, compressedState);

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(),
loaderURL.toString(),
require.resolve('./child-entrypoint.js'),
`${argPrefix}${compress(state)}`,
`${argPrefix}${compressedState}`,
...state.parseArgvResult.restArgs,
],
{
Expand Down
103 changes: 85 additions & 18 deletions src/esm.ts
@@ -1,9 +1,10 @@
import { register, RegisterOptions, Service } from './index';
import { register, RegisterOptions, Service, type TSError } from './index';
import { parse as parseUrl, format as formatUrl, UrlWithStringQuery, fileURLToPath, pathToFileURL } from 'url';
import { extname, resolve as pathResolve } from 'path';
import * as assert from 'assert';
import { normalizeSlashes, versionGteLt } from './util';
import { createRequire } from 'module';
import type { MessagePort } from 'worker_threads';

// Note: On Windows, URLs look like this: file:///D:/dev/@TypeStrong/ts-node-examples/foo.ts

Expand Down Expand Up @@ -43,6 +44,7 @@ export namespace NodeLoaderHooksAPI1 {
export interface NodeLoaderHooksAPI2 {
resolve: NodeLoaderHooksAPI2.ResolveHook;
load: NodeLoaderHooksAPI2.LoadHook;
globalPreload?: NodeLoaderHooksAPI2.GlobalPreloadHook;
}
export namespace NodeLoaderHooksAPI2 {
export type ResolveHook = (
Expand Down Expand Up @@ -74,6 +76,18 @@ export namespace NodeLoaderHooksAPI2 {
export interface NodeImportAssertions {
type?: 'json';
}
export type GlobalPreloadHook = (context?: { port: MessagePort }) => string;
}

export interface NodeLoaderHooksAPI3 {
resolve: NodeLoaderHooksAPI2.ResolveHook;
load: NodeLoaderHooksAPI2.LoadHook;
initialize?: NodeLoaderHooksAPI3.InitializeHook;
}
export namespace NodeLoaderHooksAPI3 {
// technically this can be anything that can be passed through a postMessage channel,
// but defined here based on how ts-node uses it.
export type InitializeHook = (data: any) => void | Promise<void>;
}

export type NodeLoaderHooksFormat = 'builtin' | 'commonjs' | 'dynamic' | 'json' | 'module' | 'wasm';
Expand All @@ -84,17 +98,24 @@ export interface NodeImportAssertions {
}

// The hooks API changed in node version X so we need to check for backwards compatibility.
const newHooksAPI = versionGteLt(process.versions.node, '16.12.0');
const hooksAPIVersion = versionGteLt(process.versions.node, '21.0.0')
? 3
: versionGteLt(process.versions.node, '16.12.0')
? 2
: 1;

/** @internal */
export function filterHooksByAPIVersion(
hooks: NodeLoaderHooksAPI1 & NodeLoaderHooksAPI2
): NodeLoaderHooksAPI1 | NodeLoaderHooksAPI2 {
const { getFormat, load, resolve, transformSource } = hooks;
hooks: NodeLoaderHooksAPI1 & NodeLoaderHooksAPI2 & NodeLoaderHooksAPI3
): NodeLoaderHooksAPI1 | NodeLoaderHooksAPI2 | NodeLoaderHooksAPI3 {
const { getFormat, load, resolve, transformSource, globalPreload, initialize } = hooks;
// Explicit return type to avoid TS's non-ideal inferred type
const hooksAPI: NodeLoaderHooksAPI1 | NodeLoaderHooksAPI2 = newHooksAPI
? { resolve, load, getFormat: undefined, transformSource: undefined }
: { resolve, getFormat, transformSource, load: undefined };
const hooksAPI: NodeLoaderHooksAPI1 | NodeLoaderHooksAPI2 | NodeLoaderHooksAPI3 =
hooksAPIVersion === 3
? { resolve, load, initialize, globalPreload: undefined, transformSource: undefined, getFormat: undefined }
: hooksAPIVersion === 2
? { resolve, load, globalPreload, initialize: undefined, getFormat: undefined, transformSource: undefined }
: { resolve, getFormat, transformSource, initialize: undefined, globalPreload: undefined, load: undefined };
return hooksAPI;
}

Expand All @@ -111,14 +132,44 @@ export function createEsmHooks(tsNodeService: Service) {
const nodeResolveImplementation = tsNodeService.getNodeEsmResolver();
const nodeGetFormatImplementation = tsNodeService.getNodeEsmGetFormat();
const extensions = tsNodeService.extensions;
const useLoaderThread = versionGteLt(process.versions.node, '20.0.0');

const hooksAPI = filterHooksByAPIVersion({
resolve,
load,
getFormat,
transformSource,
globalPreload: useLoaderThread ? globalPreload : undefined,
initialize: undefined,
});

function globalPreload({ port }: { port?: MessagePort } = {}) {
// The loader thread doesn't get process.stderr.isTTY properly,
// so this signal lets us infer it based on the state of the main
// thread, but only relevant if options.pretty is unset.
let stderrTTYSignal: string;
if (port && tsNodeService.options.pretty === undefined) {
port.on('message', (data: { stderrIsTTY?: boolean }) => {
if (data.stderrIsTTY) {
tsNodeService.setPrettyErrors(true);
}
});
stderrTTYSignal = `
port.postMessage({
stderrIsTTY: !!process.stderr.isTTY
});
`;
} else {
stderrTTYSignal = '';
}
return `
const { createRequire } = getBuiltin('module');
const require = createRequire(${JSON.stringify(__filename)});
${stderrTTYSignal}
require('./index').register();
`;
}

function isFileUrlOrNodeStyleSpecifier(parsed: UrlWithStringQuery) {
// We only understand file:// URLs, but in node, the specifier can be a node-style `./foo` or `foo`
const { protocol } = parsed;
Expand Down Expand Up @@ -211,7 +262,7 @@ export function createEsmHooks(tsNodeService: Service) {
format: NodeLoaderHooksFormat;
source: string | Buffer | undefined;
}> {
return addShortCircuitFlag(async () => {
return await addShortCircuitFlag(async () => {
// If we get a format hint from resolve() on the context then use it
// otherwise call the old getFormat() hook using node's old built-in defaultGetFormat() that ships with ts-node
const format =
Expand Down Expand Up @@ -239,8 +290,23 @@ export function createEsmHooks(tsNodeService: Service) {
});

// Call the old hook
const { source: transformedSource } = await transformSource(rawSource, { url, format }, defaultTransformSource);
source = transformedSource;
try {
const { source: transformedSource } = await transformSource(
rawSource,
{ url, format },
defaultTransformSource
);
source = transformedSource;
} catch (er) {
// throw an error that can make it through the loader thread
// comms channel intact.
const tsErr = er as TSError;
const err = new Error(tsErr.message.trimEnd());
const { diagnosticCodes } = tsErr;
Object.assign(err, { diagnosticCodes });
Error.captureStackTrace(err, load);
throw err;
}
}

return { format, source };
Expand Down Expand Up @@ -348,11 +414,12 @@ export function createEsmHooks(tsNodeService: Service) {
}

async function addShortCircuitFlag<T>(fn: () => Promise<T>) {
const ret = await fn();
// Not sure if this is necessary; being lazy. Can revisit in the future.
if (ret == null) return ret;
return {
...ret,
shortCircuit: true,
};
return fn().then((ret) => {
// Not sure if this is necessary; being lazy. Can revisit in the future.
if (ret == null) return ret;
return {
...ret,
shortCircuit: true,
};
});
}
21 changes: 17 additions & 4 deletions src/index.ts
Expand Up @@ -480,6 +480,7 @@ export interface Service {
ignored(fileName: string): boolean;
compile(code: string, fileName: string, lineOffset?: number): string;
getTypeInfo(code: string, fileName: string, position: number): TypeInfo;
setPrettyErrors(pretty: boolean): void;
/** @internal */
configFilePath: string | undefined;
/** @internal */
Expand Down Expand Up @@ -690,7 +691,13 @@ export function createFromPreloadedConfig(foundConfigResult: ReturnType<typeof f
}

// Install source map support and read from memory cache.
const useBuiltInSourceMaps = versionGteLt(process.versions.node, '20.0.0');
function installSourceMapSupport() {
if (useBuiltInSourceMaps) {
//@ts-ignore added to node somewhat recently, not yet in DT.
process.setSourceMapsEnabled(true);
return;
}
const sourceMapSupport = require('@cspotcode/source-map-support') as typeof _sourceMapSupport;
sourceMapSupport.install({
environment: 'node',
Expand Down Expand Up @@ -719,11 +726,16 @@ export function createFromPreloadedConfig(foundConfigResult: ReturnType<typeof f
});
}

const shouldHavePrettyErrors = options.pretty === undefined ? process.stdout.isTTY : options.pretty;
let shouldHavePrettyErrors!: boolean;
let formatDiagnostics: (diagnostics: readonly _ts.Diagnostic[], host: _ts.FormatDiagnosticsHost) => string;

const formatDiagnostics = shouldHavePrettyErrors
? ts.formatDiagnosticsWithColorAndContext || ts.formatDiagnostics
: ts.formatDiagnostics;
function setPrettyErrors(pretty: boolean) {
shouldHavePrettyErrors = pretty;
formatDiagnostics = shouldHavePrettyErrors
? ts.formatDiagnosticsWithColorAndContext || ts.formatDiagnostics
: ts.formatDiagnostics;
}
setPrettyErrors(options.pretty !== undefined ? options.pretty : !!process.stderr.isTTY);

function createTSError(diagnostics: ReadonlyArray<_ts.Diagnostic>) {
const diagnosticText = formatDiagnostics(diagnostics, diagnosticHost);
Expand Down Expand Up @@ -1282,6 +1294,7 @@ export function createFromPreloadedConfig(foundConfigResult: ReturnType<typeof f
getNodeEsmGetFormat,
getNodeCjsLoader,
extensions,
setPrettyErrors,
};
}

Expand Down