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 の |
Package manager |
npm / pnpm / yarn / bun。 |
Validation library |
|
パッケージマネージャの選択は意図的に表面的なものに留めている。Bun が推奨 PM であり実行時に必須であるが、他の PM もサポートされているのは、ウィザードがインストール手順でプロジェクトを拒否しないためだけの理由による。
主è¦ãªä¾å¶
ããã±ã¼ã¸ |
ç¨é |
ãã¼ã¸ã§ã³ |
|---|---|---|
|
ReScript ã³ã³ãã¤ã© |
|
|
æ¨æºã©ã¤ãã©ãª |
|
|
ã³ã³ãã¤ã«æ¸ã¿ |
|
|
HTMX ファーストのハンドラ API を備えたサーバーサイド JSX フレームワーク |
|
|
Bun のランタイム API(Bun.serve、FormData など)に対する ReScript バインディング |
|
|
ããªãã¼ã·ã§ã³ã®ããã¯ã¨ã³ãï¼ã¦ã£ã¶ã¼ãã§é¸æï¼ |
|
|
必要な場合にクライアント側アセットをバンドルするためのバンドラ |
|
|
開発用に |
|
HTMX 自体は実行時に CDN から読み込まれる(src/Layout.res 経由で TemplateVersions.HTMX_CDN に固定されている)。npm の依存ではない。
主è¦ãªãã¡ã¤ã«¶
rescript.json¶
rescript-x は特定の設定形状を必要とする — jsx.module = "Hjsx" に加え、ResX.Globals と RescriptBun.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 段階のイメージ:
deps— 存在するいずれかのロックファイル(bun.lock、pnpm-lock.yamlなど)に対するbun install --ignore-scriptsbuilder—bunx rescript && bun run build(ReScript およびクライアントアセットをコンパイル)runtime—node_modules/、src/、dist/、package.json、rescript.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 ã¹ã¯ãªãã¶
ã¹ã¯ãªãã |
説æ |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
あらゆるスクリプトはウィザードのパッケージマネージャ選択にかかわらず bun(または rescript を直接)呼び出す。PM の選択は README での依存インストール方法と packageManager Corepack フィールドにのみ影響する — いずれにせよ実行時には bun が必要である。
2æ¥ç®ä»¥éã®ã¬ã·ã¶
このテンプレートは意図的に最小限かつ Bun 固有である — 他テンプレート横断のレシピのほとんどは直接適用できない。多くのユーザーが次に手を伸ばす 2 つのパターンを示す:
永続化 — 同梱の README には、インメモリの todo
refをDb.res内のbun:sqliteストレージへ昇格させる手順(ラッパー、スキーマ、.gitignoreの調整を含む)が解説されている。Bun は埋め込み SQLite ドライバを同梱しているため、これはマイグレーションではなく 2 日目の作業となる。HTMX のセルフホスト —
htmx.min.jsをpublic/に配置し、src/Layout.resの<script src>を unpkg CDN から/htmx.min.jsに差し替える。Vite はpublic/を本番ビルドにそのままコピーする。
ããã¸ã§ã¯ããéããå¾ã® ReScript å´ã®ã¨ãã£ã¿ã¯ã¼ã¯ããã¼ã«ã¤ãã¦ã¯ã機能概要 ãåç §ãã¦ã»ããã
è£è¶³¶
Bun は実行時に必須である。
package.jsonのすべての npm スクリプトはbunを直接呼び出す(bun run、bun --watch run、bun test、bun build --compile)。Bun 1.3 以降を別途インストールする必要がある (https://bun.sh) — README の Prerequisites セクションでこの点が明示されている。パッケージマネージャの選択は表面的なものである。 npm / pnpm / yarn の選択は、README に示されるインストールコマンドと
packageManagerCorepack フィールドのみに影響する。ウィザードが他の 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を自動で有効化する。bunPM(または res-x プロジェクト)が出荷される際、CommonFiles.ciWorkflow(..., setupBun = true)がbun-version: latestを指定したsetup-bun@v2ステップを差し込み、ワークフローでbun installとbun testをそのまま実行できるようにする。rescript.jsonは生成されるのではなく、そのまま出荷される。rescript-xはjsx.module = "Hjsx"に加え、ResX.GlobalsとRescriptBun.Globalsを open する 4 つのcompiler-flagsを必要とする。ProjectFileBuilders.rescriptJsonではその形状を生成できないため、テンプレートはres-x/rescript.jsonからリソースとして読み込む。HTMX は
https://unpkg.com/htmx.org@<TemplateVersions.HTMX_CDN>から読み込まれる(フローティングではなく固定)。htmx.min.jsをpublic/に配置し、<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 --compileはdist/appにスタンドアロンバイナリを生成する。ローカルでbun run compileを実行した後、Dockerfile の runtime ステージのCMDを["/app/dist/app"]に差し替えることで、実行時にbunを必要としない自己完結したデプロイが可能になる。