Hono (Node.js)

Node.js REST Validation

Node.js 上で動作する、本番形態の Hono REST サービスである。テンプレートは意図的に "Hello World" ではなく、多くのチームが初週から手を伸ばし、もうリバースエンジニアリングしたくないと感じる 2 日目以降の 4 つの関心事を配線している: 永続化 (libsql + Drizzle ORM 経由の SQLite)、ランタイムボディバリデーション (ウィザードで選択した zod または sury)、構造化リクエストロギング、および Scalar UI を介して /docs で対話的に提供される OpenAPI 3.1 ドキュメントである。

Node 上で JSON HTTP API を作りたいが、どのデータベースクライアントを使うか、バリデーションをどこに置くか、ドキュメントをどう公開するかに最初のスプリントを費やしたくない場合に本テンプレートを選ぶ。同梱の users CRUD はスタック全体を示しており、新しいエンドポイントを差し込む場所が正確に分かる。

生成内容

my-project/
├── rescript.json                  # depends on @rescript/core + validation lib (no JSX)
├── package.json                   # type: "module", hono + node-server + drizzle + scalar deps
├── drizzle.config.ts              # drizzle-kit config — schema: ./src/Schema.res.mjs, dialect: sqlite
├── vitest.config.mjs              # loads vitest.setup.mjs before any module imports
├── vitest.setup.mjs               # pins DATABASE_URL=:memory: before Db.res top-level runs
├── src/
│   ├── Server.res                 # `let app = ...` + `let start = () => ...` (importable, no port bind)
│   ├── ServerMain.res             # entry point: calls Server.start()
│   ├── Routes.res                 # module Users { register: app => ... } — GET/POST/PUT/DELETE /users
│   ├── Schema.res                 # Drizzle SQLite schema (sqliteTable + intCol/textCol)
│   ├── Db.res                     # libsql client + Drizzle wrapper + query helpers (eq, and, asc, …)
│   ├── Logger.res                 # hono/logger middleware binding
│   ├── Scalar.res                 # @scalar/hono-api-reference binding
│   ├── Validation.res             # zod or sury — parseCreateUserInput for POST /users body
│   ├── ZodOpenapi.res             # zod variant only — @hono/zod-openapi bindings
│   ├── Hono.res                   # auto-generated Hono bindings (createApp/get/post/json/text/...)
│   ├── HonoNodeServer.res         # auto-generated @hono/node-server bindings (serve/honoFetch)
│   └── __tests__/Server.test.mjs  # vitest — imports Server.res.mjs, calls app.request("/health")
├── README.md                      # API + Database + OpenAPI Docs + Project Layout sections
├── LICENSE                        # MIT, holder = project name
├── .nvmrc                         # Node 24
├── .env.example                   # DATABASE_URL=file:./data/app.db (or libsql:// for production)
├── .gitignore                     # node_modules + ReScript output + dist/, data/, drizzle/, .env
├── .editorconfig                  # 2-space indent, LF line endings
└── .github/
    ├── dependabot.yml             # weekly npm updates
    └── workflows/ci.yml           # install + rescript build + vitest

ウィザードオプション

オプション

効果

Project name

npm name、LICENSE のホルダーになり、README から参照される

Package manager

npm / pnpm / yarn / bun。packageManager、README のインストール/実行スニペット、および CI キャッシュキーを設定する。README の Database セクションは、選択された db:generate / db:migrate の呼び出しを置換する

Validation library

zodsury。同梱する src/Validation.ressrc/ZodOpenapi.res を生成するかどうか、@hono/zod-openapidependencies に追加するかどうか、zodsury のどちらをインストールするかを決定する

主要な依存

パッケージ

用途

バージョン

rescript

ReScript コンパイラ

TemplateVersions.RESCRIPT

@rescript/core

標準ライブラリ

TemplateVersions.RESCRIPT_CORE

@rescript/runtime

コンパイル済み .res.mjs がインポートするランタイムスタブ

TemplateVersions.RESCRIPT_RUNTIME

hono

HTTP フレームワーク (ルーター、ミドルウェア、コンテキスト)

TemplateVersions.HONO

@hono/node-server

Node.js アダプタ — Hono の fetch ハンドラを HTTP サーバーに変える

TemplateVersions.HONO_NODE_SERVER

@hono/zod-openapi

