# Pure ESM Package in 2024


import { LinkCard } from "@/components/markdown"

CommonJS を窓から投げ捨てるための自分用メモ。

https://deno.com/blog/commonjs-is-hurting-javascript

<LinkCard href="https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c" />

## パッケージのエントリポイントは全て exports で指定する

Node.js v12.16.0 以降でサポートされた conditional exports で、エントリポイントを指定する。

```json title=package.json
{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  }
}
```

`"."` はルートエントリポイントを指す。つまり、`import hoge from "package"``hoge``package/dist/index.js` から読み込まれる。さらにこのエントリポイントの型定義ファイルを`types` フィールドで指定する。`default` フィールドでは、実際に読み込まれる JS ファイルを指定する。

https://nodejs.org/api/packages.html?ref=trap.jp#conditional-exports

conditional exports 内での `types` フィールドによる型定義ファイルの指定は、TypeScript 4.7 でサポートされた。

https://devblogs.microsoft.com/typescript/announcing-typescript-4-7/

TypeScript 4.7 以前のバージョンにどうしても対応しないといけない場合は、[`typesVersion`](https://www.typescriptlang.org/docs/handbook/declaration-files/publishing.html#version-selection-with-typesversions) フィールドを利用できる[^typesversion]

[^typesversion]: 実際にHonoの[`package.json`](https://github.com/honojs/hono/blob/b38e40e21ab9fd1330b2b0d17d0e4cac78b37ab7/package.json#L338-L512)ではconditional exportsと並行して`types``typesVersion`フィールドを利用している。

以前までは `main``modules` フィールドでエントリポイントを指定していたが、conditional exports の方が直感的に書けるのでこれを使う。conditional exports に対応していない古い Node.js のために `main``module` フィールドを残すケースも多い。しかし、こんな古いバージョンのためにコードを複雑にするのはやめたいので、`engines` で Node.js の最小バージョンを指定する。

```json title=package.json
{
  "engines": {
    "node": ">=16"
  }
}
```

なお、複数のディレクトリにエントリポイントを持つ場合は、以下のように記述する。

```json title=package.json diff
  {
    "exports": {
      ".": {
        "default": "./dist/index.js"
      },
+     "./hoge": {
+       "default": "./dist/hoge.js"
+     }
    }
  }
```

この場合、`import hoge from "package/hoge"``hoge``package/dist/hoge.js` から読み込まれる。

https://trap.jp/post/1666/

https://zenn.dev/makotot/articles/5edb504ef7d2e6

実際の `package.json` は以下のようになる。

```json title=package.json
{
  "name": "pure-esm-package",
  "description": "A minimum example of a pure ESM package",
  "version": "0.0.0",
  "author": "r4ai",
  "license": "MIT",
  "type": "module",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/r4ai/pure-esm-package.git"
  },
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  },
  "files": ["dist", "README.md", "LICENSE"]
}
```

## TypeScript の設定

```sh
bun add -D typescript
```

今回はランタイムに Bun を使っているので、Bun の型定義ファイルもインストールしておく。

```sh
bun add -D @types/bun
```

`tsconfig.json` で、 `"module": "Node16", "moduleResolution": "Node16"` を指定する。`Node16` の代わりに `NodeNext` でも良い。`Node` にはしないように注意。

手動で Node.js のバージョンに合わせた `tsconfig.json` を書くのは大変なので、TypeScript が提供している [`tsconfig/bases`](https://github.com/tsconfig/bases) を使う。今回は Node.js v16 に対応した `@tsconfig/node16` を使う。なお、`"module": "Node16", "moduleResolution": "Node16"``@tsconfig/node16/tsconfig.json` に含まれているので、これを継承した場合は手動で追加する必要はない。

```sh
bun add -D @tsconfig/node16
```

実際の `tsconfig.json` は以下のようになる。

```json title=tsconfig.json
{
  "extends": "@tsconfig/node16/tsconfig.json",
  "compilerOptions": {
    // Enable latest features
    "moduleDetection": "force",
    "jsx": "react-jsx",
    "allowJs": true,

    // Bundler mode
    "verbatimModuleSyntax": true,
    "noEmit": true,

    // Best practices
    "strict": true,
    "skipLibCheck": true,
    "noFallthroughCasesInSwitch": true,

    // Some stricter flags
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noPropertyAccessFromIndexSignature": true
  }
}
```

ビルド用には、この設定を拡張した `tsconfig.build.json` を別途用意する。

```json title=tsconfig.build.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "noEmit": false,
    "declaration": true,
    "declarationMap": true,
    "rootDir": "./src",
    "outDir": "./dist"
  },
  "include": ["./src/**/*.ts"],
  "exclude": ["./src/**/*.test.ts", "./**/*.spec.ts"]
}
```

この `tsconfig.build.json` では以下のことを行っている。

- `noEmit: false` - コンパイル結果を出力する
- `declaration: true` - 型定義ファイルを出力する
- `declarationMap: true` - 型定義ファイルのマップファイルを出力する。これにより、VSCodeなので定義ジャンプをした際に、実際のコードにジャンプできる
- `rootDir` - ソースコードのルートディレクトリを指定する
- `outDir` - コンパイル結果の出力先ディレクトリを指定する
- `include` - コンパイル対象のファイルを指定する。ここでは任意の `.ts` ファイルを対象にしている
- `exclude` - テスト用のファイルを除外する。ここでは `.test.ts``.spec.ts` ファイルを除外している

ビルドには `tsc` を使う。

```sh
bun run tsc --project tsconfig.build.json
```

この段階で、`package.json` は次のようになる:

```json title=package.json diff
  {
    "name": "pure-esm-package",
    "description": "A minimum example of a pure ESM package",
    "version": "0.0.0",
    "author": "r4ai",
    "license": "MIT",
    "type": "module",
    "repository": {
      "type": "git",
      "url": "git+https://github.com/r4ai/pure-esm-package.git"
    },
    "exports": {
      ".": {
        "types": "./dist/index.d.ts",
        "import": "./dist/index.js"
      }
    },
    "files": [
      "dist",
      "README.md",
      "LICENSE"
    ],
+   "scripts": {
+     "build": "bun run tsc --project tsconfig.build.json"
+   },
+   "devDependencies": {
+     "@tsconfig/node16": "^16.1.3",
+     "@types/bun": "latest",
+     "typescript": "^5.0.0"
+   }
  }
```

## おまけ

### EditorConfig

EditorConfig の設定を追加する。

```ini title=.editorconfig
# EditorConfig is awesome: https://EditorConfig.org

# top-most EditorConfig file
root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.md]
trim_trailing_whitespace = false
```

### テスト

テストには bun test を使う。

```ts title=src/hi.ts
export const hi = (name: string) => `Hi, ${name}!`
```

```ts title=src/hi.test.ts
import { hi } from "./hi.js"

import { describe, test, expect } from "bun:test"

describe("Hi!", () => {
  test("Hi, Alice!", () => {
    expect(hi("Alice")).toBe("Hi, Alice!")
  })
})
```

テストの実行:

```sh
$ bun test
bun test v1.1.4 (fbe2fe0c)

src/hi.test.ts:
 Hi! > Hi, Alice! [0.07ms]

 1 pass
 0 fail
 1 expect() calls
Ran 1 tests across 1 files. [19.00ms]
```

https://bun.sh/docs/cli/test

### Formatter, Linter

Biome を使う。

```sh
bun add -D --exact @biomejs/biome
```

```json title=biome.json
{
  "$schema": "https://biomejs.dev/schemas/1.3.1/schema.json",
  "organizeImports": {
    "enabled": true
  },
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true,
      "complexity": {
        "noBannedTypes": "off"
      }
    }
  },
  "formatter": {
    "indentStyle": "space"
  },
  "javascript": {
    "formatter": {
      "semicolons": "asNeeded"
    }
  },
  "vcs": {
    "enabled": true,
    "clientKind": "git",
    "useIgnoreFile": true
  }
}
```

lintとformatの実行:

```sh
bunx @biomejs/biome check --apply .
```

https://biomejs.dev/guides/getting-started/

### Git hooks

Lefthook を利用し、コミット時に lint と format、lockfile の整合性チェックを行う。

```sh
bun add -D lefthook
```

```json title=package.json diff
  {
    // ...
    "scripts": {
      "build": "bun run tsc --project tsconfig.build.json",
      "test": "bun test",
      "check": "bunx @biomejs/biome check --apply .",
+     "prepare": "lefthook install"
    },
    // ...
  }
```

```yaml title=lefthook.yml
# yaml-language-server: $schema=https://raw.githubusercontent.com/SchemaStore/schemastore/master/src/schemas/json/lefthook.json

pre-commit:
  parallel: true
  commands:
    biome:
      glob: "*.{js,ts,jsx,tsx,json,jsonc}"
      run: |
        bunx @biomejs/biome check --apply {staged_files}
        git add {staged_files}

    check-lockfile:
      glob: "**/package.json"
      run: bun install --frozen-lockfile
```

https://github.com/evilmartians/lefthook?tab=readme-ov-file

### バージョニング

Changesets を使う。

```sh
bun add -D @changesets/cli @changesets/changelog-github
```

```json title=.changeset/config.json
{
  "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
  "changelog": [
    "@changesets/changelog-github",
    { "repo": "r4ai/pure-esm-package" }
  ],
  "commit": false,
  "fixed": [],
  "linked": [],
  "access": "public",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "bumpVersionsWithWorkspaceProtocolOnly": true,
  "ignore": []
}
```

```json title=package.json diff
  {
    "scripts": {
      "build": "bun run tsc --project tsconfig.build.json",
      "test": "bun test",
      "check": "bunx @biomejs/biome check --apply .",
+     "changeset": "changeset",
+     "release": "bun run build && bun run test && bun run changeset publish",
      "prepare": "lefthook install",
+     "prepublishOnly": "bun run build"
    },
  }
```

https://github.com/changesets/changesets

### ランタイムのバージョン管理

Node.js と Bun のバージョン管理には [mise](https://mise.jdx.dev/) を使う。

```txt title=.tool-versions
nodejs 20.12.2
bun 1.1.4
```

`.tool-versions` に記述したバージョンをインストールする:

```sh
mise install
```

### CI / CD

CI では、テストを実行し、ビルド可能かを確認する。

```yaml title=.github/workflows/ci.yml
name: CI

on:
  push:
  pull_request:
    branches:
      - main
  workflow_dispatch:

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Bun and Node.js
        uses: jdx/mise-action@v2

      - name: Install dependencies
        run: bun install --frozen-lockfile

      - name: Build
        run: bun run build

      - name: Test
        run: bun run test
```

CD では、パッケージのリリースを行う。GitHub Secrets の `NPM_TOKEN` に npm のアクセストークンを設定しておく。

```yaml title=.github/workflows/cd.yml
name: CD

on:
  push:
    branches:
      - main
  workflow_dispatch:

concurrency: ${{ github.workflow }}-${{ github.ref }}

jobs:
  deploy-npm-packages:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Node.js and Bun
        uses: jdx/mise-action@v2

      - name: Create .npmrc
        run: |
          cat << EOF > "$HOME/.npmrc"
            //registry.npmjs.org/:_authToken=$NPM_TOKEN
          EOF
        env:
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

      - name: Install dependencies
        run: bun install --frozen-lockfile

      - name: Create Release Pull Request or Publish to npm
        id: changesets
        uses: changesets/action@v1
        with:
          # This expects you to have a script called release which does a build for your packages and calls changeset publish
          publish: bun run release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
```

## おわりに

完成したリポジトリ:

https://github.com/r4ai/pure-esm-package