res-x (Bun 上の HTMX)

Bun HTMX Vite Validation Bun-only

res-x (npm 上の rescript-x) で構築され、Bun 上で動作し、Vite でバンドルされるサーバー駆動型 Web アプリケーションである。JSX はサーバー側で HTML をレンダリングし、HTMX は型付きエンドポイントから返される HTML フラグメントを差し替えることでクライアント側のインタラクティビティを駆動する。React も仮想 DOM もアプリケーションロジックのクライアント側バンドルも存在せず、ブラウザは単に HTML を受け取りサーバーとやり取りするだけである。

高速な初回読み込み、クライアントフレームワーク不要、そして可能な限りシンプルな状態モデル(あらゆるインタラクションが JSX フラグメントを返す HTTP リクエストである)を求めるなら、このテンプレートを選ぶとよい。スターターには 2 つのエンドツーエンドの例(カウンタと todo フォーム)が含まれており、九割のケースで使うパターンを示している。

生成内容

my-app/
├── rescript.json                       # jsx.module = Hjsx, opens ResX.Globals + RescriptBun.Globals
├── package.json                        # type:module, bun-driven scripts, vite devDep
├── vite.config.js                      # rescript-x/res-x-vite-plugin.mjs, dev port 9000
├── Dockerfile                          # multi-stage oven/bun image (deps → builder → runtime)
├── src/
│   ├── App.res                         # entry — Bun.serve + path-based routing + Layout fallback
│   ├── Handler.res                     # per-request context + ResX.Handlers.make bootstrap
│   ├── Layout.res                      # shared HTML shell, loads HTMX from CDN
│   ├── Counter.res                     # /counter/{increment,decrement} + outerHTML <span> swap
│   ├── TodoForm.res                    # /todos POST: validates input, re-renders list or form+error
│   ├── Validation.res                  # zod or sury — selected in the wizard
│   └── __tests__/App.test.mjs          # bun:test smoke test
├── README.md                           # Application + HTMX + Layout + Deploy + Persistence sections
├── LICENSE                             # MIT, holder = project name
├── .nvmrc                              # Node 24 (used by tooling that looks at .nvmrc)
├── .gitignore                          # adds dist/, build/, .env, .res-x-cache/
├── .editorconfig
└── .github/
    ├── dependabot.yml
    └── workflows/ci.yml                # auto-enables setup-bun (bun-version: latest)

ウィザードオプション

オプション

効果

Project name

npm の name、LICENSE の保有者、および rescript.json に差し込まれる name フィールドになる

Package manager

npm / pnpm / yarn / bun。packageManager Corepack フィールド、README のインストールコマンド、CI のキャッシュキーにのみ影響する — すべての実行時スクリプトはいずれにせよ bun にハードコードされている

Validation library

zodsury。出荷される src/Validation.res のバリアントを選択し、rescript.jsondependencies 配列に sury を追加する(zod は ReScript 側の bs-deps を持たない)

パッケージマネージャの選択は意図的に表面的なものに留めている。Bun が推奨 PM であり実行時に必須であるが、他の PM もサポートされているのは、ウィザードがインストール手順でプロジェクトを拒否しないためだけの理由による。

主要な依存

パッケージ

用途

バージョン

rescript

ReScript コンパイラ

TemplateVersions.RESCRIPT

@rescript/core

標準ライブラリ

TemplateVersions.RESCRIPT_CORE

@rescript/runtime

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

TemplateVersions.RESCRIPT_RUNTIME

rescript-x

HTMX ファーストのハンドラ API を備えたサーバーサイド JSX フレームワーク

TemplateVersions.RESCRIPT_X

rescript-bun

Bun のランタイム API(Bun.serve、FormData など)に対する ReScript バインディング

TemplateVersions.RESCRIPT_BUN

zod または sury

バリデーションのバックエンド(ウィザードで選択)

TemplateVersions.ZOD / TemplateVersions.SURY

