Pure ESM Package in 2024
CommonJS を窓から投げ捨てるための自分用メモ。
パッケージのエントリポイントは全て exports で指定する
Node.js v12.16.0 以降でサポートされた conditional exports で、エントリポイントを指定する。
{ "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } } }
"."
はルートエントリポイントを指す。つまり、import hoge from "package"
で hoge
が package/dist/index.js
から読み込まれる。さらにこのエントリポイントの型定義ファイルをtypes
フィールドで指定する。default
フィールドでは、実際に読み込まれる JS ファイルを指定する。
conditional exports 内での types
フィールドによる型定義ファイルの指定は、TypeScript 4.7 でサポートされた。
TypeScript 4.7 以前のバージョンにどうしても対応しないといけない場合は、typesVersion
フィールドを利用できる1。
以前までは main
や modules
フィールドでエントリポイントを指定していたが、conditional exports の方が直感的に書けるのでこれを使う。conditional exports に対応していない古い Node.js のために main
や module
フィールドを残すケースも多い。しかし、こんな古いバージョンのためにコードを複雑にするのはやめたいので、engines
で Node.js の最小バージョンを指定する。
{ "engines": { "node": ">=16" } }
なお、複数のディレクトリにエントリポイントを持つ場合は、以下のように記述する。
{ "exports": { ".": { "default": "./dist/index.js" }, "./hoge": { "default": "./dist/hoge.js" } } }
この場合、import hoge from "package/hoge"
で hoge
が package/dist/hoge.js
から読み込まれる。
実際の 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
は以下のようになる。
{ "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
を別途用意する。
{ "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
は次のようになる:
{ "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 の設定を追加する。
# 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 を使う。
export const hi = (name: string) => `Hi, ${name}!`
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
{ "$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
{ // ... "scripts": { "build": "bun run tsc --project tsconfig.build.json", "test": "bun test", "check": "bunx @biomejs/biome check --apply .", "prepare": "lefthook install" }, // ... }
# 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
{ "$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": [] }
{ "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 を使う。
nodejs 20.12.2 bun 1.1.4
.tool-versions
に記述したバージョンをインストールする:
mise install
CI / CD
CI では、テストを実行し、ビルド可能かを確認する。
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 のアクセストークンを設定しておく。
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 }}
おわりに
完成したリポジトリ:
脚注
-
実際にHonoの
package.json
ではconditional exportsと並行してtypes
とtypesVersion
フィールドを利用している。 ↩