前提
フロントエンドは要らないので、 Vue.js や Vite, turbopack のような高機能なフレームワークは使わない。
実装
最初のディレクトリ構成と各ファイルの内容
$ tree mycli-example
mycli-example
├── app
│ ├── package.json
│ └── src
│ └── index.ts
└── bin
└── mycli
{
"name": "mycli-example",
"version": "1.0.0",
"scripts": {
"dev": "node src/index.ts"
}
}
console.log('Hello world!');
実行してみる。
$ npm run dev
> mycli-example@1.0.0 dev
> node src/index.ts
Hello world!
ちゃんと動く。
別ファイルにモジュールを分けて書く。
1ファイルにはまとめにくい、もう少し複雑なプログラムを想定する。
export function hello(name: string) {
console.log(`Hello, ${name}!`);
}
index.ts 側ではそれをインポートして使用する。
import { hello } from './module.ts';
hello('world');
実行する。
$ npm run dev
> mycli-example@1.0.0 dev
> node src/index.ts
(node:13187) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
import { hello } from './module.ts';
^^^^^^
SyntaxError: Cannot use import statement outside a module
エラーになったので、エラー内容にあるように package.json に設定を追加してみる。
{
"name": "mycli-example",
"version": "1.0.0",
+ "type": "module",
"scripts": {
"dev": "node src/index.ts"
}
}
実行するとエラー内容が変わった。
$ npm run dev
> mycli-example@1.0.0 dev
> node src/index.ts
node:internal/errors:496
ErrorCaptureStackTrace(err);
^
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for /path/to/mycli-example/app/src/index.ts
node
では .ts
の拡張子を持つファイルをインポートできないとのこと。
代わりに ts-node
を使用してみる。
npm install -D ts-node
tsconfig.json ファイルを作成し、最小限の設定を追加する。
{
"compilerOptions": {
"module": "esnext"
},
"ts-node": {
"esm": true,
"experimentalSpecifierResolution": "node"
}
}
まだエラーが出るが、エラー内容は変わった。
$ npm run dev
> mycli-example@1.0.0 dev
> ts-node src/index.ts
/path/to/mycli-example/app/node_modules/ts-node/src/index.ts:859
return new TSError(diagnosticText, diagnosticCodes, diagnostics);
^
TSError: ⨯ Unable to compile TypeScript:
src/index.ts:1:23 - error TS5097: An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled.
1 import { hello } from './module.ts';
~~~~~~~~~~~~~
今度もエラー内容にあるように tsconfig.json に設定を追加してみる。
{
"compilerOptions": {
"module": "esnext",
+ "allowImportingTsExtensions": true,
+ "noEmit": true
},
"ts-node": {
"esm": true,
"experimentalSpecifierResolution": "node"
}
}
実行。
$ npm run dev
> mycli-example@1.0.0 dev
> ts-node src/index.ts
Hello, world!
ようやく動いた!
今はまだコードが少ないのでこれでも問題ないが、規模が大きくなってくると毎回の実行に時間がかかるようになる。
開発時以外は、ビルド(トランスパイル)された JavaScript ファイルを実行したい。
以前は Webpack を使っていたが、開発が停止しているとのこと。
ひとまず tsc を使ってみる。
npm install -D typescript
{
"name": "mycli-example",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "ts-node src/index.ts",
+ "build": "tsc"
},
"devDependencies": {
"ts-node": "^10.9.2",
"typescript": "^5.4.3"
}
}
ビルドしてみる。
npm run build
error TS2792: Cannot find module 'XXX'. Did you mean to set the 'moduleResolution' option to 'nodenext', or to add aliases to the 'paths' option?
"moduleResolution": "nodenext"
を指定すると "module": "nodenext"
とする必要がある。moduleResolution は node でも問題ないので、ひとまず以下のように tsconfig.json を修正する。
{
"compilerOptions": {
"module": "esnext",
+ "moduleResolution": "node",
+ "outDir": "./dist",
"allowImportingTsExtensions": true,
"noEmit": true
},
"ts-node": {
"esm": true,
"experimentalSpecifierResolution": "node"
}
}
npm run build
動いた...が、ファイルが作成されない。
tsconfig.json に設定した "noEmit": true
が原因。
ただこれを外すと "allowImportingTsExtensions": true
が設定できなくなってしまう。
ちなみに noEmit を外した時の allowImportingTsExtensions が出すエラーメッセージには「noEmit か emitDeclarationOnly を設定する必要がある」となっているが、代わりに emitDeclarationOnly を設定しても js ファイルは作成されない(定義ファイルのみ出力されるようになる)。
tsc はあきらめて、 esbuild を使用する。
最近は tsx もいいらしい。
npm i -D esbuild
npx esbuild
で実行することもできるが、オプションをいろいろ設定する必要があるため esbuild の API を使用する。
import * as esbuild from 'esbuild';
await esbuild.build({
entryPoints: ['./src/index.ts'],
target: 'esnext',
platform: 'node',
format: 'esm',
bundle: true,
minify: true,
outdir: 'dist',
});
{
"name": "mycli-example",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "ts-node src/index.ts",
- "build": "tsc"
+ "build": "node esbuild.js",
+ "exec": "node dist/index.js"
},
"devDependencies": {
+ "esbuild": "^0.20.2",
"ts-node": "^10.9.2",
"typescript": "^5.4.3"
}
}
ビルドしてみる。
npm run build
app/dist/ に index.js が生成された。
function o(l){console.log(`Hello, ${l}!`)}o("world");
minify: true
の設定によりソースコードの圧縮が行われている。
ビルドして作成されたのは JavaScript ファイルなので、 node で実行する。
$ npm run exec
> mycli-example@1.0.0 exec
> node dist/index.js
Hello, world!
ちゃんと動いた。
外部ライブラリを使う場合
適当に外部ライブラリを使うようにコードを修正してみる。
+ import * as dotenv from 'dotenv';
import { hello } from './module.ts';
+ dotenv.config();
hello('world');
ビルドして再度実行してみる。
$ npm run build
> mycli-example@1.0.0 build
> node esbuild.js
$ npm run exec
> mycli-example@1.0.0 exec
> node dist/index.js
file:///home/mwatanabe/project/mycli-example/app/dist/index.js:1
Error: Dynamic require of "fs" is not supported
ESM では使えない CommonJS 特有の機能が正常に動いていない。
import * as esbuild from 'esbuild';
await esbuild.build({
entryPoints: ['./src/index.ts'],
target: 'esnext',
platform: 'node',
format: 'esm',
bundle: true,
minify: true,
outdir: 'dist',
+ banner: {
+ js: "import{createRequire}from'module';const require=createRequire(import.meta.url);",
+ },
});
require
が実行できるよう、 index.js の先頭にコードを差し込む設定を追加する。
こちらの記事に詳しく書かれていた。
ビルドしてみる。
npm run build
+ import{createRequire}from'module';const require=createRequire(import.meta.url);
var O=Object.create;var m=Object.defineProperty;...(省略)
ちゃんと追加されているので、実行してみる。
$ npm run exec
> mycli-example@1.0.0 exec
> node dist/index.js
Hello, world!
実行できた。
#!/bin/sh -e
cd "$(dirname "$0")/../app/"
npm run exec "$@"
export PATH=$PATH:/path/to/mycli-example/bin
$ mycli
> mycli-example@1.0.0 exec
> node dist/index.js
Hello, world!
完成。
補足
ts-node ではなく tsx を使うと高速らしいので、プログラムの規模によってはそれでも良いかもしれない。