import type { HtmlNode } from "./jsx-runtime.ts"; import type { FlashLevel, FlashMessage, FlashPosition, FlashAction, FlashActionStyle, FlashCallback, } from "./types.ts"; import { buildUrlWithQuery, type CurrentUrlOptions } from "./client/url.ts"; import { getExposedProps, getLockedProps, getValidateRules, getReactiveProps, getModelableProps, } from "./decorators.ts"; import { _renderFlowPage } from "./jsx-runtime.ts"; import { getStreamStore, queueStream } from "./streaming.ts"; import { RuleBuilder, runValidationAsync } from "@zerotal/validator"; import type { Schema, FieldRuleDefinition } from "@zerotal/validator"; import type { ValidateBuilder } from "./decorators.ts"; import { ValidationError } from "./validation.ts"; import type { ValidationRules } from "./validation.ts"; import { _applyWireValues, _isRegisteredModel } from "./synths/ModelSynth.ts"; import { resolveUploadValue } from "./uploads/TemporaryUploadedFile.ts"; import { toScriptJson } from "./utils.ts"; import { _compose } from "./mixins.ts"; import type { Compose } from "./mixins.ts"; import { _validateEventPayload } from "./events.ts"; import type { FlowEvents, EventName, EventArgs } from "./events.ts"; import { route, request, safeRedirectPath, HttpContext } from "@zerotal/core"; import type { RouteParamValues, RouteParamsArg, RouteTarget } from "@zerotal/core"; import { isProdLike, deployEnv } from "@zerotal/core"; /** * Convert the validator's one-message-per-field result (`Record`) into * Flow's error bag (`Record`), which the `$errors` proxy + client expect. */ function _toErrorBag(errors: Record): Record { const out: Record = {}; for (const [field, msg] of Object.entries(errors)) out[field] = [msg]; return out; } /** A signal that never aborts — returned by `Component.signal` outside a running `@task`. */ const _NEVER_ABORT: AbortSignal = new AbortController().signal; // ── ErrorField sentinel ────────────────────────────────────────────────────── /** * Sentinel returned by the {@link Component.errors} proxy when a field is accessed as a property * (`this.errors.email`). The JSX compiler detects `__isErrorField` and emits `flow:error` + * `flow:show`, so `error={this.errors.email}` wires up display of that field's first message. */ export class ErrorField { readonly __isErrorField = true as const; readonly __field: string; readonly __value: string; constructor(field: string, messages: string[]) { this.__field = field; this.__value = messages[0] ?? ""; } } // ── ErrorsProxy ─────────────────────────────────────────────────────────────── // Returned by Component.errors. Provides: // this.errors.has("field") → boolean // this.errors.field → ErrorField (for error={this.errors.field}) /** @internal */ export interface ErrorsProxy { /** True when the given field has at least one error. */ has(field: string): boolean; /** True when any field has an error. */ any(): boolean; /** * Add one or more validation errors manually. * * @example * this.errors.add("email", "Invalid email or password."); * this.errors.add({ email: "Invalid email or password." }); * this.errors.add({ password: ["Too short", "Needs a number"] }); */ add(field: string, message: string): void; add(errors: Record): void; /** Clear errors — all fields, or just the named one. */ clear(field?: string): void; // Field access (`this.errors.email`) returns an ErrorField for `error={…}`. [field: string]: ErrorField | ((...args: never[]) => unknown); } interface ErrorsProxyHooks { read(): Record; add(field: string, message: string): void; clear(field?: string): void; } function _makeErrorsProxy(hooks: ErrorsProxyHooks): ErrorsProxy { return new Proxy({} as ErrorsProxy, { get(_t, prop: string | symbol) { if (typeof prop !== "string") return undefined; if (prop === "has") { return (field: string) => { const msgs = hooks.read()[field]; return Array.isArray(msgs) && msgs.length > 0; }; } if (prop === "any") { return () => { const errors = hooks.read(); return Object.keys(errors).some( (key) => Array.isArray(errors[key]) && errors[key].length > 0, ); }; } if (prop === "add") { return (fieldOrMap: string | Record, message?: string) => { if (typeof fieldOrMap === "string") { hooks.add(fieldOrMap, message ?? ""); return; } for (const [field, msg] of Object.entries(fieldOrMap)) { for (const m of Array.isArray(msg) ? msg : [msg]) hooks.add(field, m); } }; } if (prop === "clear") { return (field?: string) => hooks.clear(field); } const msgs = hooks.read()[prop] ?? []; return new ErrorField(prop, msgs); }, }); } /** Escape a JSON string for embedding inside a single-quoted HTML attribute. * The browser decodes the entities back to valid JSON in `el.dataset`. */ function _attrJson(s: string): string { return s.replace(/&/g, "&").replace(/'/g, "'").replace(/) .sort() .map((k) => `${JSON.stringify(k)}:${_stableJson((value as Record)[k])}`); return `{${entries.join(",")}}`; } /** * FNV-1a, 32-bit, as fixed-width hex. * * Fixed width is the point: the occurrence counter is appended straight after * it, so a variable-length hash would make `1` and `10` ambiguous. * Not cryptographic — a collision costs two children sharing an id, the same * cost as the occurrence index they replace. */ function _fnv1a(input: string): string { let hash = 0x811c9dc5; for (let i = 0; i < input.length; i++) { hash ^= input.charCodeAt(i); hash = Math.imul(hash, 0x01000193) >>> 0; } return hash.toString(16).padStart(8, "0"); } function _isProduction(): boolean { // `deployEnv()`, not `APP_ENV` — the latter holds the runtime mode once the app // has booted, so this answered "no" on every production server. return isProdLike(deployEnv()) || Bun.env["NODE_ENV"] === "production"; } /** Classes already warned about, so a hundred-row list logs once. */ /** * Encode one interpolated value as a JS literal for a client expression. * * The scripts are evaluated with `Alpine.evaluate(rootEl, script)`, so each value has to * arrive as source text. JSON is a subset of JS expression syntax for every shape that * survives `JSON.stringify`, which is what makes this exact rather than approximate — and it * is what removes the old `@security` note from `client()`, where the caller built the string * and owned the escaping. * * The cases JSON alone gets wrong are handled explicitly: `undefined` and functions stringify * to nothing, `NaN` / `Infinity` become `null`, and `BigInt` throws. */ function _encodeClientValue(value: unknown): string { if (value === undefined) return "undefined"; if (typeof value === "bigint") return `${value}n`; if (typeof value === "number" && !Number.isFinite(value)) return String(value); const json = JSON.stringify(value); if (json === undefined) return "undefined"; // functions, symbols // U+2028/U+2029 are legal inside a JSON string and legal in a JS string literal only since // ES2019. Escaping them costs nothing and keeps the output safe in any JS context. return json.replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029"); } const _warnedKeyless = new Set(); function _warnKeylessChild(className: string): void { if (_warnedKeyless.has(className)) return; _warnedKeyless.add(className); console.warn( `[Zerotal Flow] <${className}> is rendered more than once with identical props and no \`key\`. ` + `Two children with the same id share one island: the second inherits the first's DOM and ` + `state. Pass a key tied to the row's own identity — child(${className}, { key: item.id }).`, ); } /** Reset the once-per-class warning set. Tests. @internal */ export function _resetKeylessWarnings(): void { _warnedKeyless.clear(); } /** * The id suffix for a child rendered without an explicit `key`. * * ``. The hash is a fixed * eight characters so the counter appended after it is unambiguous, and it is * computed from the props that *identify* the child rather than the ones that * update it — @reactive and @modelable props exist to change without remounting, * and folding them in would trade a shifted-list bug for a child that resets on * every parent update. * * A module function rather than a method: `Component` is extended by mixins that * export anonymous classes, and a `private` member on one of those is a * declaration error (TS4094). * * @internal */ function _autoChildKey( childIds: readonly string[], base: string, props: Record, reactiveKeys: ReadonlySet, modelableKeys: ReadonlySet, className: string, ): string { const identity: Record = {}; for (const key of Object.keys(props)) { if (reactiveKeys.has(key) || modelableKeys.has(key)) continue; identity[key] = props[key]; } const hash = _fnv1a(_stableJson(identity)); const prefix = `${base}${hash}`; const occurrence = childIds.filter((id) => id.startsWith(prefix)).length; // Only the genuinely ambiguous case warns. A keyless child whose props differ // from its siblings is now content-addressed and safe, so warning there would // be noise; two siblings the framework cannot tell apart is the residual risk. if (occurrence > 0 && !_isProduction()) _warnKeylessChild(className); return `${hash}${occurrence}`; } // ── Internal effect bags ────────────────────────────────────────────────────── /** * The bag of side effects an action queued (flashes, a redirect, client scripts, events, * downloads, a title, and the current error bag). Drained by {@link Component._drainEffects} * and read by the WebSocket handler to build the response frames sent to the client. * * @internal */ export interface FlowEffects { flashes: FlashMessage[]; redirectUrl: string | null; shouldRefresh: boolean; scripts: string[]; errors: Record; events: Array<{ name: string; data: Record; to?: string; self?: boolean }>; downloads: Array<{ filename: string; content: string; mime: string }>; title: string | null; } // ── Redirect builder ─────────────────────────────────────────────────────────── /** * Builder returned by {@link Component.redirect} (and `redirectRoute`/`redirectIntended`) so a * flash can be chained onto the redirect, mirroring the HTTP ResponseBuilder API. * * @example * ```ts * return this.redirect("/", 303).withSuccess("Welcome back."); * ``` */ export interface RedirectFlash { withSuccess(message: string): this; withError(message: string): this; withInfo(message: string): this; withWarning(message: string): this; /** Flash with an explicit level. */ with(message: string, level: FlashLevel): this; } /** * Options accepted by `this.flash(message, options)` — the object form of the * fluent builder, for when config is computed dynamically. `type` is an alias * for `level` (Tailwind/Notyf parlance). */ export interface FlashOptions { level?: FlashLevel | undefined; /** Alias for `level`. */ type?: FlashLevel | undefined; title?: string | undefined; position?: FlashPosition | undefined; /** Auto-dismiss after N ms. `0` keeps the toast until dismissed. */ duration?: number | undefined; dismissible?: boolean | undefined; /** Custom icon (emoji/text), or `false` to hide the status icon. */ icon?: string | false | undefined; progressBar?: boolean | undefined; /** Action buttons that each invoke a `@expose` method on click. */ actions?: FlashAction[] | undefined; /** A `@expose` method invoked when the toast is dismissed. */ onClose?: FlashCallback | undefined; } /** * Fluent toast builder returned by `this.flash(...)`. Each setter mutates the * queued flash in place and returns the builder, so calls chain: * * @example * this.flash("Post deleted") * .title("Removed") * .warning() * .position("top-center") * .duration(8000) * .progressBar(); */ export interface FlashBuilder { /** Set the status level explicitly. */ level(level: FlashLevel): this; success(): this; error(): this; warning(): this; info(): this; /** Bold header above the message. */ title(title: string): this; /** Anchor this toast at a screen corner (overrides the `` default). */ position(position: FlashPosition): this; /** Auto-dismiss after `ms`. Pass `0` to keep it until the user dismisses it. */ duration(ms: number): this; /** Keep the toast until dismissed (equivalent to `duration(0)`). */ noAutoDismiss(): this; /** Show/hide the close (×) button. Defaults to `true`. */ dismissible(value?: boolean): this; /** Override the status icon with emoji/text, or pass `false` to hide it. */ icon(icon: string | false): this; /** Show a countdown progress bar. Defaults to `true`. */ progressBar(value?: boolean): this; /** * Add an action button that invokes a `@expose` method on click (then dismisses). * Call it more than once to render multiple buttons. `method` is the method name; * `args` are forwarded to it; `style` is a color shorthand or a {@link FlashActionStyle}. * * @example * this.flash("Post deleted") * .action("Undo", "restorePost", [postId], "info") * .action("Delete forever", "purge", [postId], { color: "error", variant: "solid", uppercase: true }); */ action(label: string, method: string, args?: unknown[], style?: string | FlashActionStyle): this; /** * Invoke a `@expose` method when the toast is dismissed (timeout, ×, or action). * * @example this.flash("Heads up").noAutoDismiss().onClose("acknowledge"); */ onClose(method: string, args?: unknown[]): this; } // ── Component ────────────────────────────────────────────────────────────────────── /** * Copy a parent's `props` onto a freshly constructed child. * * Underscore-prefixed keys are skipped: those are the framework's own fields * (`_flowId`, `_flowPath`, `_childIds`) and a parent overwriting one would * detach the child from its own identity. * * The cast is the boundary. `Partial` is a checked contract at the call site; * inside, assignment is by string key and no index signature exists to express * that. Written once here rather than at each of the four render paths, which is * both less duplication and three fewer casts. */ function _assignChildProps(target: object, props: Record): void { const bag = target as Record; for (const [key, value] of Object.entries(props)) { if (!key.startsWith("_")) bag[key] = value; } } /** * Base class for Flow pages — reactive server-side components rendered over a WebSocket. * * @remarks * A Flow component's state lives on the **server**. You subclass `Component`, hold state in * fields, and implement {@link render} to return an {@link HtmlNode} (authored in `.tsx` with * `jsxImportSource: "@zerotal/flow"`). The root element must carry `data-flow-root`. * * Only fields and methods the component opts into are reachable from the browser: * - `@expose` marks a field as reactive (streamed to the client, writable via `flow:model` / * `$flow.$set`) or a method as a callable **action** (invoked over the socket, e.g. `flow:click`). * - `@locked` sends a field to the client read-only — it round-trips in the snapshot but the * client cannot mutate it (enforced in {@link $set}). * * Between requests, the component is serialised into an **HMAC-signed snapshot** and rehydrated * on the next round-trip, so tampering with client-held state is rejected. When an action runs, * the server re-renders and streams a DOM diff that the browser applies via Alpine's morph — * only the changed HTML crosses the wire. * * Lifecycle hooks fire in order around each request: {@link onBoot} → ({@link onMount} on the * initial GET / {@link onHydrate} on round-trips) → property updates → the action → * {@link onUpdate} → {@link onRendering} → {@link render} → {@link onRendered} → * {@link onDehydrate}. Actions may emit side effects — flashes, redirects, events, downloads, * client scripts — which are drained after the action and folded into the response frame. * * @example * A counter with an exposed field and an action, bound reactively: * ```tsx * import { Component, expose } from "@zerotal/flow"; * * export class Counter extends Component { * @expose count = 0; * * @expose increment() { * this.count++; * } * * override async render() { * return ( *
*

{this.count}

* *
* ); * } * } * ``` * * @example * A form-style component with validation, a flash, and a redirect from an action: * ```tsx * import { Component, expose, locked } from "@zerotal/flow"; * * export class ProfilePage extends Component { * @locked userId = 0; * @expose name = ""; * * @expose async save() { * await this.validate({ name: (rule) => rule.required().min(2) }); * await User.update(this.userId, { name: this.name }); * return this.redirect("/dashboard").withSuccess("Profile saved."); * } * * override async render() { * return ( *
* * {this.errors.has("name") && {this.errors.name}} * *
* ); * } * } * ``` */ export abstract class Component { /** * Brand identifying Flow components — used for file-route detection. * @internal */ static readonly __isFlowPage = true as const; /** * Compose one or more feature mixins onto this class, folding them left-to-right, so a page can * be built from focused, reusable behaviours instead of one bloated class. * * @remarks * Each mixin receives the accumulated base and returns an extended class, so `Component`'s full * surface (`flash()`, `redirect()`, `validate()`, the client magics, …) and every mixin's * `@expose`/`@locked` members flow through to the final page — fully type-checked. Mixin props * register on the mixin prototype, which sits in the page's prototype chain, so snapshot, * reactivity, client writes, and `@url` sync all work exactly as for props declared on the page. * * `using` composes onto whatever class it is called on, so it also works on an intermediate base * (`AdminPage.using(Pagination)` keeps `AdminPage` in the chain), and the composed class carries * `using` itself, so `Component.using(a, b).using(c)` chains past the 8-mixin overload set. * * Pages composed this way render via the runtime path rather than the AOT compiler (which only * statically sees a page's own `extends Component` plus locally-declared members) — the same * fallback complex pages already use; behaviour is identical, you just don't get the * ahead-of-time compile step for that page. * * @param mixins - Mixin factories applied left-to-right (each takes the prior result as its base). * @returns A class extending this one with every mixin's members mixed in. * * @example * ```tsx * // app/flow/mixins/pagination.ts * import { Component, expose, type Constructor } from "@zerotal/flow"; * * export function Pagination>(Base: T) { * abstract class WithPagination extends Base { * @expose page = 1; * @expose next() { this.page++; } * } * return WithPagination; * } * * // app/flow/UsersPage.tsx * class UsersPage extends Component.using(Pagination) { * override async render() { * return
Page {this.page}
; * } * } * ``` * * @category Composition */ static using: Compose = _compose; // ── @internal: framework bookkeeping, not part of the developer API ────────── // `_skipMount` used to live here, declared as "set to true by hydrate() so the // framework skips calling onMount()". Nothing ever read it. Mount-skipping is // structural and always has been: the router branches between `onHydrate()` // and `onMount()`, and the action dispatcher calls `onMount()` only when an // action asks for it. // // It was removed rather than wired up because wiring it would have been wrong. // `$refresh` and `$mount` deliberately re-run `onMount()` on a page that was // hydrated — so a flag whose stated meaning is "this page has been hydrated, // do not mount it" would have broken both the moment anyone honoured it. An // inert flag that reads as load-bearing is worse than none: the next person // building render modes would reach for it, find it does nothing, and ship a // double-mount. `hooks.test.ts` pins the real guarantee instead. /** Populated by flash() — read by the WS handler to emit flash frames. @internal */ _flashes: FlashMessage[] = []; /** Set by redirect() — read by the WS handler to emit a redirect frame. @internal */ _redirectUrl: string | null = null; /** Optional HTTP-style status passed to redirect() (advisory; client navigates regardless). @internal */ _redirectStatus: number | null = null; /** Set by refresh() — re-runs onMount() before the action executes. @internal */ _shouldRefresh = false; /** * Raw JS expressions queued by the `$` tagged template during an action. * Drained by _drainEffects() and forwarded to the client in the patch frame. * @internal */ _clientScripts: string[] = []; /** Validation errors set by validate() / addError(). Included in the patch frame. @internal */ _errors: Record = {}; /** * Typed access to this component's validation error bag. * * - `this.errors.has("field")` → `boolean` * - `this.errors.field` → an {@link ErrorField} for `error={this.errors.field}` * - `this.errors.add("field", "message")` → add an error manually * - `this.errors.add({ field: "message" })` → add several at once * - `this.errors.clear(field?)` → clear one field, or all * * @category Validation */ get errors(): ErrorsProxy { return _makeErrorsProxy({ read: () => this._errors, add: (field, message) => this.addError(field, message), clear: (field) => this.resetValidation(field), }); } /** Cross-component events queued by dispatch(). Sent as EventFrames. * `to` restricts delivery to components of that class name; `self` to this one. @internal */ _events: Array<{ name: string; data: Record; to?: string; self?: boolean }> = []; /** File downloads queued by download(). Sent as DownloadFrames. @internal */ _downloads: Array<{ filename: string; content: string; mime: string }> = []; /** This component's id — set by the route handler (SSR) or the WS dispatcher. @internal */ _flowId = ""; /** The page route's path — inherited by nested children for middleware lookup. @internal */ _flowPath = ""; /** True when this instance was rebuilt from a snapshot (WS update render). @internal */ _isHydrated = false; /** Child component ids collected during the current render (→ memo.children). @internal */ _childIds: string[] = []; /** Child ids from the previous render (frame.snapshot.memo.children). @internal */ _prevChildIds: string[] = []; /** * Named slot HTML this component received from its parent's template (`name → html`, * default slot keyed `"default"`). Populated by the parent's `child()` call and restored * from the snapshot on every round-trip. Read it in `render()` via `this.slot(name)`. * @internal */ _flowSlots: Record = {}; /** * Live stream sender — attached by the WS dispatcher for the duration of an * action so this.stream() can push frames mid-execution. No-op during SSR. * @internal */ _streamSender: ((ref: string, content: string, replace: boolean) => void) | null = null; /** * The cancellation signal for the currently-running `@task` action — attached by the WS * dispatcher for the task's duration, tripped when the client calls `this.cancel()`. * Null outside a task. Read it through {@link signal} / {@link cancelled}. * @internal */ _taskSignal: AbortSignal | null = null; /** * The AbortSignal for the running `@task`. Pass it to `fetch`/an SDK, or check * `this.signal.aborted` in a loop, for cooperative cancellation. Outside a task it is an * already-inert signal that never aborts, so `@task` code reads the same regardless. * * @category Actions */ get signal(): AbortSignal { return this._taskSignal ?? _NEVER_ABORT; } /** * True when the running `@task` has been cancelled from the client. * @category Actions */ get cancelled(): boolean { return this._taskSignal?.aborted ?? false; } /** * Opt into durable/resumable snapshots: `static durable = true` (or `{ ttl, scope }`). * The signed snapshot is persisted server-side after every request, keyed by user (or * session) + route, and restored on a fresh GET so the user resumes exactly. See * {@link clearDurable} to drop the stored state on flow completion. * * @category State & exposure */ /** * The page's document title — a string, or a function of the component. * * ```ts * static title = "Search"; * static title = (c: SearchPage) => (c.query ? `Search: ${c.query}` : "Search"); * ``` * * Resolved on the server for every render and every patch, so the function form tracks * state without the page doing anything: change `query` in an action and the tab title * follows. Only the resolved string is ever sent to the browser. * * Declared here rather than left to convention. It was read structurally off the * constructor, which meant no type, no autocomplete, and no entry in the API surface for * something the docs already told people to write. * * @category Rendering */ static title?: string | ((component: never) => string); /** * Opt a component out of interactivity: `static interactive = false`. * * Every Flow component today is maximally interactive — rendered on the server, * dehydrated into a state snapshot, tracked by the client, and re-rendered over * a socket. That is right for a counter and wasteful for a page header, a nav * rail or a marketing section, none of which will ever receive an action. * * A static child is rendered in full by its parent and nothing else: no * snapshot, no `` + ``, }; } // Streamed: paint the placeholder now and queue the real render to be // appended to this same response once the shell has been flushed. Only // possible during an initial GET — a WS patch has no open response to append // to, so `getStreamStore()` is undefined there and this falls through to the // ordinary inline render below. const streamStore = opts.stream ? getStreamStore() : undefined; if (streamStore) { const childPage = new ChildClass(); childPage._flowId = childId; childPage._flowPath = this._flowPath; if (opts.slots) childPage._flowSlots = opts.slots; if (opts.props) { _assignChildProps(childPage, opts.props as Record); } queueStream({ childId, render: async () => { const streamCtx = HttpContext.tryGet(); await childPage.onBoot(streamCtx); await childPage.onMount(streamCtx); const inner = await _renderFlowPage(childPage, () => childPage.render()); await childPage.onDehydrate(); const snap = dehydrate(childPage, { id: childId, name, path: this._flowPath }); return ( inner + `` ); }, }); return { html: `
` + `${childPage.placeholder().html}` + `
`, }; } // Initial render (SSR) or a child newly added by this update: full render. const childPage = new ChildClass(); childPage._flowId = childId; childPage._flowPath = this._flowPath; if (opts.slots) childPage._flowSlots = opts.slots; if (opts.props) { _assignChildProps(childPage, opts.props as Record); } // A child is populated from its parent's props (assigned above), not from route segments, // but it still gets the request context — the same object the routed page received. const ctx = HttpContext.tryGet(); await childPage.onBoot(ctx); await childPage.onMount(ctx); const innerHtml = await _renderFlowPage(childPage, () => childPage.render()); await childPage.onDehydrate(); const snapshot = dehydrate(childPage, { id: childId, name, path: this._flowPath, }); return { html: `
` + `${innerHtml}` + `` + `
`, }; } /** * Render a `static interactive = false` child: markup, and nothing else. * * Same lifecycle as an interactive child up to the point where the client would * be involved — `onBoot`, `onMount`, `render` — and then it stops. No * `onDehydrate`, no snapshot, no state script, no wrapper. * * The absent wrapper is the load-bearing part. `data-flow-root` is what tells * the client morph to leave a subtree alone, which is right for something with * live state and exactly wrong here: this child's only route to an update is * its parent re-rendering it, and a skipped subtree would never receive one. */ /** @internal Called only by {@link child}. */ async _renderStaticChild( ChildClass: new () => C, opts: { props?: Partial; slots?: Record }, ): Promise { const childPage = new ChildClass(); childPage._flowPath = this._flowPath; if (opts.slots) childPage._flowSlots = opts.slots; if (opts.props) { _assignChildProps(childPage, opts.props as Record); } const ctx = HttpContext.tryGet(); await childPage.onBoot(ctx); await childPage.onMount(ctx); return _renderFlowPage(childPage, () => childPage.render()); } // ── Streaming ───────────────────────────────────────────────────────────── /** * Push content to the client MID-ACTION — before the final patch — into any * element marked `flow:stream="ref"`. Appends by default; pass * `{ replace: true }` to overwrite instead. * * Works only during WebSocket actions (no-op during SSR). * * @security `content` is injected as raw innerHTML on the client. Never pass * unescaped user input directly — sanitize or escape it first, or use a * trusted HTML sanitizer (e.g. DOMPurify) client-side. * * @example * @expose async generate() { * for await (const token of llm.stream(this.prompt)) { * this.stream('answer', token); * } * } * * @param ref the `flow:stream="ref"` target element. * @param content raw HTML appended (or replacing) inside the target. * @param opts pass `{ replace: true }` to overwrite instead of append. * @category Actions */ stream(ref: string, content: string, opts: { replace?: boolean } = {}): void { this._streamSender?.(ref, content, opts.replace ?? false); } // ── Built-in actions (invoked by the bridge, not user-callable) ─────────── /** * @internal Handles flow:model updates from the client. * Silently ignores unexposed or @locked properties — both are defense-in-depth * against confused-deputy attacks (HMAC signing is the primary guard). */ $set(key: string, value: unknown): void { const exposed = getExposedProps(this); const locked = getLockedProps(this); if (!exposed.has(key) && !locked.has(key)) return; if (locked.has(key)) return; // Form object: fill the existing instance instead of replacing it with a plain // object, so its class + methods survive a `value={this.form.field}` edit. const cur = (this as Record)[key]; if ( cur && typeof cur === "object" && (cur as { __isFlowForm?: unknown }).__isFlowForm === true && value && typeof value === "object" ) { (cur as { fill(v: Record): void }).fill(value as Record); return; } // Model: same reasoning as the form above — fill the hydrated instance rather than // replacing it with the plain object the client sent, so its class, methods and dirty // tracking survive a `value={this.user.name}` edit. `_applyWireValues` keeps only // `fillable` minus `hidden`, so a field the model never offered is ignored rather than // written or thrown over. if ( cur && typeof cur === "object" && _isRegisteredModel(cur) && value && typeof value === "object" ) { _applyWireValues(cur, value); return; } // Signed upload references become TemporaryUploadedFile instances (signature verified); // all other values pass through unchanged. (this as Record)[key] = resolveUploadValue(value); } /** @internal Triggers onMount re-run (used by $flow.refresh()). */ $refresh(): void { this._shouldRefresh = true; } /** * @internal Apply a client-driven property update, firing the updating/updated * lifecycle hooks (generic + per-property) around the write. Only writable * (`@expose`, non-`@locked`) props fire hooks and get written; everything else * is silently ignored by `$set` (defense-in-depth). An `onUpdating` hook may * throw to reject the update. */ async _applyClientUpdate(key: string, value: unknown, arrayKey?: string): Promise { const exposed = getExposedProps(this); const locked = getLockedProps(this); if (!exposed.has(key) || locked.has(key)) { this.$set(key, value); // no-op for unknown/locked, kept for symmetry return; } await this._emitUpdateHook("onUpdating", key, value, arrayKey); this.$set(key, value); await this._emitUpdateHook("onUpdated", key, value, arrayKey); // Real-time validation: if this field carries a `@validate` rule, validate // just it now and set/clear its error. The changed value reaches here from `flow:model.live` / // `.blur` syncs (and dirty-field batches), so errors appear/clear as the user edits — the // refreshed `_errors` bag is sent to the client in this round-trip's patch. Never throws. await this._validateField(key); } /** @internal Fire a generic update hook + its per-property `on…` variant. */ async _emitUpdateHook( base: "onUpdating" | "onUpdated", prop: string, value: unknown, key?: string, ): Promise { await (this[base] as (p: string, v: unknown, k?: string) => Promise)(prop, value, key); const specific = `${base}${prop.charAt(0).toUpperCase()}${prop.slice(1)}`; const fn = (this as Record)[specific]; if (typeof fn === "function") { await (fn as (v: unknown, k?: string) => unknown).call(this, value, key); } } // ── Effect draining (called by WS handler after action) ────────────────── /** * The title to send with this frame — `static title` resolved against this component. * * Resolved per frame rather than once, which is what lets the function form track state: * change what it reads and the next patch carries the new title. * * @internal */ _resolveTitle(): string | null { const declared = (this.constructor as { title?: unknown }).title; if (typeof declared === "function") { return String((declared as (c: unknown) => unknown)(this)); } return typeof declared === "string" ? declared : null; } /** @internal — read by the WS handler after action execution. */ _drainEffects(): FlowEffects { const effects: FlowEffects = { flashes: this._flashes.splice(0), redirectUrl: this._redirectUrl, shouldRefresh: this._shouldRefresh, scripts: this._clientScripts.splice(0), errors: this._errors, events: this._events.splice(0), downloads: this._downloads.splice(0), title: this._resolveTitle(), }; this._redirectUrl = null; this._redirectStatus = null; this._shouldRefresh = false; // _errors intentionally NOT reset here: errors persist across actions so the // client continues showing field messages after e.g. a failed save that then // dispatches a flash. Call resetValidation() or a passing validate() to clear. return effects; } }