@zerotal/flow 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/package.json +84 -0
- package/src/Component.ts +1498 -0
- package/src/Form.ts +189 -0
- package/src/Layout.ts +47 -0
- package/src/augment.ts +94 -0
- package/src/client/FlowClientError.ts +12 -0
- package/src/client/FlowComponent.ts +96 -0
- package/src/client/alpinejs-morph.d.ts +5 -0
- package/src/client/bridge.ts +3463 -0
- package/src/client/confirm.ts +338 -0
- package/src/client/cspAdapter.ts +79 -0
- package/src/client/cspEvaluator.ts +494 -0
- package/src/client/errorOverlay.ts +124 -0
- package/src/client/headless.ts +948 -0
- package/src/client/index.csp.ts +47 -0
- package/src/client/index.ts +56 -0
- package/src/client/optimistic.ts +52 -0
- package/src/client/timeline.ts +160 -0
- package/src/client/url.ts +51 -0
- package/src/commands/MakeFlowCommand.ts +290 -0
- package/src/compiler/cache.ts +101 -0
- package/src/compiler/directives.ts +107 -0
- package/src/compiler/helpers.ts +26 -0
- package/src/compiler/index.ts +172 -0
- package/src/compiler/transform.ts +2070 -0
- package/src/components.ts +1465 -0
- package/src/config.ts +52 -0
- package/src/decorators.ts +978 -0
- package/src/dehydrate.ts +333 -0
- package/src/durable.ts +243 -0
- package/src/events.ts +118 -0
- package/src/frameworkEvents.ts +40 -0
- package/src/headless.ts +1147 -0
- package/src/index.ts +267 -0
- package/src/jsx-dev-runtime.ts +14 -0
- package/src/jsx-runtime.ts +1272 -0
- package/src/mixins.ts +199 -0
- package/src/observability.ts +167 -0
- package/src/pagination.ts +300 -0
- package/src/presence.ts +81 -0
- package/src/provider/FlowProvider.ts +1401 -0
- package/src/registry.ts +118 -0
- package/src/renderContext.ts +55 -0
- package/src/router.ts +401 -0
- package/src/shared.ts +226 -0
- package/src/store.ts +116 -0
- package/src/synths/CarbonIntervalSynth.ts +22 -0
- package/src/synths/CarbonSynth.ts +20 -0
- package/src/synths/CollectionSynth.ts +62 -0
- package/src/synths/DateSynth.ts +24 -0
- package/src/synths/ModelSynth.ts +78 -0
- package/src/synths/TemporaryUploadedFileSynth.ts +19 -0
- package/src/synths/index.ts +148 -0
- package/src/testing.ts +361 -0
- package/src/types.ts +315 -0
- package/src/uploads/FileUploads.ts +94 -0
- package/src/uploads/TemporaryUploadedFile.ts +274 -0
- package/src/uploads/sign.ts +18 -0
- package/src/urlSafety.ts +0 -0
- package/src/utils.ts +216 -0
- package/src/validation.ts +55 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Changelog â @zerotal/flow
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are documented here. The format is
|
|
4
|
+
based on [Keep a Changelog](https://keepachangelog.com/); this package
|
|
5
|
+
follows the Zerotal monorepo's unified versioning.
|
|
6
|
+
|
|
7
|
+
**Maturity: `stable`**
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **A session mutated inside a WebSocket action is now saved.** `SessionMiddleware` does not write its cookie inline — it registers a finalizer through `ctx.onResponseReady()` and saves from there, so a session set before a throw still reaches the client. The HTTP dispatcher ran those finalizers; the WebSocket dispatcher never did. Any action that changed the session therefore produced no `Set-Cookie`, so the session-relay frame carried no token and the change was lost the moment the browser navigated. Signing in was the loudest case — the login succeeded, redirected, and bounced straight back to the login screen — but flashes and `@session` props set during an action were dropped the same way. The WS dispatcher now runs `ctx._responseFinalizers` before reading `Set-Cookie`, matching the HTTP path including its log-and-continue on a failing finalizer.
|
|
14
|
+
- **Route middleware on a Flow page no longer crashes the action pipeline.** The WebSocket dispatcher passed the request's `ScopedResolver` where `Pipeline.via()` expects the container. A `ScopedResolver` has no `bound()`/`makeSync()`, so the moment a page's route middleware needed _resolving_ rather than plain construction, the action failed with `container.bound is not a function`. It now passes the container, as the HTTP router already did; the `as never` cast that hid the mismatch is gone.
|
|
15
|
+
|
|
16
|
+
## [1.0.0] — 2026-08-05
|
|
17
|
+
|
|
18
|
+
_First public release._
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **`make:flow` scaffolding command.** The flagship server-driven layer previously had no generator â every `Component` was hand-written (the audit's #1 DX gap). `bun zt make:flow Name` emits a ready-to-run class with the JSX pragma, imports, and an `@expose`/`render` skeleton: `--child` scaffolds a child component (with `setup(props)`), `--crud` a resourceful page (list + create/edit/delete + `@validate` + a form), and `--layout Foo` wraps a page in a layout (with the correct relative import depth for nested names like `Users/Index`). The target directory is auto-detected (`app/flow/pages` for pages, `app/flow/components` for children) or set with `--dir`; since Flow uses file-based routing the page is served automatically, and the command also prints the explicit `Router.flow(...)` line. Registered lazily by `FlowProvider` in console mode, so it's available in any app that registers the provider.
|
|
23
|
+
- **HTTP fallback for WebSocket-blocked networks (Tier 1).** A client circuit breaker: after `HTTP_FALLBACK_AFTER` (3) failed socket handshakes the bridge stops waiting on the WebSocket and sends action frames as plain `POST /__flow/http` requests, which run the _exact same_ server pipeline (hydrate â dispatch â render â patch) over a collecting fake connection and return the frames as a JSON array the client applies â so actions, validation, flashes, redirects, events, and offline-queue replay all keep working behind strict proxies/firewalls that drop WS upgrades, with no component change. It's a graceful degrade, not a configured mode: WS reconnection keeps running and the client upgrades back automatically the moment the socket succeeds (`onopen` resets the failure count and exits HTTP mode). `this.cancel()` POSTs an out-of-band `$cancel` in HTTP mode too. Trade-offs while degraded: no server-pushed frames (so `@task` arrives as one batched update, not token-by-token) and no broadcast-driven `@presence`/`@shared`/`@on("echo:â¦")` (those ride the separate broadcasting socket); everything driven by the user's own actions still works. Nothing is sent over HTTP until the socket has actually failed.
|
|
24
|
+
- **Reactive lists (`<For>`) + optimistic collections.** `<For each={this.items} keyBy="id">{(item) => â¦}</For>` compiles to an Alpine `<template x-for>`, so any client change to the array re-renders the list instantly (server patches still keep it authoritative) â the missing piece that made optimistic list ops impossible while `.map()` renders to static HTML. The item template supports element structure, static attrs, `class`/`className` (â `:class`), reactive attrs, `on*` arrow handlers (â Alpine `@event`, `this.`â`$flow.`), and `{item.field}` text (â `x-text`); anything else is a clear compile error pointing at a raw `x-for` escape hatch. On top of it, `this.appendOptimistic(prop, item)` / `this.removeOptimistic(prop, match)` mutate the array before the server confirms: the change shows at once, **survives interim (broadcast/event) patches** (pending ops are re-applied over each server merge), and is **reconciled when the owning action's patch resolves** â server list on success, automatic rollback on failure. Pure reconciliation core in `client/optimistic.ts`; new `For`/`ForProps` exports.
|
|
25
|
+
- **Dev error overlay.** Under `serve --dev`, an unexpected throw â in a server action **or** the initial `GET` render (`onMount`/`render`) â pops a full-screen overlay (Vite/Next style) with the error class, message, and stack, naming the action + component and dimming framework/`node_modules` frames so app frames stand out. Dismiss with Esc or a backdrop click; after an action error the patch still reconciles underneath, so you return to a live page (pairs with the time-travel timeline). The detail is attached **only under the dev worker** â production sends none (an action flashes; an initial-render error returns a normal 500). Only unexpected throws overlay: validation errors and **intended HTTP errors** (auth `401`/`403`, `404`, redirects â anything carrying a numeric `status`) pass straight through to their normal handling. New module `client/errorOverlay.ts`; the error frame gained optional dev-only `name`/`stack`/`action` fields; the initial-render error rides an embedded `flow-boot-error` script the bridge reads on load.
|
|
26
|
+
- **Type-safe real-time events (Tier 1).** An app-augmentable `FlowEvents` contract maps event names to payload types; `dispatch`/`dispatchTo`/`dispatchSelf` are now generic over it, so a known event's payload is type-checked at every dispatch site (server actions and client expressions alike) â wrong shape, missing payload, or a payload on a `void` event all fail to compile â while unknown names stay untyped (a `string` fallback keeps `echo:â¦` and gradual adoption compiling, no breakage). `@on` autocompletes to known names; annotate handlers with `EventPayload<K>`. No codegen. An optional runtime guard (`registerFlowEvent(name, guard)`) throws from `dispatch` on a malformed payload â for the untrusted client-originated path the types can't cover. New module `events.ts`; exports `registerFlowEvent`, `FlowEvents`, `EventName`, `EventPayload`.
|
|
27
|
+
|
|
28
|
+
- **`@presence` â multiplayer "who's here" (Tier 1).** Binds a property to a broadcast presence channel (static or a `(self) => string` resolver) and keeps it filled with the live member list, refreshed on join/leave via a new `$presence` built-in. The resolved channel is server-derived and carried signed in the snapshot; the prop is server-controlled (locked). Client-only cursor/typing whispers ride the same channel via `this.whisper(event, data)` / `this.onWhisper(event, cb)`. Broadcasting is an optional peer â resolved through a dynamic import, a no-op when absent â so flow takes no new hard dependency.
|
|
29
|
+
- **Time-travel devtools (Tier 1).** Under `serve --dev`, the client records the signed snapshot stream it already receives â one frame per applied patch plus the initial mount â into a capped ring buffer, and can scrub back to any frame: a jump re-applies that frame's snapshot + HTML to the live component (client-only, no server round-trip), restoring state and DOM exactly. It surfaces as a **Timeline tab in the `@zerotal/devtools` panel** (registered via that package's new `window.__zerotalDevtools` extension API) so all the framework's tooling is unified; when devtools isn't present it falls back to a standalone â± panel. Frames list by action + changed fields + time with click-to-jump and a âµ Resume-live button; a `window.__flow.timeline` console API (`frames`/`framesFor`/`jump`/`live`) mirrors it. Dev-gated via the `dev` flag on the WS `ready` frame â production ships none of it. The recording core is a DOM-free module (`client/timeline.ts`); the DOM apply step is injected from the bridge, and the patch morph was extracted to a shared `_morphComponent` reused by both a patch and a jump.
|
|
30
|
+
- **Durable / resumable snapshots (Tier 1).** Opt a component in with `static durable = true` (or `{ ttl, scope }`) and its signed snapshot is persisted server-side after every request, keyed by user (or session) + route. On a fresh GET a valid stored snapshot is restored and `onHydrate()` runs instead of `onMount()`, so an in-progress flow resumes exactly across a full reload, tab close/reopen, device switch, or reconnect after the held client snapshot is gone â nothing stored in the browser. HMAC verification + identity keying isolate users and reject tampered/rotated-key entries (graceful fresh mount). `this.clearDurable()` forgets the entry on flow completion. The store is pluggable via `setDurableStore()` (default in-process TTL store; swap a `@zerotal/cache`/Redis-backed `{ get, set, delete }` for redeploy survival). `scope: "user"` keys by the authenticated user (session fallback when anonymous); `scope: "session"` keys per-device.
|
|
31
|
+
- **`@task` â streaming, cancellable actions (Tier 1).** Mark an async method `@task` (implicitly `@expose`) and write a field in a loop â while the action runs the framework flushes throttled **field-level** snapshot diffs (the changed fields only, no HTML, no per-chunk re-render), so a reactive binding of the field (`text={this.answer}`, `x-text`, a reactive `:attr`) streams to the browser live as `this.answer += token` runs. The field stays the source of truth (no `flow:stream` element, no double-write); the final patch re-renders once to reconcile any static template positions. The triggering control keeps its loading state for the whole run (partial patches don't clear it; only the final patch does). `this.signal` (a standard `AbortSignal`) + `this.cancelled` give cooperative cancellation; the client's `this.cancel()` sends an out-of-band `$cancel` frame (bypassing the per-component send queue the running task occupies), which the server handles before dispatch to abort the task's controller. Implemented via a `partial` patch flag and a chained delta base (each streaming patch is delta-encoded against the previous one the client received).
|
|
32
|
+
- **`@shared` â convergent multiplayer state (Tier 1).** Binds a property to server-authoritative shared state on a channel: mutating it in an action writes to a per-channel **room store** and broadcasts to the channel, so every other subscriber re-reads and converges via a new `$shared` built-in. Before each action the prop is refilled from the store (read-latest); after, changed props are written back and broadcast (`toOthers`). The resolved channel is signed in the snapshot (`SnapshotMemo.shared`) and the prop is server-controlled (locked). The store is pluggable via `setSharedStore()` (default in-process; swap a Redis-backed `{ get, set, has }` for multi-instance). Same optional-peer broadcasting as `@presence` â without it, state still converges within a single window's round-trips. v1 semantics: last-write-wins.
|
|
33
|
+
- **Delta transport.** WebSocket patches carry only the changed snapshot fields (rebuilt client-side against the copy it holds, still HMAC-verified) via `encodeSnapshotDelta`/`applySnapshotDelta`, and omit `html` when the re-render is byte-identical to the last patch on that connection. `ZT_FLOW_TRANSPORT_LOG=1` logs delta-vs-full sizes.
|
|
34
|
+
- **JSX-native layouts.** Override `Component.layout(page)` to wrap the rendered root in any component â named regions are plain props. `static layout` still works; the method wins when both are present. Nav-persistence uses a `data-flow-layout` marker (declared on the shell, else derived from the wrapper source).
|
|
35
|
+
- **Named slots on child components.** `slots={{ ⦠}}` + `this.slot(name)` / `this.hasSlot(name)`; slot HTML is carried, signed, in the child snapshot (`SnapshotMemo.slots`).
|
|
36
|
+
- **Compiler prop parity.** `transition`, the `flow:offline` family (`showOnOffline`/`hideOnOffline`/`offlineClass`/`offlineAttr`), `sortItem`, and `loadingTarget`/`dirtyTarget` (`flow:target`) now compile to their directives; the AOT `directives.ts` map and the runtime `PULSE_PROP_MAP` were reconciled.
|
|
37
|
+
- **Stateful fast refresh in dev.** Editing a component under `serve --dev` now re-renders each mounted component from its held, still-valid snapshot with the newly-compiled code â **preserving state** â instead of a full reload, and refetches the stylesheet so new classes apply. Implemented as a `$rerender` built-in (a no-op re-render that skips `onMount`), gated to the dev worker via a `dev` flag on the WS `ready` frame, triggered by the flow WS reconnect; falls back to a one-shot full reload if the held snapshot is incompatible with the new code.
|
|
38
|
+
- **Tier 2 interaction polish.** `<Skeleton>` pulsing placeholder (+ `<Loading skeleton>` and lazy `placeholder()` use); **hover-prefetch** for `navigate hover` links (fetch-on-hover cache so the click swaps instantly â the prop existed but was unimplemented); and **optimistic-UI failed state** via `showOnError`/`hideOnError` (driven by an `actionError` flag the server sets when an action or a rejected `onUpdating` write throws). Rollback itself was already a property of snapshot reconciliation. `lazy`/`defer` are now typed on component JSX props.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- **Loading indicators wait out a short delay by default (no flash on fast actions).** `showOnLoading`, `hideOnLoading`, `loadingClass`, and `<Loading>` now reveal only after ~200ms in flight, so an action that finishes inside that window shows nothing â no spinner flash. `loadingAttr` (e.g. `loadingAttr="disabled"`) stays **immediate**, so a submit button still guards against a double-click even on a sub-100ms action. Previously only the opt-in `<Loading delay>` / `flow:loading.delay` was delayed; `delay` is now the default and kept for back-compat. Cleared instantly on resolve, so a just-over-threshold action barely blips.
|
|
43
|
+
- **`live` text inputs debounce their server sync (~150ms) by default.** A `live` text/textarea input no longer fires a WebSocket `$set` on every keystroke â it debounces ~150ms (matching Livewire's `wire:model.live`), so real-time validation and reactive server state update when you pause, not per character. Discrete controls (checkbox, radio, `<select>`, range) still sync immediately. The DOM and client-reactive bindings update on every keystroke regardless; only the server round-trip waits, and no value is ever lost (local state is current and any action flushes the full snapshot).
|
|
44
|
+
- **WebSocket actions serialize per component.** At most one action per component is in flight; the next waits for the previous patch to apply. Required by delta transport (deltas are order-dependent â a pipelined patch computed against a stale base would corrupt the client snapshot and trip the invalid-request rate limit). Offline replay runs through the same serial queue; a 15s ack timeout prevents wedging.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- **`@expose`/`@locked` on a getter now fails loudly.** A getter (a `@computed`, or any accessor) has no writable snapshot storage, so exposing or locking one would serialize a value the framework then tries to write back on the next update â clobbering the getter. TypeScript already rejects it (the decorators' Stage 3 context types exclude getters), but Bun transpiles without typechecking, so the decorator would still run and silently misregister. The `@expose`/`@locked`/`@url`/`@session`/`@transient`/`@reactive`/`@modelable` decorators now throw at class definition on a getter/setter/accessor, with a message pointing to `@computed` (for a derived value) or an `@expose` field (for client-writable state).
|
|
49
|
+
- **`@computed` getters compile in a text child.** The AOT compiler only knew about `@expose`/`@locked`, so `{this.uptime}` for a `@computed` getter failed validation and the whole page silently fell back to the runtime renderer. The compiler now recognises `@computed` (it sits on a getter, which the member scan previously skipped) and allows it wherever a text child emits a **static** value (`{this.x}`, `{String(this.x)}`) â matching how `@expose`/`@locked` text children already render (static, updates on the next server patch). Reactive sites still reject it correctly (a `@computed` isn't in the snapshot, so `text={this.x}` / reactive `:class` can't bind to it) â now with a message that says so and points to `{this.x}` or `@expose`. Also corrected the docs claim that `@computed` is "reactive on the client": it renders a static server-evaluated value, not a client-reactive binding.
|
|
50
|
+
- **Removed two dead binding-resolution defences.** The runtime resolver's "brute-force own-property scan" and the twin `hasOwnProperty(hint)` fallback existed to recover `@locked` props "whose decorator registration failed to land" â but `getExposedProps`/`getLockedProps` now drain the prototype chain first, so every decorated prop is reliably registered before the value-scan. Verified across the full suite + the showcase that the brute-force branch never fired; it could only ever match a _non-decorated_ field (a false positive), so removing it also removes that footgun.
|
|
51
|
+
- **Component bindings resolve statically (compile-time bind-name injection).** The AOT string compiler bails on any function component, so component-heavy pages render through the runtime `jsx()` path, where TSC's jsx transform has already discarded the `show`â`sheetOpen` propâkey mapping. That made value-based bind resolution fragile: a bound child clobbered the getter capture, and props sharing a value (two `false`s) couldn't be told apart, so an overlay-with-a-bound-form could render **unbound** (with only a `console.warn`). A new pass (`buildBindInjectedRender`) reprints a bailed page's `render()` with `__flowBinds={{ show: "sheetOpen" }}` added to every component that binds `show`/`bind`/`query` to `this.<key>`; the component reads it via `_injectedBindKey(props, attr)` (before `name=`/value resolution), so the bound prop is resolved from the source **statically and unambiguously**. The reprint is otherwise identical to the source (behaviour unchanged apart from robust binding), runs through the existing cache as a `.tsx` entry, and falls back to the untouched runtime render on any failure. Wired for every overlay (`Modal`/`Drawer`/`Dialog`/`Sheet`), form control (`Switch`/`Checkbox`/`Select`/`RadioGroup`/`Listbox`), and `Combobox` (`bind` + `query`). `__flowBinds` is stripped by `jsx()` and never emitted as an HTML attribute. (Applies to pages the compiler sees â file-based routes automatically; `Router.flow` pages once their `__sourceFile` is set.) Audit of the other seams: `_resolveValueBind` (`value=`/`checked=`) is already value-identity-guarded and only applies to leaf inputs (no children to clobber the capture); `error=`/`<ErrorMessage>` carries a self-describing sentinel â neither is ambiguous.
|
|
52
|
+
- **`text={this.x}` binds to its own property, not a sibling binding's.** On the runtime (non-AOT) render path, `_resolveBindName` trusted a single-slot getter capture â but every prop of a JSX element is evaluated before `jsx()` runs, so a later binding like `class={"row " + this.accent}` overwrote the capture that `text={this.count}` had set. The element then emitted `flow:text="accent"` and rendered the accent string in place of the count. The resolver now value-guards the capture (only trusts it when the captured property's current value is the one being resolved) and falls back to value-identity + the hint, so `text` (and `show`) always resolve to their own property regardless of other bindings on the same element. `value=`/`checked=` were already value-checked and unaffected.
|
|
53
|
+
- **`<Head>` content survives SPA navigation.** On a same-layout `navigate`, `_processHead` only ever re-read the live `document` â but the initial hoist removes each `<template data-flow-head>` from the DOM, and a persistent-layout swap carries only the page root (not the shell's `<Head>`). So the managed head was cleared and never restored: a stylesheet or fonts declared in a JSX-native layout's `<Head>` vanished on the first navigation (the page rendered unstyled), and page-level `<title>`/`<meta>` were dropped too. `navigate` now re-applies head from the **incoming** parsed document (which still carries every head template â shell stylesheet and page meta alike), deduping by identity so an unchanged stylesheet is left in place rather than removed-and-re-added (no unstyled flash between visits).
|
|
54
|
+
- **`lazy`/`defer` on a child component in JSX now actually defer.** `<Widget lazy />` passed `lazy` through as a component _prop_ instead of a `child()` option, so the child mounted eagerly (blocking on its `onMount`) and never showed its placeholder. The JSX runtime now routes `lazy`/`defer` to the child-loading options.
|
|
55
|
+
- **Browser Back to the first page did nothing.** SPA `navigate` only re-rendered on `popstate` when the history entry carried the `flowNavigate` marker, which a fresh page load's initial entry lacked â so pressing Back to the first visited page changed the URL but left the page in place. The initial entry is now stamped at startup.
|
|
56
|
+
- **`transition` prop.** Was typed but absent from `PULSE_PROP_MAP`, so it emitted an inert `transition` attribute instead of `flow:transition`.
|
|
57
|
+
- **`children`/`slots` on components type-check.** Added to `JSX.IntrinsicAttributes` so passing children or a `slots` prop to a `Component` no longer errors.
|
|
58
|
+
|
|
59
|
+
### Notes
|
|
60
|
+
|
|
61
|
+
- Conforms to the Zerotal package conventions (provider in `src/provider/`, PascalCase config factory, `ZerotalError`-based errors, test coverage).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zerotal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# @zerotal/flow
|
|
2
|
+
|
|
3
|
+
> Reactive server-side rendering for Zerotal — server-rendered components, streamed over WebSocket, morphed into the DOM by Alpine.
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
Part of the [Zerotal](../../README.md) framework. Requires **Bun ≥ 1.3.14**.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bun add @zerotal/flow
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
> **Why `typescript` is a production dependency.** Flow ahead-of-time compiles
|
|
18
|
+
> your `Component` classes' JSX into string-concatenation render functions **at
|
|
19
|
+
> application boot** (`FlowProvider.onStarting()`), using the TypeScript
|
|
20
|
+
> compiler API to parse each page's source. Zerotal packages ship TypeScript
|
|
21
|
+
> source rather than build output, so this compile step runs on the server that
|
|
22
|
+
> boots the app — the compiler is genuinely needed at runtime, not only at dev
|
|
23
|
+
> time.
|
|
24
|
+
|
|
25
|
+
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:
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import { FlowProvider } from "@zerotal/flow";
|
|
29
|
+
|
|
30
|
+
export default [
|
|
31
|
+
// …your other providers
|
|
32
|
+
FlowProvider,
|
|
33
|
+
];
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Set the JSX pragma per file with `/** @jsxImportSource @zerotal/flow */`, or globally in `tsconfig.json` via `"jsxImportSource": "@zerotal/flow"`.
|
|
37
|
+
|
|
38
|
+
## How it works
|
|
39
|
+
|
|
40
|
+
1. A `GET` request 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.
|
|
41
|
+
2. The browser upgrades to a WebSocket. User interactions (clicks, input changes, navigation) are sent as frames carrying the snapshot plus the action to invoke.
|
|
42
|
+
3. The server **verifies the signature**, hydrates the component from the snapshot, applies any client-written property values, and runs the invoked action method.
|
|
43
|
+
4. The component re-renders and the server **diffs** the new output against the old.
|
|
44
|
+
5. Only the changed DOM patches are streamed back and **morphed** into the page by `@alpinejs/morph`. A fresh snapshot ships with each patch.
|
|
45
|
+
|
|
46
|
+
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.
|
|
47
|
+
|
|
48
|
+
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.
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
/** @jsxImportSource @zerotal/flow */
|
|
54
|
+
import { Component, expose, validate } from "@zerotal/flow";
|
|
55
|
+
|
|
56
|
+
export class CounterPage extends Component {
|
|
57
|
+
@expose count: number = 0;
|
|
58
|
+
@expose @validate("required|min:2") name: string = "";
|
|
59
|
+
|
|
60
|
+
@expose increment(): void {
|
|
61
|
+
this.count++;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
override async render() {
|
|
65
|
+
return (
|
|
66
|
+
<div>
|
|
67
|
+
{/* Two-way bind an input by passing state to `value` */}
|
|
68
|
+
<input value={this.name} live placeholder="Your name" />
|
|
69
|
+
<span error={this.errors.name} class="text-red-500" />
|
|
70
|
+
|
|
71
|
+
<p>Count: {this.count}</p>
|
|
72
|
+
|
|
73
|
+
{/* Server action — round-trips and re-renders */}
|
|
74
|
+
<button onClick={this.increment} loadingAttr="disabled">
|
|
75
|
+
+
|
|
76
|
+
</button>
|
|
77
|
+
|
|
78
|
+
{/* Client expression — updates the DOM instantly, no round-trip */}
|
|
79
|
+
<button onClick={() => this.count--}>−</button>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Register the route with the `flow` router helper:
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
import { Router } from "@zerotal/core";
|
|
90
|
+
import { CounterPage } from "./CounterPage.tsx";
|
|
91
|
+
|
|
92
|
+
Router.flow("/counter", CounterPage);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
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.
|
|
96
|
+
|
|
97
|
+
## Decorators
|
|
98
|
+
|
|
99
|
+
All imported from `@zerotal/flow`.
|
|
100
|
+
|
|
101
|
+
| Decorator | Purpose |
|
|
102
|
+
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
|
103
|
+
| `@expose` | Two-way contract with the browser — on a property it syncs both ways; on a method it makes it callable over the WebSocket. |
|
|
104
|
+
| `@locked` | Pushed to the client for display only; survives round-trips in the snapshot but the client cannot mutate it. |
|
|
105
|
+
| `@validate("rule")` | Attaches validation rules to a property, picked up by `this.validate()`. |
|
|
106
|
+
| `@url` / `@url({ as, history })` | Syncs a property to the URL query string (custom name and history push supported). |
|
|
107
|
+
| `@session` | Persists a property in the HTTP session across page loads (not in the snapshot). |
|
|
108
|
+
| `@computed` | A getter derived from other state; not stored, memoized per render, reactive on the client. |
|
|
109
|
+
| `@transient` | Excluded from the snapshot; reset to its default on every round-trip. |
|
|
110
|
+
| `@renderless` | An exposed method that runs but skips the re-render cycle (downloads, side-effects). |
|
|
111
|
+
| `@on("event")` | Listens for cross-component events and real-time broadcasts (auto-exposed). |
|
|
112
|
+
| `@reactive` | A child prop the parent re-pushes whenever it changes, re-rendering the child. |
|
|
113
|
+
| `@modelable` | A two-way child prop — writes from the child flow back up to the parent. |
|
|
114
|
+
|
|
115
|
+
Action helpers available inside any exposed method include `this.flash()`, `this.redirect()`, `this.refresh()`, `this.title()`, `this.client()`, `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`.
|
|
116
|
+
|
|
117
|
+
## Built-in components
|
|
118
|
+
|
|
119
|
+
**UI components:** `Link`, `Head`, `Title`, `Persist`, `Modal`, `Drawer`, `Flash`, `Alert`, `Loading`, `Errors`, `ErrorMessage`, `Table`, `Pager`, `InfiniteScroll`, `Dropdown`, `Tabs`, `Tooltip`, `FileUpload`.
|
|
120
|
+
|
|
121
|
+
**Headless (unstyled, accessible) primitives:** `Switch`, `Checkbox`, `Select`, `RadioGroup`, `Listbox`, `Combobox`, `Disclosure`, `Accordion`, `Popover`, `Field`, `Label`, `Description`, `Fieldset`, `Legend`.
|
|
122
|
+
|
|
123
|
+
**Mixins & helpers:** compose features with `ComponentWith(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.
|
|
124
|
+
|
|
125
|
+
**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.
|
|
126
|
+
|
|
127
|
+
**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.
|
|
128
|
+
|
|
129
|
+
Bundled Alpine plugins are surfaced as props: `mask` (`@alpinejs/mask`), `trap` (`@alpinejs/focus`), `collapse` (`@alpinejs/collapse`), `anchor` (`@alpinejs/anchor`), and `$persist` (`@alpinejs/persist`).
|
|
130
|
+
|
|
131
|
+
## Subpath exports
|
|
132
|
+
|
|
133
|
+
| Subpath | Contents |
|
|
134
|
+
| -------------------------------- | ------------------------------------------------------------------------------------------ |
|
|
135
|
+
| `@zerotal/flow` | Full public API — `Component`, `Layout`, `Form`, decorators, built-in components, helpers. |
|
|
136
|
+
| `@zerotal/flow/jsx-runtime` | JSX runtime for production builds (`jsxImportSource`). |
|
|
137
|
+
| `@zerotal/flow/jsx-dev-runtime` | JSX runtime used in development. |
|
|
138
|
+
| `@zerotal/flow/compiler-helpers` | Runtime helpers the AOT compiler emits into compiled templates. |
|
|
139
|
+
| `@zerotal/flow/testing` | `FlowTest` — drive the full server pipeline in-process, no WebSocket needed. |
|
|
140
|
+
|
|
141
|
+
## Documentation
|
|
142
|
+
|
|
143
|
+
- [Overview](../../docs/flow/index.md)
|
|
144
|
+
- [Decorators](../../docs/flow/decorators.md)
|
|
145
|
+
- [Lifecycle Hooks](../../docs/flow/lifecycle.md)
|
|
146
|
+
- [Components](../../docs/flow/components.md)
|
|
147
|
+
- [Forms](../../docs/flow/forms.md)
|
|
148
|
+
- [Layouts & Composition](../../docs/flow/layouts.md)
|
package/package.json
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zerotal/flow",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"maturity": "experimental",
|
|
6
|
+
"private": false,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts",
|
|
12
|
+
"./jsx-runtime": "./src/jsx-runtime.ts",
|
|
13
|
+
"./jsx-dev-runtime": "./src/jsx-dev-runtime.ts",
|
|
14
|
+
"./compiler-helpers": "./src/compiler/helpers.ts",
|
|
15
|
+
"./store": "./src/store.ts",
|
|
16
|
+
"./testing": "./src/testing.ts"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"CHANGELOG.md",
|
|
20
|
+
"src",
|
|
21
|
+
"!src/**/*.test.ts",
|
|
22
|
+
"!src/**/*.test.tsx",
|
|
23
|
+
"!src/**/*.spec.ts",
|
|
24
|
+
"!src/**/__fixtures__/**"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"bun": ">=1.3.14"
|
|
31
|
+
},
|
|
32
|
+
"sideEffects": [
|
|
33
|
+
"./src/augment.ts",
|
|
34
|
+
"./src/synths/*.ts"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "bun test",
|
|
38
|
+
"typecheck": "tsc --noEmit"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@alpinejs/anchor": "^3.14.9",
|
|
42
|
+
"@alpinejs/collapse": "^3.14.9",
|
|
43
|
+
"@alpinejs/focus": "^3.14.9",
|
|
44
|
+
"@alpinejs/mask": "^3.14.9",
|
|
45
|
+
"@alpinejs/morph": "^3.14.9",
|
|
46
|
+
"@alpinejs/persist": "^3.14.9",
|
|
47
|
+
"@alpinejs/resize": "^3.14.9",
|
|
48
|
+
"@zerotal/core": "1.0.0",
|
|
49
|
+
"@zerotal/orm": "1.0.0",
|
|
50
|
+
"@zerotal/validator": "1.0.0",
|
|
51
|
+
"alpinejs": "^3.14.9",
|
|
52
|
+
"typescript": "^5.8.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@zerotal/broadcasting": "1.0.0",
|
|
56
|
+
"@types/alpinejs": "^3.13.10",
|
|
57
|
+
"@types/alpinejs__anchor": "^3.13.1",
|
|
58
|
+
"@types/alpinejs__collapse": "^3.13.4",
|
|
59
|
+
"@types/alpinejs__focus": "^3.13.4",
|
|
60
|
+
"@types/alpinejs__mask": "^3.13.4",
|
|
61
|
+
"@types/alpinejs__persist": "^3.13.4",
|
|
62
|
+
"@types/alpinejs__resize": "^3.14.0",
|
|
63
|
+
"bun-types": "^1.3.14"
|
|
64
|
+
},
|
|
65
|
+
"description": "Flow — server-driven reactive UI for Zerotal with signed snapshots, delta transport, and real-time primitives.",
|
|
66
|
+
"keywords": [
|
|
67
|
+
"zerotal",
|
|
68
|
+
"bun",
|
|
69
|
+
"typescript",
|
|
70
|
+
"framework",
|
|
71
|
+
"reactive",
|
|
72
|
+
"ssr",
|
|
73
|
+
"websocket",
|
|
74
|
+
"livewire",
|
|
75
|
+
"server-driven"
|
|
76
|
+
],
|
|
77
|
+
"repository": {
|
|
78
|
+
"type": "git",
|
|
79
|
+
"url": "git+https://github.com/zerotaldev/zerotal.git",
|
|
80
|
+
"directory": "packages/flow"
|
|
81
|
+
},
|
|
82
|
+
"homepage": "https://github.com/zerotaldev/zerotal/tree/main/packages/flow#readme",
|
|
83
|
+
"bugs": "https://github.com/zerotaldev/zerotal/issues"
|
|
84
|
+
}
|