Skip to content

Commit

Permalink
Merge branch 'develop' into add-loader-tests
Browse files Browse the repository at this point in the history
  • Loading branch information
privatenumber authored Oct 18, 2023
2 parents 4f66d3d + 7e916f5 commit a287d92
Show file tree
Hide file tree
Showing 11 changed files with 149 additions and 63 deletions.
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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 (
specifier,
context,
Expand Down
30 changes: 30 additions & 0 deletions src/esm/register.ts
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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;
50 changes: 40 additions & 10 deletions src/utils/transform/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType> extends Map<string, ReturnType> {
/**
* By using tmpdir, the expectation is for the OS to clean any files
Expand All @@ -17,7 +19,16 @@ class FileCache<ReturnType> extends Map<string, ReturnType> {
* 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;
Expand All @@ -40,7 +51,10 @@ class FileCache<ReturnType> extends Map<string, ReturnType> {
};
});

setImmediate(() => this.expireDiskCache());
setImmediate(() => {
this.expireDiskCache();
this.removeOldCacheDirectory();
});
}

get(key: string) {
Expand Down Expand Up @@ -90,10 +104,7 @@ class FileCache<ReturnType> extends Map<string, ReturnType> {
fs.promises.writeFile(
path.join(this.cacheDirectory, `${time}-${key}`),
JSON.stringify(value),
).catch(

() => {},
);
).catch(noop);
}

return this;
Expand All @@ -105,13 +116,32 @@ class FileCache<ReturnType> extends Map<string, ReturnType> {
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 (
Expand Down

0 comments on commit a287d92

Please sign in to comment.