Client Scripts
How to ship TypeScript/JavaScript files that run in the browser — the .client.ts convention, the clientScript() helper, hashing, and limitations.
What this page covers
How to author *.client.ts files that are bundled and served to the browser, how to reference their URLs at SSR time with clientScript(), how the production pipeline content-hashes them, and the current .html-source-page and browser-context limitations.
The .client.* convention
Any TypeScript or JavaScript file under pages/, components/, or src/ that ends with .client.<ext> (where <ext> is ts, tsx, js, or jsx) is treated as a client-script entry:
pages/
search-widget.client.ts ← bundled as "search-widget"
components/
analytics.client.tsx ← bundled as "analytics"
src/
my-lib.client.js ← bundled as "my-lib" The entry name is the file stem minus .client — so search-widget.client.ts → "search-widget". Entry names must be unique across all discovery roots; a duplicate triggers a build error.
layouts/ is intentionally excluded from discovery: client scripts that span the whole site belong in components/ or src/, not layouts/.
Referencing a client script from a page
Use the clientScript() SSR helper to get the correct URL at render time:
import { clientScript } from "@takazudo/zfb";
export default function SearchPage() {
return (
<html>
<head>
<title>Search</title>
</head>
<body>
<div id="search-root" />
{/* clientScript returns the stable URL; the build pipeline rewrites it */}
<script type="module" src={clientScript("search-widget")} />
</body>
</html>
);
}clientScript("search-widget") returns / (or the base-prefixed equivalent). The production build pipeline rewrites this to the hashed URL (/) in the final HTML.
What happens at build time
zfb build runs three steps for each discovered entry:
Bundle — each
.client.*file is passed to esbuild and bundled independently as an ESM module. The bundle includes only the entry and its transitive imports. If the entry imports a framework (preact,react), those are bundled in too.Hash — the
ProductionAssetPipelinereads the bundle bytes, content-hashes them, and writesdist/.assets/ client/ <name>- <hash>. js Rewrite — every occurrence of the stable URL (
/, possibly with aassets/ client/ <name>. js baseprefix) in the rendered HTML is replaced with the hashed URL.
During zfb dev, client scripts are bundled but not hashed. The stable URL is served directly. This means the URL returned by clientScript() is live-reloadable in dev without a full page cycle.
Bundle mode and configuration
Client scripts receive the same compile-time mode values as islands, module workers, and the page/SSR bundle:
| Command | import.meta.env.DEV | import.meta.env.PROD | process.env.NODE_ENV |
|---|---|---|---|
zfb dev | true | false | "development" |
zfb build | false | true | "production" |
The bundle.loaders and bundle.define settings also apply to client scripts and their workers. Define values are raw esbuild expressions and become browser-visible when referenced; do not use them for secrets.
TypeScript does not infer these bundler contracts. Add the matching ambient declarations so mode values, custom-loader imports, raw imports, and define names in client scripts pass zfb check and tsc.
Importing text with ?raw
Use a static default import with the exact ?raw suffix to load a file's contents as a string:
import shaderSource from "./shaders/noise.frag?raw";
console.log(shaderSource);The target may have any extension, including a JavaScript-looking extension: it is a terminal text dependency and is never executed or parsed as a module. The file must contain valid UTF-8 text and resolve through a literal . or . project-local path. Named, namespace, side-effect, type-only, dynamic, and re-export forms are not supported. Neither are ?url, extra query parameters, or a non-literal specifier; these forms fail with an error that shows the supported import.
?raw is independent of bundle.loaders. It works in the entry and in first-party modules imported by it. During zfb dev, the original text file is tracked as a dependency, so editing, deleting, or recreating it schedules the client script for rebundling.
Module workers
zfb bundles a first-party module worker when it sees this literal constructor shape in a client-script graph:
const searchWorker = new Worker(
new URL("./workers/search.worker.ts", import.meta.url),
{ type: "module" },
);The URL must name an exact project-local relative JS/TS file, with no query or fragment. zfb keeps worker code out of the server graph, bundles each worker as a self-contained browser entry, and rewrites the URL to a flat companion:
./worker-<encoded-project-relative-path>.js?v=<graph-hash>For a client script, that companion is served under / (with the configured base prefix, if any). The filename is stable and reversible: path separators use -s-, dots use -d-, literal hyphens use -h-, and other bytes use -xHH-. The ?v= value is exactly eight lowercase hexadecimal characters and changes when the first-party worker graph or its output-affecting bundle/resolver inputs change, including transitive imports, nested workers, and terminal raw files.
Worker-graph files are watched during zfb dev; edits rebuild the owning client script, and removing a worker edge prunes its old companion. Discovery does not traverse installed node_modules, so third-party transitive workers are left to their package tooling. A SharedWorker written with the same literal new URL(..., import.meta.url) form is a named build error rather than an unrewritten URL that would 404.
Sub-path deploy (base)
If your site mounts under a sub-path (e.g. base: "/pj/mysite/" in zfb.config.json), clientScript() automatically includes the prefix:
// With base="/pj/mysite/" configured:
clientScript("search-widget")
// → "/pj/mysite/assets/client/search-widget.js"The production pipeline uses the same base-prefixed URL as the rewrite key, so the hash swap still fires. You do not need to manage the prefix manually.
SSR-only note (v1)
clientScript() is designed for SSR-context use: rendering a <script> tag during server-side render. Calling it in browser-executed code also works, but the base prefix (globalThis.__zfb.base) is not shipped to the browser in v1 — so a browser-side call returns the unprefixed stable URL rather than the base-prefixed one.
For the typical use-case of generating a <script src="…"> tag at render time, this is not a problem: the tag is written once by the SSR renderer and the URL is correct.
import.meta.glob is not supported
Client scripts do not support Vite's import.meta.glob(...) macro — not even the eager, string-literal form that islands support. Client-script graphs that use ?raw, module workers, or plugin preprocessing may be bundled from a temporary mirror, but that stage expands only those features — it does not expand import.meta.glob. A glob call (in the .client.* file itself or any module it imports) therefore ships to the browser unexpanded. This is not caught at build time — zfb build succeeds, and the browser throws when the script runs, because import.meta.glob is undefined outside of Vite.
If you need a glob-like file list inside a client script, compute it yourself (a plugin virtual module, or a small pre-build script that writes a real .ts file) rather than reaching for import.meta.glob. See Islands: import.meta.glob support for the full support matrix and alternatives.
.html-source-page limitation
Pages authored as plain .html files (Option B, pages/) bypass the asset URL rewrite pass entirely. A clientScript() URL embedded inside an .html-source page is not rewritten to the hashed equivalent. If you need hashed client-script URLs on a static HTML page, convert it to a .tsx page instead.
Full example
pages/ — the client entry:
// pages/search-widget.client.ts
import { h, render } from "preact";
function SearchWidget() {
return <div class="search-widget">Search…</div>;
}
const root = document.getElementById("search-root");
if (root) {
render(<SearchWidget />, root);
}pages/ — the SSR page that loads the widget:
import { clientScript } from "@takazudo/zfb";
export default function SearchPage() {
return (
<html>
<head>
<title>Search</title>
</head>
<body>
<div id="search-root" />
<script type="module" src={clientScript("search-widget")} />
</body>
</html>
);
}In development the browser fetches the stable URL /. After zfb build the HTML contains / and the hashed file is present in dist/.