Deno で esbuild を使った知見

## まえがき

最近 [esbuild](https://esbuild.github.io/) を使っていくつかプロダクトを作ったときに得た知見をまとめようと思います。

[公式ドキュメント: Deno instead of node](https://esbuild.github.io/getting-started/#deno)

## esbuild の思想と限界

いきなり限界の話をするのもどうかと思いますが、esbuild が何に使えて何に使えないかを知ることは重要です。esbuild は高速なビルドツールとして Vite や Amazon CDK の内部に使用されるなどデファクトスタンダードの地位を確立しつつあります。しかし内部的に使用されているということは esbuild 単体での能力ではある程度規模の大きなアプリケーションを作成するのは難しいことを示しています。

実際に公式の FAQ では次のように述べられています:

> Think of esbuild as a "linker" for the web. It knows how to transform and bundle JavaScript and CSS. But the details of how your source code ends up as plain JavaScript or CSS may need to be 3rd-party code.

> 訳: esbuild は Web 用の「リンカ」と考えてください。esbuild は JavaScript と CSS を変換しバンドルする方法を知っています。しかし JavaScript や CSS の最終出力を詳細に制御するにはサードパーティのコードが必要かもしれません。

> I'm hoping that plugins will allow the community to add major features (e.g. WebAssembly import) without needing to contribute to esbuild itself. However, not everything is exposed in the plugin API and it may be the case that it's not possible to add a particular feature to esbuild that you may want to add. This is intentional; esbuild is not meant to be an all-in-one solution for all frontend needs.

> esbuild 開発者はプラグインによって主要な機能 (WebAssembly のインポートなど) が esbuild 自体にコントリビュートすることなく追加できることを願っています。しかしながら、すべての機能が API として公開されているわけではなく、追加したい機能が esbuild に追加することができないこともあるでしょう。これは意図的で、esbuild はすべてのフロントエンドの要求を満たすオールインワンの解決策ではないということを意味します。

https://esbuild.github.io/faq/#production-readiness

すなわち esbuild はトランスパイルとバンドルに特化しているため、すべての作業を esbuild で行うことは想定されていません (これは Webpack との大きな違いと考えています)。以下のセクションではビルド結果の出力やファイルのコピーを行う例を示していますが、本来 esbuild とは別に実行されるべき処理だと想定されているということに留意する必要があります。

## Import Map について

Deno ではブラウザ互換のインポート (`import` 文を使ったインポート; いわゆる ESM インポート) が可能で、以下のようなインポートをすることが可能です。

```typescript
import * as lodash from 'https://deno.land/x/lodash@4.17.19/lodash.js';
```

これに加えて、 [import map](https://developer.mozilla.org/ja/docs/Web/HTML/Element/script/type/importmap) にも対応しています。次に示すように import map を用意することでモジュール名を指定してインポートする (エイリアスを用意する) ことができます。型注釈のインポートにも利用できます。

```json:import_map.json
{
  "imports": {
    "lodash": "https://deno.land/x/lodash@4.17.19/lodash.js",
    "lodash/types": "npm:@types/lodash@4.17.19"
  }
}
```

```typescript:some.ts
// @deno-types="lodash/types"
import * as lodash from 'lodash';
```

このファイルを Deno で実行する際には deno.json の `importMap` プロパティにファイルパスを指定するか、import map の内容を直接記述します。

```json:deno.json
{
  "importMap": "./import_map.json"
}
```
```jsonc:deno.json
{
  // deno.json のトップレベル
  "imports": {
    "lodash": "https://deno.land/x/lodash@4.17.19/lodash.js",
    "lodash/types": "npm:@types/lodash@4.17.19"
  }
}
```

または CLI オプションで次のように指定します。

```sh
$deno run --importmap import_map.json some.ts
```

~~また、vscode では `deno.importmap` を指定することで補完を効かせることができます。~~ `deno.json` から自動で読み取ってくれるようです。これは Deno 本体で import maps を利用する方法ですが [外部モジュールを使う](#external_module) では esbuild でバンドルする方法を紹介します。

<div id="minimam_web_build" />

## Web ビルド最小構成

:::note info
基本的には [Vite](https://ja.vitejs.dev/) などのフレームワークを利用することを推奨します。
:::

とりあえず最小の構成でビルドするサンプルです。公式ドキュメントにある通り、`esbuild.stop()` を呼ぶ必要があります。

```typescript:build.ts
import * as esbuild from 'esbuild';
import * as posix from 'posix';

await esbuild.build({
  entryPoints: [posix.resolve('src', 'script.ts')],
  bundle: true,
  outdir: posix.resolve('dist'),
  minify: true,
  sourcemap: 'external',
});

await esbuild.stop();
```

<div id="watch" />

## ファイルの変更を監視 (watch) する

`esbuild.BuildContext.watch` を呼び出すことで watch モードになります。処理は返却されるので入力待ちなどでメインプロセスを維持する必要があります。以下のソースでは Enter キーを押すことで強制再ビルドさせる機能を追加しています。q を入力したときに終了するなども可能です。

```typescript
const ctx = await esbuild.context(config);

await ctx.watch();
console.log('Watching...');

for await (const chunk of Deno.stdin.readable) {
  const text = decoder.decode(chunk).trim();

  if (text === 'r') {
    // rebuild
    await ctx.rebuild().catch(() => {});
  } else if (text === 'q') {
    // quit
    await ctx.dispose();
    break;
  }
}
```

<div id="serve" />

## ビルドしながらサーバーをホスト (serve) する

`esbuild.BuildContext.serve` を呼び出すことで HTTP サーバーをホストすることができます。[上述](#ファイルの変更を監視-watch-する) の watch モードと同様にメインプロセスを維持する必要があります。

```typescript
const ctx = await esbuild.context(config);

const { host, port } = await ctx.serve({
  servedir: config.outdir
});
console.log(`Serving on ${host}:${port}`);

// ...
```

また、`esbuild.BuildContext.watch` を続けて呼び出すことで watch モードで HTTP サーバーを建てることができます。

```typescript
const ctx = await esbuild.context(config);

const { host, port } = await ctx.serve({
  servedir: config.outdir
});
console.log(`Serving on ${host}:${port}`);

await ctx.watch();
console.log('Watching...');
```

esbuild の serve モードは Server Sent Events によるホットリロード用のエンドポイント (`/esbuild`) を提供します。そのためクライアントに次の記述を追加することでホットリロードが可能です ([公式 docs](https://esbuild.github.io/api/#live-reload))。

```typescript
new EventSource('/esbuild').addEventListener('change', () => location.reload())
```

参考: [ライブリロードのコードを自動的に追加するプラグイン](https://github.com/Tsukina-7mochi/mypage-dev/blob/main/plugins/liveReloadPlugin.ts)

<div id="using_post_process" />

## ポストプロセス (Babel など) を使う

`onEnd` コールバックで出力結果を加工することが可能です。その際には `BuildOptions.write` を `false` にする必要があります。こうすることで `BuildResult.outputFiles` から出力結果を参照できるようになります。次のレポジトリは Babel を使用する例です。

https://github.com/Tsukina-7mochi/github-cards/blob/main/plugins/babelPlugin.ts

<div id="using_jsx" />

## JSX/TSX (React, preact など) を使う

`deno.json` に次のように JSX と JSX Fragment のファクトリの設定を記述します。

```json:React の場合
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxFactory": "react.createElement",
    "jsxFragmentFactory": "react.Fragment"
  },
}
```

```json:preact の場合
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxFactory": "preact.h",
    "jsxFragmentFactory": "preact.Fragment"
  },
}
```

同様の設定を esbuild に対しても行います。

```typescript
import denoConfig from './deno.json' assert { type: 'json' };

const config: esbuild.BuildOptions = {
  // ...
  jsxFactory: denoConfig.compilerOptions.jsxFactory,
  jsxFragment: denoConfig.compilerOptions.jsxFragmentFactory,
};
```

ビルドされる側のファイルでは次のように利用します。

```tsx
/** @jsxImportSource preact */

// @deno-types="preact/types"
import * as preact from 'preact';  // この import は必須 (のはず)

const renderApp = function(target: HTMLElement) {
  preact.render(<App />, target);
}
```

<div id="plugin_result" />

## ビルド結果を出力する (プラグイン)

単純には `onEnd` コールバックを利用することで実装可能です ([参考](https://github.com/Tsukina-7mochi/esbuild-plugin-result-deno/blob/main/mod.ts))。ただしこの方法では `onEnd` コールバック内でポストプロセスを行う処理を待たないため、このようなプラグインがある場合は esbuild の外で結果を出力する必要があります。

なお、`onEnd` コールバックによる実装のプラグインを公開しています。

https://deno.land/x/esbuild_plugin_result

<div id="external_module" />

## 外部モジュールを使う

### プラグインを使ってバンドルする方法

`esbuild_deno_loader` プラグインを利用します。このプラグインではリモート URL、npm モジュール、jsr モジュール、import maps の解決ができます。

https://deno.land/x/esbuild_deno_loader@0.9.0

~~`esbuild-plugin-cache-deno` を利用します。このプラグインはリモート URL のインポートを解決・キャッシュするほか、importmap に基づいてモジュール名を解決する機能があります。~~

- ~~https://deno.land/x/esbuild_plugin_cache_deno~~
- ~~https://github.com/Tsukina-7mochi/esbuild-plugin-cache-deno~~

### 外部モジュールのままにする方法

ビルドオプションの `external` フィールドを設定することで外部ファイルとして設定することができ、読み込み時に解決するようになります ([公式ドキュメント](https://esbuild.github.io/api/#external))。

```typescript
const config: esbuild.BuildOptions = {
  // ...
  external: ['https://esm.sh/preact*'],
};
```

特定の拡張子を外部モジュール化するには loader による設定も可能です。

```typescript
const config: esbuild.BuildOptions = {
  // ...
  loader: {
    '.html': 'external',
  },
};
```

<div id="#copying_files" />

loader として `copy` を指定することでファイルをコピーすることができます。

```typescript
const config: esbuild.BuildOptions = {
  // ...
  loader: {
    '.png': 'copy',
  }
};
```

glob を使ってコピー対象を指定するプラグインも公開しています。

https://deno.land/x/esbuild_plugin_copy

```typescript
import copyPlugin from 'esbuild-plugin-sass';
import importmap from './import_map.json' assert { type: 'json' };

const config: esbuild.BuildOptions = {
  // ...
  plugins: [
    copyPlugin({
      // base directory of source files
      baseDir: './src',
      // base directory of destination files
      baseOutDir: './dist',
      // files should be copied
      files: [
        { from: 'imgs/*', to: 'imgs/[name][ext]' },
        { from: 'wasm/*', to: 'wasm/[name][ext]' },
      ],
    }),
  ],
};
```

<div id="using_sass" />

## Sass を使う

Sass/SCSS ファイルをバンドルするにはプラグインが必要です。プラグインの `onLoad` コールバックで `.sass`, `.scss` ファイルを読み込む際に Sass コンパイラを呼ぶ必要があります。

筆者の作成したものですが、 Deno, Node.js の両方に対応したプラグインが存在します。

https://jsr.io/@tsukina-7mochi/esbuild-plugin-sass

```typescript
import { sassPlugin } from "jsr:@tsukina-7mochi/esbuild-plugin-sass";

// 単体のファイルとして出力する
const config: esbuild.BuildOptions = {
  // ...
  plugins: [
    sassPlugin(),
  ],
};

// スクリプトを使ってページに埋め込む
const config: esbuild.BuildOptions = {
  // ...
  plugins: [
    sassPlugin({ loader: 'css' }),
  ],
};
```

<div id="build_for_deno" />

## Deno 向けにビルドする

Deno 標準のバンドラ `deno bundle` コマンドは非推奨となっており、esbuild や rollup などの利用が推奨されています。
esbuild の場合ブラウザ向けビルドや Node.js 向けビルドではインポートが CommonJS 形式になってしまうため、ビルドオプションに `platform` と `target` を指定します ([参考ソース](https://github.com/Tsukina-7mochi/delichon/blob/v0.2.1/build.ts))。

```typescript
const config: esbuild.BuildOptions = {
  // ...
  platform: 'neutral',
  target: 'deno1',
};
```

<div id="actions_linter_formatter" />

## GitHub Actions で linter と formatter を使う

`denoland/setup-deno` を使います。`deno lint` で静的解析、 `deno fmt --check` で formatter によるチェックを行えます。

```yaml
name: lint, check format

on: push

permissions:
  contents: read

jobs:
  lint-and-fmt:
    runs-on: ubuntu-latest
    timeout-minutes: 1

    steps:
      - uses: actions/checkout@v3

      - name: Setup Deno
        uses: denoland/setup-deno@v1
        with:
          deno-version: v1.x

      - name: Lint
        run: deno lint

      - name: Check format
        run: deno fmt --check
```