🚀

最強 DX な Server-side TypeScript 開発環境を作ってみた (swc)

2022/10/09に公開

What

  • 最強の Developer eXperience な Server-side TypeScript 開発環境を作ってみた。
  • あまり Server-side TS に詳しくないので、問題などあればご指摘願いたい。

Requirements

粒度バラバラだが、必ず実現したいのはこの辺り。

  • TypeScript
  • No tsc build (Use swc or esbuild)
  • yarn berry (v3)
  • VSCode debugger
  • Docker compatible
  • Hot reload
  • Absolute path import

Frontend engineer な人から見れば「何を当たり前のことを」と思うかもしれない。
でもこれらを Server-side Node.js で実現しようとすると意外と壁が多かった。特に NestJS はいろいろ困難。

TL;DR

とりあえずメジャーな express.js で試してみた例。
https://github.com/arx-8/try-server-side-ts/tree/swc-express

Fastify でもうまくいくはず。es decorators とかいう負の遺産(やや偏見)をゴリゴリ使ってる NestJS は build できない。
swc config 弄れば対応できるのかもしれないが、今のところ es decorators を使う気はないので試してない。

それぞれどうしているか?

環境は Mac M1。

TypeScript

  • type check は普通に tsc

No tsc build (Use swc)

  • build は swc。爆速 🚀

yarn berry (v3)

Setup 手順

# asdf を使って必要な runtime を一括 install
cut -d' ' -f1 .tool-versions | grep "^[^\#]" | xargs -I {} asdf plugin add {}
asdf install

# yarn 3 を始める
# see vers list: https://github.com/yarnpkg/berry/releases
yarn set version 3.2.3

# Setup yarn config
# PnP/Zero-Installs はまだ運用が面倒くさそうなので使わない
yarn config set nodeLinker node-modules

# 非ライブラリで not save-exact にするメリットはないので、save-exact を強制する
# yarn berry (v3) では save-prefix option -> defaultSemverRangePrefix になった
# .npmrc の save-exact は無視される
yarn config set defaultSemverRangePrefix ""

# yarn plugin の追加
# yarn v1 の `yarn install --production` 相当のことをしたいため
yarn plugin import workspace-tools
# npm-check-updates 相当のことをしたいため
yarn plugin import interactive-tools

# init
yarn init -p -y
yarn
  • 特に、yarn install --production が廃止され plugin とやらに移行したのには面食らった。
  • また、yarn 本体や plugin を commit しないといけないのもモヤっとする。
    • .gitattributes を書いて、GitHub で余計な表示がされないよう設定する。
    • PnP/Zero-Installs ガッツリ使うなら、Git LFS も必要そう。

あと、asdf はいいぞ。
全部 asdf + .tool-versions で完結するの本当に楽。GitHub Actions の actions/setup-node@v3 も対応している。

VSCode debugger

  • .vscode/launch.json の通り。これは特に奇抜なことはなし。

Docker compatible

Dockerfile
# Build container
FROM node:18.9.0 as builder
WORKDIR /work
COPY . .
RUN yarn install --immutable && \
    yarn build && \
    yarn workspaces focus --all --production

# Runtime container
FROM gcr.io/distroless/nodejs:18

COPY --from=builder /work/dist /dist
COPY --from=builder /work/node_modules /node_modules

CMD ["/dist/index.js"]
  • multi stage build で Runtime container の image size を減らしている
    • yarn workspaces focus --all --production で devDependencies の packages を減らし、
    • COPY --from=builder /work/node_modules /node_modules の copy 量が減り、
    • 結果、Runtime container の image size が減る

webpack などで single js にすればさらに減らせるのだが、以下の記事の通り問題がありそうなのでやめた。

ref. node.js のメトリクスの計測、ベンチマークの改善、Docker イメージの絞り方を勉強した - mizdev

シングルファイル化は現実的なアプリでは厳しい気がする。ファイルの相対パスが崩れて、他の設定を読む系のものが動かないし、エラーのスタックトレースを追うのが難しくなる。あくまで可能な場合の最終手段という感じ。

Hot reload

package.json
// ...
"scripts": {
    "build:cleanup": "rm -rf dist",
    "dev:build": "yarn build:cleanup && swc src -d dist --config-file configs/.swcrc.dev",
    "dev:start": "nodemon --watch dist --ext js --exec 'node dist/index.js'",
    "dev": "yarn dev:build --watch & sleep 3 && yarn dev:start",
// ...

nodemon で hot reload させている

"dev": "yarn dev:build --watch & sleep 3 && yarn dev:start",

  • swc --watch で build すると、そのプロセスが終わらない = 後続の && yarn dev:start に進めず起動できない、となってしまう。
  • なので、watch は & で background 実行させ、初回 build が終わるまで sleep で適当に待たせ、その後起動させてる。

Absolute path import

  • .swcrc と tsconfig.json の組合せで実現している。

未解決の問題

  • es modules に対応できてない
  • lodash-es や ky を import して実行すると、以下のようなエラーになる
    • import (ESM) が require (CommonJS) に変換されているので当然といえば当然だが
$ node dist/index.js
/Users/arx/try-server-side-ts/dist/handlers/index.js:10
const _ky = /*#__PURE__*/ _interopRequireDefault(require("ky"));
                                                 ^

Error [ERR_REQUIRE_ESM]: require() of ES Module /Users/arx/try-server-side-ts/node_modules/ky/distribution/index.js from /Users/arx/try-server-side-ts/dist/handlers/index.js not supported.
Instead change the require of /Users/arx/try-server-side-ts/node_modules/ky/distribution/index.js in /Users/arx/try-server-side-ts/dist/handlers/index.js to a dynamic import() which is available in all CommonJS modules.
    at Object.<anonymous> (/Users/arx/try-server-side-ts/dist/handlers/index.js:10:50)
    at Object.<anonymous> (/Users/arx/try-server-side-ts/dist/index.js:7:19) {
  code: 'ERR_REQUIRE_ESM'
}

Node.js v18.9.0
  • .swcrc / module.type を "es6" にすると別のエラーが起きたり、色々試したが今のところ解決できてない。誰か助けて。

なぜ swc にしたのか? (なぜ他の〇〇にしなかったのか?)

Parcel 2 にしなかった理由

  • single js になってしまうから。
  • minify (dead code eliminate etc.) が効かないっぽく、build size が肥大化するから。

esbuild にしなかった理由

  • single js になってしまうから。
    • bundle: false, の設定をしてもダメ。

etsc (esbuild-node-tsc) にしなかった理由

  • Absolute path import できなかったから。

vite-plugin-node にしなかった理由

  • 開発 build で debugger を attach できなかったから。

まとめ

  • 激遅 tsc build や、読みづらい relative path import や、node_modules 丸コピの Fat Docker image などを解決できた。
  • 正直 yarn berry にするメリットが感じられない。PnP/Zero-Installs 使わないのであれば、設定や binary 管理が増えるデメリットしかない気がする。
  • esbuild の方が勢いある感じもするが、swc も Next.js や Parcel 2 に採用されるなどしてるので頑張ってほしい。

Discussion