fluent-emoji-flat:headstone

Pure ESM Package in 2024

2024/04/23 に公開

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

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

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

radix-icons:file
package.json
{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  }
}

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

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

TypeScript 4.7 以前のバージョンにどうしても対応しないといけない場合は、typesVersion フィールドを利用できる1

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

radix-icons:file
package.json
{
  "engines": {
    "node": ">=16"
  }
}

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

radix-icons:file
package.json
{
  "exports": {
    ".": {
      "default": "./dist/index.js"
    },
    "./hoge": {
      "default": "./dist/hoge.js"
    }
  }
}

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

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

radix-icons:file
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 の設定

bun add -D typescript

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

bun add -D @types/bun

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

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

bun add -D @tsconfig/node16

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

radix-icons:file
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 を別途用意する。

radix-icons:file
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 を使う。

bun run tsc --project tsconfig.build.json

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

radix-icons:file
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"
  ],
  "scripts": {
    "build": "bun run tsc --project tsconfig.build.json"
  },
  "devDependencies": {
    "@tsconfig/node16": "^16.1.3",
    "@types/bun": "latest",
    "typescript": "^5.0.0"
  }
}

おまけ

EditorConfig

EditorConfig の設定を追加する。

radix-icons:file
.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 を使う。

skill-icons:typescript
src/hi.ts
export const hi = (name: string) => `Hi, ${name}!`
skill-icons:typescript
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!")
  })
})

テストの実行:

$ 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]

Formatter, Linter

Biome を使う。

bun add -D --exact @biomejs/biome
radix-icons:file
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の実行:

bunx @biomejs/biome check --apply .

Git hooks

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

bun add -D lefthook
radix-icons:file
package.json
{
  // ...
  "scripts": {
    "build": "bun run tsc --project tsconfig.build.json",
    "test": "bun test",
    "check": "bunx @biomejs/biome check --apply .",
    "prepare": "lefthook install"
  },
  // ...
}
radix-icons:file
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

バージョニング

Changesets を使う。

bun add -D @changesets/cli @changesets/changelog-github
radix-icons:file
.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": []
}
radix-icons:file
package.json
{
  "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"
  },
}

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

Node.js と Bun のバージョン管理には mise を使う。

radix-icons:file
.tool-versions
nodejs 20.12.2
bun 1.1.4

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

mise install

CI / CD

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

radix-icons:file
.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 のアクセストークンを設定しておく。

radix-icons:file
.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 }}

おわりに

完成したリポジトリ:

脚注

  1. 実際にHonoのpackage.jsonではconditional exportsと並行してtypestypesVersionフィールドを利用している。