# 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/[email protected]/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