Guide/guides

プロトタイプファースト

文書として書かれた仕様は議論の的になりますが、顧客がクリックできる仕様には訂正が返ってきます。プロトタイプモードを使うと、バックエンドがまだ何も存在しない段階で機能の画面を作れます。サーバーの要らない静的ファイルホストに置いて顧客に触ってもらい、そのあと同じコードからバックエンド開発を始められます。見せたページが、そのまま出荷するページになります。

プロトタイプファースト

文書として書かれた仕様は議論の的になりますが、顧客がクリックできる仕様には訂正が返ってきます。プロトタイプモードを使うと、バックエンドがまだ何も存在しない段階で機能の画面を作れます。サーバーの要らない静的ファイルホストに置いて顧客に触ってもらい、そのあと同じコードからバックエンド開発を始められます。見せたページが、そのまま出荷するページになります。

仕組みはファイルひとつです。resources/js/prototype/index.tsfixture が、ページコンポーネントに対して型付けされたメモリ上のシードデータから、ルート名ごとに 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: stateroutes が空の definePrototype({ … })
dev:prototypebuild:prototype vite --mode prototype と、codegen && check --prototype && vite build --mode prototype
prototype: import.meta.env.GUREN_PROTOTYPE ? { load, base } : undefined resources/js/app.tsxstartInertiaClient() に渡す
prototype: () => import('../resources/js/prototype/index.js') src/app.tscreateApp() に渡す
ImportMetaEnv.GUREN_PROTOTYPE resources/js/vite-env.d.ts に宣言

GUREN_PROTOTYPEvite --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.tsrouteManifestroutes のキーと各ハンドラーの 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、mergePropsonce props、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.jsoncassets バインディングに "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 ルートはミドルウェアを走らせ、契約を強制し(paramsquerybody のスキーマは 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 ディレクトリは用済みなら削除し、次の機能のために残しても構いません。

次のステップ