npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@takazudo/zfb-runtime

v6.0.1

Published

JavaScript runtime for zfb static sites — Hono-backed page router, content snapshots, and client-side hydration.

Readme

@takazudo/zfb-runtime

A content-site engine built on the web's Request/Response model. Write pages in TSX and MDX once, then prerender them to static HTML, serve them on Cloudflare Workers, or run the engine as a local content server inside your own app. Ships as one Rust binary.

The JS-side runtime for zfb's SSG-first build pipeline. It exposes createPageRouter, a Hono-backed page router whose returned fetch handler is shape-compatible with the Cloudflare Workers (request) => Promise<Response> model. The Rust build host drives this router at build time to emit static HTML; the same handler also serves SSR requests at the edge.

Full documentation: https://takazudomodular.com/pj/zudo-front-builder/. Source: https://github.com/Takazudo/zudo-front-builder.

Install

npm install @takazudo/zfb-runtime @takazudo/zfb
# or: pnpm add @takazudo/zfb-runtime @takazudo/zfb

@takazudo/zfb is a peer dependency — createPageRouter shares module-level state (ContentSnapshot) with @takazudo/zfb/content, so both packages must resolve to the same instance.

What this package does

zfb's render pipeline goes:

user pages/ + content/ + layouts/ + components/
  → bundler (Rolldown by default)      // single ESM file, Worker entry
  → embedded V8 host                   // same WinterCG surface as CF Workers
  → @takazudo/zfb-runtime              // <-- this package
  → (request) => Promise<Response>     // Worker fetch handler

This package supplies the page-router factory the Worker entry calls. It is built on Hono but does not leak Hono types through the public surface — consumers import from src/index.ts, which re-exports the page-router types, the client-router and prefetch API, lifecycle event constants, and plugin types, none of which require Hono.

The runtime uses the owned @takazudo/zfb/zudo-react renderer. The page router imports its renderToString implementation directly, so consumers do not select or install a separate framework renderer.

Public API

