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 を使っているので、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]
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 の整合性チェックを行う。
{
// ...
"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 を使う。
.tool-versions
に記述したバージョンをインストールする:
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 }}
おわりに
完成したリポジトリ: