zfb
GitHub repository

Type to search...

to open search from anywhere

defineConfig

Define a zfb project configuration with full type inference.

Signature

defineConfig(config: ZfbConfig): ZfbConfig

defineConfig is exported from zfb/config. The helper is identity-typed: it returns its argument unchanged. Its only job is to give your editor IntelliSense and type-checking against the ZfbConfig shape. The actual schema is enforced by Rust serde at config-load time, so the same rules apply whether you author your config in TypeScript or JSON.

Typing the zfb/config import

zfb.config.ts imports defineConfig from the bare specifier zfb/config. At config-load time zfb aliases that import to a runtime-only stub (so your config parses without the @takazudo/zfb package installed), and no real zfb/config module exists in node_modules — the published types live under the scoped subpath @takazudo/zfb/config. So a plain tsc --noEmit (what zfb check runs) has nothing to type the bare import against and reports Cannot find module 'zfb/config'.

Bridge the two with a one-line ambient declaration that re-exports the scoped package's types. Put zfb-shim.d.ts in a directory your tsconfig includes; components/zfb-shim.d.ts works with the stock template. If you put the file at the project root instead, add "zfb-shim.d.ts" to include explicitly.

// components/zfb-shim.d.ts — types the bare `zfb/config` specifier.
declare module "zfb/config" {
  export * from "@takazudo/zfb/config";
}

Re-export — do not hand-copy the ZfbConfig shape into the shim. A hand-mirrored field list silently lags the engine: every new config field has to be re-added by hand, or zfb check rejects a perfectly valid field with TS2353: Object literal may only specify known properties. export * tracks the published @takazudo/zfb/config automatically, so the shim can never fall behind.

Config shape

All keys are camelCase. The shape is enforced by the Rust serde deserializer (crates/zfb/src/config.rs) — the TypeScript type in packages/zfb/src/config.ts mirrors it for editor IntelliSense.

  • outDir?: string — output directory used by build and preview; dev treats it as a read-only prebuilt seed rather than a live-output directory. Default: "dist". An explicit --outdir overrides it for build and preview.

  • publicDir?: string — static assets directory, copied verbatim. Default: "public".

  • host?: string — dev/preview server bind host.

  • port?: number — dev/preview server port. The built-in fallback differs per command when this is omitted: zfb dev falls back to 3000, zfb preview falls back to 4321. Setting port here overrides both commands' fallback; the CLI --port flag in turn overrides this config value. See CLI Reference.

  • allowedHosts?: string[] — Host header values the dev/preview server accepts when bound to a non-localhost interface (mirrors Vite's server.allowedHosts). Only consulted for non-loopback binds; localhost, the explicitly bound host, and any IP-literal Host (127.0.0.1, [::1], the LAN URLs the startup banner prints) are always allowed — DNS rebinding needs a DNS name, so raw IPs are safe. Entries match exactly (case-insensitive, request port stripped); a leading-dot entry like ".example.com" also matches subdomains.

  • framework?: "preact" | "react" — JSX framework runtime. Default: "preact".

  • collections?: CollectionDef[] — content collections. Each entry has:

    • name: string — identifier used in getCollection calls.

    • path: string — directory relative to the project root.

    • schema?: Record<string, unknown> — optional JSON Schema for frontmatter validation (enforced by zfb check; the build does not validate frontmatter against it).

    • include?: string[] — glob patterns (globset dialect, relative to path); when set, only matching entries are kept.

    • exclude?: string[] — glob patterns; matching entries are dropped (evaluated after include).

    • idStripSuffix?: string — suffix stripped from each entry's slug and module specifier (e.g. ".en" for multi-locale layouts).

    • allowOutsideRoot?: boolean — opt in to a path that escapes the project root via .. (e.g. a monorepo-shared content directory living outside this package). Default false keeps path confined to the project root. Absolute paths are rejected regardless of this flag — only ..-relative escapes are relaxed. See Collections outside the project root for the monorepo use case and the preset-trust and dev-watching caveats.

  • tailwind?: { enabled?: boolean } — Tailwind options. enabled defaults to true; set tailwind: { enabled: false } to disable Tailwind while keeping authored CSS and CSS Modules.

  • prefetch?: { disabled?: boolean } — prefetch options. When disabled: true, the runtime's prefetch wiring is skipped entirely via a build-time meta tag.

  • minifyHtml?: boolean — opt in to production HTML minification. Default: false (off). The minifier runs as Rust-side post-processing and does not spawn a Node.js minifier subprocess. The first version is conservative: rendered HTML pages are candidates, source .html passthrough pages remain verbatim, and non-HTML outputs are skipped. zfb build --minify-html / --no-minify-html can override the config value for one build.

  • strictBrokenLinks?: boolean — raise broken-link diagnostics from markdown link validation to errors, failing zfb build with a non-zero exit instead of merely warning. Default: false (off, and the key may be omitted entirely). When markdown.features.linkValidation is absent, setting this to true force-enables link validation with its defaults rather than doing nothing; when it is present, only its failOnBroken is overridden and every other setting is preserved. zfb build --strict-broken / --no-strict-broken can override the config value for one build. Build-only — zfb dev is never affected. Scope is the linkValidation mechanism only; the separate resolveMarkdownLinks.onBrokenLinks knob below is unaffected. See Link validation.

  • bundle?: BundleConfig — esbuild settings shared by the page/SSR, islands, client-script, and module-worker passes. exclude, mainFields, and external are escape hatches for the --platform=neutral page/SSR pass; loaders and define apply across all four passes. See Bundle settings.

  • plugins?: PluginConfig[] — user-supplied plugins. Each entry has name (npm specifier or ./-relative path) and optional options (an arbitrary JSON object passed to the plugin's hooks). See Plugins for the full hook contract — setup, preBuild, postBuild, devMiddleware, previewMiddleware — including virtual modules, import aliases, and dev-only injected routes.

  • presets?: Partial<ZfbConfig>[] — config presets to merge before validation. Each preset is a partial ZfbConfig-shaped object, typically the return value of a preset package's factory function. Array fields (plugins, collections, extraWatchPaths, allowedHosts) are prepended from presets so the main config's entries retain their position. Scalar and object fields are key-presence-based: a key the main config provides wins even when its value equals the built-in default, while an omitted key is filled from the first preset that supplies it. Explicit null blocks the preset value. Nested presets inside a preset are not recursively expanded. Preset authors should use definePreset so that relative-path plugins resolve correctly.

  • adapter?: string — deploy-target adapter package name. Omit for a pure static build. A package like "@takazudo/zfb-adapter-cloudflare" wraps the SSR bundle into a deploy-ready entry (e.g. dist/_worker.js for Cloudflare Workers Static Assets, Pages-compatible).

  • output?: "static" | "hybrid" | "auto" — project output mode. "static" errors at build start if any route exports prerender = false; "hybrid" always enables V8/SSR even if no SSR routes currently exist; "auto" (default) detects from the route set.

  • site?: string — canonical origin URL (e.g. "https://example.com"). When set, exposes globalThis.__zfb.site so layouts can build canonical <link> tags, OpenGraph meta, sitemap absolute hrefs, and hreflang alternates. Must be an absolute HTTP/HTTPS URL; omit for builds that do not need server-side canonical URL construction. Distinct from base — see below.

  • base?: string — public URL prefix for asset URLs. Use when the site is deployed under a sub-path (e.g. "/pj/my-site/"). Distinct from site: base prefixes asset URLs; site is the full canonical origin used in metadata.

  • copyPublicWithBase?: boolean — whether public/ assets are copied under the base sub-path segment (default true) or flat to the dist/ root (false). Set false when the deploy pipeline relocates the entire dist/ tree into the base path (e.g. cp -a dist/. deploy-root/pj/site/) to avoid a double-nested dist/<base>/<base>/... path. See Static Assets — flat copy for deploy-relocation pipelines.

  • stripMdExt?: boolean — strip .md/.mdx extensions from internal link hrefs during MDX compilation and append a trailing /. Default: false.

  • trailingSlash?: boolean — append a trailing / to extensionless absolute hrefs when rewriting base paths. Default: false.

  • markdown?: MarkdownConfig — Markdown/MDX parsing options. Fields:

    • gfm?: GfmFlag — GFM construct toggles (strikethrough, table, autolinks, and more). See GFM.

    • toc?: { heading?: string; maxDepth?: number } — inserts a table of contents after the first heading matching heading (default "TOC"), up to maxDepth levels (default 2). Wires the same underlying plugin and config shape as markdown.features.headingMarkerToc, documented at Heading-marker TOC.

    • externalLinks?: { target?: string; rel?: string[] } — annotates external <a> elements with target/rel. See External links.

    • cjkFriendly?: boolean — CJK-friendly emphasis and autolink-boundary handling. Default true (on). See CJK-friendly emphasis.

    • hardBreaks?: boolean — convert soft line breaks into <br>. Default false (off). See Hard breaks.

    • features?: MarkdownFeaturesConfig — per-feature opt-in toggles (mermaid, directives, code enrichment, reading time, and more). See the Markdown Features feature map.

  • resolveMarkdownLinks?: ResolveMarkdownLinksConfig — markdown link resolver settings. Enable to rewrite [label](./other.mdx) links to their rendered route URLs. Extensionless (./other) and directory-style (other/) targets resolve too, probing {name}.mdx{name}.md{name}/index.mdx{name}/index.md. Relative targets resolve from the source file's directory; a directory-style link written from a non-index page against its rendered URL (which sits one directory deeper — ../sibling/ from section/article.mdx) resolves via a URL-space fallback when every file-space candidate misses. Fields:

    • enabled?: boolean — must be set to true for anything to happen. Default false: the resolver does nothing and links pass through unchanged.

    • docsDir?: string — legacy single-directory form. Scanned against a hard-coded /docs/ route prefix. Ignored once dirs is non-empty.

    • dirs?: { dir: string; routePrefix: string }[] — explicit per-directory source map (e.g. EN docs at src/content/docs//docs/, JA docs at src/content/docs-ja//ja/docs/). Takes precedence over docsDir and is required for any project with more than one docs root (locale mirrors).

    • onBrokenLinks?: "warn" | "error" | "ignore" — what to do when a .md/.mdx link cannot be resolved. Default "warn" (emits a warning, build continues).

    See Resolve links and the Markdown Features feature map for the full behavior.

  • emitRoutesManifest?: boolean — whether zfb build writes the post-build route manifest to <outDir>/__zfb/routes.json. Default: true (emit). Set false to suppress.

  • emitRenderArtifacts?: boolean — whether zfb build writes a JSON render artifact — the content-region HTML as shipped, compiler-allocated headings, a contract version, and a raw-source digest — for every markdown/MDX-backed HTML route whose page renders exactly one top-level content region, under <outDir>/__zfb/render/. Default: false (off — unlike emitRoutesManifest's default-on posture). zfb build --emit-render-artifacts / --no-emit-render-artifacts can override the config value for one build; CLI beats config beats the default. Build-only — zfb dev never writes render artifacts. See Render artifacts for the full JSON contract.

  • extraWatchPaths?: string[] — extra absolute filesystem paths the dev watcher follows in addition to the in-project source roots. See Watching paths outside the project root.

  • watchPollFallback?: boolean — make zfb dev watch the filesystem by polling instead of by OS-native change notifications (FSEvents on macOS, inotify on Linux, …). Default: false (native). Turn it on for hosts where the native backend never delivers — network-mounted project directories, some CI/sandboxed containers, a stalled fseventsd. See Poll-based file watching.

  • watchPollIntervalMs?: number — re-scan interval, in milliseconds, for the poll backend. Default: 500. Must be between 50 and 10000 inclusive; anything outside that range is a config-load error, and a value below 100 is accepted with a warning. Only takes effect when watchPollFallback is true — setting it alone is accepted but dormant, with a warning rather than an error. See Poll-based file watching.

The following keys are part of the same ZfbConfig type — defineConfig and zfb check type-check them like any other field. They configure the Rust engine's code-rendering and plugin behaviour:

  • codeHighlight — syntect syntax-highlight theme options. See the syntax-highlighting guide. Fields:

    • theme?: string — single-theme mode. A syntect built-in or user-loaded theme name (e.g. "InspiredGitHub", "Solarized (dark)"); defaults to "base16-ocean.dark". Tokens get an inline color:. Mutually exclusive with themeLight / themeDark. These are syntect theme names, not Shiki names like "dracula".

    • themesDir?: string — directory of .tmTheme files, relative to the project root. Each file becomes available by its declared name to theme, themeLight, or themeDark. Applies to both single- and dual-theme mode. Must be relative and must not escape the root via ..; a missing directory errors at build start.

    • themeLight?: string — light-mode syntect theme name for dual-theme highlighting. Must be set together with themeDark — setting only one is a build error. Mutually exclusive with theme. When the pair is set, each block is highlighted twice and tokens carry --shiki-light / --shiki-dark custom properties instead of an inline color:, the <pre> gets class="syntect-dual" plus --shiki-light-bg / --shiki-dark-bg, and the consumer resolves the active colour with a light-dark() CSS rule.

    • themeDark?: string — dark-mode syntect theme name for dual-theme highlighting. Must be set together with themeLight; mutually exclusive with theme. See themeLight for the full dual-mode contract.

    • mode?: "inline" | "class" — token output mode. "inline" (default) bakes per-token colours into style="color:…" (or the dual --shiki-* properties). "class" emits a semantic role class per token instead, so colours resolve through re-themeable CSS custom properties. Class mode is mutually exclusive with every theme knob (theme, themeLight, themeDark, themesDir) — themes do not affect class emission, so setting one alongside mode: "class" is a build error. See the guide's Class mode section.

    • classPrefix?: string — class-name prefix for class mode (default "hi-", yielding hi-root, hi-kw, …). Must match /^[A-Za-z][A-Za-z0-9_-]*$/ (first char an ASCII letter; rest letters, digits, _, or -); an empty, leading-digit, or otherwise non-matching prefix is a build error. Only meaningful in class mode. The shipped default stylesheet's .hi-* selectors are rewritten to the configured prefix; the --zfb-hi-* custom properties stay --zfb-hi-*.

    • roleClasses?: Partial<Record<Role, string>> — per-role class overrides for class mode, e.g. { keyword: "text-violet-600 dark:text-violet-400" }. Keys must be one of the 18 fixed role names (escape, operator, comment, string, number, constant, keyword, function, type, namespace, property, variable, tag, attribute, punctuation, inserted, deleted, heading) — an unknown key is a build error. A value may hold several space-separated classes but must not contain the bare token line (collides with the code-enrichment line wrapper). Mapped Tailwind utilities are auto-safelisted via @source inline; setting roleClasses while tailwind.enabled is false is allowed but emits a build warning (no safelist can be generated on the authored-CSS path).

    • defaultStylesheet?: boolean — whether to inject the built-in --zfb-hi-* token stylesheet (zfb-hi.css) into the combined styles.css. Default true. Only meaningful in class mode; set false to supply your own stylesheet entirely.

  • pluginHookTimeoutSecs — timeout (seconds) for plugin hook invocations.

Bundle settings

The browser-facing islands, client-script, and module-worker passes use the same mode, loader, and define settings as the page/SSR pass. This keeps code that is shared between server and browser graphs from changing meaning as it crosses a bundle boundary.

Bundle mode

zfb owns three compile-time substitutions. They are selected by the command, not by minification:

Commandimport.meta.env.DEVimport.meta.env.PRODprocess.env.NODE_ENV
zfb devtruefalse"development"
zfb buildfalsetrue"production"

These values apply to islands, client scripts, their module workers, and the page/SSR bundle. zfb does not synthesize other import.meta.env.* keys. The three keys in the table are reserved and cannot be overridden through bundle.define.

bundle.exclude, bundle.mainFields, bundle.external

The page/SSR pass bundles with esbuild's --platform=neutral, which is deliberately minimal: its main-fields resolution list is empty by default, and it rejects packages that resolve only through a CJS-only require() path. These three fields are escape hatches for CJS-only dependencies that reach the neutral pass — either directly imported or newly pulled in by an eager import.meta.glob(...) expansion. All three are absent/empty by default, which is byte-identical to a build without this knob.

bundle.exclude — project-relative glob patterns (gitignore-style, matched in POSIX form against the path relative to the project root) for source files the bundler must never pull into the esbuild graph. A matched file is neither staged into the shadow tree nor expanded by an eager import.meta.glob(...). Unrelated to CollectionDef.exclude (content filtering, not bundling).

import { defineConfig } from "zfb/config";

export default defineConfig({
  bundle: {
    exclude: ["components/**/*.stories.tsx"],
  },
});

Before: a route's import.meta.glob("./components/**/*.tsx") picks up Button.stories.tsx, which imports a CJS-only test-mocking package, and the neutral-platform build fails to resolve it. After: the glob skips *.stories.tsx files entirely and the build succeeds.

bundle.mainFields — an explicit esbuild --main-fields list for the neutral pass. Setting e.g. ["main", "module"] lets a dependency that ships only main/module (no exports map) resolve, since neutral otherwise consults no main-fields at all.

import { defineConfig } from "zfb/config";

export default defineConfig({
  bundle: {
    mainFields: ["main", "module"],
  },
});

Before: The "main" field here was ignored. Main fields must be configured explicitly when using the "neutral" platform. After: esbuild consults main then module and resolves the dependency.

bundle.external — bare specifiers to mark --external for the neutral pass, so esbuild leaves them unbundled instead of trying to resolve them. The other escape hatch for a CJS-only dependency that mainFields alone can't satisfy. Appended to the framework-provided externals.

import { defineConfig } from "zfb/config";

export default defineConfig({
  bundle: {
    external: ["some-cjs-only-package"],
  },
});

Before: esbuild tries to resolve some-cjs-only-package under --platform=neutral and fails. After: esbuild marks the bare import external instead of resolving it.

bundle.loaders

Use loaders to assign an additional esbuild loader to a file extension:

import { defineConfig } from "zfb/config";

export default defineConfig({
  bundle: {
    loaders: {
      ".fixture": "text",
      ".data": "binary",
    },
  },
});

Keys must start with .. The inline-only v1 values are text, json, base64, dataurl, binary, and empty. The file and copy loaders are rejected because they emit sibling assets that these bundle pipelines do not publish. .css, .module.css, .mdx, and .md are reserved by zfb and cannot be overridden.

?raw imports do not use this map. import text from "./file.ext?raw" always loads valid UTF-8 text, regardless of the extension or its configured loader.

bundle.define

define values are raw esbuild replacement expressions, not automatically quoted strings:

import { defineConfig } from "zfb/config";

export default defineConfig({
  bundle: {
    define: {
      __APP_NAME__: '"my-app"', // the replacement includes JSON quotes
      __FEATURE_ENABLED__: "true",
      __BUILD_META__: '{"channel":"preview"}',
    },
  },
});

Without the inner JSON quotes, a value is parsed as a raw expression (and may be rejected) rather than as a string literal. Values are forwarded verbatim and are not filtered by the PUBLIC_ environment-variable policy.

Treat every bundle.define value as public code

bundle.define is trusted operator-authored build input, not a secret store or an input-sanitization boundary. Every configured definition is available to browser bundles. esbuild may remove a definition that no reachable code uses, but tree-shaking is not a secrecy guarantee. Never put credentials, private tokens, or untrusted expressions in this map.

TypeScript ambient declarations

zfb supplies the mode values, loader results, raw modules, and define replacements while bundling, but it does not generate TypeScript declarations for them. zfb check runs tsc --noEmit, so TypeScript source that uses these features needs matching ambient declarations in a .d.ts file included by your tsconfig. For example, the .fixture text loader and the three bundle.define entries above can be typed with components/zfb-env.d.ts, which the stock template's tsconfig already includes:

// components/zfb-env.d.ts
interface ImportMetaEnv {
  readonly DEV: boolean;
  readonly PROD: boolean;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

declare module "*?raw" {
  const source: string;
  export default source;
}

declare module "*.fixture" {
  const contents: string;
  export default contents;
}

declare const __APP_NAME__: string;
declare const __FEATURE_ENABLED__: boolean;
declare const __BUILD_META__: { channel: string };

The filename and directory are not special; the file only has to be covered by tsconfig.json's include. If you prefer a project-root zfb-env.d.ts, add that path to include explicitly. A declaration file outside include is invisible to tsc.

These declarations are type-only. They do not configure esbuild, inject runtime values, or enable an import that zfb does not support. Keep custom module patterns and global types aligned with bundle.loaders and bundle.define; otherwise zfb check or tsc can accept the wrong runtime assumption or reject code that zfb can bundle. The *?raw declaration can always return string because that result is part of zfb's fixed raw-import contract.

Watching paths outside the project root

extraWatchPaths lets zfb dev live-reload when files outside the project tree change — useful when a project reads content from a sibling repo, a file: dep that ships content alongside code, or a shared filesystem directory.

import { defineConfig } from "zfb/config";

export default defineConfig({
  extraWatchPaths: [
    "/home/me/knowledge-base",
    "/srv/shared-content",
  ],
});

Semantics:

  • Absolute paths only. Each entry must be an absolute path. Relative paths are rejected at config-load with an extraWatchPaths[N]: ... must be an absolute path error — the dev watcher registers each entry verbatim, outside the project root, so it has no anchor to resolve a relative path against.

  • Canonicalisation. Each entry is canonicalised (Path::canonicalize) once when zfb dev boots. Symlinks are resolved; downstream events reach the rebuild logic with the canonical form, so the path the watcher emits matches the form you'd see by running realpath on the configured value.

  • Missing-at-boot. If a configured path does not exist at the moment zfb dev starts, it is skipped with a warning. The watcher does not poll for the path to appear later — if you create the directory after the dev server is already running, restart zfb dev to pick it up.

  • Recursive. Each entry is watched recursively. Sub-directories created after boot are picked up automatically by the OS-level recursive watch.

  • Rebuild scope. Events from these paths fall outside the dependency graph's coverage (the graph only tracks in-tree edges), so they conservatively trigger broader rebuilds than equivalent in-tree edits. The trade-off is intentional: correctness over precision for out-of-root sources.

Security note. Opt-in only — do not point this at unbounded directories like $HOME or /. On Linux the recursive watcher registers every subdirectory and can quickly hit the inotify max_user_watches ceiling (default ~8192 on many distributions) on a large tree. If you need to watch a sprawling source, watch the narrowest sub-tree that contains the files you actually edit.

This is a dev-mode feature. Production builds (zfb build) snapshot the filesystem once and do not rely on watcher events, so extraWatchPaths has no effect on shipped output.

Watching out-of-root files is not the same as importing them

extraWatchPaths only makes the dev watcher notice changes outside the project root — it does not make those files importable. zfb builds by shadow-copying the project root into a temporary directory before running esbuild; a relative import that walks outside the project root (e.g. ../../../../packages/ui/src/button.tsx) resolves outside that shadow copy, and the build fails with a Could not resolve "..." error. The error message names the real (non-shadow) path of the importing file and explains this shadow-copy boundary.

Workaround: expose the out-of-root target as a package import instead of a relative one. Add a wildcard exports entry to the target package's package.json (e.g. "./src/*": "./src/*") and import it by package specifier (e.g. @scope/pkg/src/button.tsx) — node_modules (including file:/workspace-linked packages) IS included in the shadow copy, so package-specifier imports resolve normally. See issue #1385 for the full context; this is diagnose-only for now — the escape itself stays unsupported.

Poll-based file watching

zfb dev normally learns about edits from the operating system's own filesystem-change notifications — FSEvents on macOS, inotify on Linux, and the platform equivalent elsewhere. That is the fastest path and the default. On some hosts it never delivers: a project directory on a network mount, certain CI or sandboxed containers, or a macOS fseventsd that has stalled. Hot-reload then looks dead even though zfb dev is running fine.

watchPollFallback: true swaps that native backend for a polling one, which re-scans the watched roots on a fixed interval instead of waiting to be told about changes.

import { defineConfig } from "zfb/config";

export default defineConfig({
  watchPollFallback: true,
  watchPollIntervalMs: 500,
});

When to reach for it

Do not enable it pre-emptively — polling costs CPU that native notifications do not, and it is slower to react. Turn it on when hot-reload is actually broken on a given machine.

The dev server tells you when that has happened. At boot it runs a watcher-liveness self-check: it makes its own filesystem change and waits to observe it. If nothing arrives within the deadline, zfb dev prints a loud hot-reload looks dead on this machine warning, lists the common causes (a stalled fseventsd, a Dropbox/OneDrive/iCloud-synced project directory intercepting events, antivirus/EDR software hooking filesystem calls), and names watchPollFallback: true as the remedy to try. That warning is the clearest signal to set this flag.

Its absence is weaker evidence. The self-check writes its markers into a scratch directory deliberately kept clear of the roots the dev server really watches — an overlap would make the probe's own writes look like project changes and trigger a rebuild storm on every check. So it measures whether the backend delivers events for that one location, not for every watched path. A failure confined to a single watched path, such as an extraWatchPaths entry on a network mount that stopped delivering, can leave hot-reload broken while the self-check still passes. If edits to a particular directory never trigger a rebuild and no warning appeared, the poll backend is still worth trying.

If the poll backend is already enabled and the same warning still appears, it says so and suggests a different remedy instead — raise watchPollIntervalMs, or check whether the watched directories have become unreachable from the process (a network mount that dropped, for instance). Enabling a fallback that is already on would not help.

The interval

  • Default 500ms when watchPollIntervalMs is omitted.

  • Valid range 5010000ms, inclusive. Anything outside it fails at config load — too low busy-loops the polling thread, too high makes hot-reload feel broken.

  • Below 100ms is accepted with a warning. A re-scan that frequent can add noticeable CPU cost on a large project tree.

  • Dormant without the flag. Setting watchPollIntervalMs while watchPollFallback is false is accepted, warns, and has no effect until the fallback is enabled. It is deliberately not an error, so a preset can pre-stage an interval for projects that may later opt in.

Behaviour differences from the native backend

Everything downstream of event delivery is shared between the two backends — change classification, debouncing, and dynamic watch registration all behave identically. What differs is how changes are noticed in the first place:

  • Latency is roughly one poll interval (plus the debounce window), instead of milliseconds after the OS event. At the 500ms default, expect edits to take up to about half a second longer to trigger a rebuild.

  • A file created and deleted within a single interval produces no event at all. The next scan never sees the path, so neither the creation nor the deletion is reported.

  • Modification detection is mtime-based at whole-second granularity. File contents are not compared. An overwrite that leaves the file's modification time in the same second goes unnoticed until a later change bumps the mtime.

  • Each child of a newly created directory arrives as its own creation event. This is a deliberate divergence, and an improvement: the native backend can report a created directory whose children never surface as individual events.

This flag affects zfb dev only. Production builds snapshot the filesystem once and never rely on watcher events, so neither key changes shipped output.

Examples

// zfb.config.ts — the recommended form
import { defineConfig } from "zfb/config";

export default defineConfig({
  outDir: "dist",
  framework: "preact",
  collections: [
    {
      name: "blog",
      path: "content/blog",
    },
  ],
  tailwind: { enabled: true },
});

The loader accepts zfb.config.ts (preferred) and zfb.config.json (legacy fallback). When only zfb.config.json is present it is read via serde_json; plugin paths declared as ./..., ../..., or absolute paths are resolved relative to the config file (npm specifiers like "@takazudo/some-plugin" work in both forms).

// zfb.config.json — legacy form, still supported
{
  "outDir": "dist",
  "framework": "preact",
  "collections": [
    {
      "name": "blog",
      "path": "content/blog"
    }
  ],
  "tailwind": { "enabled": true }
}

Validation

The loader enforces the following rules and reports errors with the file path plus line:column for JSON parse failures:

  • Collection names must be unique.

  • path cannot be an absolute path.

  • path cannot contain .. segments that escape the project root — unless that collection sets allowOutsideRoot: true, which relaxes this one check (absolute paths are still rejected).

  • bundle.loaders keys must start with ., use an inline loader, and not override an extension reserved by zfb.

  • bundle.define cannot override the mode-owned DEV, PROD, or NODE_ENV substitutions.

  • watchPollIntervalMs must be between 50 and 10000 (inclusive). A value below 100, and a value set without watchPollFallback: true, are both accepted with a warning rather than rejected.

Revision History

CreatedUpdated