diff --git a/package-lock.json b/package-lock.json index c9b39c7..3d6b2af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,10 @@ "@isaacs/cached": "^1.0.1", "@isaacs/catcher": "^1.0.4", "foreground-child": "^3.1.1", + "get-tsconfig": "^4.7.2", "mkdirp": "^3.0.1", "pirates": "^4.0.6", + "resolve-pkg-maps": "^1.0.0", "rimraf": "^6.0.1", "signal-exit": "^4.1.0", "sock-daemon": "^1.4.2", @@ -3956,7 +3958,6 @@ "version": "4.8.1", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.8.1.tgz", "integrity": "sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==", - "dev": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -6032,7 +6033,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" diff --git a/package.json b/package.json index 161c564..4898cd8 100644 --- a/package.json +++ b/package.json @@ -57,8 +57,10 @@ "@isaacs/cached": "^1.0.1", "@isaacs/catcher": "^1.0.4", "foreground-child": "^3.1.1", + "get-tsconfig": "^4.7.2", "mkdirp": "^3.0.1", "pirates": "^4.0.6", + "resolve-pkg-maps": "^1.0.0", "rimraf": "^6.0.1", "signal-exit": "^4.1.0", "sock-daemon": "^1.4.2", diff --git a/src/hooks/hooks.mts b/src/hooks/hooks.mts index 4978a49..accf621 100644 --- a/src/hooks/hooks.mts +++ b/src/hooks/hooks.mts @@ -13,6 +13,7 @@ import { MessagePort } from 'node:worker_threads' import { classifyModule } from '../classify-module.js' import { DaemonClient } from '../client.js' import { getDiagMode } from '../diagnostic-mode.js' +import { resolveMapping } from '../service/resolve-mapping.js' // in some cases on the loader thread, console.error doesn't actually // print. sync write to fd 1 instead. @@ -54,6 +55,7 @@ export const resolve: ResolveHook = async ( context, nextResolve, ) => { + url = resolveMapping(url, context.parentURL) const { parentURL } = context const target = /* c8 ignore start */ diff --git a/src/service/resolve-mapping.ts b/src/service/resolve-mapping.ts new file mode 100644 index 0000000..2f535ed --- /dev/null +++ b/src/service/resolve-mapping.ts @@ -0,0 +1,93 @@ +import { tsconfig } from './tsconfig.js' +import { relative, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createPathsMatcher, type TsConfigResult } from 'get-tsconfig' +import { + resolveImports, + type PathConditionsMap, +} from 'resolve-pkg-maps' +import { walkUp } from 'walk-up-path' +import { catcher } from '@isaacs/catcher' +import { readFile } from '../ts-sys-cached.js' +import { classifyModule } from '../classify-module.js' + +const resolveTypescriptMapping = ( + url: string, + parent: string | undefined +): string | undefined => { + if (!url || url.startsWith('file:///') || !parent) return undefined + const options = tsconfig().options + + const relativePath = url + const tsconfigArg = { + config: { compilerOptions: { ...options, baseUrl: './' } }, + path: options.configFilePath as string, + } as TsConfigResult + + const pathMather = createPathsMatcher(tsconfigArg) + + if (!pathMather) return undefined + const found = pathMather(relativePath) + if (found.length === 0) return undefined + return relative( + dirname(fileURLToPath(parent)), + found[0] as string + ).toString() +} + +const getPackageJson = ( + from: string +): + | ({ imports: PathConditionsMap } & { path: string }) + | undefined => { + for (const d of walkUp(from)) { + const pj = catcher(() => { + const json = readFile(d + '/package.json') + if (!json) return undefined + const pj = JSON.parse(json) as { imports: PathConditionsMap } + return pj + }) + if (pj) { + return { imports: pj?.imports, path: d } + } + } + return undefined +} + +const resolvePackageJsonMapping = ( + url: string, + parent: string | undefined +): string | undefined => { + if ( + !url || + url.startsWith('file:///') || + !url.startsWith('#') || + !parent + ) + return undefined + + const options = getPackageJson(dirname(fileURLToPath(parent))) + if (options === undefined) return undefined + + const found = resolveImports(options.imports, url, [ + 'default', + 'node', + classifyModule(url) === 'commonjs' ? 'require' : 'import', + ]) + if (found.length === 0) return undefined + return relative( + dirname(fileURLToPath(parent)), + join(options.path, found[0] as string) + ).toString() +} + +export const resolveMapping = ( + url: string, + parent: string | undefined +): string => { + return ( + resolvePackageJsonMapping(url, parent) ?? + resolveTypescriptMapping(url, parent) ?? + url + ) +} diff --git a/src/service/transpile-only.ts b/src/service/transpile-only.ts index 18182f1..88e735e 100644 --- a/src/service/transpile-only.ts +++ b/src/service/transpile-only.ts @@ -66,6 +66,7 @@ const createTsTranspileModule = ({ ) { continue } + if (option.name === 'paths') continue options[option.name] = option.transpileOptionValue } diff --git a/src/service/tsconfig.ts b/src/service/tsconfig.ts index bf248a2..236fa96 100644 --- a/src/service/tsconfig.ts +++ b/src/service/tsconfig.ts @@ -5,7 +5,7 @@ // return the same object if it parses to the same values. import { catcher } from '@isaacs/catcher' import { statSync } from 'fs' -import { resolve } from 'path' +import { dirname, resolve } from 'node:path' import ts from 'typescript' import { walkUp } from 'walk-up-path' import { error, warn } from '../debug.js' @@ -73,7 +73,7 @@ export const tsconfig = () => { // also default to recommended setting for node programs { compilerOptions: { - rootDir: dir, + rootDir: dirname(configPath), skipLibCheck: true, isolatedModules: true, esModuleInterop: true, @@ -106,7 +106,11 @@ export const tsconfig = () => { noEmit: false, }, }) - const newConfig = ts.parseJsonConfigFileContent(res, ts.sys, dir) + const newConfig = ts.parseJsonConfigFileContent( + res, + ts.sys, + dirname(configPath) + ) const newConfigJSON = JSON.stringify(newConfig) if (loadedConfig && newConfigJSON === loadedConfigJSON) { // no changes, keep the old one diff --git a/test/bin.ts b/test/bin.ts index 8cd141a..5051aad 100644 --- a/test/bin.ts +++ b/test/bin.ts @@ -8,8 +8,13 @@ const bin = fileURLToPath( new URL('../dist/esm/bin.mjs', import.meta.url), ) -const run = (args: string[]) => - spawnSync(process.execPath, [bin, ...args], { encoding: 'utf8' }) +const run = (args: string[], tsconfigPath?: string) => + spawnSync(process.execPath, [bin, ...args], { + encoding: 'utf8', + env: { + TSIMP_PROJECT: tsconfigPath, + }, + }) t.teardown(() => run(['--stop'])) @@ -65,6 +70,36 @@ t.test('actually run a program', async t => { export const f: Foo = { bar: true, baz: 'xyz' } console.error(f) `, + + // subpath import tests + 'package.json': JSON.stringify({ + type: 'module', + imports: { + '#utilities/*': './utilities/*', + }, + }), + 'tsconfig.json': JSON.stringify({ + compilerOptions: { + baseUrl: '.', + resolvePackageJsonImports: true, + paths: { + '+utilities/*': ['./utilities/*'], + }, + }, + }), + + utilities: { + source: { + 'constants.ts': + 'enum Constants { one = "one", two = "two", three = "three" }; export default Constants;', + }, + }, + + test: { + 'getOne.ts': `import Constants from "../utilities/source/constants.js"; console.log(Constants.one);`, + 'getTwo.ts': `import Constants from "#utilities/source/constants.ts"; console.log(Constants.two);`, + 'getThree.ts': `import Constants from "+utilities/source/constants.js"; console.log(Constants.three);`, + }, }) const rel = relative(process.cwd(), dir).replace(/\\/g, '/') @@ -125,4 +160,41 @@ t.test('actually run a program', async t => { t.equal(status, 0) t.equal(stdout, 'ok\n') }) + + t.test('run file with subpath imports', async t => { + run(['--restart'], `./${rel}/tsconfig.json`) + + { + const pathToFile = `./${rel}/test/getOne.ts` + const { stdout, status } = run( + [pathToFile], + `./${rel}/tsconfig.json` + ) + + t.equal(status, 0) + t.equal(stdout, 'one\n') + } + + { + const pathToFile = `./${rel}/test/getTwo.ts` + const { stdout, status } = run( + [pathToFile], + `./${rel}/tsconfig.json` + ) + + t.equal(status, 0) + t.equal(stdout, 'two\n') + } + + { + const pathToFile = `./${rel}/test/getThree.ts` + const { stdout, status } = run( + [pathToFile], + `./${rel}/tsconfig.json` + ) + + t.equal(status, 0) + t.equal(stdout, 'three\n') + } + }) })