この記事は
npm へ公開可能なパッケージを TypeScript で作成しながら、JS/TS 開発で良く使われるツールを紹介する記事です。
typescript-npm-starter
という名前の Hello, world! パッケージを公開するという体で話を進めます。
Hello, world!
まずは今回作る Hello, world! パッケージの仕様です。
main
関数をエクスポートしていて、それを実行すると Hello, world! をプリントします。
$ npm i typescript-npm-starter
$ node -e "require('typescript-npm-starter').main()"
Hello, world!
今後の保守性を考えて JavaScript ではなく TypeScript を選択しました。
TypeScript
何はともあれまずは TypeScript での初期実装をします。
typescript
と同時に rimraf
というクロスプラットフォーム版の rm -rf
的なパッケージも追加します。
ビルド前に前回のビルド成果物を削除するために使います。
$ npm init -y
$ npm i -D typescript rimraf
以下のように各ファイルを作成します。
{
"compilerOptions": {
"target": "esnext",
"module": "commonjs",
"lib": ["esnext"],
"types": ["node"],
"strict": true,
"importHelpers": true,
"experimentalDecorators": true,
"moduleResolution": "node",
"esModuleInterop": true,
"outDir": "dist"
},
"include": ["src"]
}
export function main(): void {
console.log("Hello, world!");
}
以後、追加変更する部分のみを記載します。
{
"files": ["dist"],
"main": "dist/lib/index.js",
"scripts": {
"prebuild": "rimraf dist",
"build": "tsc"
}
}
以下のようにビルドして実行できるようになりました。
$ npm run build
$ node -e "require('.').main()"
Hello, world!
dist
フォルダに JS が出力されます。
ユニットテスト (Jest)
今後の機能追加や仕様変更の際に壊れないよう、ユニットテストを追加します。
Jest というフレームワークが人気です。
$ npm i -D jest @types/jest ts-jest
設定ファイルを作成します。
今回は *.test.ts
というファイル名をテストファイルとして認識するように設定します。
/** @type {import('@jest/types/build/Config').InitialOptions} */
module.exports = {
preset: "ts-jest",
testMatch: ["<rootDir>/src/**/*.test.ts"],
collectCoverage: true,
errorOnDeprecated: true,
testEnvironment: "node"
};
テストファイルを TypeScript で書くのに必要な型定義ファイルを tsconfig に追加します。
また、テストファイル自体はビルドしなくていいので exclude しておきます。
{
"compilerOptions": {
"types": ["node", "jest"]
},
"exclude": ["**/*.test.ts"]
}
main()
関数が正しく Hello, world!
してくれるか心配なので、以下のようなテストを書きました。
import { main } from ".";
describe("main()", () => {
it("print message", () => {
const log = jest.spyOn(console, "log").mockReturnValue();
main();
expect(log).nthCalledWith(1, "Hello, world!");
log.mockRestore();
});
});
ユニットテストの実行コマンドを test
スクリプトとして定義します。
"scripts": {
"build": "tsc",
"test": "jest"
}
これでテストできるようになりました。
$ npm t
カバレッジもバッチリです。
$ npx http-server -o coverage/lcov-report
そろそろ dist
などが Git に追加されないか心配になってきたので除外します。
coverage/
dist/
node_modules/
環境明示 (engines / .npmrc)
Node.js v12 で開発しているとします。
v8 や v10 で動くかどうか分からないのに、誰かが古い Node.js で npm install typescript-npm-starter
などしてマシンが壊れないか心配です。
また npm や yarn などのパッケージマネージャはそのバージョンによってロックファイル (package-lock.json
など) の生成ロジックが異なることがあります。
もし今後このパッケージの開発者が増えて、皆が好き勝手な Node.js 環境でプルリクして毎回 package-lock.json
を変えてくれては困ります。
幸いなことにどちらも防げる方法があります。
{
"engines": {
"node": ">= 12",
"npm": ">= 6.12"
}
}
これで対応バージョンを宣言できます。
そして以下のファイルを作成します。
engine-strict = true
save-exact = true
engine-strict = true
を指定すると、engines
で指定したバージョン以外で install しようとした時、以下のようなエラーを吐いて停止してくれるようになります。
npm ERR! code ENOTSUP
npm ERR! notsup Unsupported engine for typescript-npm-starter@0.0.0-development: wanted: {"node":">= 12","npm":">= 6.12"} (current: {"node":"10.16.3","npm":"6.9.0"})
npm ERR! notsup Not compatible with your version of node/npm: typescript-npm-starter@0.0.0-development
npm ERR! notsup Not compatible with your version of node/npm: typescript-npm-starter@0.0.0-development
npm ERR! notsup Required: {"node":">= 12","npm":">= 6.12"}
npm ERR! notsup Actual: {"npm":"6.9.0","node":"10.16.3"}
分かりやすいですね。
コーディング規約 (ESLint / Prettier)
環境が同じでもコードのスタイルや品質は十人十色です。
この Hello, world! を OSS として公開したら、2 スペースとシングルクォートが好きなのにタブとダブルクォートのプルリクが来てしまわないか心配です。
そこで
- スペースやタブ、クォートの種類などのスタイル
- ある程度のコーディング規約、ベストプラクティスの強制
ができるように設定します。
前者は Prettier と ESLint、後者は ESLint が主に担っています。
$ npm i -D @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint eslint-config-prettier eslint-plugin-import eslint-plugin-prettier eslint-plugin-simple-import-sort prettier
単純にどちらも適用すると設定によってはコンフリクトが起こり、不安定な差分が出たりエディタがフリーズしたりするなどの原因となるため、現在は ESLint のプラグインとして Prettier を導入して ESLint だけを適用する、というのが主流となっています。
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
"prettier/@typescript-eslint",
"plugin:prettier/recommended"
],
plugins: ["simple-import-sort"],
env: {
es6: true,
node: true,
jest: true
},
rules: {
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-use-before-define": "off",
"import/default": "off",
"import/order": "off",
"no-console": "off",
"no-empty": ["error", { allowEmptyCatch: true }],
"simple-import-sort/sort": "error",
"sort-imports": "off"
},
overrides: [
{
files: "*.js",
rules: {
"import/order": ["error", { "newlines-between": "always" }],
"simple-import-sort/sort": "off"
}
},
{
files: ["*.{config,d,test}.ts"],
rules: {
"@typescript-eslint/no-explicit-any": "off"
}
}
]
};
!*.js
!*.ts
coverage/
dist/
node_modules/
デフォルトではドット始まりのファイル名 (.*
) が対象外とされているため、JS/TS は対象外から除外しておきます。
/** @type {import('prettier').Options} */
module.exports = {
printWidth: 100,
semi: false,
singleQuote: true,
trailingComma: "all",
overrides: [
{
files: "*.{md,yml}",
options: {
printWidth: 80,
semi: true,
singleQuote: false,
trailingComma: "none"
}
}
]
};
coverage/
dist/
node_modules/
こちらは .gitignore
と同一で OK です。
scripts
を一気に増やすので便利コマンドを追加します。
$ npm i -D npm-run-all
{
"scripts": {
"fix": "run-p fix:*",
"fix:eslint": "npm run lint:eslint -- --fix",
"fix:prettier": "npm run lint:prettier -- --write",
"lint": "run-p lint:*",
"lint:eslint": "eslint --ext js,ts .",
"lint:prettier": "prettier -l \"**/*.{json,md,yml}\"",
"lint:type": "tsc --noEmit",
"test": "run-s lint test:*",
"test:unit": "jest"
}
}
npm run lint
で eslint
, prettier
, tsc
(タイプチェック) が一気に実行されます。
また npm run test
で npm run lint
のすべてと jest
が実行されます。
$ npm t
fix
は lint
を行い、出たエラーが自動修正可能なものならその修正を適用するコマンドです。
$ npm run fix
エディタ (Visual Studio Code)
今更ですがエディタの設定をします。
コードを 1 行書くたびに fix
したり lint
エラー箇所を目で探すのは ready perfectly と言えません。
まずは拡張機能です。
以下のファイルを Git に追加することで、新しい開発者がこのプロジェクトを VSCode で開いた時にインストールを促す通知が出ます。
見た目やシステム言語に関する拡張をプロジェクトへ入れるかどうかは議論の余地がある部分でしょうが、個人的に強く推奨したい便利なものは入れてしまいます。
もちろんインストールせずに無視することもできます。
Hello, world! には最低でもこれだけ必要です。
{
"recommendations": [
"christian-kohler.path-intellisense",
"coenraads.bracket-pair-colorizer",
"dbaeumer.vscode-eslint",
"eamodio.gitlens",
"esbenp.prettier-vscode",
"ms-ceintl.vscode-language-pack-ja",
"oderwat.indent-rainbow",
"orta.vscode-jest",
"pflannery.vscode-versionlens",
"shardulm94.trailing-spaces",
"streetsidesoftware.code-spell-checker",
"yzhang.markdown-all-in-one"
]
}
保存したら VSCode を再起動して通知が出ることを確認し、インストールします。
次に設定ファイルです。
先に説明したような ESLint と Prettier のコンフリクトをうまく防ぐ設定です。
javascript
と typescript
については Prettier と editor.formatOnSave
を無効にして、ESLint だけを効かせるのがポイントです。
{
"[javascript]": {
"editor.formatOnSave": false
},
"[typescript]": {
"editor.formatOnSave": false
},
"editor.codeActionsOnSave": {
"source.fixAll": true
},
"editor.formatOnSave": true,
"editor.rulers": [100],
"eslint.validate": ["javascript", "typescript"],
"prettier.disableLanguages": ["javascript", "typescript"],
"typescript.tsdk": "node_modules/typescript/lib"
}
コミットフック (lint-staged)
しかし、VSCode 以外のエディタで開発して npm run fix
せずに push することは依然として可能です。
すべてのプルリクエストにそういう可能性がある状態ではレビューの負担が大きいので、Hello, world! を公開する前に対策が必要です。
そこで、エラーのあるコードをそもそもコミットできないように制限してしまいます。
$ npm i -D husky lint-staged
module.exports = {
hooks: {
"pre-commit": "lint-staged"
}
};
Git の pre-commit フックで lint-staged
コマンドを実行してくれます。
lint-staged
は Git のステージングファイルそれぞれに対して設定に応じたコマンドを実行してくれます。
module.exports = {
"*.{js,ts}": "eslint --fix",
"*.{json,md,yml}": "prettier --write"
};
いずれかのコマンドがコード 0 以外で終了したらコミットは中止されます。
最後に git add <対象>
が実行されたあとにコミットプロセスが再開するため、コミットされるファイルは常に自動修正された状態となります。
CircleCI (CI)
まだです。
コミットフックは git commit -n
でバイパスできます。
あるいは手動で無力化されているかもしれません。
最後は CI で守らなければやはりレビューの負担は大きいものとなります。
今回は CircleCI を利用します。
(設定ファイル以外の導入方法は数ある他の記事に譲ります)
https://qiita.com/tatane616/items/8624e61473a9957d9a81
https://qiita.com/gold-kou/items/4c7e62434af455e977c2
これにより GitHub のステータスを確認するだけで test をパスしたかどうかが一目瞭然となります。
version: 2.1
orbs:
node: circleci/node@3.0.0
workflows:
default:
jobs:
- node/test:
matrix:
parameters:
version:
- "14.4"
- "12.18"
今回は Node.js v12 と v14 の 2 バージョンで自動テストさせます。
current である v14 で動かなくなってもすぐに検知できるので、いざバージョンアップしようとしたときにドタバタせず済みます。
最新版だけでいい場合は以下のように設定できます。
version: 2.1
orbs:
node: circleci/node@3.0.0
workflows:
default:
jobs:
- node/test
bin (コマンド)
ところで、npm パッケージにはこれまでに紹介した ESLint や Prettier のようにコマンドラインで実行できるものがあります。
typescript-npm-starter
も、ユーザーはコマンドとして実行したいと思うかもしれません。
さらに、コマンドがあればインストールせず npx
で実行できるようにもなります。
$ npx typescript-npm-starter
Hello, world!
まだ公開せず、一旦神界に戻って修行しましょう。
#!/usr/bin/env node
import { main } from "../lib";
main();
shebang つきの TS ファイルです。
tsc
でトランスパイルした JS にも残ります。
Node.js は shebang をコメントとして解釈できます。
{
"bin": {
"typescript-npm-starter": "dist/bin/index.js"
}
}
この記述をしておくと typescript-npm-starter
という名前のコマンド (実体は dist/bin/index.js
へのシンボリックリンク) がインストールされて使えるようになります。
ES Modules
ここまで TypeScript で開発してきたように、typescript-npm-starter
を利用する側もまた TypeScript で開発しているかもしれません。
実は Node.js モジュールシステムは CommonJS といって、TypeScript の ES Modules とは別物です。
TypeScript から CommonJS モジュールを使うためには tsconfig に余計な設定を増やしたり型定義ファイルを書いたりする必要があります。
TypeScript 製なのに TypeScript から活用しづらいという滑稽な状態なので対応します。
今回は型定義ファイルのソースマップとオリジナルのソースを配布物に同梱します。
以下のような dist
を出力できれば完成です。
dist
├── bin
│ └── index.js
├── esm
│ ├── index.d.ts
│ ├── index.d.ts.map
│ └── index.js
└── lib
└── index.js
{
"extends": ".",
"compilerOptions": {
"module": "esnext",
"declaration": true,
"declarationMap": true,
"outDir": "dist/esm"
},
"include": ["src/lib"]
}
ES Modules にトランスパイルする tsconfig です。
もとの tsconfig から継承し、module
を commonjs
から esnext
に変更しています。
declaration
で型定義ファイル、declarationMap
でそのソースマップを生成します。
{
"files": ["dist", "src"],
"main": "dist/lib/index.js",
"module": "dist/esm/index.js",
"types": "dist/esm/index.d.ts",
"bin": {
"typescript-npm-starter": "dist/bin/index.js"
},
"scripts": {
"prebuild": "rimraf dist",
"build": "run-p build:*",
"build:common": "tsc",
"build:esm": "tsc -p tsconfig.esm.json"
}
}
module
と types
フィールドが重要です。
そして配布対象を指定する files
に src
を追加しています。
あらためてビルドしたら、最後に配布物の確認をします。
$ npm run build
$ npm pack --dry-run
npm notice
npm notice 📦 typescript-npm-starter@0.0.0-development
npm notice === Tarball Contents ===
npm notice 144B dist/bin/index.js
npm notice 61B dist/esm/index.js
npm notice 152B dist/lib/index.js
npm notice 1.5kB package.json
npm notice 143B dist/esm/index.d.ts.map
npm notice 73B dist/esm/index.d.ts
npm notice 235B src/lib/index.test.ts
npm notice 59B src/bin/index.ts
npm notice 64B src/lib/index.ts
npm notice === Tarball Details ===
npm notice name: typescript-npm-starter
npm notice version: 0.0.0-development
npm notice filename: typescript-npm-starter-0.0.0-development.tgz
npm notice package size: 1.3 kB
npm notice unpacked size: 2.4 kB
npm notice shasum: 0c4f60955ea3b82c7fa4d4ee36e948c55ca53b62
npm notice integrity: sha512-21Gvgd9Mt+2IW[...]V/+3FCR1bX/xQ==
npm notice total files: 9
npm notice
typescript-npm-starter-0.0.0-development.tgz
まとめ
Hello, world! に本気を出した結果、ちょっとした TS ライブラリや日々の自動化コマンドをサクッと作って公開するのに便利なテンプレートが完成しました。
また堅牢なエコシステム構築は、新規参画者のキャッチアップを促進する助けにもなります。
「monorepo じゃない。やり直し」「CDN 対応してないやん」「ここまで commitlint なし」
など言いたいことは山ほどあるかもしれません。
コメントや編集リクエストで皆さんの慎重さを見習わせてください。