TLDR;
- TypeScript で書いてトランスパイルしたものを node に ESM で読ませたい
- commonjs にすればって感じだけど、使いたいライブラリが dual package 対応してなかったり、top level await が使いたくて、とかで ESM で実行したいときもある
- 1 ファイル実行するのに全部トランスパイルするのはアレなので、ts-node や esbuild-register で実行したい
- ts-node: (experimental ではあるけど) ESM の実行はできる。ただ tsconfig-paths/register が使えない
- esbuild-register: 未対応 (set “type”: “module” in
package.json
breaks the build · Issue #26 · egoist/esbuild-register)
- という感じで少なくとも現時点で「paths のエイリアスを解決しながら TypeScript を ESM 向けにトランスパイルして実行する」手段がなさそうなので、実行できるようにする
作る
esbuild-register でやるか、ts-node でやるか。
両方ざっと眺めてみたけど、esbuild-register ではそもそも ESM の実行自体できないので、そこから手を入れる必要があってめんどくさそうだった
ts-node なら loader を上書きしてエイリアスさえ読めるようにしてやれば実行できるのでこっちでやる
ts-node だと、ESM は
bashnode --loader ts-node/esm ./src/hello.ts
で実行する。で、この ts-node/esm
の実態が node_modules/ts-node/esm.mjs
にある
node_modules/ts-node/esm.mjsimport { 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 を使う
bashyarn add -D tsconfig-paths typescript ts-node
で、実際に書いた loader が以下。
loader.jsimport 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 } = typescriptconst __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 ファイルを実行してみる
bash$ yarn ts ./src/hello.tsyarn 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! hogehello! hoge✨ Done in 2.16s
問題なし。
おまけ
ちなみに、ts-node はトランスパイラに swc を指定できるようになったので、esbuild じゃないと遅くてやってられないよというなら差し替えることもできる
Third-party transpilers | ts-node
bashyarn add -D @swc/core @swc/helpers
swc をいれつつ、tsconfig.json に設定をかませる
tsconfig.json{// ..."ts-node": {"transpileOnly": true,"transpiler": "ts-node/transpilers/swc-experimental"}}
これで、さっきと同じスクリプトを実行してみる
bash$ yarn ts ./src/hello.tsyarn 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! hogehello! hoge✨ Done in 0.53s.
2.16 秒かかっていた処理が 0.53 秒で終わる