npmパッケージをTypeScriptで作成して自動デプロイで公開するまでの手順の詳細

先日はじめてnpmパッケージの作成~公開までをやってみました。
私が最近TypeScriptで書くことが多いため、今回TypeScriptで作成しました。

今回はその手順を詳しく、次に作る方の参考になるように、手順を書いていきたいと思います。

作成にあたって使用した技術は、

- TypeScript
- Jest
- GitHub Actions

なども含みますので、JestやGithub Actionsのコードについても示していきます。

## 作ったパッケージの概要
作ったのはこちらです。

https://github.com/hatsu38/jp-prefectures.js

これは、都道府県データを簡単に扱えるようにしたパッケージです。
使い方はREADMEにも書かれていますが、一部抜粋すると以下のようです。

```javascript
import { findByCode, filterByArea, prefectureNames } from "jp-prefectures";

findByCode(13);
//=> {code: 13, name: "東京都", enName: "tokyo", area: "関東", capital: "新宿区"}

filterByArea("関東")
/*=>
[
  {code: 8, name: "茨城県", enName: "ibaraki", area: "関東", capital: "水戸市"},
  {code: 9, name: "栃木県", enName: "tochigi", area: "関東", capital: "宇都宮市"},
  ...,
  {code: 14, name: "神奈川県", enName: "kanagawa", area: "関東", capital: "横浜市"}
]
*/

prefectureNames()
//=> ["北海道", "青森県", ..., "沖縄県"]
```

上記のような関数で、都道府県情報が取得できるようになるというものです。

さて、パッケージの概要がわかったところで、作り方の解説に移ります。


## 事前準備 npmjs.comに登録

npmを公開するに当たっては、以下のサイトに登録しておく必要があります。

https://www.npmjs.com/signup


