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 |
Package manager |
npm / pnpm / yarn / bun。 |
Validation library |
|
主要な依存¶
パッケージ |
用途 |
バージョン |
|---|---|---|
|
ReScript コンパイラ |
|
|
標準ライブラリ |
|
|
コンパイル済み |
|
|
HTTP フレームワーク (ルーター、ミドルウェア、コンテキスト) |
|
|
Node.js アダプタ — Hono の fetch ハンドラを HTTP サーバーに変える |
|
|
zod バリアントのみ — OpenAPI 仕様のソースを兼ねる宣言的なルート |
|
|
Scalar UI を |
|
|
バリデーションバックエンド (HTTP ボディパーサー) |
|
|
libsql / SQLite クライアント (ローカルファイルおよび Turso に対して動作する) |
|
|
ORM + クエリビルダー (読み書き) |
|
|
スキーマ差分 + マイグレーション生成 |
|
|
テストランナー (サーバーを起動するのではなく |
|
|
|
|
主要なファイル¶
src/Server.res¶
Hono アプリの定義。意図的に分離された 2 つのトップレベルバインディングを持つ:
let app = Hono.createApp()— アプリはモジュールロード時に構築され、ミドルウェアとルートが取り付けられ、バインディングがエクスポートされる。このモジュールをインポートすることはネットワークに関しては 副作用がない (ポートはバインドされない)。let start = () => { HonoNodeServer.serve(...); Console.log(...) }— 実際のserve()呼び出しをラップしており、何かが明示的にServer.start()を呼んだときだけ起動が発生する。
この分離があるおかげでテストハーネスが動作する: Server.test.mjs は import { 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.json の start と dev スクリプトは 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 つを処理する:
process.envからDATABASE_URLを読む (デフォルト:file:./data/app.db)モジュールロード時にクライアントと
dbDrizzle ラッパーを構築するクエリヘルパーをエクスポートする:
select、from、insert、values、update、set、deleteFrom、where、orderBy、limit、offset、groupBy、allAsync、getAsync、returning、加えて比較演算子 (eq、gt、lt、inArray、likeなど) と論理コンビネータ (and、or、not) である
クライアントはモジュールロード時に構築されるため、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 のバインディング — createApp、createRoute、openapiRoute、doc。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 スクリプト¶
スクリプト |
説明 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
通常のセッションでは 2 つのターミナルを開いておく: ReScript ウォッチャー用の npm run res:dev と、自動再起動サーバー用の npm run dev である。
2日目以降のレシピ¶
Hono エンドポイントの追加 — 新しいルート + Drizzle テーブル + zod バリデーション + OpenAPI ドキュメントを追加するエンドツーエンドのウォークスルー
Drizzle のセットアップ — スキーマモデリングとマイグレーションパターン
OpenAPI ドキュメントの追加 — スタブの
/openapi.jsonから実際に生成された仕様への移行
プロジェクトを開いた後の 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 → 400、Ok → 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.res、drizzle.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フラグが必要となる場合がある)。