Skip to content

Commit

Permalink
feat: support Node v20.6.0 module.register() & --import flag (#337)
Browse files Browse the repository at this point in the history
  • Loading branch information
privatenumber committed Oct 17, 2023
1 parent e46366d commit 23e4694
Show file tree
Hide file tree
Showing 10 changed files with 109 additions and 53 deletions.
13 changes: 10 additions & 3 deletions README.md
Expand Up @@ -153,15 +153,15 @@ tsx --no-cache ./file.ts

`tsx` is a standalone binary designed to be used in place of `node`, but sometimes you'll want to use `node` directly. For example, when adding TypeScript & ESM support to npm-installed binaries.

To use `tsx` as a Node.js loader, pass it in to the [`--loader`](https://nodejs.org/api/esm.html#loaders) flag. This will add TypeScript & ESM support for both ESM and CommonJS contexts.
To use `tsx` as a Node.js loader, pass it in to the [`--import`](https://nodejs.org/api/module.html#enabling) flag. This will add TypeScript & ESM support for both Module and CommonJS contexts.

```sh
node --loader tsx ./file.ts
node --import tsx ./file.ts
```

Or as an environment variable:
```sh
NODE_OPTIONS='--loader tsx' node ./file.ts
NODE_OPTIONS='--import tsx' node ./file.ts
```

> **Note:** The loader is limited to adding support for loading TypeScript/ESM files. CLI features such as _watch mode_ or suppressing "experimental feature" warnings will not be available.
Expand All @@ -170,6 +170,13 @@ NODE_OPTIONS='--loader tsx' node ./file.ts

If you only need to add TypeScript support in a Module context, you can use the ESM loader:

##### Node.js v20.6.0 and above
```sh
node --import tsx/esm ./file.ts
```

##### Node.js v20.5.1 and below

```sh
node --loader tsx/esm ./file.ts
```
Expand Down
18 changes: 1 addition & 17 deletions src/cjs/index.ts
Expand Up @@ -11,7 +11,7 @@ import type { TransformOptions } from 'esbuild';
import { installSourceMapSupport } from '../source-map';
import { transformSync, transformDynamicImport } from '../utils/transform';
import { resolveTsPath } from '../utils/resolve-ts-path';
import { compareNodeVersion } from '../utils/compare-node-version';
import { nodeSupportsImport, supportsNodePrefix } from '../utils/node-features';

const isRelativePathPattern = /^\.{1,2}\//;
const isTsFilePatten = /\.[cm]?tsx?$/;
Expand All @@ -31,17 +31,6 @@ const tsconfigPathsMatcher = tsconfig && createPathsMatcher(tsconfig);

const applySourceMap = installSourceMapSupport();

const nodeSupportsImport = (
// v13.2.0 and higher
compareNodeVersion([13, 2, 0]) >= 0

// 12.20.0 ~ 13.0.0
|| (
compareNodeVersion([12, 20, 0]) >= 0
&& compareNodeVersion([13, 0, 0]) < 0
)
);

const extensions = Module._extensions;
const defaultLoader = extensions['.js'];

Expand Down Expand Up @@ -137,11 +126,6 @@ Object.defineProperty(extensions, '.mjs', {
enumerable: false,
});

const supportsNodePrefix = (
compareNodeVersion([16, 0, 0]) >= 0
|| compareNodeVersion([14, 18, 0]) >= 0
);

// Add support for "node:" protocol
const defaultResolveFilename = Module._resolveFilename.bind(Module);
Module._resolveFilename = (request, parent, isMain, options) => {
Expand Down
12 changes: 12 additions & 0 deletions src/esm/index.ts
@@ -1,2 +1,14 @@
import { isMainThread } from 'node:worker_threads';
import { supportsModuleRegister } from '../utils/node-features';
import { registerLoader } from './register';

// Loaded via --import flag
if (
supportsModuleRegister
&& isMainThread
) {
registerLoader();
}

export * from './loaders.js';
export * from './loaders-deprecated.js';
4 changes: 1 addition & 3 deletions src/esm/loaders-deprecated.ts
Expand Up @@ -7,7 +7,7 @@ import { fileURLToPath } from 'url';
import type { ModuleFormat } from 'module';
import type { TransformOptions } from 'esbuild';
import { transform, transformDynamicImport } from '../utils/transform';
import { compareNodeVersion } from '../utils/compare-node-version';
import { nodeSupportsDeprecatedLoaders } from '../utils/node-features';
import {
applySourceMap,
fileMatcher,
Expand Down Expand Up @@ -109,7 +109,5 @@ const _transformSource: transformSource = async function (
return result;
};

const nodeSupportsDeprecatedLoaders = compareNodeVersion([16, 12, 0]) < 0;

export const getFormat = nodeSupportsDeprecatedLoaders ? _getFormat : undefined;
export const transformSource = nodeSupportsDeprecatedLoaders ? _transformSource : undefined;
28 changes: 16 additions & 12 deletions src/esm/loaders.ts
Expand Up @@ -2,12 +2,14 @@ import type { MessagePort } from 'node:worker_threads';
import path from 'path';
import { pathToFileURL, fileURLToPath } from 'url';
import type {
ResolveFnOutput, ResolveHookContext, LoadHook, GlobalPreloadHook,
ResolveFnOutput, ResolveHookContext, LoadHook, GlobalPreloadHook, InitializeHook,
} from 'module';
import type { TransformOptions } from 'esbuild';
import { compareNodeVersion } from '../utils/compare-node-version';
import { transform, transformDynamicImport } from '../utils/transform';
import { resolveTsPath } from '../utils/resolve-ts-path';
import {
supportsNodePrefix,
} from '../utils/node-features';
import {
applySourceMap,
tsconfigPathsMatcher,
Expand All @@ -34,7 +36,7 @@ type resolve = (
recursiveCall?: boolean,
) => MaybePromise<ResolveFnOutput>;

const isolatedLoader = compareNodeVersion([20, 0, 0]) >= 0;
let mainThreadPort: MessagePort | undefined;

type SendToParent = (data: {
type: 'dependency';
Expand All @@ -43,12 +45,21 @@ type SendToParent = (data: {

let sendToParent: SendToParent | undefined = process.send ? process.send.bind(process) : undefined;

export const initialize: InitializeHook = async (data) => {
if (!data) {
throw new Error('tsx must be loaded with --import instead of --loader\nThe --loader flag was deprecated in Node v20.6.0');
}

const { port } = data;
mainThreadPort = port;
sendToParent = port.postMessage.bind(port);
};

/**
* Technically globalPreload is deprecated so it should be in loaders-deprecated
* but it shares a closure with the new load hook
*/
let mainThreadPort: MessagePort | undefined;
const _globalPreload: GlobalPreloadHook = ({ port }) => {
export const globalPreload: GlobalPreloadHook = ({ port }) => {
mainThreadPort = port;
sendToParent = port.postMessage.bind(port);

Expand All @@ -66,8 +77,6 @@ const _globalPreload: GlobalPreloadHook = ({ port }) => {
`;
};

export const globalPreload = isolatedLoader ? _globalPreload : undefined;

const resolveExplicitPath = async (
defaultResolve: NextResolve,
specifier: string,
Expand Down Expand Up @@ -149,11 +158,6 @@ async function tryDirectory(

const isRelativePathPattern = /^\.{1,2}\//;

const supportsNodePrefix = (
compareNodeVersion([14, 13, 1]) >= 0
|| compareNodeVersion([12, 20, 0]) >= 0
);

export const resolve: resolve = async function (

Check warning on line 161 in src/esm/loaders.ts

View workflow job for this annotation

GitHub Actions / Test (ubuntu-latest)

Unexpected unnamed async function

Check warning on line 161 in src/esm/loaders.ts

View workflow job for this annotation

GitHub Actions / Test (ubuntu-latest)

Async function has a complexity of 24. Maximum allowed is 10
specifier,
context,
Expand Down
30 changes: 30 additions & 0 deletions src/esm/register.ts
@@ -0,0 +1,30 @@
import module from 'node:module';
import { MessageChannel } from 'node:worker_threads';
import { installSourceMapSupport } from '../source-map';

export const registerLoader = () => {
const { port1, port2 } = new MessageChannel();

installSourceMapSupport(port1);
if (process.send) {
port1.addListener('message', (message) => {
if (message.type === 'dependency') {
process.send!(message);
}
});
}

// Allows process to exit without waiting for port to close
port1.unref();

module.register(
'./index.mjs',
{
parentURL: import.meta.url,
data: {
port: port2,
},
transferList: [port2],
},
);
};
3 changes: 2 additions & 1 deletion src/run.ts
@@ -1,6 +1,7 @@
import type { StdioOptions } from 'child_process';
import { pathToFileURL } from 'url';
import spawn from 'cross-spawn';
import { supportsModuleRegister } from './utils/node-features';

export function run(
argv: string[],
Expand Down Expand Up @@ -34,7 +35,7 @@ export function run(
'--require',
require.resolve('./preflight.cjs'),

'--loader',
supportsModuleRegister ? '--import' : '--loader',
pathToFileURL(require.resolve('./loader.mjs')).toString(),

...argv,
Expand Down
9 changes: 1 addition & 8 deletions src/source-map.ts
@@ -1,14 +1,7 @@
import type { MessagePort } from 'node:worker_threads';
import sourceMapSupport, { type UrlAndMap } from 'source-map-support';
import type { Transformed } from './utils/transform/apply-transformers';
import { compareNodeVersion } from './utils/compare-node-version';

/**
* Node.js loaders are isolated from v20
* https://github.com/nodejs/node/issues/49455#issuecomment-1703812193
* https://github.com/nodejs/node/blob/33710e7e7d39d19449a75911537d630349110a0c/doc/api/module.md#L375-L376
*/
const isolatedLoader = compareNodeVersion([20, 0, 0]) >= 0;
import { isolatedLoader } from './utils/node-features';

export type RawSourceMap = UrlAndMap['map'];

Expand Down
9 changes: 0 additions & 9 deletions src/utils/compare-node-version.ts

This file was deleted.

36 changes: 36 additions & 0 deletions src/utils/node-features.ts
@@ -0,0 +1,36 @@
type Version = [number, number, number];

const nodeVersion = process.versions.node.split('.').map(Number) as Version;

const compareNodeVersion = (version: Version) => (
nodeVersion[0] - version[0]
|| nodeVersion[1] - version[1]
|| nodeVersion[2] - version[2]
);

export const nodeSupportsImport = (
// v13.2.0 and higher
compareNodeVersion([13, 2, 0]) >= 0

// 12.20.0 ~ 13.0.0
|| (
compareNodeVersion([12, 20, 0]) >= 0
&& compareNodeVersion([13, 0, 0]) < 0
)
);

export const supportsNodePrefix = (
compareNodeVersion([16, 0, 0]) >= 0
|| compareNodeVersion([14, 18, 0]) >= 0
);

export const nodeSupportsDeprecatedLoaders = compareNodeVersion([16, 12, 0]) < 0;

/**
* Node.js loaders are isolated from v20
* https://github.com/nodejs/node/issues/49455#issuecomment-1703812193
* https://github.com/nodejs/node/blob/33710e7e7d39d19449a75911537d630349110a0c/doc/api/module.md#L375-L376
*/
export const isolatedLoader = compareNodeVersion([20, 0, 0]) >= 0;

export const supportsModuleRegister = compareNodeVersion([20, 6, 0]) >= 0;

0 comments on commit 23e4694

Please sign in to comment.