登録が完了したらターミナルから `npm adduser`で [npmjs.com](https://npmjs.com/) に接続できるように設定します。


```bash
$ npm adduser
npm notice Log in on https://registry.npmjs.org/
Username: hatsu38
Password:
Email: (this IS public)
```


## package.jsonを作成

```terminal
npm init
```
を実行すると、対話形式で`package.json`ファイルの作成を行います。

ちなみに公開にあたっては、package名に大文字は使えないので、ご注意ください。

こちらで作成を行ったあとも編集が可能です。以降の手順でも編集は行いますので、ひとまずデフォルトで進めて構いません。


## TypeScript導入

```terminal
npm install typescript
```

TypeScriptをインストールします。

インストールが完了すると、`node_modules`フォルダが作成されます。そのフォルダは `.gitignore`に追加しておきます。

.gitignoreファイル↓

```
node_modules/
```

tsconfig.jsonも作成しておきます。

```terminal
tsc --init
```

作成された`tsconfig.json`を一部修正します、以下のようになりました。

```json
{
  "compilerOptions": {
    "target": "es2015",                                /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */
    "module": "es2015",                           /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
    "declaration": true,                         /* Generates corresponding '.d.ts' file. */
    "sourceMap": true,                           /* Generates corresponding '.map' file. */
    "outDir": "./dist",                              /* Redirect output structure to the directory. */
    "rootDir": "./",                             /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
    "importHelpers": true,                       /* Import emit helpers from 'tslib'. */
    "strict": true,                                 /* Enable all strict type-checking options. */
    "moduleResolution": "node",                  /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
    "baseUrl": "./",                             /* Base directory to resolve non-absolute module names. */
    "rootDirs": ["./src/"],                              /* List of root folders whose combined content represents the structure of the project at runtime. */
    "typeRoots": ["node_modules/@types", "./src/types"],  /* List of folders to include type definitions from. */
    "allowSyntheticDefaultImports": true,        /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
    "esModuleInterop": true,                        /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
    "resolveJsonModule": true,
    "skipLibCheck": true,                           /* Skip type checking of declaration files. */
    "forceConsistentCasingInFileNames": true,       /* Disallow inconsistently-cased references to the same file. */
  },
  "include": ["src/**/*"],
  "exclude": [
    "node_modules",
    "dist",
    "test",
  ],
}
```


## index.tsファイル作成

今回、srcフォルダに`index.ts`ファイルを作っていきます。

```terminal
mkdir src && touch src/index.ts
```

`src/index.ts` の中身については、冒頭で紹介したリポジトリのコードの一部を利用することにします。

```typescript
import prefs from "../data/prefectures.json";

interface Prefecture {
  code: number;
  name: string;
  enName: string;
  area: string;
  capital: string;
}

function findByName(value: string): Prefecture | undefined {
  return prefs.find((pref: Prefecture) => pref.name === value);
}

function findByCode(value: string | number): Prefecture | undefined {
  return prefs.find((pref: Prefecture) => pref.code === Number(value));
}

export {
  findByName,
  findByCode,
};

```

`data/prefectures.json`の中身は以下で折りたたんで記載します。


<details><summary>data/prefectures.jsonの中身</summary><div>

```json
[
  { "code": 1, "name": "北海道", "enName": "hokkaido", "area": "北海道", "capital": "札幌市" },
  { "code": 2, "name": "青森県", "enName": "aomori", "area": "東北", "capital": "青森市" },
  { "code": 3, "name": "岩手県", "enName": "iwate", "area": "東北", "capital": "盛岡市" },
  { "code": 4, "name": "宮城県", "enName": "miyagi", "area": "東北", "capital": "仙台市" },
  { "code": 5, "name": "秋田県", "enName": "akita", "area": "東北", "capital": "秋田市" },
  { "code": 6, "name": "山形県", "enName": "yamagata", "area": "東北", "capital": "山形市" },
  { "code": 7, "name": "福島県", "enName": "fukushima", "area": "東北", "capital": "福島市" },
  { "code": 8, "name": "茨城県", "enName": "ibaraki", "area": "関東", "capital": "水戸市" },
  { "code": 9, "name": "栃木県", "enName": "tochigi", "area": "関東", "capital": "宇都宮市" },
  { "code": 10, "name": "群馬県", "enName": "gunma", "area": "関東", "capital": "前橋市" },
  { "code": 11, "name": "埼玉県", "enName": "saitama", "area": "関東", "capital": "さいたま市" },
  { "code": 12, "name": "千葉県", "enName": "chiba", "area": "関東", "capital": "千葉市" },
  { "code": 13, "name": "東京都", "enName": "tokyo", "area": "関東", "capital": "新宿区" },
  { "code": 14, "name": "神奈川県", "enName": "kanagawa", "area": "関東", "capital": "横浜市" },
  { "code": 15, "name": "新潟県", "enName": "niigata", "area": "中部", "capital": "新潟市" },
  { "code": 16, "name": "富山県", "enName": "toyama", "area": "中部", "capital": "富山市" },
  { "code": 17, "name": "石川県", "enName": "ishikawa", "area": "中部", "capital": "金沢市" },
  { "code": 18, "name": "福井県", "enName": "fukui", "area": "中部", "capital": "福井市" },
  { "code": 19, "name": "山梨県", "enName": "yamanashi", "area": "中部", "capital": "甲府市" },
  { "code": 20, "name": "長野県", "enName": "nagano", "area": "中部", "capital": "長野市" },
  { "code": 21, "name": "岐阜県", "enName": "gifu", "area": "中部", "capital": "岐阜市" },
  { "code": 22, "name": "静岡県", "enName": "shizuoka", "area": "中部", "capital": "静岡市" },
  { "code": 23, "name": "愛知県", "enName": "ehime", "area": "中部", "capital": "名古屋市" },
  { "code": 24, "name": "三重県", "enName": "mie", "area": "関西", "capital": "津市" },
  { "code": 25, "name": "滋賀県", "enName": "shiga", "area": "関西", "capital": "大津市" },
  { "code": 26, "name": "京都府", "enName": "kyoto", "area": "関西", "capital": "京都市" },
  { "code": 27, "name": "大阪府", "enName": "osaka", "area": "関西", "capital": "大阪市" },
  { "code": 28, "name": "兵庫県", "enName": "hyogo", "area": "関西", "capital": "神戸市" },
  { "code": 29, "name": "奈良県", "enName": "nara", "area": "関西", "capital": "奈良市" },
  { "code": 30, "name": "和歌山県", "enName": "wakayama", "area": "関西", "capital": "和歌山市" },
  { "code": 31, "name": "鳥取県", "enName": "tottori", "area": "中国", "capital": "鳥取市" },
  { "code": 32, "name": "島根県", "enName": "shimane", "area": "中国", "capital": "松江市" },
  { "code": 33, "name": "岡山県", "enName": "okayama", "area": "中国", "capital": "岡山市" },
  { "code": 34, "name": "広島県", "enName": "hiroshima", "area": "中国", "capital": "広島市" },
  { "code": 35, "name": "山口県", "enName": "yamaguchi", "area": "中国", "capital": "山口市" },
  { "code": 36, "name": "徳島県", "enName": "tokushima", "area": "四国", "capital": "徳島市" },
  { "code": 37, "name": "香川県", "enName": "kagawa", "area": "四国", "capital": "高松市" },
  { "code": 38, "name": "愛媛県", "enName": "ehime", "area": "四国", "capital": "松山市" },
  { "code": 39, "name": "高知県", "enName": "kochi", "area": "四国", "capital": "高知市" },
  { "code": 40, "name": "福岡県", "enName": "fukuoka", "area": "九州", "capital": "福岡市" },
  { "code": 41, "name": "佐賀県", "enName": "saga", "area": "九州", "capital": "佐賀市" },
  { "code": 42, "name": "長崎県", "enName": "nagasaki", "area": "九州", "capital": "長崎市" },
  { "code": 43, "name": "熊本県", "enName": "kumamoto", "area": "九州", "capital": "熊本市" },
  { "code": 44, "name": "大分県", "enName": "oita", "area": "九州", "capital": "大分市" },
  { "code": 45, "name": "宮崎県", "enName": "miyazaki", "area": "九州", "capital": "宮崎市" },
  { "code": 46, "name": "鹿児島県", "enName": "kagoshima", "area": "九州", "capital": "鹿児島市" },
  { "code": 47, "name": "沖縄県", "enName": "okinawa", "area": "九州", "capital": "那覇市" }
]
```
</div>
</details>


## Lintの導入

```terminal
npm i --save-dev eslint prettier eslint-plugin-prettier @typescript-eslint/eslint-plugin@latest @typescript-eslint/parser eslint-config-prettier ts-node
```

開発環境でしか必要ないパッケージのインストールなので、`--save-dev`オプションを付けておきます。

lintに必要なパッケージのインストールを行ったあと、 `.eslintrc.json` を作成します。

```json
{
  "env": {
    "node": true,
    "jest": true,
    "browser": true
  },
  "parser": "@typescript-eslint/parser",
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/eslint-recommended",
    "plugin:@typescript-eslint/recommended",
    "prettier"
  ],
  "ignorePatterns": ["dist/**"],
  "rules": {
    // 0は無効、1は警告、2はエラー
    "indent": ["error", 2,
      {"SwitchCase": 1}
    ], // インデントはSpaceを2個
    "prefer-const": "error", // 再代入を行わない変数はconstを利用
    "quotes": ["error", "double"], // ダブルクオーテーションで文字列を囲う
    "semi": ["error", "always"], // セミコロンを必須にする
    "no-var": "error", // var禁止
    "no-unused-vars": "error", // 使用していない変数を削除
    "no-debugger": "error", // デバッガーは残さない
    "@typescript-eslint/no-explicit-any": "off"
  }
}
```


Linの設定が完了したら、`package.json`を編集して lintを走らせるScriptも設定しておきます。


```json
"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "lint": "eslint", // 追加
    "lint:fix": "eslint --fix"  // 追加
  },
```

上記を`package.json`に追加します。

すると以下のコマンドでLintのチェック、Lintによる修正を行ってくれます。

```terminal
$ npm run lint .

> practice@1.0.0 lint
> eslint "."


$ npm run lint:fix .

> practice@1.0.0 lint
> eslint "."

```


## Jestによるテストの導入

### Jestの導入
テストは[Jest](https://jestjs.io/)を利用します。

以下のコマンドでインストールを行います。

```terminal
npm install --save-dev typescript jest ts-jest @types/jest
```

インストール後、以下のコマンドで`jest.config.ts`を作成します。

```terminal
$ npx jest --init

The following questions will help Jest to create a suitable configuration for your project

✔ Would you like to use Jest when running "test" script in "package.json"? … yes
✔ Would you like to use Typescript for the configuration file? … yes
✔ Choose the test environment that will be used for testing › node
✔ Do you want Jest to add coverage reports? … yes
✔ Which provider should be used to instrument code for coverage? › babel
✔ Automatically clear mock calls and instances between every test? … yes
```

こちらも対話形式でjestの設定ファイル (`jest.config.ts`)が作成されます。

作成された `jest.config.ts`の`preset`を`"ts-jest"`に変更します。

```typescript
- // preset: undefined,
+ preset: "ts-jest",
```


また、上記の対話で`package.json`のscriptsのtestコマンドが変わっているかもですが、以下のようになっていたら、大丈夫です。

```json
  "scripts": {
    "test": "jest",
    "lint": "eslint",
    "lint:fix": "eslint --fix"
  },
```

これで `npm run jest`を実行すると、jestによるテストが走るようになります。

### テスト作成

テストファイルは testフォルダに書くことにします。

```terminal
mkdir -p test/src && touch test/src/index.test.ts
```

test/src/index.test.tsの中身は以下のようにしました。
なおjestの書き方などについては今回扱いません。

```typescript
import data from "../../data/prefectures.json";
import {
  findByName,
  findByCode,
} from "../../src/index";

test("findByName", () => {
  expect(findByName("北海道")).toBe(data[0]);
  expect(findByName("hoge")).toBe(undefined);
});

test("findByCode", () => {
  expect(findByCode("1")).toBe(data[0]);
  expect(findByCode("01")).toBe(data[0]);
  expect(findByCode(1)).toBe(data[0]);
  expect(findByCode(0)).toBe(undefined);
});
```

これでテストの作成も完了です。
実際に走らせ見ると、以下のようになりました。

![スクリーンショット 2021-08-01 14.58.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/107345/6d5b97e4-4ebb-3c0d-e13b-88a3603f0ef6.png)

## GitHub ActionsでLintとTestのチェック

GitHub Actionsで先程作ったテストとLintのチェックをプルリクエストが作られたタイミングで走るようにしておきます。

```terminal
mkdir -p .github/workflows && touch .github/workflows/ci.yml
```

ci.ymlの中身は以下のようにします。
以下のGitHubのDocsを参考にしていますので、解説は飛ばします。

```yml
name: CI

on:
  pull_request:
  push:
    branches:
      - master

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [10.x, 12.x, 14.x, 15.x] // 複数のnodeのバージョンでICを実行する

    steps:
      - uses: actions/checkout@v2

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v2
        with:
          node-version: ${{ matrix.node-version }}

      - uses: actions/cache@v2
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node${{ matrix.node-version }}-${{ hashFiles('./package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node${{ matrix.node-version }}-${{ hashFiles('./package-lock.json') }}

      - name: Install
        run: npm ci --prefer-offline --no-audit --no-optional --ignore-scripts
        env:
          CI: true

      - name: Build
        run: npm run build --if-present

      - name: Test
        run: npm run test

      - name: Lint
        run: npm run lint .
```

プルリクエストを作ってCIがうまく走ると以下のようになります。

![スクリーンショット 2021-07-31 1.12.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/107345/49a7d41b-d8ba-b6b9-7639-b8c9fd160e1f.png)


## いよいよ公開

npmパッケージの公開は

```terminal
npm publish
```

で可能です。が、公開する前に、

- Lintが通っているか
- テストが通るか

という確認を行いたいので、その設定を行っておきます。

まずは `package.json`の変更を行います。

### 公開前にLintとTestを実行する

`npm publish` コマンドのライフサイクルがあります。

https://docs.npmjs.com/cli/v7/using-npm/scripts#npm-publish

> npm publish
・prepublishOnly
・prepack
・prepare
・postpack
・publish
・postpublish
prepare will not run during --dry-run


です。publish(公開)される前に実行をしたいので、今回は

- prepublishOnlyのタイミングで、 `npm run test && npm run lint`
- prepareのタイミングで、 `npm run build`

を実行します。

よってscriptsは以下のように変更しました。buildのscriptも追加しています。

```json
  "scripts": {
    "build": "tsc --project tsconfig.json",
    "test": "jest",
    "lint": "eslint",
    "lint:fix": "eslint --fix",
    "prepublishOnly": "npm run test && npm run lint",
    "prepare": "npm run build"
  },
```


### build結果に応じた修正

#### Entrypointの修正
buildを行うと、`tsconfig.json`に `"outDir": "./dist",`と書いてあるとおり、`distフォルダ`にbuildされたファイルが出力されます。

最初に呼ばれるファイルはBuildされたファイルのindex.jsにしたいので、
package.jsonのmainを変更します。

```json
"main": "./dist/src/index.js",
```

#### 型定義ファイルの指定
また型定義ファイルも指定してあげます。
`tsconfig.json`に`"declaration": true,`と書いてあれば、Build時に型定義ファイルもdistフォルダに出力されます。
その出力されたファイルを型定義ファイルとして用いることを`package.json`で示します。


```json
"types": "./dist/src/index.d.ts",
```


### 不要なファイルを公開しない指定
実際にこのパッケージをインストールして扱う際には、出力された`dist`のフォルダだけで充分なので、それ以外のファイルは公開されないように指定します。指定方法は、`package.json`に以下を追加します。

```json
  "files": [ "dist/" ],
```

ちなみに`README`や`package.json`ファイルは上記のように指定しても公開されます。
その他にも明示しても含まれるファイル、明示しなくても含まれないファイルは存在しますので、気になる方は以下のドキュメントを参考にしてみてください。

https://docs.npmjs.com/cli/v7/configuring-npm/package-json#files

またgitで管理する必要のないファイルが増えたので、併せて`.gitignore`の編集も行っておきましょう。`.gitignore`は以下のようになっています。

```
node_modules/
coverage/
dist/
```

#### ここまでできたらほぼ完成

ここまでできたら完成です。

いよいよ `npm publish`を実行してみましょう！

また公開にあたって、以下のようなコマンドでパッケージのバージョンを自動で上げることもできます。

```terminal
npm version major
npm version minor
npm version patch
```

また非公開にしたい場合は以下の `unpublish`コマンドで可能です。

```terminal
npm unpublish パッケージ名@バージョン
```

https://docs.npmjs.com/cli/v7/commands/npm-unpublish

公開したら、実際に自分のプロジェクトにインストールして使ってみてください！

## おまけ 自動デプロイ

GithubのRelease機能を使い、このリリースが作成されたら自動でnpm publishを行うように設定していきます。GithubのRelease機能↓

https://docs.github.com/ja/github/administering-a-repository/releasing-projects-on-github/managing-releases-in-a-repository


Deployを行うGithub Actionsは`deploy.yml`に作成しておきます。

```terminal
touch .github/workflows/deploy.yml 
```

`deploy.yml`の中身は以下のようなものです。

```yml
name: Deploy

on:
  release:
    types: [created]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Node 14 setup
        uses: actions/setup-node@v2
        with:
          node-version: '14'
          registry-url: 'https://registry.npmjs.org'

      - name: Install
        run: npm install

      - name: Test
        run: npm run test

      - name: Build
        run: npm run build --if-present

      - name: Publish
        run: npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} # GitHub上にSecretKeyを設定
```


一番下の行にある、NPM_TOKENはGitHub上で設定します。

![Actions_secrets.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/107345/24edd96d-e04a-457d-488b-fb27a39b936d.png)

![Actions_secrets.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/107345/1209dd48-63c5-4c7f-019c-c196b1237349.png)

Tokenに入れる値は、`.npmrcファイル`にある`authToken=`以降の値です。

```terminal
$ cat .npmrc
//registry.npmjs.org/:_authToken=hogehoge
```

こちらの設定ができたら、自動デプロイが可能になっています。

ターミナルで、以下の手順でリリースを作成可能です。リリースを作成すると、自動デプロイが走ります。

1.npmのバージョンを更新

  ```terminal
  npm version (patch | mainer | major)
  ``` 

2.TagをPush

```terminal
git push origin v1.0.5
```

3.リリース

```terminal
$ gh release create  v1.0.5
? Title (optional)  v1.0.5
? Release notes Write my own
? Is this a prerelease? No
? Submit? Publish release
```

![スクリーンショット 2021-08-02 9.17.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/107345/476cd56b-c137-1e22-3412-a318b13b45c7.png)

上の画像のようになっていたらnpmに公開もできています！

## 終わりに

これでnpmパッケージ公開の流れについてはおしまいです。
TypeScriptで実行、Jestの導入、Github Actionsの実行など公開に必須ではないものの、実際に公開するにあたっては欲しい機能などと思います。
npmパッケージを作る際には、ぜひ参考にしてみてください。

もし気に入ったらスターなどで応援してくれると喜びます。それでは。

https://github.com/hatsu38/jp-prefectures.js

https://www.npmjs.com/package/jp-prefectures