zod バリアントのみ — OpenAPI 仕様のソースを兼ねる宣言的なルート

TemplateVersions.HONO_ZOD_OPENAPI

@scalar/hono-api-reference

Scalar UI を /docs にマウントする

TemplateVersions.SCALAR_HONO_API_REFERENCE

zod または sury

バリデーションバックエンド (HTTP ボディパーサー)

TemplateVersions.ZOD / SURY

@libsql/client

libsql / SQLite クライアント (ローカルファイルおよび Turso に対して動作する)

TemplateVersions.LIBSQL_CLIENT

drizzle-orm

ORM + クエリビルダー (読み書き)

TemplateVersions.DRIZZLE_ORM

drizzle-kit (dev)

スキーマ差分 + マイグレーション生成

TemplateVersions.DRIZZLE_KIT

vitest (dev)

テストランナー (サーバーを起動するのではなく app.request を使用する)

TemplateVersions.VITEST

@vitest/coverage-v8 (dev)

test:coverage のカバレッジプロバイダ

TemplateVersions.VITEST_COVERAGE_V8

主要なファイル

src/Server.res

Hono アプリの定義。意図的に分離された 2 つのトップレベルバインディングを持つ:

  • let app = Hono.createApp() — アプリはモジュールロード時に構築され、ミドルウェアとルートが取り付けられ、バインディングがエクスポートされる。このモジュールをインポートすることはネットワークに関しては 副作用がない (ポートはバインドされない)。

  • let start = () => { HonoNodeServer.serve(...); Console.log(...) } — 実際の serve() 呼び出しをラップしており、何かが明示的に Server.start() を呼んだときだけ起動が発生する。

この分離があるおかげでテストハーネスが動作する: Server.test.mjsimport { app } from "../Server.res.mjs" を行い、ポート 3000 をバインドすることなく app.request("/health") を通じてエンドポイントを駆動できる。

同梱の app は以下を登録する:

  • Logger.logger() ミドルウェア

  • 未捕捉例外を JSON 500 レスポンスに変換するグローバルな onError ハンドラ

  • GET / (health のテキスト)、GET /health (JSON { status: "ok" })

  • Routes.Users.register(app) (CRUD モジュール)

  • GET /openapi.json (スタブ仕様 — 補足を参照)

  • GET /docs (/openapi.json にバインドされた Scalar UI)

let app = Hono.createApp()
app->Hono.use(Logger.logger())
app->Hono.onError((err, ctx) => {
  Console.error(err)
  ctx->Hono.status(500)->Hono.json({"error": "Internal Server Error"})
})

let start = () => {
  HonoNodeServer.serve({fetch: app->HonoNodeServer.honoFetch, port: 3000})
  Console.log("Server on http://localhost:3000 — docs at /docs")
}

src/ServerMain.res

2 行のエントリポイント:

Server.start()

package.jsonstartdev スクリプトは Server.res.mjs ではなく ServerMain.res.mjs を実行する — これが実際のポートバインドをトリガーする。テストは Server.res.mjs を直接インポートし、このファイルを通ることはない。

src/Routes.res

ドメインごとにグループ化されたルート登録。同梱の module Users が標準パターンを示している: let register = (app) => { app->Hono.get(...); app->Hono.post(...) }。新しいグループを追加するには module Posts = { let register = ... } を追加し、Server.res から Routes.Posts.register(app) を呼び出す。

POST ハンドラはバリデーションが置かれる場所の標準例でもある:

app->Hono.post("/users", async ctx => {
  let raw = await ctx->Hono.req->Hono.jsonBody
  switch Validation.parseCreateUserInput(raw) {
  | Error(msg) => ctx->Hono.status(400)->Hono.json({"error": msg})
  | Ok(payload) =>
    let inserted =
      await Db.db
      ->Db.insert(Schema.users)
      ->Db.values({"name": payload.name, "email": payload.email})
      ->Db.returning
    ctx->Hono.status(201)->Hono.json(inserted)
  }
})

バリデーション失敗は {"error": msg} を伴う 400 となる — 決してデータベースには到達しない。

src/Schema.res

Drizzle のスキーマ。永続化に集中している (zod / sury を絡めない) ため、db:generate が読む差分は小さく曖昧さがない。