createPageRouter is server-only — it builds a Hono app, so it lives at the @takazudo/zfb-runtime/server subpath. Keeping it out of the client-safe . barrel means an island importing the bare @takazudo/zfb-runtime never drags hono into a --platform=browser bundle (issue #1298). Its types remain importable from the root barrel (they carry no runtime).

import { createPageRouter } from "@takazudo/zfb-runtime/server";
import type {
  CreatePageRouterOptions,
  PageDefinition,
  PageModule,
  PageHeading,
  PageRouter,
  ContentSnapshot,
  EntrySnapshot,
} from "@takazudo/zfb-runtime";

Client-router / view-transitions / prefetch

import {
  ClientRouter,               // framework-agnostic <head> helper — mount it to opt a page into SPA navigation
  navigate,                   // imperative navigation
  supportsViewTransitions,    // browser capability check
  transitionEnabledOnThisPage, // reads zfb-view-transitions-enabled meta
  prefetch,                   // prefetch a URL on demand
  prefetchInit,               // bootstrap prefetch strategy (e.g. { prefetchAll: true })
  TRANSITION_BEFORE_PREPARATION,
  TRANSITION_AFTER_PREPARATION,
  TRANSITION_BEFORE_SWAP,
  TRANSITION_AFTER_SWAP,
  TRANSITION_PAGE_LOAD,
  TRANSITION_NAVIGATION_ABORTED,
  swapFunctions,              // swap step overrides for advanced consumers
  swap,
} from "@takazudo/zfb-runtime";

The ./snapshot and ./client-router subpath exports are also available for consumers that only need part of the surface:

import type { ContentSnapshot } from "@takazudo/zfb-runtime/snapshot";
import { ClientRouter } from "@takazudo/zfb-runtime/client-router";

The two differ in one way that matters: the root barrel is side-effect-free — importing anything from it, ClientRouter included, registers no listeners and touches no history — while evaluating the ./client-router subpath calls init() and activates the router on the current page. See Enabling SPA soft-navigation below for how a normal zfb build gets that activation for you.

Enabling SPA soft-navigation (the runtime ships automatically)

Mounting <ClientRouter /> in your layout <head> is normally all you need. The build detects the usage and automatically ships the client-router runtime to the browser — you do not have to add a manual "use client" bootstrap island.

import { ClientRouter } from "@takazudo/zfb-runtime";

export default function Layout({ children }) {
  return (
    <html>
      <head>
        <ClientRouter fallback="animate" />
      </head>
      <body>{children}</body>
    </html>
  );
}

ClientRouterProps:

  • fallback?: "none" | "animate" | "swap" controls the fallback strategy when native View Transitions are unavailable. The default is "animate".
  • prefetchAll?: boolean bootstraps same-origin link prefetching with the default hover strategy. Treat this as a site-wide router policy and mount the same value on every page that participates in SPA navigation.
  • preserveHtmlAttrs?: string[] emits <meta name="zfb-preserve-html-attrs"> so the swap step preserves runtime-owned <html> attributes such as data-theme. Mount the same list on every soft-navigable page: the router reads the outgoing page's meta before each swap, so a page that omits a name can drop that attribute when navigating away.
  • traverseRefetch?: boolean emits <meta name="zfb-traverse-refetch" content="true"> to opt out of the same-URL Back/Forward fast path for per-request SSR pages whose content can change at the same URL. Mount the same value on every page in that SPA navigation set so traversal behavior is deterministic.

How the runtime reaches the browser. <ClientRouter /> itself only renders SSR <head> tags, and the module it lives in is pure: importing ClientRouter from the @takazudo/zfb-runtime barrel registers nothing and runs no code on the page. Everything the router does at startup — seeding this page's history entry and scroll position, marking the scripts the initial load already ran, and registering the popstate / load / pageshow / scroll listeners plus the click and form-submit intercepts — happens inside a single init() call.

init() is invoked as a side effect when @takazudo/zfb-runtime/client-router is evaluated in the browser, guarded so it never runs during SSR:

// inside @takazudo/zfb-runtime — client-router.ts
if (typeof document !== "undefined") {
  init();
}

init() is idempotent: calling it again (a second <ClientRouter /> mount, an HMR re-run, a manual call) is a no-op for the parts already done.

To get that side-effect import into the client bundle, zfb's island scanner detects when a page transitively reaches <ClientRouter /> and injects import "@takazudo/zfb-runtime/client-router" into the islands asset (assets/islands.js) — even when the project has no "use client" islands of its own. The asset is then loaded on the page, the import runs, and init() fires on the client with zero boilerplate. (Auto-include added in zfb #289 / #307; the runtime is byte-for-byte absent from projects that never reach <ClientRouter />.)

What counts as a ClientRouter usage the scanner detects:

  • a named ClientRouter import from the @takazudo/zfb-runtime barrel — import { ClientRouter } from "@takazudo/zfb-runtime" (renamed forms like { ClientRouter as CR } match on the imported name), or
  • any import or re-export of the @takazudo/zfb-runtime/client-router subpath (e.g. when you only need navigate / prefetch), or
  • a named ClientRouter imported from a local barrel that re-exports the runtime via export * from "@takazudo/zfb-runtime".

The importing module has to be reachable from a page in pages/ — that is the import graph the scanner walks.

When you need a manual side-effect import. Detection keys off the import of ClientRouter, and a couple of shapes are deliberately not treated as a trigger — firing on them would ship the runtime to projects that only reference ClientRouter as a type or never call it:

  • a namespace import — import * as rt from "@takazudo/zfb-runtime" used as rt.ClientRouter, and
  • a type-only import — import type { ClientRouter } (or { type ClientRouter }).

When detection misses, nothing else picks up the slack: the barrel import is side-effect-free, so an undetected <ClientRouter /> renders its meta tags into <head> and no runtime is ever shipped to activate against them. If soft-navigation is not working because your reference takes one of these forms (or the mounting module is otherwise not reachable from a page), force the runtime in with an explicit side-effect import from a page-reachable "use client" island. The island renders nothing; running its bundle in the browser fires the same typeof document !== "undefined" guard (init() is idempotent, so reaching it again is a no-op):

// src/components/client-router-bootstrap.tsx
"use client";
import "@takazudo/zfb-runtime/client-router";

export default function ClientRouterBootstrap() {
  return null;
}

// Stable marker name so the SSR marker, the scanner record, and the hydration
// manifest agree under production minification.
ClientRouterBootstrap.displayName = "ClientRouterBootstrap";
import { Island } from "@takazudo/zfb";
import ClientRouterBootstrap from "@/components/client-router-bootstrap";

// Mount once near the end of <body>; when="load" registers the intercepts as
// soon as the islands runtime starts.
<Island when="load">
  <ClientRouterBootstrap />
</Island>;

This is the escape hatch, not the default — prefer a plain import { ClientRouter } from "@takazudo/zfb-runtime", which the scanner detects on its own. See the Client-Side Routing concept guide for the full API.

navigate() needs <ClientRouter /> mounted on the current page

The root barrel exports navigate and syncHistoryEntry, but not init, and importing from that barrel installs no interception listeners. Each helper still does exactly what its own docs say when you call it — a direct navigate() call navigates, syncHistoryEntry() writes its history entry — but nothing starts intercepting the user's link clicks and form submits on your behalf.

Soft navigation needs two separate things on the current page:

  1. The opt-in meta tag. navigate() reaches <meta name="zfb-view-transitions-enabled">, which <ClientRouter /> renders into <head>, before it will do a soft swap; with that tag absent it falls back to a full location.href load.
  2. An activated router. That comes from init() — in a normal zfb build, from the import "@takazudo/zfb-runtime/client-router" the island scanner injects once it sees a page reach <ClientRouter /> (see above).

So the answer for both is the same: mount <ClientRouter /> in the layout <head> of every page that should be soft-navigable, and let the build ship the runtime. init itself is exported from the @takazudo/zfb-runtime/client-router subpath, not the root barrel, for the rare case where you want to call it directly instead of relying on the subpath's own import-time activation.

Persisting elements and island state across navigations (data-zfb-transition-persist)

Add data-zfb-transition-persist="<id>" to an element (matched by the same id on both the outgoing and incoming page) to keep it alive across a soft navigation instead of letting it be discarded and re-created — the router lifts it out of the old body and reattaches it into the new one. This works on plain elements (<video>, <canvas>) and on island wrapper elements ([data-zfb-island]) alike; for an island, the live component instance (and its internal state) survives too, not just the DOM node.

<div data-zfb-island="SidebarTree" data-zfb-transition-persist="sidebar-tree" data-props={props}>
  <SidebarTree {...props} />
</div>

By default, a persisted island still gets its data-props refreshed to match the incoming page on every swap; if the refreshed props differ from what the live instance currently holds, the island is unmounted and remounted fresh with the new props (so it can't get stuck showing stale data), otherwise nothing happens and the instance's state carries over untouched. Set data-zfb-transition-persist-props to any value other than "false" (conventionally "true") to opt OUT of that props refresh and keep the island's current props/state exactly as they are, regardless of what the incoming page's props would have been — the attribute's absence, or the literal string "false", is what makes props refresh (mirrors Astro's data-astro-transition-persist-props). See the Client-Side Routing concept guide for the full walkthrough, including when to reach for the opt-out.

createPageRouter(options) → PageRouter

Build a fetch-handler that serves the supplied pages. The returned function is shape-compatible with a Worker default.fetch.

const router = createPageRouter({
  pages,            // PageDefinition[]
  contentSnapshot,  // ContentSnapshot embedded by the bundler
});

export default { fetch: router };

Side effects.

  1. Calls setContentSnapshot(contentSnapshot) on the zfb/content module so any user page importing getCollection(name) resolves from memory rather than the Node fs API. Workers have no fs, so this branch is the production path. Idempotent; subsequent calls overwrite (matches the dev-mode live-reload contract).
  2. Constructs an internal Hono app and registers app.all(page.route, …) for every entry in pages. The handler imports the page module, awaits the page function, renders its result with the owned renderer, and returns the string in a Response.

PageDefinition

interface PageDefinition {
  readonly route: string;                            // Hono path pattern
  readonly module: () => Promise<PageModule>;        // thunk for code-split friendliness
}

PageModule

The shape every page module must export:

interface PageModule {
  readonly default: (props: Record<string, unknown>) => unknown;
  readonly prerender?: boolean;          // literal `false` excludes from SSG
  readonly contentType?: string;         // overrides Content-Type (e.g. "application/xml")
  readonly headings?: readonly PageHeading[]; // MDX-emitted TOC data
  readonly paths?: () => unknown[] | Promise<unknown[]>; // enumerates concrete URLs for dynamic routes (SSG)
  readonly getStaticProps?: () => Promise<{ props: Record<string, unknown> }>; // fetches props at build/render time; result spread into default()
}

interface PageHeading {
  readonly depth: number;
  readonly slug: string;
  readonly text: string;
}

Default Content-Type is text/html; charset=utf-8.

ContentSnapshot / EntrySnapshot

Direct TypeScript mirror of the Rust contract in crates/zfb-content/src/content_bridge.rs. Field names are snake_case (module_specifier, rel_path) to match the JSON serialization.

interface EntrySnapshot {
  readonly slug: string;
  readonly frontmatter: unknown;        // null when source had none; getCollection normalises to {}
  readonly body: string;                // empty for .tsx entries
  readonly module_specifier: string;
  readonly rel_path: string;
}

interface ContentSnapshot {
  readonly collections: Readonly<Record<string, readonly EntrySnapshot[]>>;
}

The Rust side guarantees deterministic order (collections sorted by name, entries sorted by slug). The TS side does not re-sort — it preserves the order the bundle delivers, so the determinism story is "identical Rust input → identical bundle bytes → identical render output" without an extra sort step.

Bundle shape consumed by the embedded V8 host

zfb's embedded V8 host loads a single ESM Worker bundle produced by the bundler step. The bundle's entry point must look like this:

// dist/worker.mjs (shape — generated by the bundler, not committed)
import { createPageRouter } from "@takazudo/zfb-runtime/server";

import HomePage from "./pages/index.tsx";
import BlogPost from "./pages/blog/[slug].tsx";
// ... user content + layouts + components, bundled flat

const router = createPageRouter({
  pages: [
    { route: "/",            module: () => Promise.resolve({ default: HomePage }) },
    { route: "/blog/:slug",  module: () => Promise.resolve({ default: BlogPost }) },
    // ... one entry per route, expanded from `paths()` static evaluation
  ],
  contentSnapshot: {
    // Embedded JSON literal — the Rust bundler injects the snapshot via
    // an `import.meta.env`-style replacement or a top-level inline.
    collections: { /* ... */ },
  },
});

export default { fetch: router };

The host then drives the Worker by sending GET requests for each enumerated route and writing the response body to dist/{route}/index.html.

Contract this package commits to

  • createPageRouter is the single export the build host wires to.
  • The returned function is always (request: Request) => Promise<Response>, even if the underlying Hono path returns synchronously.
  • Content-Type defaults to text/html; charset=utf-8. Page modules with a contentType field override it.
  • Errors in page evaluation surface as 500 responses with a diagnostic text body; the host's source-map plumbing projects those back to the user's TSX line.
  • The ContentSnapshot registration is idempotent and observable via getContentSnapshot() re-exported from zfb/content. Dev-mode hosts can call setContentSnapshot(undefined) to clear between rebuilds if needed (today the runtime overwrites on each createPageRouter call, which is the documented happy path).

Local development

pnpm --filter @takazudo/zfb-runtime test
pnpm --filter @takazudo/zfb-runtime typecheck

Tests run in vitest under Node's node environment (no jsdom — the server-side router targets the Workers fetch model, which Node implements natively). Client-router DOM suites opt into @vitest-environment happy-dom per test file. The owned renderer is stubbed where router behavior is tested independently. Determinism is asserted by rendering twice from independently-constructed routers and comparing byte-equal.

The embedded V8 host is not booted from this package's tests — that integration belongs to the Rust-side build host. The end-to-end acceptance criterion ("Worker bundle returns correct HTML for each route") is exercised by the host crate's test suite, not here.

Why a peer dependency on zfb

createPageRouter calls setContentSnapshot from zfb/content. The two modules share module-level state, so they must resolve to the same instance — pinning zfb as a peer dep makes that explicit and lets pnpm hoist a single shared copy. Install matching published versions of @takazudo/zfb-runtime and @takazudo/zfb so that shared state remains single-instanced.