vite (dev)

必要な場合にクライアント側アセットをバンドルするためのバンドラ

TemplateVersions.VITE

concurrently (dev)

開発用に rescript -wbun --watch を組み合わせる

TemplateVersions.CONCURRENTLY

HTMX 自体は実行時に CDN から読み込まれる(src/Layout.res 経由で TemplateVersions.HTMX_CDN に固定されている)。npm の依存ではない。

主要なファイル

rescript.json

rescript-x は特定の設定形状を必要とする — jsx.module = "Hjsx" に加え、ResX.GlobalsRescriptBun.Globals モジュールを open する compiler-flags のセットである。ProjectFileBuilders.rescriptJson ではその形状を生成できないため、ウィザードはこれをそのまま出荷する:

{
  "name": "my-app",
  "sources": {"dir": "src", "subdirs": true},
  "package-specs": {"module": "esmodule", "in-source": true},
  "suffix": ".res.mjs",
  "jsx": {"module": "Hjsx", "version": 4},
  "dependencies": ["@rescript/core", "rescript-x", "rescript-bun"],
  "compiler-flags": [
    "-open RescriptCore",
    "-open RescriptBun",
    "-open RescriptBun.Globals",
    "-open ResX.Globals"
  ]
}

sury バリアントを選択すると , "sury"dependencies 配列に追加される。zod には登録すべき ReScript 側のバインディングがない。

src/App.res

エントリポイント。Bun.serve が res-x ハンドラをマウントし、内部の render 関数がリクエストの path に対してパターンマッチングを行い、res-x が HTML Response に変換する JSX を返す。Handler.handler.hxPost / hxGet 経由で登録された HTMX エンドポイントは、このフォールバックレンダラが動作する に res-x によってマッチングされる。

let server = Bun.serve({
  port,
  development: ResX.BunUtils.isDev,
  fetch: async (request, _server) => {
    await Handler.handler.handleRequest({
      request,
      setupHeaders: () =>
        Headers.make(~init=FromArray([("Content-Type", "text/html")])),
      render: async ({path, requestController, headers: _}) => {
        switch path {
        | list{} =>
          <Layout title="res-x starter">
            <h1> {Hjsx.string("res-x starter")} </h1>
            <Counter />
            <TodoForm />
          </Layout>
        | _ =>
          requestController.setStatus(404)
          <Layout title="Not found">
            <h1> {Hjsx.string("404 — not found")} </h1>
          </Layout>
        }
      },
    })
  },
})

src/Handler.res

リクエストごとのコンテキストとハンドラのブートストラップ。useContext() 経由で利用可能にしたい値(データベース接続、認証済みユーザー、リクエストメタデータなど)で context レコードを拡張する。デフォルトでは説明用に単一のオプショナル userId のみが含まれている。

src/Layout.res

共有 HTML シェル。https://unpkg.com/htmx.org@<TemplateVersions.HTMX_CDN> から HTMX を読み込む(フローティングではなく固定版)ため、クライアント側で何もバンドルすることなくページが起動する。public/ にセルフホスト版を配置したら、<script src>/htmx.min.js に差し替えるとよい。

src/Counter.res

最小限の HTMX パターン: ref 内のサーバー側状態、2 つの hx-post エンドポイント、そして単一の <span> に対する outerHTML の差し替えである:

let count = ref(0)
let counterId = "counter-value"

@jsx.component
let make = () => {
  let onIncrement = Handler.handler.hxPost(
    "/counter/increment",
    ~securityPolicy=ResX.SecurityPolicy.allow,
    ~handler=async _ => {
      count := count.contents + 1
      <span id={counterId}> {Hjsx.string(Int.toString(count.contents))} </span>
    },
  )

  // ... onDecrement is the mirror image ...

  <section>
    <button
      type_="button"
      hxPost={onIncrement}
      hxSwap={ResX.Htmx.Swap.make(OuterHTML)}
      hxTarget={ResX.Htmx.Target.make(CssSelector(`#${counterId}`))}>
      {Hjsx.string("+")}
    </button>
    // ...
  </section>
}