let users = sqliteTable("users", {
  "id": intCol("id", {"primaryKey": true, "autoIncrement": true}),
  "name": textCol("name", {"notNull": true}),
  "email": textCol("email", {"notNull": true}),
})

src/Db.res

libsql クライアント + Drizzle クエリビルダーのバインディング。次の 3 つを処理する:

  1. process.env から DATABASE_URL を読む (デフォルト: file:./data/app.db)

  2. モジュールロード時にクライアントと db Drizzle ラッパーを構築する

  3. クエリヘルパーをエクスポートする: selectfrominsertvaluesupdatesetdeleteFromwhereorderBylimitoffsetgroupByallAsyncgetAsyncreturning、加えて比較演算子 (eqgtltinArraylike など) と論理コンビネータ (andornot) である

クライアントはモジュールロード時に構築されるため、Db.res.mjs をインポートするものはすべて即座にデータベースを開く — 後述の vitest セットアップを参照のこと。

src/Validation.res

parseCreateUserInput: JSON.t => result<createUserInput, string>。zod でも sury でもシグネチャが同じであるため、Routes.res はどちらのライブラリが同梱されたかで分岐しない。失敗したパースは Error(msg) として伝播し、ルートハンドラ内で HTTP 400 になる。

zod バリアントは @module("zod") の externals と parse(...) を使用する; sury バリアントは S.object + S.parseOrThrow を使用し、S.Error を捕捉して result に戻す。

src/ZodOpenapi.res (zod バリアントのみ)

@hono/zod-openapi のバインディング — createAppcreateRouteopenapiRoutedoc。zod バリアントはこれらを同梱する。@hono/zod-openapi を使えばルートを一度宣言的に記述するだけで、ランタイムのルーターと OpenAPI 仕様のソースの両方として機能させられるためである。sury バリアントには等価なファイルは 同梱されない; sury 下で OpenAPI 生成が必要な場合は、S.toJSONSchema を使い、Server.res 内で手動でドキュメントを組み立てる。

src/Logger.res

hono/logger のための 2 行のバインディング:

@module("hono/logger") external logger: unit => Hono.middleware = "logger"

Server.res 内で app->Hono.use(Logger.logger()) を通じてマウントされる。

src/Scalar.res

@scalar/hono-api-reference のバインディング。Server.res 内で app->Hono.get("/docs", Scalar.apiReference({"spec": {"url": "/openapi.json"}})) としてマウントされる。

drizzle.config.ts

drizzle-kit の設定 — スキーマ位置、dialect、出力ディレクトリ、およびデータベース URL。環境から DATABASE_URL を読む (デフォルト: file:./data/app.db)。

export default defineConfig({
  schema: "./src/Schema.res.mjs",
  out: "./drizzle",
  dialect: "sqlite",
  dbCredentials: { url: process.env.DATABASE_URL ?? "file:./data/app.db" },
});

vitest.config.mjs + vitest.setup.mjs

微妙な問題を解決する 2 ファイルのペア: Db.res はモジュールロード時に libsql クライアントを構築するため、Server.res.mjs (これは推移的に Db.res.mjs をインポートする) をインポートするテストは、放っておけば ./data/app.db を開こうとする — 新規チェックアウトや統合テストの一時ディレクトリには存在しないファイルである。

vitest.setup.mjs は、いかなるテストファイルの静的インポートが実行される前に process.env.DATABASE_URL = ":memory:" を固定する。vitest.config.mjs はそれを setupFiles で登録する。結果として、テストはプロセスごとのインメモリ SQLite に対して実行され、ディスクには一切触れず、Server.res にテスト専用の分岐は不要となる。

src/__tests__/Server.test.mjs

コンパイル済み Server.res.mjs から app をインポートし、Hono 組み込みの app.request(...) を介してエンドポイントを動かす — これは Hono が HTTP サーバーを動かさずにテストするために同梱している、同じ fetch スタイルのハーネスである。

import { app } from "../Server.res.mjs";
const res = await app.request("/health");
expect(res.status).toBe(200);

これが動作するのは、まさに Server.res がモジュールロード時に serve() を呼ばない からこそ である。

.env.example

テンプレートが読む唯一の環境変数を文書化している:

DATABASE_URL=file:./data/app.db

Turso の libsql:// URL (または libsql 互換のエンドポイント) に置き換えることで、ローカルファイルを超えてスケールできる。

npm スクリプト

スクリプト

