CLI で TypeScript のプログラムを動かす

## 前提

フロントエンドは要らないので、 Vue.js や Vite, turbopack のような高機能なフレームワークは使わない。

## 実装

### 最初のディレクトリ構成と各ファイルの内容

```sh
$ tree mycli-example
mycli-example
├── app
│   ├── package.json
│   └── src
│       └── index.ts
└── bin
    └── mycli
```

```json:app/package.json
{
  "name": "mycli-example",
  "version": "1.0.0",
  "scripts": {
    "dev": "node src/index.ts"
  }
}
```

```ts:app/src/index.ts
console.log('Hello world!');
```

実行してみる。

```sh
$ npm run dev

> mycli-example@1.0.0 dev
> node src/index.ts

Hello world!
```

ちゃんと動く。

### 別ファイルにモジュールを分けて書く。

1ファイルにはまとめにくい、もう少し複雑なプログラムを想定する。

```ts:app/src/module.ts
export function hello(name: string) {
  console.log(`Hello, ${name}!`);
}
```

index.ts 側ではそれをインポートして使用する。

```ts:app/src/index.ts
import { hello } from './module.ts';

hello('world');
```

実行する。

```sh
$ 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 に設定を追加してみる。

```diff:app/package.json
 {
   "name": "mycli-example",
   "version": "1.0.0",
+  "type": "module",
   "scripts": {
     "dev": "node src/index.ts"
   }
 }
```

実行するとエラー内容が変わった。

```sh
$ 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` を使用してみる。

```sh
npm install -D ts-node
```

tsconfig.json ファイルを作成し、最小限の設定を追加する。

```json:app/tsconfig.json
{
  "compilerOptions": {
    "module": "esnext"
  },
  "ts-node": {
    "esm": true,
    "experimentalSpecifierResolution": "node"
  }
}
```

まだエラーが出るが、エラー内容は変わった。

```sh
$ 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 に設定を追加してみる。

```diff:app/tsconfig.json
 {
   "compilerOptions": {
     "module": "esnext",
+    "allowImportingTsExtensions": true,
+    "noEmit": true
   },
   "ts-node": {
     "esm": true,
     "experimentalSpecifierResolution": "node"
   }
 }
```

実行。

```sh
$ npm run dev

> mycli-example@1.0.0 dev
> ts-node src/index.ts

Hello, world!
```

ようやく動いた！

今はまだコードが少ないのでこれでも問題ないが、規模が大きくなってくると毎回の実行に時間がかかるようになる。

開発時以外は、ビルド（トランスパイル）された JavaScript ファイルを実行したい。

以前は Webpack を使っていたが、開発が停止しているとのこと。

ひとまず tsc を使ってみる。

```sh
npm install -D typescript
```

```diff:package.json
 {
   "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"
   }
 }
```

ビルドしてみる。

```sh
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 を修正する。

```diff:app/tsconfig.json
 {
   "compilerOptions": {
     "module": "esnext",
+    "moduleResolution": "node",
+    "outDir": "./dist",
     "allowImportingTsExtensions": true,
     "noEmit": true
   },
   "ts-node": {
     "esm": true,
     "experimentalSpecifierResolution": "node"
   }
 }
```

```sh
npm run build
```

動いた...が、ファイルが作成されない。

tsconfig.json に設定した `"noEmit": true` が原因。

ただこれを外すと `"allowImportingTsExtensions": true` が設定できなくなってしまう。

ちなみに noEmit を外した時の allowImportingTsExtensions が出すエラーメッセージには「noEmit か emitDeclarationOnly を設定する必要がある」となっているが、代わりに emitDeclarationOnly を設定しても js ファイルは作成されない（定義ファイルのみ出力されるようになる）。

tsc はあきらめて、 esbuild を使用する。

最近は tsx もいいらしい。

```sh
npm i -D esbuild
```

`npx esbuild` で実行することもできるが、オプションをいろいろ設定する必要があるため esbuild の API を使用する。

```js:app/esbuild.js
import * as esbuild from 'esbuild';

await esbuild.build({
  entryPoints: ['./src/index.ts'],
  target: 'esnext',
  platform: 'node',
  format: 'esm',
  bundle: true,
  minify: true,
  outdir: 'dist',
});
```

```diff:package.json
 {
   "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"
   }
 }
```

ビルドしてみる。

```sh
npm run build
```

app/dist/ に index.js が生成された。

```js:app/dist/index.js
function o(l){console.log(`Hello, ${l}!`)}o("world");
```

`minify: true` の設定によりソースコードの圧縮が行われている。

ビルドして作成されたのは JavaScript ファイルなので、 node で実行する。

```sh
$ npm run exec

> mycli-example@1.0.0 exec
> node dist/index.js

Hello, world!
```

ちゃんと動いた。

### 外部ライブラリを使う場合

適当に外部ライブラリを使うようにコードを修正してみる。

```diff:app/index.ts
+ import * as dotenv from 'dotenv';
  import { hello } from './module.ts';

+ dotenv.config();

  hello('world');
```

ビルドして再度実行してみる。

```sh
$ 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 特有の機能が正常に動いていない。

```diff:app/esbuild.js
 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 の先頭にコードを差し込む設定を追加する。

こちらの記事に詳しく書かれていた。

https://zenn.dev/junkor/articles/2bcd22ca08d21d#esbuild%E3%81%AE%E3%83%93%E3%83%AB%E3%83%89%E3%82%AA%E3%83%97%E3%82%B7%E3%83%A7%E3%83%B3

ビルドしてみる。

```sh
npm run build
```

```diff:app/dist/index.js
+ import{createRequire}from'module';const require=createRequire(import.meta.url);
  var O=Object.create;var m=Object.defineProperty;...(省略)
```

ちゃんと追加されているので、実行してみる。

```sh
$ npm run exec

> mycli-example@1.0.0 exec
> node dist/index.js

Hello, world!
```

実行できた。



```sh:bin/mycli
#!/bin/sh -e

cd "$(dirname "$0")/../app/"

npm run exec "$@"
```

```sh
export PATH=$PATH:/path/to/mycli-example/bin
```

```sh
$ mycli

> mycli-example@1.0.0 exec
> node dist/index.js

Hello, world!
```

完成。

### 補足

ts-node ではなく tsx を使うと高速らしいので、プログラムの規模によってはそれでも良いかもしれない。