ハンドラは更新された <span> を返し、HTMX がそれを所定の位置に差し替える — ページのリロードなし、クライアントフレームワークなしである。

src/TodoForm.res

フォーム入力パターン。フォームエンコードされたデータを /todos に POST し、入力を Validation.parseTodoInput(選択したバリデーションライブラリで実装される)に通し、新しい todo をリストに追加するか、ステータス 400 を設定してインラインエラー付きでフォームを再レンダリングする:

let onSubmit = Handler.handler.hxPost(
  "/todos",
  ~securityPolicy=ResX.SecurityPolicy.allow,
  ~handler=async ({request, requestController}) => {
    let formData = await request->Request.formData
    let rawName = formData->ResX.FormDataHelpers.getString("name")->Option.getOr("")
    let rawDescription = formData->ResX.FormDataHelpers.getString("description")->Option.getOr("")
    switch Validation.parseTodoInput(~name=rawName, ~description=rawDescription) {
    | Ok({name, description}) =>
      todos := todos.contents->Array.concat([{name, description}])
      renderList()
    | Error(msg) =>
      requestController.setStatus(400)
      // re-render the form with the error
    }
  },
)

同じ outerHTML 差し替えパターンが適用される: ハンドラの戻り値が新しい DOM サブツリーになる。

src/Validation.res

parseTodoInput: (~name: string, ~description: string) => result<todoInput, string> — todo フォームの実行時コントラクトである。シグネチャは zod バリアントと sury バリアントで同一である。zod バリアントは、スキーマルール経由で name(1–80 文字)と description(≤240 文字)に対して trim / min / max のバリデーションを行う。

src/__tests__/App.test.mjs

スモークテストは vitest ではなく bun:test の下で ../App.res.mjs をインポートする。コンパイル済み出力は rescript-bun 経由でグローバルな Bun オブジェクトを参照するため、Node の下で実行すると Bun is not defined でクラッシュする:

import { describe, it, expect } from "bun:test"

describe("App module", () => {
  it("compiles to an importable module", async () => {
    const module = await import("../App.res.mjs")
    expect(module).toBeDefined()
  })
})

bun test は独自のカバレッジレポーターを同梱しているため、vitest の devDeps を別途必要としない — そしてテンプレートはそれらを意図的に省略している。

Dockerfile

oven/bun:1 をベースとする 3 段階のイメージ:

  1. deps — 存在するいずれかのロックファイル(bun.lockpnpm-lock.yaml など)に対する bun install --ignore-scripts

  2. builderbunx rescript && bun run build(ReScript およびクライアントアセットをコンパイル)

  3. runtimenode_modules/src/dist/package.jsonrescript.json を含む oven/bun:1-slim のスリムなレイヤーで、非特権 bun ユーザー (uid 1000) に切り替え、4444 を公開し、bun run src/App.res.mjs を実行する

OCI イメージに対応したマネージドプラットフォーム(Fly.io、Railway、Render、Google Cloud Run、Scaleway Serverless Containers)はこのイメージを直接デプロイできる。シングルバイナリのワークフローの場合は、ローカルで bun run compile を実行した後に runtime の CMD./dist/app に差し替えるとよい。

npm スクリプト

スクリプト

説明

start

bun run src/App.res.mjs — コンパイル済みサーバーを 1 回実行する

dev

concurrently "rescript -w" "bun --watch run src/App.res.mjs" — ReScript ウォッチャーと Bun のホットリロードを組み合わせる

build

vite build — res-x Vite プラグインを通じてクライアントアセットをバンドルする

compile

bun build --compile src/App.res.mjs --outfile dist/app — スタンドアロンの Bun バイナリを生成する

test

bun test — bun:test のスモークスイートを実行する

test:coverage