説明

start

node src/ServerMain.res.mjs — サーバーを 1 回実行する

dev

node --watch src/ServerMain.res.mjs — ファイル監視による再起動付きで実行する

test

vitest run — スモークスイートを実行する (インメモリ DB)

test:coverage

vitest run --coverage — 同上、v8 カバレッジ付き

db:generate

drizzle-kit generateSchema.res.mjs と現在の DB を比較し、マイグレーション SQL を ./drizzle/ に出力する

db:migrate

drizzle-kit migrateDATABASE_URL が指すデータベースに保留中のマイグレーションを適用する

res:build

rescript — ワンショットの ReScript コンパイル

res:dev

rescript -w — 保存時に再コンパイル

res:clean

rescript clean — 生成された .res.mjs を削除

通常のセッションでは 2 つのターミナルを開いておく: ReScript ウォッチャー用の npm run res:dev と、自動再起動サーバー用の npm run dev である。

2日目以降のレシピ

プロジェクトを開いた後の ReScript 側のエディタワークフローについては 機能概要 を参照のこと。

補足

  • /openapi.json はスタブとして同梱される。 デフォルトのレスポンスは {"openapi": "3.1.0", "info": {...}, "paths": {}} である — Scalar UI はクリーンにマウントされるが、エンドポイントは 0 個と表示される。実際の仕様を配線するには (a) @hono/zod-openapi でルートを宣言する (zod バリアントには ZodOpenapi.res が同梱されている) か、(b) sury から JSON Schema を生成し (S.toJSONSchema)、OpenAPI ドキュメントを手動で組み立てる。OpenAPI ドキュメントの追加 レシピは両方の経路を順を追って説明している。

  • バリデーションはあらゆる外部入力の手前に立つ壁である。 ルートハンドラが標準の場所である: parseCreateUserInput(raw)Error 400Ok DB。バリデーターの上流のどこでも生の JSON を分解しないこと。

  • Db.res はモジュールロード時にデータベースを開く。 これは意図的なトレードオフである — 遅延初期化の配管なしに、すべてのルートハンドラがホットなクライアントを得る — その代わり、どこからでも import "../Db.res.mjs" を行うとオープンがトリガーされる。vitest セットアップはモジュールロード前に DATABASE_URL=:memory: を固定するため、テストはディスクに一切触れない; テスト設定をカスタマイズする場合はその順序を維持すること。

  • Server.res は副作用がない状態を保たなければならない。 let app = ...let start = () => ... の分離はテストで強制される (server-test はトップレベルの HonoNodeServer.serve(...) 呼び出しがないことをアサートする)。モジュールロード時の作業が必要な場合は start() 内で行い、インポーターの安全性を保つこと。

  • sury バリアントには ZodOpenapi.res がない。 これは見落としではなく意図的である — @hono/zod-openapi は zod 専用である。sury ユーザーはより小さな package.json (@hono/zod-openapi なし) を得て、必要なときに自分で OpenAPI 生成を書く。

  • CORS はコメントアウトされており、有効化されていない。 1 行の app->Hono.use(Hono.cors({"origin": "..."})) ブロックが Server.res にコメント状態で同梱されている。API がブラウザのオリジンから呼ばれる際にコメントを外すこと。

  • データベース URL の優先順位。 Db.resdrizzle.config.ts、およびテストハーネスはすべて process.env から DATABASE_URL を読む (フォールバックは file:./data/app.db)。本番デプロイでは libsql:// URL に設定するべきである。

  • マイグレーションは ./drizzle/ に置かれる。 このディレクトリはデフォルトで gitignore されている — マイグレーションをバージョン管理下に置きたい場合は (本番チームの標準的なワークフローである)、自分のプロジェクトで ignore を反転させること。

  • CI ワークフローはフルスイートを実行する。 npm install + rescript build + vitest。Vitest は vitest.setup.mjs を介してインメモリデータベースを使うため、CI はスモークテストを通過するために外部サービスを必要としない。

  • OpenAPI 3.0 ではなく 3.1 である。 スタブ仕様は "openapi": "3.1.0" を宣言している。仕様からクライアントを生成する場合、ジェネレータが 3.1 をサポートしていることを確認すること (現代的なもののほとんどはサポートしている; openapi-generator はオプトインのために --openapi-generator-version フラグが必要となる場合がある)。