@k8ordo/framework

デプロイ

vite buildの出力を公開できるようにします。staticモードではdist/client/を静的ホスティングに置き、serverモードではdist/を置いてnode dist/server.jsで起動します。

このページの内容

staticモード

staticモードでは、dist/client/だけを静的ホスティングで配信します。

ビルドの出力

text
dist/
  client/
    index.html
    index.rsc
    products/
      index.html
      index.rsc
      1/
        index.html
        index.rsc
    old/
      index.html
    404.html
    sitemap.xml
    assets/
  rsc/
  ssr/
  package.json
  • index.html:そのURLのページです。描画に使ったペイロードが埋め込まれています。
  • index.rsc:同じページのペイロードです。クライアント側の遷移で読み込みます。
  • old/index.html:redirect.tsから書いたページで、行き先へリダイレクトします。隣にindex.rscはありません。
  • 404.html:not-found.tsxを描画したファイルです。
  • sitemap.xml:siteを渡したときだけ書かれます。
  • assets/:クライアントのスクリプトとスタイルです。

ハイドレーションと遷移でのペイロードの使われ方は、仕組みを見てください。

dist/client/のほかはページの描画に使ったもので、配信しません。

vite build
k8ordo: wrote 4 routes and 404.html and sitemap.xml

ビルドは最後に、書いたルートの数と、404.htmlとsitemap.xmlを書いたかどうかを1行で出します。数にはリダイレクトのページも含まれます。シェルを書いたときは、その数と場所、_redirectsも並びます。

静的ホスティング

ホスティングには次の3つが必要です。

  • /products/1のようなURLに、products/1/index.htmlを返す
  • index.rscをそのまま返す(Content-TypeはHTML以外なら何でもよい)
  • 持っていないURLに404.htmlを返す

サイトに無いURLへの遷移では、応答がペイロードでないので、普通の文書の読み込みに切り替わります。ホスティングはその読み込みに404.htmlを返します。/robots.txtのように同じoriginに置いた別のファイルへのリンクも、同じ仕組みでファイルそのものが開きます。

情報

メモ

404.htmlに404のステータスを付けるかどうかは、ホスティングの設定です。ビルドが書けるのはページまでで、応答のステータスは書けません。

ダウンロードのリンク

html
<a download href="/report.csv">Report</a>

ホスティングがダウンロードとして返すファイルへのリンクには、download属性を付けます。そのリンクはルーターが扱わず、ブラウザがそのままダウンロードします。

404.htmlの中身

404.htmlは、サイトに無いURLのために1度だけ描画されます。not-found.tsxより上の階層にパラメータを受け取る区間があると、そのパラメータにはどのルートも宣言していない値が入ります。

ブラウザはこのファイルをハイドレーションしません。訪問者が開いたURLで描画し直します。Client Componentは、usePathname()で最初の描画から訪問者のURLを読めます。

このサイトの404.htmlもそうなっています。ファイルは日本語で書かれていて、/en/…のURLで開くと、ブラウザが描画し直した時点で英語になります。JavaScriptが動かない環境では、ビルドで描画した日本語のまま残ります。

sitemap.xml

vite.config.ts
import { framework } from '@k8ordo/framework/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    framework({
      mode: 'static',
      paths: () => ['/products/1'],
      site: 'https://example.com',
    }),
  ],
});
dist/client/sitemap.xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url><loc>https://example.com/</loc></url>
  <url><loc>https://example.com/products</loc></url>
  <url><loc>https://example.com/products/1</loc></url>
</urlset>

framework()のsiteにサイトの配信元を渡すと、ビルドは書いたページをすべて並べたsitemap.xmlも書きます。リダイレクトとroute.tsの応答、404.htmlはページではないので載りません。

siteを渡さなければ、sitemap.xmlは書かれません。

_redirects

dist/client/_redirects
# @k8ordo/framework: built URLs the rules below would also catch
/posts/1 /posts/1 200
/posts/1/index.rsc /posts/1/index.rsc 200
/posts/2 /posts/2 200
/posts/2/index.rsc /posts/2/index.rsc 200
# @k8ordo/framework: values the build did not write, answered by their fallback.tsx's shell
/posts/:p1 /posts/!fallback/ 200
/posts/:p1/index.rsc /posts/!fallback/index.rsc 200

fallback.tsxのあるページがあると、ビルドはシェルと一緒にdist/client/_redirectsも書きます。NetlifyとCloudflareが読む書き換えのルールです。ファイルの無い/posts/3にはシェルを、そのペイロードにはシェルのペイロードを、URLを変えずに返します。シェルについては、パラメータを見てください。