bun test --coverage — 同じだが、Bun の組み込みカバレッジレポーター付きで実行する

res:build

rescript — 単発のコンパイル

res:dev

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

res:clean

rescript clean

あらゆるスクリプトはウィザードのパッケージマネージャ選択にかかわらず bun(または rescript を直接)呼び出す。PM の選択は README での依存インストール方法と packageManager Corepack フィールドにのみ影響する — いずれにせよ実行時には bun が必要である。

2日目以降のレシピ

このテンプレートは意図的に最小限かつ Bun 固有である — 他テンプレート横断のレシピのほとんどは直接適用できない。多くのユーザーが次に手を伸ばす 2 つのパターンを示す:

  • 永続化 — 同梱の README には、インメモリの todo refDb.res 内の bun:sqlite ストレージへ昇格させる手順(ラッパー、スキーマ、.gitignore の調整を含む)が解説されている。Bun は埋め込み SQLite ドライバを同梱しているため、これはマイグレーションではなく 2 日目の作業となる。

  • HTMX のセルフホストhtmx.min.jspublic/ に配置し、src/Layout.res<script src> を unpkg CDN から /htmx.min.js に差し替える。Vite は public/ を本番ビルドにそのままコピーする。

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

補足

  • Bun は実行時に必須である。 package.json のすべての npm スクリプトは bun を直接呼び出す(bun runbun --watch runbun testbun build --compile)。Bun 1.3 以降を別途インストールする必要がある (https://bun.sh) — README の Prerequisites セクションでこの点が明示されている。

  • パッケージマネージャの選択は表面的なものである。 npm / pnpm / yarn の選択は、README に示されるインストールコマンドと packageManager Corepack フィールドのみに影響する。ウィザードが他の PM 選択をブロックしないのは、依存インストール手順では依然として動作するからだが、実行時は常に Bun である。

  • vitest ではなく bun test を使う。 コンパイル済みの .res.mjs は rescript-bun 経由でグローバルな Bun オブジェクトを参照する。Node の下でスイートを実行すると Bun is not defined でクラッシュする。bun test は独自のカバレッジレポーター (bun test --coverage) を同梱しているため、vitest の devDeps は不要である。

  • CI は oven-sh/setup-bun を自動で有効化する。 bun PM(または res-x プロジェクト)が出荷される際、CommonFiles.ciWorkflow(..., setupBun = true)bun-version: latest を指定した setup-bun@v2 ステップを差し込み、ワークフローで bun installbun test をそのまま実行できるようにする。

  • rescript.json は生成されるのではなく、そのまま出荷される。 rescript-xjsx.module = "Hjsx" に加え、ResX.GlobalsRescriptBun.Globals を open する 4 つの compiler-flags を必要とする。ProjectFileBuilders.rescriptJson ではその形状を生成できないため、テンプレートは res-x/rescript.json からリソースとして読み込む。

  • HTMX は https://unpkg.com/htmx.org@<TemplateVersions.HTMX_CDN> から読み込まれる(フローティングではなく固定)。htmx.min.jspublic/ に配置し、<script src>/htmx.min.js に向けることでセルフホストできる。

  • 開発ポートはサーバーが 4444、Vite が 9000 である。 App.res は 4444 をハードコードし、vite.config.js は Vite の開発サーバーを 9000 に設定する。Dockerfile の EXPOSE 4444 はサーバーポートと一致する。

  • カウンタと todo リストの状態はプロセスローカルな ref 内に存在する。HTMX パターンを探求するには便利だが、再起動のたびに消滅する。インメモリ形状を超えて成長したときには、README の Persistence セクションで bun:sqlite への昇格手順が解説されている。

  • bun build --compiledist/app にスタンドアロンバイナリを生成する。ローカルで bun run compile を実行した後、Dockerfile の runtime ステージの CMD["/app/dist/app"] に差し替えることで、実行時に bun を必要としない自己完結したデプロイが可能になる。