hono, prisma, zod, cloudflare

Naoki Tomita Naoki Tomita December 26th, 2025 life typescript

今年はプログラマーの仕事はぐっと減らし、リモートで週2,3日稼働で可能な範囲で受けています。ちょうどfluct時代にお世話になったもっちーさんが独立してさっそく困っているということで、もちプロダクトのお手伝いをしてました。【PR】もっちーさんはお手伝いを募集中!

事業特性的にCloudflare環境(Worker + D1)を使うことになり、個人的には初めてまともにtypescriptでAPIを書きました。

1年ほどやってみての所感を放流しておきます。

Cloudflare Workers

Cloudflareは以前から好き。SimpleというよりEasyではあるのだがよくできてる。めんつゆ最高。謎のテクノロジーでsqliteがエッジで動く

DuckDBを使いたくて(Betaの)Cloudflare Containers使ってみた時は、npm run dev 叩いたら裏でdocker build/runが走ってすんなり使え、「おお、wrangler.jsoncにBindingを記述したらコンテナがDIされてきたぞ」と感心した。1

  "containers": [
    {
      "class_name": "DuckDBContainer",
      "image": "container/Dockerfile",
      "max_instances": 10
    }
  ],

Hono

yusukebe氏がめちゃがんばってる流行りのフレームワーク。さすが特に困ることなく使える😀 typescriptの型パズルをがんばってくれていてすごい

OpenAPI定義をどちらでやるか

@hono/zod-openapihono-openapi を使う2パターンがあって名前も似てて検索結果も混じりやすい。標準honoのapi2 に乗っかる方が好みなので、middleware提供スタイルであるhono-openapiの方を利用した。

CIでopenapi.jsonを生成するのを入れてたが、ある時なぜかdescribeRoute()の記述が取れないハンドラーがあった。調べたら「onError()があるsub app」で起きるエッジケースだったようなので、PRを送った。

fix: resolve target handler marked as COMPOSED_HANDLER
https://github.com/rhinobase/hono-openapi/pull/204

入力はzodでバリデーションが走るとして、レスポンスについては

describeRoute({
  validateResponse: process.env.NODE_ENV === 'test'

みたいにしておき、作成されたopenapi.jsonを元にGETを(必要パラメータはexamplesから代入して)全部叩くみたいなテストを入れた。

Prisma

型とスキーマ管理は欲しいしまあ妥当な選択肢だった… と思う。がORMはやっぱ自分の足元を撃ちますな。普通に使っててもすぐ波動拳になるし3

...findUnique({
  where: {
    id: cid,
  },
  include: {
    device_groups: {
      select: {
        group: {
          include: {
            devices: {
              select: {
                device: {
                  include: {
                    spot: {
                      include: {
                        owner_company: true,
                      },
                    },
                  },
                },
              },
            },
          },
        },
      },
    },
  },
  ...

あと最新版がブロッカーになっていて辛い。そもそも、prisma, zod, hono-openapi, honoあたりは結局まとめてバージョンあげるがんばりが必要そう。かつ、作成されたtypeやclientなどを利用しているfront側アプリも全部。これはダルい

prisma generator

しかしprismaはジェネレーターできるのが良くて、protobufを懐かしく思い出した。今回、Active Recordっぽい動きをさせたいCRUDハンドラーばかりだったので、まるっとgenereate。jsでゴリゴリtsを書くのおもしろい

const fs = require('node:fs');
const path = require('node:path');
const {generatorHandler} = require('@prisma/generator-helper');

// npx prisma generate --generator handler
generatorHandler({
  outputDir: './generated/handlers/',

  onManifest() {
    return {
      prettyName: 'Hono Handler',
    };
  },

  onGenerate(options) {
    if (options.generator.output) {
      this.outputDir = options.generator.output.value;
    }
    if (!fs.existsSync(this.outputDir)) {
      fs.mkdirSync(this.outputDir, {recursive: true});
    }
    
    for (const model of options.dmmf.datamodel.models) {
      ...

DMMF構造体がもう少し(リレーションの情報とか)取りやすければ良いのだけど、モデル名・テーブル名・フィールド名だけでもけっこうやりたいことはできる

CIでのファイルの生成時(つまりcludflare workersとかwrangler devではない環境)ではcloudflare:workers moduleが読まれると動かないので、一部遅延読み込みにするなどをして対応したがもっといいやり方があるのかもしれない

const { getContainer } = await import('@cloudflare/containers')

generate祭り

実際こんな感じ

                 ┌─▶ D1 migrations
                 │
 schema.prisma ──┼─▶ prisma client
                 ├─▶ ERD
                 ├─▶ zod schemata / types
                 └─▶ handlers(hono) ────▶ openapi.json
                                            └─▶ api/README.md

prisma generate部分はprisma.schemaでまとめて管理できるけど

generator client {
  provider        = "prisma-client-js"
  output          = "./generated/client/"
  previewFeatures = ["driverAdapters"]
}

generator zod {
  provider         = "zod-prisma-types"
  output           = "./generated/zod/"
  writeBarrelFiles = false
  createInputTypes = false
}

generator handler {
  provider = "node ./db/handler-generator.js"
  output   = "./generated/handlers/"
}

generator erd {
  provider  = "prisma-erd-generator"
  output    = "./README.md"
  tableOnly = true
}

それ以外を順番に生成するのをpackage.jsonで管理するのに、npm-run-all2を使った

  "scripts": {
    ...
    "generate": "run-s generate:*",
    "generate:cf-type": "wrangler types --env-interface CloudflareBindings",
    "generate:schema-format": "prisma format",
    "generate:schema-generates": "prisma generate",
    "generate:api-openapi.json": "npx --yes tsx doc/openapi-generator.ts",
    "generate:api-readme.md": "npx --yes openapi-to-md doc/openapi.json > doc/README.md"
  },

D1 migrationをどちらでやるか

prismaを使う場合Cloudflare D1のマイグレーション管理のやり方は2パターンあるが、今回ターゲットがD1ということもありwranglerコマンドを使う方にした。

具体的には、schema.prismaをいい感じにしたあと、こんなので差分をmigrationファイルとして出力させて(必要なら微調整)

npx prisma migrate diff --from-local-d1 --to-schema-datamodel schema.prisma --script > db/migrations/0002_aaaaa.sql

ローカルで適用して確認

find .wrangler/state/v3/d1/miniflare-D1DatabaseObject/ -type f -name '*.sqlite' -delete
npx wrangler --env test d1 migrations apply test-db --local
  1. 実稼働には、暖気設定とかがほしくなりそうではある 

  2. honoのapiはmiddleware/handlerが、next呼ぶか/returnするかの違いってのが綺麗なデザインだなと思うところ。 

  3. $queryRaw に取り替えていこうか…