ESM で実行する ts-node で paths のエイリアスを解決する

TLDR;

  • TypeScript で書いてトランスパイルしたものを node に ESM で読ませたい
    • commonjs にすればって感じだけど、使いたいライブラリが dual package 対応してなかったり、top level await が使いたくて、とかで ESM で実行したいときもある
  • 1 ファイル実行するのに全部トランスパイルするのはアレなので、ts-node や esbuild-register で実行したい
  • という感じで少なくとも現時点で「paths のエイリアスを解決しながら TypeScript を ESM 向けにトランスパイルして実行する」手段がなさそうなので、実行できるようにする

作る

esbuild-register でやるか、ts-node でやるか。

両方ざっと眺めてみたけど、esbuild-register ではそもそも ESM の実行自体できないので、そこから手を入れる必要があってめんどくさそうだった

ts-node なら loader を上書きしてエイリアスさえ読めるようにしてやれば実行できるのでこっちでやる

ts-node だと、ESM は

node --loader ts-node/esm ./src/hello.ts

で実行する。で、この ts-node/esm の実態が node_modules/ts-node/esm.mjs にある

import { fileURLToPath } from 'url'
import { createRequire } from 'module'
const require = createRequire(fileURLToPath(import.meta.url))

/** @type {import('./dist/esm')} */
const esm = require('./dist/esm')
export const {
  resolve,
  getFormat,
  transformSource,
} = esm.registerAndCreateEsmHooks()

nodejs の loader については公式ドキュメントを参照

Modules: ECMAScript modules | Node.js v16.9.1 Documentation

今回の用途なら ts-node の resolve を呼ぶ前に paths のエイリアス解決をしてから渡してやれば良いはずなのでエイリアス解決を書く

エイリアスの解決には tsconfig-paths を使う

yarn add -D tsconfig-paths typescript ts-node

で、実際に書いた loader が以下。

import path from 'path'
import typescript from 'typescript'
import { createMatchPath } from 'tsconfig-paths'
import { resolve as BaseResolve, getFormat, transformSource } from 'ts-node/esm'

const { readConfigFile, parseJsonConfigFileContent, sys } = typescript

const __dirname = path.dirname(new URL(import.meta.url).pathname)

const configFile = readConfigFile('./tsconfig.json', sys.readFile)
if (typeof configFile.error !== 'undefined') {
  throw new Error(`Failed to load tsconfig: ${configFile.error}`)
}

const { options } = parseJsonConfigFileContent(
  configFile.config,
  {
    fileExists: sys.fileExists,
    readFile: sys.readFile,
    readDirectory: sys.readDirectory,
    useCaseSensitiveFileNames: true,
  },
  __dirname
)

export { getFormat, transformSource }  // こいつらはそのまま使ってほしいので re-export する

const matchPath = createMatchPath(options.baseUrl, options.paths)

export async function resolve(specifier, context, defaultResolve) {
  const matchedSpecifier = matchPath(specifier)
  return BaseResolve(  // ts-node/esm の resolve に tsconfig-paths で解決したパスを渡す
    matchedSpecifier ? `${matchedSpecifier}.ts` : specifier,
    context,
    defaultResolve
  )
}

完成。tsconfig-paths と ts-node/esm を繋いでやってるだけ。

これで、エイリアス使って import してる TypeScript ファイルを実行してみる

$ yarn ts ./src/hello.ts
yarn run v1.22.10
$ node --loader ./loader.js ./src/hello.ts
(node:1871) ExperimentalWarning: --experimental-loader is an experimental feature. This feature could change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
$ echo hello! hoge
hello! hoge
✨  Done in 2.16s

問題なし。

おまけ

ちなみに、ts-node はトランスパイラに swc を指定できるようになったので、esbuild じゃないと遅くてやってられないよというなら差し替えることもできる

Third-party transpilers | ts-node

yarn add -D @swc/core @swc/helpers

swc をいれつつ、tsconfig.json に設定をかませる

{
  // ...
  "ts-node": {
    "transpileOnly": true,
    "transpiler": "ts-node/transpilers/swc-experimental"
  }
}

これで、さっきと同じスクリプトを実行してみる

$ yarn ts ./src/hello.ts
yarn run v1.22.10
$ node --loader ./loader.js ./src/hello.ts
(node:1987) ExperimentalWarning: --experimental-loader is an experimental feature. This feature could change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
$ echo hello! hoge
hello! hoge
✨  Done in 0.53s.

2.16 秒かかっていた処理が 0.53 秒で終わる