diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index dea7cf627..c234f852d 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -54,11 +54,43 @@ export default defineConfig({ { text: 'Scripts', link: '/scripts' }, ] }, + { + text: 'Node.js API', + base: '/node', + items: [ + { + text: 'Global enhancement', + link: '/', + items: [ + { + text: 'CommonJS only', + link: '/cjs' + }, + { + text: 'Module only', + link: '/esm' + }, + ], + }, + { + text: 'Isolated methods', + items: [ + { + text: 'tsImport()', + link: '/ts-import' + }, + { + text: 'tsx.require()', + link: '/tsx-require' + }, + ], + }, + ], + }, { text: 'Integration', items: [ { text: 'VSCode', link: '/vscode' }, - { text: 'Node.js', link: '/node' }, ] }, { diff --git a/docs/node.md b/docs/node.md deleted file mode 100644 index 9ed4cc777..000000000 --- a/docs/node.md +++ /dev/null @@ -1,197 +0,0 @@ ---- -outline: deep ---- - -# Node.js integration - -This guide details how to integrate `tsx` with Node.js, allowing you to enhance Node.js with TypeScript support without directly running `tsx`. Because Node.js offers Module and CommonJS contexts, you can opt into enhancing them selectively. - -This setup is useful for running binaries with TypeScript support, developing packages that load TypeScript files, or direct usage of `node`. - - -::: info Only TypeScript & ESM support -When using the Node.js integrations, CLI features such as [_Watch mode_](/watch-mode) will not be available. -::: - -## Module & CommonJS enhancement - -### Command-line API - -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 --import tsx ./file.ts -``` - -Node.js also allows you to pass in command-line flags via the [`NODE_OPTIONS`](https://nodejs.org/api/cli.html#node_optionsoptions) environment variable: -```sh -NODE_OPTIONS='--import tsx' node ./file.ts -``` - -This means you can also add tsx to binaries to add TypeScript support: -```sh -NODE_OPTIONS='--import tsx' npx eslint -``` - -### Node.js API - -Add this at the top of your entry-file: -```js -import 'tsx' -``` - -## Only Module enhancement - -> For situations where you need TypeScript support only in a Module context (using `.mjs` files or `package.json` with `"type": "module"`). - -### Command-line API - -```sh -# Node.js v20.6.0 and above -node --import tsx/esm ./file.ts - -# Node.js v20.5.1 and below -node --loader tsx/esm ./file.ts -``` - -### Registration & Unregistration -```js -import { register } from 'tsx/esm/api' - -// register tsx enhancement -const unregister = register() - -// Unregister when needed -unregister() -``` - -#### Tracking loaded files -Detect files that get loaded with the `onImport` hook: - -```ts -register({ - onImport: (file: string) => { - console.log(file) - // file:///foo.ts - } -}) -``` - -## Only CommonJS enhancement - -> For situations where you need TypeScript and ESM support only in a CommonJS context (using `.cjs` files or `package.json` with `"type": "commonjs"`). - -### Command-line API - -Pass _tsx_ into the `--require` flag: - -```sh -node --require tsx/cjs ./file.ts -``` - -This is the equivalent of adding the following at the top of your entry file, which you can also do: - -```js -require('tsx/cjs') -``` - -### Registration & Unregistration - -To manually register and unregister the tsx enhancement: - -```js -const tsx = require('tsx/cjs/api') - -// Register tsx enhancement -const unregister = tsx.register() - -// Unregister when needed -unregister() -``` - -## Enhanced `import()` & `require()` - -tsx exports enhanced `import()` or `require()` functions, allowing you to load TypeScript/ESM files without affecting the runtime environment. - -### `tsImport()` - -The `import()` function enhanced to support TypeScript. Because it's the native `import()`, it supports [top-level await](https://v8.dev/features/top-level-await). - -::: warning Caveat -`require()` calls in the loaded files are not enhanced. -::: - -#### ESM usage - -Note, the current file path must be passed in as the second argument to resolve the import context. - -```js -import { tsImport } from 'tsx/esm/api' - -const loaded = await tsImport('./file.ts', import.meta.url) -``` - -#### CommonJS usage - -```js -const { tsImport } = require('tsx/esm/api') - -const loaded = await tsImport('./file.ts', __filename) -``` - -#### Tracking loaded files -Detect files that get loaded with the `onImport` hook: - -```ts -tsImport('./file.ts', { - parentURL: import.meta.url, - onImport: (file: string) => { - console.log(file) - // file:///foo.ts - } -}) -``` - -### `tsx.require()` - -The `require()` function enhanced to support TypeScript and ESM. - -::: warning Caveat -`import()` & asynchronous `require()` calls in the loaded files are not enhanced. -::: - -#### CommonJS usage - -Note, the current file path must be passed in as the second argument to resolve the import context. - -```js -const tsx = require('tsx/cjs/api') - -const loaded = tsx.require('./file.ts', __filename) -const filepath = tsx.require.resolve('./file.ts', __filename) -``` - -#### ESM usage - -```js -import { require } from 'tsx/cjs/api' - -const loaded = require('./file.ts', import.meta.url) -const filepath = require.resolve('./file.ts', import.meta.url) -``` - -#### Tracking loaded files - -Because the CommonJS API tracks loaded modules in `require.cache`, you can use it to identify loaded files for dependency tracking. This can be useful when implementing a watcher. - -```js -const resolvedPath = tsx.require.resolve('./file', import.meta.url) - -const collectDependencies = module => [ - module.filename, - ...module.children.flatMap(collectDependencies) -] - -console.log(collectDependencies(tsx.require.cache[resolvedPath])) -// ['/file.ts', '/foo.ts', '/bar.ts'] -``` diff --git a/docs/node/cjs.md b/docs/node/cjs.md new file mode 100644 index 000000000..b60e40df1 --- /dev/null +++ b/docs/node/cjs.md @@ -0,0 +1,48 @@ + +# Only CommonJS mode + +Node.js runs files in CommonJS mode when the file extension is `.cjs` (or `.cts` if TypeScript), or `.js` when [`package.json#type`](https://nodejs.org/api/packages.html#type) is undefined or set to `commonjs`. + +This section is only for adding tsx in CommonJS mode (doesn't affect `.mjs` or `.mts` files, or `.js` files when `package.js#type` is set to `module`). + +::: warning Not for 3rd-party packages +This enhances the entire runtime so it may not be suitable for loading TypeScript files from a 3rd-party package as it may lead to unexpected behavior in user code. + +For importing TypeScript files in CommonJS mode without affecting the environment, see [`tsx.require()`](http://localhost:5173/node/tsx-require). +::: + +## Command-line API + +Pass _tsx_ into the `--require` flag: + +```sh +node --require tsx/cjs ./file.ts +``` + +### `NODE_OPTIONS` environment variable + +```sh +NODE_OPTIONS='--require tsx/cjs' npx some-binary +``` + +## Programmatic API + +Load `tsx/cjs` at the top of your entry-file: + +```js +require('tsx/cjs') +``` + +### Registration & Unregistration + +To manually register and unregister the tsx enhancement: + +```js +const tsx = require('tsx/cjs/api') + +// Register tsx enhancement +const unregister = tsx.register() + +// Unregister when needed +unregister() +``` diff --git a/docs/node/esm.md b/docs/node/esm.md new file mode 100644 index 000000000..f7ffb56c3 --- /dev/null +++ b/docs/node/esm.md @@ -0,0 +1,90 @@ +# Only Module mode + +Node.js runs files in Module mode when the file extension is `.mjs` (or `.mts` if TypeScript), or `.js` when [`package.json#type`](https://nodejs.org/api/packages.html#type) is set to `module`. + +This section is only for adding tsx in Module mode (doesn't affect `.cjs` or `.cts` files, or `.js` files when `package.js#type` is undefined or set to `commonjs`). + +::: warning Not for 3rd-party packages +This enhances the entire runtime so it may not be suitable for loading TypeScript files from a 3rd-party package as it may lead to unexpected behavior in user code. + +For importing TypeScript files in Module mode without affecting the environment, see the *Scoped registration* section below or [`tsImport()`](http://localhost:5173/node/ts-import). +::: + +## Command-line API + +```sh +# Node.js v20.6.0 and above +node --import tsx/esm ./file.ts + +# Node.js v20.5.1 and below +node --loader tsx/esm ./file.ts +``` + +### `NODE_OPTIONS` environment variable + +```sh +# Node.js v20.6.0 and above +NODE_OPTIONS='--import tsx/esm' npx some-binary + +# Node.js v20.5.1 and below +NODE_OPTIONS='--loader tsx/esm' npx some-binary +``` + +## Programmatic API + +### Registration & Unregistration +```js +import { register } from 'tsx/esm/api' + +// register tsx enhancement +const unregister = register() + +// Unregister when needed +unregister() +``` + +#### Tracking loaded files +Detect files that get loaded with the `onImport` hook: + +```ts +register({ + onImport: (file: string) => { + console.log(file) + // file:///foo.ts + } +}) +``` + +#### Tracking loaded files +Detect files that get loaded with the `onImport` hook: + +```ts +register({ + onImport: (file: string) => { + console.log(file) + // file:///foo.ts + } +}) +``` + +### Scoped registration +If you want to register tsx without affecting the environment, you can add a namespace. + +```js +import { register } from 'tsx/esm/api' + +// register tsx enhancement +const api = register({ + namespace: Date.now().toString() +}) + +// You get a private `import()` function to load TypeScript files +// Since this is namespaced, it will not cache hit from prior imports +const loaded = await api.import('./file.ts', import.meta.url) + +// This is using the same namespace as above, so it will yield a cache hit +const loaded2 = await api.import('./file.ts', import.meta.url) + +// Unregister when needed +api.unregister() +``` diff --git a/docs/node/index.md b/docs/node/index.md new file mode 100644 index 000000000..1fdcbf015 --- /dev/null +++ b/docs/node/index.md @@ -0,0 +1,37 @@ +--- +outline: deep +--- + +# Node.js API + +> [!CAUTION] Caution: For Advanced Users +The Node.js API is for advanced usage and should not be necessary for the majory of use-cases. + +The Node.js API allows you to enhance Node with _tsx_ without directly running `tsx`. This is useful for adding TypeScript support to binaries (e.g. `eslint`), or to your 3rd-party package without affecting the environment (e.g. loading config files), or simply using `node` directly to reduce overhead. + +Note, when using the Node.js integrations, CLI features such as [_Watch mode_](/watch-mode) will not be available. + +## Global enhancement + +### Command-line API + +Run `node` with `tsx` in the [`--import`](https://nodejs.org/api/module.html#enabling) flag. This will add TypeScript & ESM support for both Module and CommonJS modes, and is identical to what running `tsx` does under the hood. + +```sh +node --import tsx ./file.ts +``` + +#### `NODE_OPTIONS` environment variable + +Node.js also accepts command-line flags via the [`NODE_OPTIONS`](https://nodejs.org/api/cli.html#node_optionsoptions) environment variable. This is useful when adding tsx to Node-based binaries. + +```sh +NODE_OPTIONS='--import tsx' npx eslint +``` + +### Programmatic API + +Load `tsx` at the top of your entry-file: +```js +import 'tsx' +``` diff --git a/docs/node/ts-import.md b/docs/node/ts-import.md new file mode 100644 index 000000000..52cb05ffc --- /dev/null +++ b/docs/node/ts-import.md @@ -0,0 +1,49 @@ +# `tsImport()` + +`tsImport()` is an enhanced `import()` that can load TypeScript files. Because it's an enhancement over the native `import()`, it even supports [top-level await](https://v8.dev/features/top-level-await)! + +Use this function for importing TypeScript files in Module mode without adding TypeScript support to the entire runtime. + +The current file path must be passed in as the second argument to resolve the import context. + +Since this is designed for one-time use, it does not cache loaded modules. + +::: warning Caveat +CommonJS files are currently not enhanced due to this [Node.js bug](https://github.com/nodejs/node/issues/51327). +::: + +## ESM usage + + +```js +import { tsImport } from 'tsx/esm/api' + +const loaded = await tsImport('./file.ts', import.meta.url) + +// If tsImport is used to load file.ts again, +// it does not yield a cache-hit and re-loads it +const loadedAgain = await tsImport('./file.ts', import.meta.url) +``` + +If you'd like to leverage module caching, see the [ESM scoped registration](http://localhost:5173/node/esm#scoped-registration) section. + +## CommonJS usage + +```js +const { tsImport } = require('tsx/esm/api') + +const loaded = await tsImport('./file.ts', __filename) +``` + +## Tracking loaded files +Detect files that get loaded with the `onImport` hook: + +```ts +tsImport('./file.ts', { + parentURL: import.meta.url, + onImport: (file: string) => { + console.log(file) + // file:///foo.ts + } +}) +``` diff --git a/docs/node/tsx-require.md b/docs/node/tsx-require.md new file mode 100644 index 000000000..808da57d5 --- /dev/null +++ b/docs/node/tsx-require.md @@ -0,0 +1,45 @@ +# `tsx.require()` + +`tsx.require()` is an enhanced `require()` function that can load TypeScript and ESM files. + +Use this function for importing TypeScript files in CommonJS mode without adding TypeScript support to the entire runtime. + +Note, the current file path must be passed in as the second argument to resolve the import context. + +::: warning Caveat +`import()` & asynchronous `require()` calls in the loaded files are not enhanced. +::: + +## CommonJS usage + +```js +const tsx = require('tsx/cjs/api') + +const tsLoaded = tsx.require('./file.ts', __filename) +const tsFilepath = tsx.require.resolve('./file.ts', __filename) +``` + +## ESM usage + +```js +import { require } from 'tsx/cjs/api' + +const tsLoaded = require('./file.ts', import.meta.url) +const tsFilepath = require.resolve('./file.ts', import.meta.url) +``` + +## Tracking loaded files + +Because the CommonJS API tracks loaded modules in `require.cache`, you can use it to identify loaded files for dependency tracking. This can be useful when implementing a watcher. + +```js +const resolvedPath = tsx.require.resolve('./file', import.meta.url) + +const collectDependencies = module => [ + module.filename, + ...module.children.flatMap(collectDependencies) +] + +console.log(collectDependencies(tsx.require.cache[resolvedPath])) +// ['/file.ts', '/foo.ts', '/bar.ts'] +``` diff --git a/docs/usage.md b/docs/usage.md index b96cce3ce..c7d1f2e76 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -2,9 +2,9 @@ outline: deep --- -# Basic usage +# Usage -## Replacing `node` with `tsx` +## Replace `node` with `tsx` `tsx` is a drop-in replacement for `node`, meaning you can use it the exact same way (supports all [command-line flags](https://nodejs.org/docs/latest-v20.x/api/cli.html)). diff --git a/src/esm/api/register.ts b/src/esm/api/register.ts index c137d7360..baafc37ff 100644 --- a/src/esm/api/register.ts +++ b/src/esm/api/register.ts @@ -1,19 +1,32 @@ import module from 'node:module'; import { MessageChannel, type MessagePort } from 'node:worker_threads'; import type { Message } from '../types.js'; +import { createScopedImport, type ScopedImport } from './scoped-import.js'; + +export type InitializationOptions = { + namespace?: string; + port?: MessagePort; +}; type Options = { namespace?: string; onImport?: (url: string) => void; }; -export type InitializationOptions = { - namespace?: string; - port?: MessagePort; +type Unregister = () => Promise; +type Register = { + (options: { + namespace: string; + onImport?: (url: string) => void; + }): Unregister & { + import: ScopedImport; + unregister: Unregister; + }; + (options?: Options): Unregister; }; -export const register = ( - options?: Options, +export const register: Register = ( + options, ) => { if (!module.register) { throw new Error(`This version of Node.js (${process.version}) does not support module.register(). Please upgrade to Node v18.9 or v20.6 and above.`); @@ -49,7 +62,7 @@ export const register = ( } // unregister - return () => { + const unregister = () => { if (sourceMapsEnabled === false) { process.setSourceMapsEnabled(false); } @@ -71,4 +84,11 @@ export const register = ( port1.on('message', onDeactivated); }); }; + + if (options?.namespace) { + unregister.import = createScopedImport(options.namespace); + unregister.unregister = unregister; + } + + return unregister; }; diff --git a/src/esm/api/scoped-import.ts b/src/esm/api/scoped-import.ts new file mode 100644 index 000000000..efe205cf5 --- /dev/null +++ b/src/esm/api/scoped-import.ts @@ -0,0 +1,44 @@ +import { pathToFileURL } from 'node:url'; + +const resolveSpecifier = ( + specifier: string, + fromFile: string, + namespace: string, +) => { + const base = ( + fromFile.startsWith('file://') + ? fromFile + : pathToFileURL(fromFile) + ); + const resolvedUrl = new URL(specifier, base); + + /** + * A namespace query is added so we get our own module cache + * + * I considered using an import attribute for this, but it doesn't seem to + * make the request unique so it gets cached. + */ + resolvedUrl.searchParams.set('tsx-namespace', namespace); + + return resolvedUrl.toString(); +}; + +export type ScopedImport = ( + specifier: string, + parentURL: string, +) => Promise; // eslint-disable-line @typescript-eslint/no-explicit-any + +export const createScopedImport = ( + namespace: string, +): ScopedImport => ( + specifier, + parentURL, +) => { + if (!parentURL) { + throw new Error('The current file path (import.meta.url) must be provided in the second argument of tsImport()'); + } + + return import( + resolveSpecifier(specifier, parentURL, namespace) + ); +}; diff --git a/src/esm/api/ts-import.ts b/src/esm/api/ts-import.ts index 184180515..0882f83e7 100644 --- a/src/esm/api/ts-import.ts +++ b/src/esm/api/ts-import.ts @@ -1,29 +1,5 @@ -import { pathToFileURL } from 'node:url'; import { register } from './register.js'; -const resolveSpecifier = ( - specifier: string, - fromFile: string, - namespace: string, -) => { - const base = ( - fromFile.startsWith('file://') - ? fromFile - : pathToFileURL(fromFile) - ); - const resolvedUrl = new URL(specifier, base); - - /** - * A namespace query is added so we get our own module cache - * - * I considered using an import attribute for this, but it doesn't seem to - * make the request unique so it gets cached. - */ - resolvedUrl.searchParams.set('tsx-namespace', namespace); - - return resolvedUrl.toString(); -}; - type Options = { parentURL: string; onImport?: (url: string) => void; @@ -42,7 +18,6 @@ const tsImport = ( const isOptionsString = typeof options === 'string'; const parentURL = isOptionsString ? options : options.parentURL; const namespace = Date.now().toString(); - const resolvedUrl = resolveSpecifier(specifier, parentURL, namespace); /** * We don't want to unregister this after load since there can be child import() calls @@ -50,11 +25,16 @@ const tsImport = ( * * This is not accessible to others because of the namespace */ - register({ + const api = register({ namespace, - onImport: isOptionsString ? undefined : options.onImport, + onImport: ( + isOptionsString + ? undefined + : options.onImport + ), }); - return import(resolvedUrl); + + return api.import(specifier, parentURL); }; /** diff --git a/src/esm/hook/resolve.ts b/src/esm/hook/resolve.ts index 1e8f6111c..890aabe1f 100644 --- a/src/esm/hook/resolve.ts +++ b/src/esm/hook/resolve.ts @@ -127,15 +127,13 @@ export const resolve: resolve = async ( || isRelativePathPattern.test(specifier) ); + const parentNamespace = context.parentURL && getNamespace(context.parentURL); if (isPath) { // Inherit namespace from parent let requestNamespace = getNamespace(specifier); - if (context.parentURL) { - const parentNamespace = getNamespace(context.parentURL); - if (parentNamespace && !requestNamespace) { - requestNamespace = parentNamespace; - specifier += `${specifier.includes('?') ? '&' : '?'}${namespaceQuery}${parentNamespace}`; - } + if (parentNamespace && !requestNamespace) { + requestNamespace = parentNamespace; + specifier += `${specifier.includes('?') ? '&' : '?'}${namespaceQuery}${parentNamespace}`; } if (data.namespace && data.namespace !== requestNamespace) { @@ -191,7 +189,12 @@ export const resolve: resolve = async ( } try { - return await resolveExplicitPath(nextResolve, specifier, context); + const resolved = await resolveExplicitPath(nextResolve, specifier, context); + const resolvedNamespace = getNamespace(resolved.url); + if (parentNamespace && !resolvedNamespace) { + resolved.url += `${resolved.url.includes('?') ? '&' : '?'}${namespaceQuery}${parentNamespace}`; + } + return resolved; } catch (error) { if ( error instanceof Error diff --git a/tests/specs/api.ts b/tests/specs/api.ts index 582258829..69dd54675 100644 --- a/tests/specs/api.ts +++ b/tests/specs/api.ts @@ -201,7 +201,7 @@ export default testSuite(({ describe }, node: NodeApis) => { { const unregister = register(); - + const { message } = await import('./file?2'); console.log(message); @@ -256,6 +256,33 @@ export default testSuite(({ describe }, node: NodeApis) => { }); expect(stdout).toBe('file.ts\nfoo.ts\nbar.ts\nindex.js'); }); + + test('namespace & onImport', async () => { + await using fixture = await createFixture({ + 'package.json': JSON.stringify({ type: 'module' }), + 'register.mjs': ` + import { register } from ${JSON.stringify(tsxEsmApiPath)}; + + const api = register({ + namespace: 'private', + onImport(file) { + console.log(file.split('/').pop()); + }, + }); + + await api.import('./file', import.meta.url); + + api.unregister(); + `, + ...tsFiles, + }); + + const { stdout } = await execaNode(fixture.getPath('register.mjs'), [], { + nodePath: node.path, + nodeOptions: [], + }); + expect(stdout).toBe('file.ts\nfoo.ts\nbar.ts\nindex.js'); + }); }); // add CJS test