From 23e4694ec6abcb836ce91114b6495bce6ee59254 Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Wed, 18 Oct 2023 02:34:19 +0900 Subject: [PATCH 1/2] feat: support Node v20.6.0 `module.register()` & `--import` flag (#337) --- README.md | 13 ++++++++--- src/cjs/index.ts | 18 +--------------- src/esm/index.ts | 12 +++++++++++ src/esm/loaders-deprecated.ts | 4 +--- src/esm/loaders.ts | 28 +++++++++++++----------- src/esm/register.ts | 30 ++++++++++++++++++++++++++ src/run.ts | 3 ++- src/source-map.ts | 9 +------- src/utils/compare-node-version.ts | 9 -------- src/utils/node-features.ts | 36 +++++++++++++++++++++++++++++++ 10 files changed, 109 insertions(+), 53 deletions(-) create mode 100644 src/esm/register.ts delete mode 100644 src/utils/compare-node-version.ts create mode 100644 src/utils/node-features.ts diff --git a/README.md b/README.md index b7ae8aad2..fa9c7d7b0 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 ``` diff --git a/src/cjs/index.ts b/src/cjs/index.ts index f778cfa74..936ceb9c3 100644 --- a/src/cjs/index.ts +++ b/src/cjs/index.ts @@ -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?$/; @@ -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']; @@ -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) => { diff --git a/src/esm/index.ts b/src/esm/index.ts index 60a755b73..09e116e4c 100644 --- a/src/esm/index.ts +++ b/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'; diff --git a/src/esm/loaders-deprecated.ts b/src/esm/loaders-deprecated.ts index 641b38b90..0ac52b34f 100644 --- a/src/esm/loaders-deprecated.ts +++ b/src/esm/loaders-deprecated.ts @@ -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, @@ -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; diff --git a/src/esm/loaders.ts b/src/esm/loaders.ts index 872db0792..4f105e88e 100644 --- a/src/esm/loaders.ts +++ b/src/esm/loaders.ts @@ -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, @@ -34,7 +36,7 @@ type resolve = ( recursiveCall?: boolean, ) => MaybePromise; -const isolatedLoader = compareNodeVersion([20, 0, 0]) >= 0; +let mainThreadPort: MessagePort | undefined; type SendToParent = (data: { type: 'dependency'; @@ -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); @@ -66,8 +77,6 @@ const _globalPreload: GlobalPreloadHook = ({ port }) => { `; }; -export const globalPreload = isolatedLoader ? _globalPreload : undefined; - const resolveExplicitPath = async ( defaultResolve: NextResolve, specifier: string, @@ -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 ( specifier, context, diff --git a/src/esm/register.ts b/src/esm/register.ts new file mode 100644 index 000000000..0c9976e4f --- /dev/null +++ b/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], + }, + ); +}; diff --git a/src/run.ts b/src/run.ts index d1d57db18..79fc375e2 100644 --- a/src/run.ts +++ b/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[], @@ -34,7 +35,7 @@ export function run( '--require', require.resolve('./preflight.cjs'), - '--loader', + supportsModuleRegister ? '--import' : '--loader', pathToFileURL(require.resolve('./loader.mjs')).toString(), ...argv, diff --git a/src/source-map.ts b/src/source-map.ts index e5ec2b4b2..148e56028 100644 --- a/src/source-map.ts +++ b/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']; diff --git a/src/utils/compare-node-version.ts b/src/utils/compare-node-version.ts deleted file mode 100644 index f95ecc727..000000000 --- a/src/utils/compare-node-version.ts +++ /dev/null @@ -1,9 +0,0 @@ -type Version = [number, number, number]; - -const nodeVersion = process.versions.node.split('.').map(Number) as Version; - -export const compareNodeVersion = (version: Version) => ( - nodeVersion[0] - version[0] - || nodeVersion[1] - version[1] - || nodeVersion[2] - version[2] -); diff --git a/src/utils/node-features.ts b/src/utils/node-features.ts new file mode 100644 index 000000000..d9c23b14d --- /dev/null +++ b/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; From 7e916f5a1db5b21589cedc1dc91b0c498f87adf9 Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Wed, 18 Oct 2023 08:41:06 +1100 Subject: [PATCH 2/2] fix(cache): scope cache directory to user (#332) Co-authored-by: Hiroki Osame --- src/utils/transform/cache.ts | 50 ++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/src/utils/transform/cache.ts b/src/utils/transform/cache.ts index 45b0a908e..47656a5e7 100644 --- a/src/utils/transform/cache.ts +++ b/src/utils/transform/cache.ts @@ -6,6 +6,8 @@ import type { Transformed } from './apply-transformers'; const getTime = () => Math.floor(Date.now() / 1e8); +const tmpdir = os.tmpdir(); +const noop = () => {}; class FileCache extends Map { /** * By using tmpdir, the expectation is for the OS to clean any files @@ -17,7 +19,16 @@ class FileCache extends Map { * Note on Windows, temp files are not cleaned up automatically. * https://superuser.com/a/1599897 */ - cacheDirectory = path.join(os.tmpdir(), 'tsx'); + cacheDirectory = path.join( + // Write permissions by anyone + tmpdir, + + // Write permissions only by current user + `tsx-${os.userInfo().uid}`, + ); + + // Maintained so we can remove it on Windows + oldCacheDirectory = path.join(tmpdir, 'tsx'); cacheFiles: { time: number; @@ -40,7 +51,10 @@ class FileCache extends Map { }; }); - setImmediate(() => this.expireDiskCache()); + setImmediate(() => { + this.expireDiskCache(); + this.removeOldCacheDirectory(); + }); } get(key: string) { @@ -90,10 +104,7 @@ class FileCache extends Map { fs.promises.writeFile( path.join(this.cacheDirectory, `${time}-${key}`), JSON.stringify(value), - ).catch( - - () => {}, - ); + ).catch(noop); } return this; @@ -105,13 +116,32 @@ class FileCache extends Map { for (const cache of this.cacheFiles) { // Remove if older than ~7 days if ((time - cache.time) > 7) { - fs.promises.unlink(path.join(this.cacheDirectory, cache.fileName)).catch( - - () => {}, - ); + fs.promises.unlink(path.join(this.cacheDirectory, cache.fileName)).catch(noop); } } } + + async removeOldCacheDirectory() { + try { + const exists = await fs.promises.access(this.oldCacheDirectory).then(() => true); + if (exists) { + if ('rm' in fs.promises) { + await fs.promises.rm( + this.oldCacheDirectory, + { + recursive: true, + force: true, + }, + ); + } else { + await fs.promises.rmdir( + this.oldCacheDirectory, + { recursive: true }, + ); + } + } + } catch {} + } } export default (