@zerotal/flow
v1.7.4
Published
Flow (experimental) — server-driven reactive UI for Zerotal with signed snapshots, delta transport, and real-time primitives.
Maintainers
Readme
@zerotal/flow
Reactive server-side rendering for Zerotal — server-rendered components, streamed over WebSocket, morphed into the DOM by Alpine.
Maturity: stable. Flow's public API follows SemVer strictly: anything importable without an @internal marker keeps its shape for the rest of the 1.x line, and its surface is snapshotted in api-surface.md, which CI diffs on every change. The WebSocket bridge is covered end to end by a real browser (@zerotal/flow/browser), and the compiled and runtime render paths are pinned to the same binding output. It carries less production exposure than the core and ORM layers, so read the release notes — but the API will not move under you.
Flow is Zerotal's server-driven UI layer. Each page is a TypeScript Component class whose state lives on the server. You write plain JSX and bind handlers like onClick={this.save}; on each interaction the server hydrates the component from a signed snapshot, runs the method, re-renders, and streams only the changed HTML back, which Alpine.js morphs into the page. There is no API layer, no client-side store, and no hand-written client reactivity — the server is the single source of truth.
Flow began life as Geleza — isiZulu for "to flow" — and the name turned out to describe the architecture better than anything we could invent. Settling on English package names, we translated it rather than replaced it.
Part of the Zerotal framework. Requires Bun ≥ 1.3.14.
Installation
bun add @zerotal/flowWhy
typescriptis a production dependency. Flow ahead-of-time compiles yourComponentclasses' JSX into string-concatenation render functions at application boot (FlowProvider.onStarting()), using the TypeScript compiler API to parse each page's source. Zerotal packages ship TypeScript source rather than build output, so this compile step runs on the server that boots the app — the compiler is genuinely needed at runtime, not only at dev time.
Register FlowProvider in bootstrap/providers.ts. It mounts the WebSocket handler at /__flow, builds the client runtime (Alpine + the Flow bridge), and injects it on every page response:
import { FlowProvider } from "@zerotal/flow";
export default [
// …your other providers
FlowProvider,
];Set the JSX pragma per file with /** @jsxImportSource @zerotal/flow */, or globally in tsconfig.json via "jsxImportSource": "@zerotal/flow".
How it works
- A
GETrequest renders the page on the server, serialises its state into a signed snapshot embedded in the HTML, and streams the result. The browser restores the page from the snapshot. - The browser upgrades to a WebSocket. User interactions (clicks, input changes, navigation) are sent as frames carrying the snapshot plus the action to invoke.
- The server verifies the signature, hydrates the component from the snapshot, applies any client-written property values, and runs the invoked action method.
- The component re-renders and the server diffs the new output against the old.
- Only the changed DOM patches are streamed back and morphed into the page by
@alpinejs/morph. A fresh snapshot ships with each patch.
Your component code therefore runs exclusively on the server: no bundler step for application logic, no client hydration mismatch, and the browser never receives your business logic.
A named method reference (onClick={this.save}) is always a server action — it round-trips and re-renders. An arrow function (onClick={() => this.count++}) is always a client expression — it updates the DOM instantly with no round-trip, and the new value is flushed to the server with your next action.
Usage
/** @jsxImportSource @zerotal/flow */
import { Component, expose, validate } from "@zerotal/flow";
export class CounterPage extends Component {
@expose count: number = 0;
@expose @validate("required|min:2") name: string = "";
@expose increment(): void {
this.count++;
}
override async render() {
return (
<div>
{/* Two-way bind an input by passing state to `value` */}
<input value={this.name} live placeholder="Your name" />
<span error={this.errors.name} class="text-red-500" />
<p>Count: {this.count}</p>
{/* Server action — round-trips and re-renders */}
<button onClick={this.increment} loadingAttr="disabled">
+
</button>
{/* Client expression — updates the DOM instantly, no round-trip */}
<button onClick={() => this.count--}>−</button>
</div>
);
}
}Register the route with the flow router helper:
import { Router } from "@zerotal/core";
import { CounterPage } from "./CounterPage.tsx";
Router.flow("/counter", CounterPage);Bindings the compiler understands directly: value={this.x} / checked={this.x} (two-way for @expose, read-only for @locked), error={this.errors.field} (reactive validation message), reactive class/style/href, plus first-class interaction props such as live, blur, loadingAttr, show, confirm, navigate, and poll. There is no flow() wrapper or this.bind(...) in component templates — the compiler reads each prop and emits the right directive for you. When you need raw Alpine, the client state is exposed as the $flow magic.
Decorators
All imported from @zerotal/flow.
| Decorator | Purpose |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| @expose | Two-way contract with the browser — on a property it syncs both ways; on a method it makes it callable over the WebSocket. |
| @locked | Pushed to the client for display only; survives round-trips in the snapshot but the client cannot mutate it. |
| @validate("rule") | Attaches validation rules to a property, picked up by this.validate(). |
| @url / @url({ as, history }) | Syncs a property to the URL query string (custom name and history push supported). |
| @session | Persists a property in the HTTP session across page loads (not in the snapshot). |
| @computed | A getter derived from other state; not stored, memoized per render, reactive on the client. |
| @transient | Excluded from the snapshot; reset to its default on every round-trip. |
| @renderless | An exposed method that runs but skips the re-render cycle (downloads, side-effects). |
| @on("event") | Listens for cross-component events and real-time broadcasts (auto-exposed). |
| @reactive | A child prop the parent re-pushes whenever it changes, re-rendering the child. |
| @modelable | A two-way child prop — writes from the child flow back up to the parent. |
Action helpers available inside any exposed method include this.flash(), this.redirect(), this.refresh(), this.$`…` , this.download(), this.dispatch(), this.stream(), and the this.errors proxy (has, any, addError, resetValidation). Lifecycle hooks: setup, onBoot, onMount, onHydrate, onUpdating/onUpdated, onUpdate, onRendering, onRendered, onDehydrate, onError.
Built-in components
UI components: Link, Head, Title, Persist, Modal, Drawer, Flash, Alert, Loading, Errors, ErrorMessage, Table, Pager, InfiniteScroll, Dropdown, Tabs, Tooltip, FileUpload.
Headless (unstyled, accessible) primitives: Switch, Checkbox, Select, RadioGroup, Listbox, Combobox, Disclosure, Accordion, Popover, Field, Label, Description, Fieldset, Legend.
Mixins & helpers: compose features with Component.using(Pagination, FileUploads) — the Pagination mixin (page state + nextPage/gotoPage/…, paired with Model.paginate() and rendered by <Pager>) and the FileUploads mixin (removeUpload, paired with <FileUpload> + TemporaryUploadedFile). Also Form + registerForm, the paginate helper + Paginator type, and Layout for shared page shells.
Slots: a child component's plain children are its default slot; a slots={{ header, footer }} prop supplies named slots. The child places them with this.slot(name) / this.slot() and branches on this.hasSlot(name) — ideal for reusable shells (cards, modals, panels). Slot markup renders in the parent's scope and is carried, signed, in the child's snapshot.
Layouts: attach a shell with static layout = AppLayout (a Layout subclass), or the JSX-native override layout(page) hook — wrap the rendered page in any component and pass named regions as ordinary props (<AppLayout title={…}>{page}</AppLayout>), matching the framework's React/Inertia Page.layout convention. Give the shell root data-flow-layout="…" so navigate keeps it mounted across visits.
Bundled Alpine plugins are surfaced as props: mask (@alpinejs/mask), trap (@alpinejs/focus), collapse (@alpinejs/collapse), anchor (@alpinejs/anchor), and $persist (@alpinejs/persist).
Subpath exports
| Subpath | Contents |
| -------------------------------- | ------------------------------------------------------------------------------------------ |
| @zerotal/flow | Full public API — Component, Layout, Form, decorators, built-in components, helpers. |
| @zerotal/flow/jsx-runtime | JSX runtime for production builds (jsxImportSource). |
| @zerotal/flow/jsx-dev-runtime | JSX runtime used in development. |
| @zerotal/flow/compiler-helpers | Runtime helpers the AOT compiler emits into compiled templates. |
| @zerotal/flow/testing | FlowTest — drive the full server pipeline in-process, no WebSocket needed. |
