クライアントスクリプト
ブラウザで実行する TypeScript/JavaScript ファイルを配信する方法 — .client.ts 規約、clientScript() ヘルパー、ハッシュ、制限事項。
このページで解説すること
*.client.ts ファイルをバンドルしてブラウザに配信する方法、clientScript() を使って SSR 時に URL を参照する方法、プロダクションパイプラインでのコンテンツハッシュの仕組み、そして現在の .html ソースページとブラウザコンテキストの制限について解説します。
.client.* 規約
pages/、components/、src/ 配下にある TypeScript または JavaScript ファイルで、.client.<ext>(<ext> は ts、tsx、js、jsx のいずれか)で終わるものはクライアントスクリプトエントリとして扱われます。
pages/
search-widget.client.ts ← "search-widget" としてバンドル
components/
analytics.client.tsx ← "analytics" としてバンドル
src/
my-lib.client.js ← "my-lib" としてバンドル エントリ名はファイルのステム(.client を除いた部分)です。たとえば search-widget.client.ts → "search-widget" となります。エントリ名はすべての探索ルートを通じてユニークでなければなりません。重複があるとビルドエラーになります。
layouts/ は探索対象から意図的に除外されています。サイト全体にわたるクライアントスクリプトは layouts/ ではなく components/ や src/ に置いてください。
ページからクライアントスクリプトを参照する
clientScript() SSR ヘルパーを使うと、レンダリング時に正しい URL を取得できます。
import { clientScript } from "@takazudo/zfb";
export default function SearchPage() {
return (
<html>
<head>
<title>検索</title>
</head>
<body>
<div id="search-root" />
{/* clientScript は安定 URL を返す。ビルドパイプラインが書き換える */}
<script type="module" src={clientScript("search-widget")} />
</body>
</html>
);
}clientScript("search-widget") は /(または base が設定されていればそのプレフィックス付きの URL)を返します。プロダクションビルドパイプラインは、最終的な HTML の中でこの URL をハッシュ付きの URL(/)に書き換えます。
ビルド時に何が起きるか
zfb build は発見した各エントリに対して 3 つのステップを実行します。
バンドル — 各
.client.*ファイルは esbuild に渡され、独立した ESM モジュールとしてバンドルされます。バンドルにはエントリとそのインポート先のみが含まれます。フレームワーク(preact、reactなど)をインポートしている場合はそれも含まれます。ハッシュ —
ProductionAssetPipelineがバンドルのバイト列を読み込み、コンテンツハッシュを計算してdist/に書き出します。assets/ client/ <name>- <hash>. js 書き換え — レンダリングされた HTML 内にある安定 URL(
/、assets/ client/ <name>. js baseプレフィックス付きの場合もあり)のすべての出現がハッシュ付き URL に置き換えられます。
zfb dev ではクライアントスクリプトはバンドルされますが、ハッシュは付きません。安定 URL がそのまま配信されます。そのため clientScript() が返す URL は、フルページサイクルなしで開発中にライブリロードできます。
バンドルモードと設定
クライアントスクリプトには、islands、module worker、ページ / SSR バンドルと同じコンパイル時モード値が渡されます。
| コマンド | import.meta.env.DEV | import.meta.env.PROD | process.env.NODE_ENV |
|---|---|---|---|
zfb dev | true | false | "development" |
zfb build | false | true | "production" |
bundle.loaders と bundle.define の設定も、クライアントスクリプトとその worker に適用されます。define の値は esbuild の生の式で、参照されるとブラウザから見えるようになります。secret には使わないでください。
TypeScript はこれらのバンドルコントラクトを推論しません。クライアントスクリプトでモード値、カスタム loader の import、raw import、define 名を zfb check と tsc に通すには、対応する ambient 宣言を追加してください。
?raw でテキストをインポートする
ファイルの内容を文字列として読み込むには、厳密な ?raw サフィックスを持つ静的な default import を使います。
import shaderSource from "./shaders/noise.frag?raw";
console.log(shaderSource);ターゲットの拡張子は問いません。JavaScript 風の拡張子でも構いません。これは終端のテキスト依存であり、モジュールとして実行もパースもされません。ファイルは妥当な UTF-8 テキストを含み、リテラルの . または . から始まるプロジェクトローカルなパスに解決される必要があります。named import、namespace import、side-effect import、type-only import、dynamic import、re-export はサポートされません。?url、追加のクエリパラメータ、リテラルでない specifier も同様で、サポートされる import 形式を示すエラーになります。
?raw は bundle.loaders から独立しています。エントリ自身でも、そこからインポートされる first-party モジュールでも使えます。zfb dev 中は元のテキストファイルが依存として追跡されるため、編集・削除・再作成するとクライアントスクリプトの再バンドルがスケジュールされます。
Module worker
クライアントスクリプトのグラフ内に次のリテラルな constructor 形式があると、zfb は first-party の module worker をバンドルします。
const searchWorker = new Worker(
new URL("./workers/search.worker.ts", import.meta.url),
{ type: "module" },
);URL は、クエリや fragment のない、厳密なプロジェクトローカル相対パスの JS / TS ファイルを指す必要があります。zfb は worker コードをサーバーグラフへ入れず、各 worker を自己完結したブラウザエントリとしてバンドルし、URL をフラットな companion へ書き換えます。
./worker-<encoded-project-relative-path>.js?v=<graph-hash>クライアントスクリプトでは、この companion は / 配下(base を設定していればそのプレフィックス付き)で配信されます。ファイル名は安定していて可逆です。パス区切りは -s-、ドットは -d-、リテラルのハイフンは -h-、その他のバイトは -xHH- で表します。?v= の値は厳密に 8 文字の小文字 16 進数で、first-party の worker グラフ、または出力に影響するバンドル / resolver 入力が変わると更新されます。推移的な import、ネストした worker、終端の raw ファイルも対象です。
zfb dev 中は worker グラフのファイルが監視されます。編集すると所有元のクライアントスクリプトが再ビルドされ、worker edge を削除すると古い companion が prune されます。探索はインストール済みの node_modules をたどらないため、third-party の推移的 worker はパッケージ側のツールに委ねられます。同じリテラルな new URL(..., import.meta.url) 形式で書かれた SharedWorker は、書き換えられない URL が実行時に 404 する状態を避けるため、名前付きのビルドエラーになります。
サブパスデプロイ(base)
サイトをサブパス配下でホストする場合(例:zfb.config.json に base: "/pj/mysite/" を設定)、clientScript() は自動的にプレフィックスを付加します。
// base="/pj/mysite/" が設定されている場合:
clientScript("search-widget")
// → "/pj/mysite/assets/client/search-widget.js"プロダクションパイプラインも同じベースプレフィックス付き URL を書き換えキーとして使用するため、ハッシュの置き換えも正しく動作します。プレフィックスを手動で管理する必要はありません。
SSR 専用の注意点(v1)
clientScript() は SSR コンテキストでの使用を想定して設計されています。サーバーサイドレンダリング中に <script> タグをレンダリングする用途です。ブラウザで実行されるコードから呼び出すことも可能ですが、v1 では base プレフィックス(globalThis.__zfb.base)はブラウザに送られないため、ブラウザ側からの呼び出しではプレフィックスなしの安定 URL が返されます。
SSR レンダリング時に <script src="…"> タグを生成するという典型的なユースケースでは、これは問題になりません。タグは SSR レンダラーが 1 回だけ書き出し、その時点で URL は正しい値になっています。
import.meta.glob はサポートされていません
クライアントスクリプトは Vite の import.meta.glob(...) マクロをサポートしていません。islands がサポートする eager な文字列リテラル形式ですらサポートされません。?raw、module worker、プラグインの前処理を使うクライアントスクリプトのグラフは一時的なミラーからバンドルされる場合がありますが、その段階が展開するのはそれらの機能だけで、import.meta.glob は展開しません。そのため import.meta.glob(...) 呼び出しは(.client.* ファイル自身の中にあっても、それがインポートする任意のモジュールの中にあっても)展開されないままブラウザへ配信されます。これは ビルド時には検出されません。zfb build は成功し、スクリプトが実行されるときにブラウザが例外を投げます。import.meta.glob は Vite の外では undefined だからです。
クライアントスクリプト内で glob のようなファイル一覧が必要な場合は、import.meta.glob に手を伸ばすのではなく、自分で計算してください(プラグインの仮想モジュール、あるいは実際の .ts ファイルを書き出す小さなプリビルドスクリプト)。完全なサポートマトリクスと代替手段については Islands: import.meta.glob のサポート を参照してください。
.html ソースページの制限
プレーンな .html ファイルとして書かれたページ(Option B、pages/)は、アセット URL 書き換えパスを完全にスキップします。.html ソースページの中に埋め込まれた clientScript() の URL は、ハッシュ付きの URL に書き換えられません。静的 HTML ページでハッシュ付きのクライアントスクリプト URL が必要な場合は、代わりに .tsx ページに変換してください。
使用例
pages/ — クライアントエントリ:
// pages/search-widget.client.ts
import { h, render } from "preact";
function SearchWidget() {
return <div class="search-widget">検索…</div>;
}
const root = document.getElementById("search-root");
if (root) {
render(<SearchWidget />, root);
}pages/ — ウィジェットを読み込む SSR ページ:
import { clientScript } from "@takazudo/zfb";
export default function SearchPage() {
return (
<html>
<head>
<title>検索</title>
</head>
<body>
<div id="search-root" />
<script type="module" src={clientScript("search-widget")} />
</body>
</html>
);
}開発中はブラウザが安定 URL / をフェッチします。zfb build 後は HTML に / が含まれ、ハッシュ付きファイルが dist/ に存在します。