前半のルールは、書き出したURLにそのURLのファイルを返します。Cloudflareはファイルより先にルールを当てるので、これが無いと書き出したページにもシェルが返ります。ファイルを先に返すNetlifyでは使われません。

public/_redirectsがあれば、その中身を2つのブロックの間に入れます。そこに/* /index.html 200のようなルールがあると、シェルより先に当たるので、ビルドが警告します。

Viteのbaseを設定していても、_redirectsはdist/client/の直下に書かれ、どのルールにもbaseが付きます。ホスティングは公開するディレクトリの直下の_redirectsしか読まないので、そこへ移します。

情報

メモ

/:lang/:idのように区間がパラメータ2つだけのパターンでは、シェルのルールが無くなった/assets/x.jsにも当たり、HTMLを返します。

Cloudflare

Workersの静的アセットで配信するときは、html_handlingを既定のauto-trailing-slashかforce-trailing-slashにします。シェルのルールは、スラッシュで終わるディレクトリのURLへ書き換えるためです。drop-trailing-slashではリダイレクトがループし、noneでは404になります。またCloudflareでは、ビルドしていない値の/posts/3/のように末尾にスラッシュがあるとシェルのルールが当たらず、404になります。

404.htmlを返すには、not_found_handlingを"404-page"にします。ファイルより先に動くWorkerのスクリプトがあると、_redirectsは読まれません。Pagesのスラッシュの扱いはauto-trailing-slashと同じで、変える設定はありません。

Cloudflareが読むルールは、固定のURLが2,000個、パラメータを含むものが100個までです。書き出した値1つに固定のルールが2つ要るので、シェルのあるパターンの下に書き出せるページはおよそ1,000です。超えるとビルドが警告します。

Vercel

vite.config.ts
import { framework } from '@k8ordo/framework/vite';
import { vercel } from '@k8ordo/framework/vercel';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [framework({ mode: 'static' }), vercel()],
});

Vercelは_redirectsを読みません。vercel()をframework()の隣に置くと、ビルドは.vercel/output/も書きます。書き出したファイルを静的ファイルとして置き、_redirectsと同じ書き換えをルートとして書きます。関数は作りません。

Vercelの上でvercel()の無いビルドがシェルを書くと、ビルドが警告します。そのままでは、シェルを返すものが無いためです。

ほかのホスティング

_redirectsを読まないホスティングでは、シェルの場所ごとに次の2つを設定します。どちらもURLを変えずにファイルを返す書き換えで、ファイルが無いときにだけ当てます。

  • /posts/:idを/posts/!fallback/index.htmlへ
  • /posts/:id/index.rscを/posts/!fallback/index.rscへ(無いと、遷移が文書の読み込みに変わります)

:idは区間1つに当たります。シェルの場所の下にredirect.tsかroute.tsを書き出したときは、そのペイロードのURLにシェルのHTMLを返すルールを、2つ目より先に置きます。ルールをファイルより先に当てるホスティングでは、書き出したファイルにそのファイルを返すルールを先頭に並べます。

GitHub Pagesのように書き換えの無いホスティングでは、シェルは使えません。ビルドしていない値には404.htmlが返ります。

手元での確認

bash
vite build
vite preview

vite previewは、staticモードのビルドをホスティングと同じように配信します。dist/client/のファイルを返し、無ければ_redirectsの書き換えをNetlifyと同じ順で当てます。どれにも当たらなければ404.htmlを404で返し、GETとHEADのほかには405を返します。

ページを描き直すことはないので、書き出したファイルとCSPの<meta>をそのまま確かめられます。@k8ordo/framework/serveのserve()も、staticモードのビルドを同じように配信します。

serverモード

serverモードでは、ビルドがリクエストごとにページを描画するハンドラと、それを動かすサーバーを書き出します。

ビルドの出力

text
dist/
  server.js
  package.json
  rsc/
    index.js
  ssr/
  client/
    assets/
      index-1a2b.js
      index-1a2b.js.br
      index-1a2b.js.gz

dist/client/にページのHTMLはありません。

  • dist/server.js:Node.jsで起動するサーバーです。
  • dist/package.json:dist/の中の.jsをES Moduleとして読ませます。
  • dist/rsc/index.js:リクエストハンドラです。
  • dist/ssr/:ハンドラがペイロードをHTMLにするときに使います。
  • dist/client/:ブラウザに配信するファイルです。圧縮の効く種類のファイルには、Brotliとgzipで圧縮したコピー(.brと.gz)が隣に置かれます。

ビルドは、サーバーとハンドラが使う依存をすべてdist/にバンドルします。外に残るのはNode.jsの組み込みモジュールだけです。実行時にnode_modulesを読まないので、@k8ordo/frameworkと@k8ordo/routerはdevDependenciesに入れます。

起動

bash
PORT=8080 HOST=0.0.0.0 node dist/server.js

dist/をサーバーに置き、node dist/server.jsで起動します。インストールは要りません。アプリのpackage.jsonもnode_modulesも無い場所で動きます。

  • PORT:待ち受けるポートです。既定は3000です。
  • HOST:待ち受けるホストです。既定のlocalhostには同じマシンからしか接続できないので、コンテナの中などでは0.0.0.0にします。Nodeはlocalhostのアドレスのうち1つ、多くは::1だけで待ち受けます。同じマシンのプロキシからは127.0.0.1でなくlocalhostを指すか、HOST=127.0.0.1にします。

SIGTERMかSIGINTを受けると、新しい接続を断り、処理中の応答を返し終えてから終了コード0で終わります。コンテナの最初のプロセスとして起動しても、止める指示ですぐに止まります。

バンドルできない依存

vite.config.ts
import { framework } from '@k8ordo/framework/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [framework({ mode: 'server' })],
  environments: {
    rsc: { resolve: { external: ['better-sqlite3'] } },
  },
});

ネイティブのバイナリを持つ依存や、自分のファイルをパスで読む依存は、バンドルすると動きません。rsc環境のresolve.externalに名前を書くと、その依存だけがバンドルから外れます。

外した依存はdependenciesに入れ、実行時にインストールします。npm install --omit=devのように開発時の依存を省いてインストールし、アプリのディレクトリの中でdist/server.jsを起動します。

serve()

serve.js
import { serve } from '@k8ordo/framework/serve';

const server = await serve({ port: 3000, host: '0.0.0.0' });
console.log(server.url);

dist/server.jsが動かすのは、@k8ordo/framework/serveのserve()です。自前のサーバーから呼ぶときは、@k8ordo/frameworkをdependenciesに入れ、実行時にもインストールします。オプションは3つです。

  • dist:ビルドの出力ディレクトリです。既定はdistで、今の作業ディレクトリから解決します。
  • port:待ち受けるポートです。既定は3000で、0を渡すと空いているポートをシステムが選びます。
  • host:待ち受けるホストです。既定のlocalhostには同じマシンからしか接続できないので、コンテナの中などでは0.0.0.0を渡します。

返り値にはurlとport、close()があります。テストからビルドに対してサーバーを立て、終わったら止められます。

serve()の応答

GETとHEADのうち、dist/client/の中のファイルを指すリクエストには、そのファイルを返します。それ以外のリクエストは、すべてハンドラに渡します。

  • キャッシュ:/assets/の下はファイル名に中身のハッシュが入っています。immutableを付けて1年キャッシュさせます。それ以外のファイルはno-cacheです。
  • 圧縮:圧縮したコピーがあるファイルは、Accept-Encodingが選ぶほうを返します。ページとペイロードは、圧縮しながらストリーミングします。
  • 再検証:ファイルには中身から作ったETagが付きます。同じソースから別々にビルドしたサーバーでも、再検証は304で終わります。
  • 範囲:1つの範囲を求めるRangeには206で答えます。Safariが<video>を再生するのに必要です。
  • 失敗:ハンドラがエラーになったときは、500と本文internal errorだけを返します。エラーの内容は、訪問者でなくサーバーのログに出ます。

パスに..や%2e%2eが含まれていても、dist/client/の外のファイルは返しません。

ほかのランタイム

render.js
import handler from './dist/rsc/index.js';

const response = await handler(
  new Request('https://example.com/products/1'),
);
console.log(response.status);

dist/rsc/index.jsは、(request: Request) => Promise<Response>の関数をdefault exportしています。ハンドラは隣のdist/ssr/を読み込むので、2つは同じ場所に置きます。依存はバンドル済みなので、インストールは要りません。

js
// Deno
Deno.serve(handler);

// Bun
Bun.serve({ fetch: handler });

// Cloudflare Workers
export default { fetch: handler };

WebのRequestとResponse、ストリームのほかに、ハンドラが実行環境に求めるのはnode:async_hooksのAsyncLocalStorageだけです。Node.jsとBunとDeno、nodejs_compatを付けたCloudflare Workersで、importしたハンドラをそのまま渡せます。

wrangler.jsonc
{
  "main": "worker.js",
  "compatibility_flags": ["nodejs_compat"],
  "assets": { "directory": "dist/client" }
}

ハンドラはファイルを配信しません。ハンドラの前でdist/client/を配信し、どのファイルにも一致しないリクエストだけをハンドラに渡します。Workersでは、wrangler.jsoncのassets.directoryにdist/clientを指定します。

警告

落とし穴

route.tsやServer Actionでnode:fsのようなNode.js専用のモジュールをimportできます。ただし、アプリはそのモジュールを持つランタイムでしか動かなくなります。

プロキシの後ろ

ハンドラはOriginヘッダーのホストとURLのホストを比べてPOSTを受け付けるので、Requestは訪問者が求めたURLで作ります。この検査については、Server Actionを見てください。

serve()はリクエストのHostヘッダーからURLを作り、転送用のヘッダーは読みません。前に置くプロキシは、元のHostを書き換えずに渡してください。ホストを含むリクエスト行(GET http://…)や、ホストとして読めないHostには400を返します。

パスからURLを自分で作るホストは、new URL(path, origin)で解決せず、originの後ろに文字列としてつなぎます。解決すると、//evil.test/で始まるパスがevil.testのURLになり、別のサイトのフォームがこの検査を通ってしまいます。

Vercel

vite.config.ts
import { framework } from '@k8ordo/framework/vite';
import { vercel } from '@k8ordo/framework/vercel';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [framework({ mode: 'server' }), vercel()],
});

@k8ordo/framework/vercelのvercel()をframework()の隣に足すと、vite buildはVercelのBuild Output API(v3)の形で.vercel/output/も書きます。vercel deploy --prebuiltで、それをそのままデプロイできます。

クライアントのビルドは、VercelのCDNに置く静的ファイルになります。どのファイルにも一致しないリクエストは、Node.jsの関数1つが処理します。その関数の中身がリクエストハンドラです。serve()のために圧縮したコピーは入れません。

Vercelの関数は自分のディレクトリの中身しか持てません。ハンドラは依存をすべてバンドルしているので、そのまま関数になります。resolve.externalで外した依存は関数に入らないので、Vercelでは動きません。

デプロイ前に開いたタブ

開いたままのタブは、読み込んだときのスクリプトを動かし続けます。遷移で読み込むペイロードは今のデプロイから送られ、タブのスクリプトに無いClient Componentを含むことがあります。ペイロードには、対応するクライアントスクリプトのURLが入っています。

タブのスクリプトと合わないペイロードは描画せず、同じURLを文書として読み込み直します。訪問者はerror.tsxでなく、新しいスクリプトで描画したページを見ます。スクリプトのURLには、読み込むものすべてのハッシュが入っています。ブラウザで動くものを変えなかったデプロイなら、タブはそのまま遷移を続けます。

serverモードでは、Server Actionの応答も同じです。スクリプトと合わない応答は反映せず、ページを読み込み直します。

サブパスでの配信

vite.config.ts
import { framework } from '@k8ordo/framework/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  base: '/docs/',
  plugins: [framework({ mode: 'static' })],
});

https://example.com/docs/のように、originの直下以外で配信するときは、そのパスをViteのbaseに書きます。アプリのほかの部分は変わりません。routes/products/page.tsxは、ルート表では/productsで、アドレスバーでは/docs/productsです。この2つのあいだでは、次のものにbaseが付いたり外れたりします。

  • href()とnavigateTo():href()が返すURLと、navigateTo()の遷移先にはbaseが付きます。
  • pathnameとusePathname():baseを外した値です。
  • ペイロードとクライアントのファイル:/docs/products/index.rscや/docs/assets/のように、baseの下に置かれます。
  • redirect.tsの行き先:ルート表と同じくアプリのルートから書き、送るときにbaseが付きます。別のoriginの行き先はそのまま送ります。
  • baseの外のURL:serverモードではハンドラが404を返します。どちらのモードでも、クライアントのランタイムはその遷移を扱わず、ブラウザが通常どおり読み込みます。
  • serverモードでServer Actionから呼ぶredirect()と、guard.tsが返すResponseのlocation:baseを付けずにそのまま送ります。どちらもURLなのでhref()で作ります。ほかの方法で作ったパスには、@k8ordo/frameworkのwithBase()でbaseを付けます。

staticモードでは、ページはルート表のパスのままdist/client/に書かれます。ホスティングはこのディレクトリを/docs/で配信します。pathsにはbaseを除いたパスを渡します。sitemap.xmlにはbaseを含めたURLが載ります。

serverモードでは、serve()がビルドの使ったbaseをdist/rsc/index.jsから読み、クライアントのファイルをその下で配信します。ハンドラを自分で呼ぶホストは、訪問者が求めたとおりbaseを含めたURLを渡します。

警告

落とし穴

baseはルートから始まるパスにします。./のような相対パスや別のoriginでは、k8ordo serves its pages under Vite's base, so base has to be a path from the rootで始まるエラーになり、ビルドできません。

k8ordo

Baselineに入った機能を、制限なく使うReactのライブラリ群。

© 2026 k8o — MIT License

組版:Noto Sans JP / M PLUS 2