From 3f42ae3dfc666cd4a6de7376ab051840e18c8c05 Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Thu, 23 May 2024 12:40:56 +0900 Subject: [PATCH] feat(esm api): configurable `tsconfig` --- docs/node/ts-import.md | 18 ++++ src/cjs/api/global-require-patch.ts | 3 + src/esm/api/register.ts | 7 +- src/esm/api/ts-import.ts | 9 +- src/esm/hook/initialize.ts | 10 +- src/utils/tsconfig.ts | 40 +++++-- tests/specs/api.ts | 160 +++++++++++++++++++++++++++- 7 files changed, 227 insertions(+), 20 deletions(-) diff --git a/docs/node/ts-import.md b/docs/node/ts-import.md index eaa7f38ae..d9e8491a9 100644 --- a/docs/node/ts-import.md +++ b/docs/node/ts-import.md @@ -34,6 +34,24 @@ const { tsImport } = require('tsx/esm/api') const loaded = await tsImport('./file.ts', __filename) ``` +## `tsconfig.json` + +### Custom `tsconfig.json` path +```ts +tsImport('./file.ts', { + parentURL: import.meta.url, + tsconfig: './custom-tsconfig.json' +}) +``` + +### Disable `tsconfig.json` lookup +```ts +tsImport('./file.ts', { + parentURL: import.meta.url, + tsconfig: false +}) +``` + ## Tracking loaded files Detect files that get loaded with the `onImport` hook: diff --git a/src/cjs/api/global-require-patch.ts b/src/cjs/api/global-require-patch.ts index 1d28fcf8b..7fd4738d7 100644 --- a/src/cjs/api/global-require-patch.ts +++ b/src/cjs/api/global-require-patch.ts @@ -1,4 +1,5 @@ import Module from 'node:module'; +import { loadTsconfig } from '../../utils/tsconfig.js'; import { extensions } from './module-extensions.js'; import { resolveFilename } from './module-resolve-filename.js'; @@ -6,6 +7,8 @@ export const register = () => { const { sourceMapsEnabled } = process; const { _extensions, _resolveFilename } = Module; + loadTsconfig(process.env.TSX_TSCONFIG_PATH); + // register process.setSourceMapsEnabled(true); // @ts-expect-error overwriting read-only property diff --git a/src/esm/api/register.ts b/src/esm/api/register.ts index 6e610bea7..6f3499c78 100644 --- a/src/esm/api/register.ts +++ b/src/esm/api/register.ts @@ -3,14 +3,18 @@ import { MessageChannel, type MessagePort } from 'node:worker_threads'; import type { Message } from '../types.js'; import { createScopedImport, type ScopedImport } from './scoped-import.js'; +export type TsconfigOptions = false | string; + export type InitializationOptions = { namespace?: string; port?: MessagePort; + tsconfig?: TsconfigOptions; }; export type RegisterOptions = { namespace?: string; onImport?: (url: string) => void; + tsconfig?: TsconfigOptions; }; export type Unregister = () => Promise; @@ -44,8 +48,9 @@ export const register: Register = ( { parentURL: import.meta.url, data: { - namespace: options?.namespace, port: port2, + namespace: options?.namespace, + tsconfig: options?.tsconfig, } satisfies InitializationOptions, transferList: [port2], }, diff --git a/src/esm/api/ts-import.ts b/src/esm/api/ts-import.ts index 0882f83e7..e0f486993 100644 --- a/src/esm/api/ts-import.ts +++ b/src/esm/api/ts-import.ts @@ -1,8 +1,9 @@ -import { register } from './register.js'; +import { register, type TsconfigOptions } from './register.js'; type Options = { parentURL: string; onImport?: (url: string) => void; + tsconfig?: TsconfigOptions; }; const tsImport = ( specifier: string, @@ -27,10 +28,10 @@ const tsImport = ( */ const api = register({ namespace, - onImport: ( + ...( isOptionsString - ? undefined - : options.onImport + ? {} + : options ), }); diff --git a/src/esm/hook/initialize.ts b/src/esm/hook/initialize.ts index 5d71f35d4..0ba6a4707 100644 --- a/src/esm/hook/initialize.ts +++ b/src/esm/hook/initialize.ts @@ -1,6 +1,7 @@ import type { GlobalPreloadHook, InitializeHook } from 'node:module'; import type { InitializationOptions } from '../api/register.js'; import type { Message } from '../types.js'; +import { loadTsconfig } from '../../utils/tsconfig.js'; type Data = InitializationOptions & { active: boolean; @@ -19,6 +20,10 @@ export const initialize: InitializeHook = async ( data.namespace = options.namespace; + if (options.tsconfig !== false) { + loadTsconfig(options.tsconfig ?? process.env.TSX_TSCONFIG_PATH); + } + if (options.port) { data.port = options.port; @@ -32,4 +37,7 @@ export const initialize: InitializeHook = async ( } }; -export const globalPreload: GlobalPreloadHook = () => 'process.setSourceMapsEnabled(true);'; +export const globalPreload: GlobalPreloadHook = () => { + loadTsconfig(process.env.TSX_TSCONFIG_PATH); + return 'process.setSourceMapsEnabled(true);'; +}; diff --git a/src/utils/tsconfig.ts b/src/utils/tsconfig.ts index 0d0700454..cc8a0b73d 100644 --- a/src/utils/tsconfig.ts +++ b/src/utils/tsconfig.ts @@ -4,19 +4,37 @@ import { parseTsconfig, createFilesMatcher, createPathsMatcher, + type TsConfigResult, + type FileMatcher, } from 'get-tsconfig'; -const tsconfig = ( - process.env.TSX_TSCONFIG_PATH - ? { - path: path.resolve(process.env.TSX_TSCONFIG_PATH), - config: parseTsconfig(process.env.TSX_TSCONFIG_PATH), - } - : getTsconfig() -); +// eslint-disable-next-line import-x/no-mutable-exports +export let fileMatcher: undefined | FileMatcher; + +// eslint-disable-next-line import-x/no-mutable-exports +export let tsconfigPathsMatcher: undefined | ReturnType; -export const fileMatcher = tsconfig && createFilesMatcher(tsconfig); +// eslint-disable-next-line import-x/no-mutable-exports +export let allowJs = false; -export const tsconfigPathsMatcher = tsconfig && createPathsMatcher(tsconfig); +export const loadTsconfig = ( + configPath?: string, +) => { + let tsconfig: TsConfigResult | null = null; + if (configPath) { + const resolvedConfigPath = path.resolve(configPath); + tsconfig = { + path: resolvedConfigPath, + config: parseTsconfig(resolvedConfigPath), + }; + } else { + tsconfig = getTsconfig(); + if (!tsconfig) { + return; + } + } -export const allowJs = tsconfig?.config.compilerOptions?.allowJs ?? false; + fileMatcher = createFilesMatcher(tsconfig); + tsconfigPathsMatcher = createPathsMatcher(tsconfig); + allowJs = tsconfig?.config.compilerOptions?.allowJs ?? false; +}; diff --git a/tests/specs/api.ts b/tests/specs/api.ts index 208c8aac2..ca4be2fd6 100644 --- a/tests/specs/api.ts +++ b/tests/specs/api.ts @@ -9,7 +9,7 @@ import { tsxEsmApiCjsPath, type NodeApis, } from '../utils/tsx.js'; -import { createPackageJson } from '../fixtures.js'; +import { createPackageJson, createTsconfig } from '../fixtures.js'; const tsFiles = { 'file.ts': ` @@ -188,7 +188,7 @@ export default testSuite(({ describe }, node: NodeApis) => { expect(stdout).toBe('Fails as expected\nfoo bar'); }); - describe('register / unregister', ({ test }) => { + describe('register / unregister', ({ test, describe }) => { test('register / unregister', async () => { await using fixture = await createFixture({ 'package.json': createPackageJson({ type: 'module' }), @@ -285,9 +285,141 @@ export default testSuite(({ describe }, node: NodeApis) => { }); expect(stdout).toBe('file.ts\nfoo.ts\nbar.ts\nindex.js'); }); + + describe('tsconfig', ({ test }) => { + test('should error on unresolvable tsconfig', async () => { + await using fixture = await createFixture({ + 'tsconfig.json': createTsconfig({ + extends: 'doesnt-exist', + }), + 'register.mjs': ` + import { register } from ${JSON.stringify(tsxEsmApiPath)}; + register(); + `, + }); + + const { exitCode, stderr } = await execaNode('register.mjs', [], { + reject: false, + cwd: fixture.path, + nodePath: node.path, + nodeOptions: [], + }); + expect(exitCode).toBe(1); + expect(stderr).toMatch('File \'doesnt-exist\' not found.'); + }); + + test('disable lookup', async () => { + await using fixture = await createFixture({ + 'tsconfig.json': createTsconfig({ + extends: 'doesnt-exist', + }), + 'register.mjs': ` + import { register } from ${JSON.stringify(tsxEsmApiPath)}; + register({ + tsconfig: false, + }); + `, + }); + + await execaNode('register.mjs', [], { + cwd: fixture.path, + nodePath: node.path, + nodeOptions: [], + }); + }); + + test('custom path', async () => { + await using fixture = await createFixture({ + 'package.json': createPackageJson({ type: 'module' }), + 'tsconfig.json': createTsconfig({ + extends: 'doesnt-exist', + }), + 'tsconfig-custom.json': createTsconfig({ + compilerOptions: { + jsxFactory: 'Array', + jsxFragmentFactory: 'null', + }, + }), + 'register.mjs': ` + import { register } from ${JSON.stringify(tsxEsmApiPath)}; + register({ + tsconfig: './tsconfig-custom.json', + }); + await import('./tsx.tsx'); + `, + 'tsx.tsx': ` + console.log(<>hi); + `, + }); + + const { stdout } = await execaNode('register.mjs', [], { + cwd: fixture.path, + nodePath: node.path, + nodeOptions: [], + }); + expect(stdout).toBe('[ null, null, \'hi\' ]'); + }); + + test('custom path - invalid', async () => { + await using fixture = await createFixture({ + 'package.json': createPackageJson({ type: 'module' }), + 'register.mjs': ` + import { register } from ${JSON.stringify(tsxEsmApiPath)}; + register({ + tsconfig: './doesnt-exist', + }); + await import('./tsx.tsx'); + `, + 'tsx.tsx': ` + console.log(<>hi); + `, + }); + + const { exitCode, stderr } = await execaNode('register.mjs', [], { + reject: false, + cwd: fixture.path, + nodePath: node.path, + nodeOptions: [], + }); + expect(exitCode).toBe(1); + expect(stderr).toMatch('Cannot resolve tsconfig at path'); + }); + + test('fallsback to env var', async () => { + await using fixture = await createFixture({ + 'package.json': createPackageJson({ type: 'module' }), + 'tsconfig.json': createTsconfig({ + extends: 'doesnt-exist', + }), + 'tsconfig-custom.json': createTsconfig({ + compilerOptions: { + jsxFactory: 'Array', + jsxFragmentFactory: 'null', + }, + }), + 'register.mjs': ` + import { register } from ${JSON.stringify(tsxEsmApiPath)}; + register(); + await import('./tsx.tsx'); + `, + 'tsx.tsx': ` + console.log(<>hi); + `, + }); + + const { stdout } = await execaNode('register.mjs', [], { + cwd: fixture.path, + nodePath: node.path, + nodeOptions: [], + env: { + TSX_TSCONFIG_PATH: 'tsconfig-custom.json', + }, + }); + expect(stdout).toBe('[ null, null, \'hi\' ]'); + }); + }); }); - // add CJS test describe('tsImport()', ({ test }) => { test('module', async () => { await using fixture = await createFixture({ @@ -436,6 +568,28 @@ export default testSuite(({ describe }, node: NodeApis) => { }); expect(stdout).toBe('foo\nfoo'); }); + + test('tsconfig disable', async () => { + await using fixture = await createFixture({ + 'package.json': createPackageJson({ type: 'module' }), + 'tsconfig.json': createTsconfig({ extends: 'doesnt-exist' }), + 'import.mjs': ` + import { tsImport } from ${JSON.stringify(tsxEsmApiPath)}; + + await tsImport('./file.ts', { + parentURL: import.meta.url, + tsconfig: false, + }); + `, + ...tsFiles, + }); + + await execaNode('import.mjs', [], { + cwd: fixture.path, + nodePath: node.path, + nodeOptions: [], + }); + }); }); } else { test('no module.register error', async () => {