プロトタイプファースト
文書として書かれた仕様は議論の的になりますが、顧客がクリックできる仕様には訂正が返ってきます。プロトタイプモードを使うと、バックエンドがまだ何も存在しない段階で機能の画面を作れます。サーバーの要らない静的ファイルホストに置いて顧客に触ってもらい、そのあと同じコードからバックエンド開発を始められます。見せたページが、そのまま出荷するページになります。
プロトタイプファースト
文書として書かれた仕様は議論の的になりますが、顧客がクリックできる仕様には訂正が返ってきます。プロトタイプモードを使うと、バックエンドがまだ何も存在しない段階で機能の画面を作れます。サーバーの要らない静的ファイルホストに置いて顧客に触ってもらい、そのあと同じコードからバックエンド開発を始められます。見せたページが、そのまま出荷するページになります。
仕組みはファイルひとつです。resources/js/prototype/index.ts の fixture が、ページコンポーネントに対して型付けされたメモリ上のシードデータから、ルート名ごとに Inertia の visit に答えます。ブラウザではサーバーの代わりを務め、サーバー上ではまだ書いていないコントローラーの代わりを務めます。しかも他のすべてと同じルートマニフェストとページの Props に対して型検査されるので、プロトタイプとバックエンドが食い違えば bun run typecheck が必ず指摘します。
flowchart LR Fixture["resources/js/prototype/index.ts<br/>seed data · one handler per route"] Static["bun run build:prototype<br/>dist/prototype/ on a static host"] Server["bun run dev<br/>routes on the prototype handler"] Promote["make:feature Post<br/>controller replaces the handler"] Fixture --> Static Fixture --> Server Server --> Promote
インストール
bunx guren add prototype
最初から組み込んで雛形を作ることもできます:
bunx create-guren-app my-app --prototype
add prototype は fixture モジュールを書き、package.json にスクリプトを 2 つ足し、オプションを 2 箇所に配線します:
| 何を | どこに |
|---|---|
resources/js/prototype/index.ts |
fixture: state と routes が空の definePrototype({ … }) |
dev:prototype、build:prototype |
vite --mode prototype と、codegen && check --prototype && vite build --mode prototype |
prototype: import.meta.env.GUREN_PROTOTYPE ? { load, base } : undefined |
resources/js/app.tsx の startInertiaClient() に渡す |
prototype: () => import('../resources/js/prototype/index.js') |
src/app.ts の createApp() に渡す |
ImportMetaEnv.GUREN_PROTOTYPE |
resources/js/vite-env.d.ts に宣言 |
GUREN_PROTOTYPE は vite --mode prototype ではリテラルの true、それ以外のすべてのビルドではリテラルの false として定義されます。そのためクライアント側の分岐と fixture の import は本番ではデッドコードになり、ブラウザで visit に答えるランタイムが本番バンドルに入ることはありません。コマンドは冪等なので、アップグレード後にもう一度実行しても、すでにあるものは変わりません。bunx guren add prototype --remove はスクリプトと 2 行の配線を取り除き、fixture は削除せずに残します。
fixture
routes の各エントリは .guren/routes.gen.ts のルート名をキーにして、そのルートの visit に答えます:
import { apiRoutes, definePrototype } from '@guren/inertia-client/prototype'
import type { ApiRoutes } from '@/.guren/api-client.gen'
import { pages } from '@/.guren/pages.gen'
import { routeManifest } from '@/.guren/routes.gen'
import type { PostData } from '@/resources/js/types/Post'
export default definePrototype({
manifest: routeManifest,
api: apiRoutes<ApiRoutes>(),
shared: {
auth: { user: { id: 1, name: 'Demo User', email: 'demo@example.com' } },
},
state: () => ({
posts: [
{ id: 1, title: 'First post', body: 'Hello', published: true },
{ id: 2, title: 'Draft', body: 'Not yet', published: false },
] as PostData[],
nextId: 3,
}),
routes: {
'posts.index': ({ state, page }) => page(pages.posts.Index, { posts: state.posts }),
'posts.show': ({ state, params, page, notFound }) => {
const post = state.posts.find((item) => item.id === Number(params.id))
return post ? page(pages.posts.Show, { post }) : notFound()
},
'posts.store': ({ state, body, errors, redirect, flash }) => {
if (!body.title) return errors({ title: 'Title is required.' })
const post: PostData = { id: state.nextId++, ...body, published: false }
state.posts.unshift(post)
flash('success', 'Post created.')
return redirect('posts.show', { id: post.id })
},
},
})
| フィールド | 意味 |
|---|---|
manifest |
.guren/routes.gen.ts の routeManifest。routes のキーと各ハンドラーの params を型付けし、ブラウザは URL をこれに照合します |
api |
apiRoutes<ApiRoutes>()、型だけのマーカー。ルートの body スキーマから各ハンドラーの body を型付けします |
shared |
shareInertiaProps() と同じく、すべてのページが自分の props の下に受け取る props。デモユーザーがあると保護された画面に到達できます。ゲストとして歩くなら user: null にします |
state |
シードデータのファクトリ。ブラウザはこのオブジェクトをリロードをまたいで sessionStorage に保持します。persist: 'local' ならタブをまたいで保持、persist: false ならメモリ上だけです |
notFoundPage |
notFound() と一致しない URL に対して描画するページ契約。ブラウザでは 200、サーバーでは 404 です。無ければサーバーと同じく Inertia のエラーダイアログが出ます |
routes |
ルート名ごとにハンドラーひとつ。マニフェストに無い名前は型エラーです |
ハンドラーは { params, query, body, state, shared } と、答え方 5 つを受け取ります:
| 呼び出し | 顧客に見えるもの |
|---|---|
page(pages.posts.Show, props) |
そのページ。props はページの Props を満たす必要があるので、コンポーネントにフィールドを足すと、コントローラーを落とすのと同じ tsc の実行で fixture も落ちます |
redirect('posts.show', { id }) |
遷移先ルートのハンドラーが走り、そのページがその URL で表示されます。303 を追った結果と同じです |
errors({ title: '…' }) |
元のページを errors 付きでもう一度。validateBody() の失敗が作るのと同じ形です。ブラウザでは第 2 引数でエラーバッグを指定できます |
notFound() |
404 ダイアログ、または notFoundPage |
location('https://…') |
外部 URL へのフルページ遷移 |
flash(key, value) は次のページにフラッシュメッセージを載せます。body に入るのは、どちらのランタイムでもフォームが送った生のボディで、スキーマを通した結果ではありません。強制変換が欲しければ自分でスキーマを呼んでください。
2 つのランタイムが違うところ。 サーバーは同じ文脈を本物のリクエストから組み立てますが、4 箇所だけ制約がきつくなります。どれもサーバーがコントローラーに対して行うのと同じ振る舞いです。FormData のボディはブラウザでは繰り返しキーを配列にしたプレーンオブジェクトとして届きます(tags[] 形式の入力や複数ファイルのフィールドも失われません)が、サーバーは validateBody() と同じく繰り返しキーの最初の値だけを残します。errors() はサーバーではエラーバッグを取りません。ValidationException に無いからです。notFoundPage はブラウザでは 200、サーバーでは 404 です。そして flash() はサーバーではセッションに書くので、セッションミドルウェアの無いアプリでは何もしません。
bunx guren make:feature Post --fields "title:string,body:text,published:boolean" --prototype がこれを全部書いてくれます。ページコンポーネント、バリデーター、PostData をエクスポートする resources/js/types/Post.ts、そして fixture に追記される 7 つのエントリ(index、create、show、edit、store、update、destroy)です。モデル、マイグレーション、Resource、コントローラーは書きません。登録すべきルートは、コントローラーの代わりに prototype を置いた形で出力されます。
歩く
bun run dev:prototype
これは Vite だけで、Bun のサーバーは動いていません。すべての text/html リクエストにプロトタイプのシェルが返り、クライアントが fixture を読み込み、すべての Inertia の visit はタブの中で答えられます。フォーム、リダイレクト、バリデーションエラー、フラッシュメッセージはすべてシードデータを相手に動きます。
リセット。 状態はタブの sessionStorage に保存されるので、リロードしても顧客の操作は残り、新しいタブはシードから始まります。任意の URL に ?prototype.reset=1 を付けて開くと、保存された状態を捨ててフラグ無しの URL をリロードします。モジュールはシェルにボタンを置くための resetPrototypeState() もエクスポートしています。決定的にリセットできないデモは顧客の前で使えないので、リセットのリンクはプレゼンターの手が届くところに置いてください。
動かないもの。 ブラウザのランタイムが横取りするのは Inertia の visit だけです:
- 素の
<a href>、ネイティブの<form>送信、window.location、直接のfetch()は fixture に届きません。静的ホストでは SPA フォールバック(URL から始まるフルリロードで、persist: falseの状態は失われます)か 404 に当たります。<Link>、useForm()、router.visit()を使ってください。 - Deferred props、
mergeProps、onceprops、encryptHistory、アセットバージョンのハンドシェイクは再現されません。現時点では Guren 自身のサーバーもそれらを出しません。 - ブラウザではミドルウェアが走りません。
authで守られたルートは、ハンドラー自身がshared.auth.userを確認しない限りゲストからも到達できます。 - プリフェッチと
cacheForはクリックより先に GET ハンドラーを走らせるので、GET ハンドラーには副作用を持たせないでください。
これらは guren check には見えません。このリストがチェックです。
出荷する
bun run build:prototype
このスクリプトはマニフェストを再生成し、guren check --prototype を走らせ、dist/prototype/ をビルドします:
dist/prototype/
├── index.html # the shell, <meta name="robots" content="noindex, nofollow">
├── 404.html # a copy of index.html, for hosts that serve it on unknown paths
├── _redirects # /* /index.html 200, for hosts that read it
├── *.js, *.css # the hashed bundle, the fixture included
└── … # everything under public/, minus the ordinary build's own output
このディレクトリを任意の静的ホストにアップロードします。どのホストでも必要なことはひとつだけで、ファイルに対応しない URL に index.html を返すことです。顧客は /posts/3 をリロードするからです。
| ホスト | SPA フォールバック |
|---|---|
| Cloudflare Pages | _redirects を読みます。設定不要 |
| Netlify | _redirects を読みます。設定不要 |
| GitHub Pages | 未知のパスに 404.html を返します。設定不要ですが、プロジェクトページは /<repo>/ の下に置かれます。後述のサブパスの注意を参照 |
| Cloudflare Workers Static Assets | wrangler.jsonc の assets バインディングに "not_found_handling": "single-page-application" を設定 |
| Vercel | デプロイするディレクトリの vercel.json に { "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] } を追加 |
| S3 + CloudFront、nginx、その他のファイルサーバー | not-found の応答を状態 200 の index.html に対応付ける |
サブパスの下で。 GitHub のプロジェクトページやプレビュー URL は、プロトタイプを / ではなく /repo/ から配信します。base を Vite プラグインに渡してください:
import { defineConfig } from 'vite'
import guren from '@guren/core/vite'
export default defineConfig({
plugins: [guren({ prototype: { base: '/repo/' } })],
})
あるいはビルドに --base /repo/ を渡します。アセット URL、page.url、ルートの照合はすべて同じ値を使い、ひとつのオリジンで別々の base に置かれた 2 つのプロトタイプは、状態を別々に保ちます。
シェル。 生成される index.html は最小限です。中身は <div id="app">、モジュールスクリプト、そして noindex の meta タグ(プロトタイプは索引されるべきものではないため)だけです。createApp({ inertia: { document } }) はサーバー側の設定で、静的ビルドはアプリを構築しないので、favicon、フォントのリンク、テーマのプリペイントスクリプトは resources/js/prototype/index.html に書きます。このファイルがあれば生成されるシェルの代わりに使われます。自分で書くときも noindex のタグは残してください。
rule
ホストしたプロトタイプは、前に何かを置かない限り公開されています。 fixture は保護された画面に到達できるよう「サインイン済み」のデモユーザーを同梱しており、ビルドの中の何も、誰が見ているかを知りません。それ自体は漏洩ではなく、裏に本物のデータはありません。しかし固定のデモアカウント付きの顧客向け URL は漏洩と見誤られやすく、シードデータには索引されたくないものが含まれるかもしれません。ホストのアクセス制御を使ってください。Cloudflare Access、Vercel のデプロイ保護、Netlify のパスワード保護、あるいは自前サーバーの basic 認証ルールです。
バックエンドを作っている間
同じ fixture がサーバー上でも答えるので、実装途中のアプリも bun run dev で描画され続けます。コントローラーの代わりに prototype ハンドラーでルートを登録します:
import { Router, prototype } from '@guren/core'
import PostController from '../app/Http/Controllers/PostController.js'
import { PostPayloadSchema } from '../app/Http/Validators/PostValidator.js'
export function registerWebRoutes(router: Router): void {
router.get('/posts', [PostController, 'index']).name('posts.index')
router.get('/posts/:id', prototype).name('posts.show')
router.post('/posts', { name: 'posts.store', body: PostPayloadSchema }, prototype)
}
prototype ルートはミドルウェアを走らせ、契約を強制し(params、query、body のスキーマは fixture が何かを見る前に 422 で答えます)、それから本物の shared props パイプラインの下で、その名前の fixture エントリを走らせます。fixture の shared.auth はサーバー自身のリゾルバーの 下に 適用されるので、本物のセッションがデモユーザーに勝ち、fixture によって誰かがログインすることはありません。page() は this.inertia() と同じ経路で描画し、redirect() は名前付きルートへの 303、errors() は ValidationException を投げ、notFound() は 404 です。
boot 時にはルールが 3 つあり、どれもルートを名指しするハードエラーです:
prototypeルートには名前が必要です。fixture がそれをキーにしているからです- 名前付きの
prototypeルートにはすべて fixture のエントリが必要です createApp()にprototypeオプション(add prototypeが書いたローダー)が必要です
本番では動かない。 サーバー側の状態はプロセスにつきひとつのオブジェクトで、すべてのリクエストが共有します。自分の開発サーバーにはそれで十分ですが、それ以外の用途には足りません。本番の boot(NODE_ENV=production)は、prototype ハンドラーのルートがひとつでも残っていれば起動を拒否し、bunx guren doctor はそれらのルートをデプロイのブロッカーとして報告します。この拒否は GUREN_PROTOTYPE_ROUTES=1 で上書きできます。意図して fixture に支えられたサーバーを動かす場合のための逃げ道です。顧客に見せるのは、これではなく静的ビルドの方です。
bunx guren context はまだ fixture 上にあるルートを prototype backlog として一覧するので、次の画面の実装を頼まれたエージェントは、コントローラーとルートの差分を取る代わりにこの一覧を読みます。
guren check --prototype
内容で活性化します。fixture も prototype ルートも、app.tsx に配線されたローダーも無いアプリは何も報告しません。--arch や --docs と同じく、このフラグはスイートを選び、exit code を決めます。それが build:prototype をこのチェックでゲートできる理由です。
| ルール | レベル |
|---|---|
名前の無い prototype ルート |
error |
fixture にエントリの無い、名前付きの prototype ルート |
error |
| 存在しないルートを名指しする fixture エントリ | error |
| メソッドとパスを共有する名前付きルートが 2 つ | error。URL のマッチャーが区別できません |
.agent() を宣言した prototype ルート |
error。何も実装していないアクションをツールマニフェストが宣伝してしまいます |
prototype ルートがあるのに createApp() に prototype が無い |
error |
ローダーが app.tsx に配線されているのに fixture ファイルが無い |
error |
| fixture にエントリの無い、名前付きの GET ルート | warn。プロトタイプでは到達できない、として一覧 |
fixture はソースから、definePrototype( の呼び出しを起点に読まれます。動的に組み立てた fixture(spread、計算されたキー)は、通過ではなく「読めない」と報告されます。CLI の中でアプリのコードが実行されることはありません。
昇格する
仕様が固まったら、フラグ無しで make:feature を実行します:
bunx guren make:feature Post --fields "title:string,body:text,published:boolean"
posts.* のルートが prototype ハンドラーに乗っているアプリでは、これがモデル、Resource、コントローラーを書き、プロトタイプ時に書いたページとバリデーターはそのまま残します。Resource の toArray() は既存の PostData に対して型付けされるので、顧客が見た形が、そのままシリアライザーが満たすべき契約になります。db/schema.ts にテーブルを足し、マイグレーションを流し、routes/web.ts の各 prototype をコマンドが出力する [PostController, 'action'] に置き換えてください。fixture のエントリは残って build:prototype に答え続けるので、バックエンドをルート単位で差し込んでいく間も、顧客のリンクは動き続けます。
すべてのルートにコントローラーが付いたら、bunx guren add prototype --remove でローダーの配線を外します。fixture ディレクトリは用済みなら削除し、次の機能のために残しても構いません。
次のステップ
- チュートリアル第 15 章は、コースのブログでこのやり方の機能開発を最初から最後まで行います。
- フロントエンドガイド: fixture が描画するページコンポーネントと型付きリンク。
- CLI リファレンス:
add prototype、make:feature --prototype、check --prototype。 - デプロイガイド: バックエンドができたあとのサーバーの出荷。