// ── Flow WS bridge ─────────────────────────────────────────────────────────── // // Responsibilities: // 1. Scan the DOM for [data-flow-root] and initialise FlowComponent instances // (including nested child components, each an isolated island) // 2. Open a WebSocket to /__flow/ws with exponential back-off reconnect // 3. Event delegation for flow:click, flow:submit, flow:model, flow:confirm // 4. Loading state (flow:loading family, with flow:target scoping + .delay) // 5. Dirty state (flow:dirty family — local edits not yet confirmed by server) // 6. Offline state (flow:offline family + body[data-flow-connection]) // 7. Per-component outbound frame queue (serialised — no race conditions) // 8. Apply server patches via Alpine.morph (respecting flow:ignore / // flow:ignore.self / flow:replace, skipping nested component roots) // 9. flow:cloak removal on init; flow:poll interval setup // 10. Declarative bindings: flow:text, flow:show, flow:bind:class/href/attr // 11. flow:intersect viewport actions; flow:sort drag-and-drop reordering // 12. flow:stream progressive content frames (mid-action server pushes) // 13. flow:navigate SPA navigation (+ .hover prefetch, flow:current links) // 14. Update the $flow Alpine store after every patch // 15. $errors Alpine magic for validation error bags // 16. Cross-component events via CustomEvent + window dispatch // 17. @url query string sync on every patch // 18. Lazy loading (IntersectionObserver) and deferred loading (DOMContentLoaded) // 19. File downloads via blob URL // 20. flow:transition enter animations on morph // 21. flow:teleport — render content at a different DOM location // 22. Offline action queue — multiple actions per component queued offline import type { Snapshot, SnapshotData, SnapshotMemo, ServerFrame } from "../types.ts"; import { FlowComponent } from "./FlowComponent.ts"; import { evaluateCsp } from "./cspEvaluator.ts"; import { buildUrlWithQuery, type CurrentUrlOptions } from "./url.ts"; import { confirmDialog, type ConfirmDialogOptions } from "./confirm.ts"; import { showErrorOverlay } from "./errorOverlay.ts"; import { applyAppend, applyRemove, type OptOp } from "./optimistic.ts"; import { clientStore } from "../store.ts"; // The `/Socket` subpath, not the package root: the root also exports // `ClientProvider`, which reaches @zerotal/core's CLI modules — one of which // does `await import("bun")`, a hard error in a browser bundle before // tree-shaking can drop it. import { Socket as BundledSocket } from "@zerotal/client/Socket"; import { route } from "@zerotal/core/routes"; import { URL_ATTRIBUTES, sanitizeUrl } from "../urlSafety.ts"; import { recordFrame as _recordTimelineFrame, isTimelineEnabled, setTimelineEnabled, setTimelineApplier, } from "./timeline.ts"; import * as _timeline from "./timeline.ts"; import { framePanes as _framePanes, renderFramePanes as _renderFramePanes, type FrameServerCost as _ServerCost, } from "./framePanes.ts"; // CSP-safe mode: set by the CSP client entry. When on, bridge-managed bindings // use the eval-free evaluateCsp() instead of `new Function`. let _bridgeCsp = false; /** @internal Enable CSP-safe evaluation in the bridge (called by the CSP client entry). */ export function setCspMode(on: boolean): void { _bridgeCsp = on; } // Holds the $flow proxy builder (assigned in registerFlowMagic) so the CSP // evaluator adapter can resolve `$flow` without going through Alpine's evaluator. let _gelMagicBuilder: ((el: Element) => unknown) | null = null; /** @internal Build the $flow proxy for an element (used by the CSP evaluator scope). */ export function _resolveGel(el: Element): unknown { return _gelMagicBuilder ? _gelMagicBuilder(el) : undefined; } type AlpineType = { reactive(o: object): Record; morph(from: Element, to: string | Element, config?: object): void; store(name: string, init?: object): unknown; magic(name: string, fn: (el: Element) => unknown): void; }; declare const Alpine: AlpineType; const PULSE_CSS = ` [flow\\:cloak] { display: none !important; } [x-cloak] { display: none !important; } [flow\\:loading] { display: none; } /* Visual loading indicators wait out a short delay so a very fast action never flashes one. The delayed marker is set LOADING_DELAY_MS after the action starts (see _setLoading), and cleared the moment it resolves — so an action that finishes inside the window shows nothing. */ [data-flow-loading-delayed] [flow\\:loading] { display: revert; } [data-flow-loading-delayed] [flow\\:loading\\.remove] { display: none !important; } [flow\\:loading\\.delay] { display: none; } [data-flow-loading-delayed] [flow\\:loading\\.delay] { display: revert; } [flow\\:loading][flow\\:target], [flow\\:loading][flow\\:target\\.except] { display: none; } [flow\\:dirty] { display: none; } [flow\\:offline] { display: none; } body[data-flow-connection="offline"] [flow\\:offline] { display: revert; } body[data-flow-connection="offline"] [flow\\:offline\\.remove] { display: none !important; } [data-flow-lazy-placeholder] { min-height: 1px; } [flow\\:transition] { transition: opacity var(--flow-transition-duration, 200ms) ease, transform var(--flow-transition-duration, 200ms) ease; } [flow\\:transition].flow-entering { opacity: 0; transform: translateY(4px); } /* show= enter/leave presets for Alpine's x-transition. The compiler emits the transition prop on a show= element as x-transition:enter/leave="flow-t-PRESET" + enter-start/leave-end="flow-t-out". The preset class carries the transition timing; .flow-t-PRESET.flow-t-out is the hidden state. */ .flow-t-fade, .flow-t-scale, .flow-t-slide-up, .flow-t-slide-down, .flow-t-slide-left, .flow-t-slide-right { transition: opacity var(--flow-transition-duration, 200ms) ease, transform var(--flow-transition-duration, 200ms) ease; } .flow-t-fade.flow-t-out { opacity: 0; } .flow-t-scale.flow-t-out { opacity: 0; transform: scale(0.95); } .flow-t-slide-up.flow-t-out { opacity: 0; transform: translateY(8px); } .flow-t-slide-down.flow-t-out { opacity: 0; transform: translateY(-8px); } .flow-t-slide-left.flow-t-out { opacity: 0; transform: translateX(8px); } .flow-t-slide-right.flow-t-out { opacity: 0; transform: translateX(-8px); } @media (prefers-reduced-motion: reduce) { .flow-t-fade, .flow-t-scale, .flow-t-slide-up, .flow-t-slide-down, .flow-t-slide-left, .flow-t-slide-right { transition: none; } } [flow\\:failed] { display: none; } [data-flow-action-error] [flow\\:failed] { display: revert; } [data-flow-action-error] [flow\\:failed\\.remove] { display: none !important; } .flow-skeleton { background: var(--flow-skeleton-color, rgba(0,0,0,0.08)); animation: flow-skeleton-pulse 1.5s ease-in-out infinite; } @keyframes flow-skeleton-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } } @media (prefers-reduced-motion: reduce) { .flow-skeleton { animation: none; } } `; function _escapeHtml(s: string): string { return String(s).replace(/&/g, "&").replace(//g, ">"); } function _injectFlowStyles(): void { if (document.getElementById("flow-styles")) return; const style = document.createElement("style"); style.id = "flow-styles"; style.textContent = PULSE_CSS; document.head.appendChild(style); } const _components = new Map(); // Bare→`$`-prefixed aliases for the small set of magics that are ALSO real Component // methods (`this.refresh()` / `this.dispatch()` …). These names are already owned by the // base class, so promoting the bare form to the `$`-magic is safe and can't shadow a // developer's own member. Every OTHER client magic lives ONLY under its `$`-prefixed name // on `$flow` (`$flow.$set`, `$flow.$store`, `$flow.$parent`, …), leaving the bare names // (`set`, `toggle`, `store`, `parent`, …) free for the developer's own props/methods. // Keep in sync with `CLIENT_CALLBACK_MAGICS` in compiler/transform.ts. const _MAGIC_ALIASES = new Set(["refresh", "dispatch", "dispatchTo", "dispatchSelf"]); // Reactive/modelable bindings (Tier 1). `_modelMaps`: childId → { childProp: parentProp }. // `_modelBaselines`: childId → { prop: lastSeenJSON } — guards against echo loops so a // parent-pushed value isn't propagated straight back to the parent (and vice-versa). const _modelMaps = new Map>(); const _modelBaselines = new Map>(); function _modelBaselineFor(id: string): Map { let b = _modelBaselines.get(id); if (!b) { b = new Map(); _modelBaselines.set(id, b); } return b; } /** * Apply parent→child reactive bindings carried on a child root's stub during morph. * Reads `data-flow-model` (records the child→parent map) and `data-flow-props` (parent- * pushed @reactive values); for each changed value, updates the child locally and * round-trips a $set so the child re-renders. Baselines are recorded so the change * isn't echoed back to the parent by `_propagateModelable`. */ function _applyChildBindings(from: Element, to: Element): void { const childId = (from as HTMLElement).dataset["flowId"]; if (!childId) return; const child = _components.get(childId); if (!child) return; const modelRaw = to.getAttribute("data-flow-model"); if (modelRaw) { try { _modelMaps.set(childId, JSON.parse(modelRaw) as Record); } catch { /* ignore */ } } const propsRaw = to.getAttribute("data-flow-props"); if (!propsRaw) return; let props: Record; try { props = JSON.parse(propsRaw) as Record; } catch { return; } const baseline = _modelBaselineFor(childId); for (const [k, v] of Object.entries(props)) { const nextJson = JSON.stringify(v); baseline.set(k, nextJson); // parent-originated → suppress echo if (JSON.stringify(child.reactive[k]) === nextJson) continue; child.reactive[k] = v; // reactive first (proxy), then raw target child.ephemeral[k] = v; _callAction(child, "$set", [k, v]); // re-render child with the new prop } } /** * After a child's patch lands, push any genuinely child-originated change to a * @modelable prop back up to the bound parent property (child→parent half of the * two-way binding). The baseline guard ensures parent-pushed values don't bounce back. */ function _propagateModelable(comp: FlowComponent): void { const map = _modelMaps.get(comp.id); if (!map) return; const parentEl = comp.rootEl.parentElement?.closest("[data-flow-root]"); const parentId = parentEl?.dataset["flowId"]; const parent = parentId ? _components.get(parentId) : undefined; if (!parent) return; const baseline = _modelBaselineFor(comp.id); for (const [childProp, parentProp] of Object.entries(map)) { const curJson = JSON.stringify(comp.reactive[childProp]); if (baseline.get(childProp) === curJson) continue; // unchanged or parent-originated baseline.set(childProp, curJson); const val = comp.reactive[childProp]; parent.reactive[parentProp] = val; // reactive first, then raw target parent.ephemeral[parentProp] = val; _callAction(parent, "$set", [parentProp, val]); } } function findComponentByEl(el: Element): FlowComponent | null { // Teleported elements live outside [data-flow-root] — look for a saved owner id const teleportRoot = el.closest("[data-flow-teleported]") as HTMLElement | null; if (teleportRoot) { const ownerId = teleportRoot.dataset["flowOwner"]; if (ownerId) return _components.get(ownerId) ?? null; } let root = el.closest("[data-flow-root]") as HTMLElement | null; while (root) { const id = root.dataset["flowId"]; if (id) return _components.get(id) ?? null; root = (root.parentElement?.closest("[data-flow-root]") as HTMLElement | null) ?? null; } return null; } function _owns(comp: FlowComponent, el: Element): boolean { return el.closest("[data-flow-root]") === comp.rootEl; } // Dotted-path get/set for nested model binding (e.g. Form fields: "form.email"). function _getPath(obj: Record, path: string): unknown { if (!path.includes(".")) return obj?.[path]; return path .split(".") .reduce((o, k) => (o == null ? undefined : (o as Record)[k]), obj); } function _setPath(obj: Record, path: string, value: unknown): void { if (!path.includes(".")) { obj[path] = value; return; } const parts = path.split("."); let o = obj as Record; for (let i = 0; i < parts.length - 1; i++) { const k = parts[i]!; if (o[k] == null || typeof o[k] !== "object") o[k] = {}; o = o[k] as Record; } o[parts[parts.length - 1]!] = value; } function _ownedEls(comp: FlowComponent, selector: string): Element[] { const els = Array.from(comp.rootEl.querySelectorAll(selector)).filter((el) => _owns(comp, el)); if (comp.rootEl.matches(selector)) els.unshift(comp.rootEl); // Teleported subtrees live outside rootEl (moved to e.g. ) but still belong // to this component — include them so flow:show / flow:text / flow:bind keep working. document .querySelectorAll(`[data-flow-teleported][data-flow-owner="${comp.id}"]`) .forEach((root) => { if (root.matches(selector)) els.push(root); root.querySelectorAll(selector).forEach((e) => els.push(e)); }); return els; } const _loadingTimers = new Map>(); const LOADING_DELAY_MS = 200; // A `live` text input debounces its per-keystroke sync by default, so fast typing doesn't fire // a WebSocket $set on every character. Discrete // controls — checkbox, radio, select, range, date, colour — send immediately (a deliberate pick, // not a stream of keystrokes). The default binding (no `live`) is deferred and never affected. const MODEL_DEBOUNCE_MS = 150; const _TEXTLIKE_INPUT = new Set([ "text", "email", "password", "search", "url", "tel", "number", "textarea", ]); /** Debounce for a `live` input's keystroke sync: text-like → MODEL_DEBOUNCE_MS, else immediate. */ function _modelDebounceMs(el: HTMLInputElement): number { return _TEXTLIKE_INPUT.has((el.type || "text").toLowerCase()) ? MODEL_DEBOUNCE_MS : 0; } function _targetsMatch(el: Element, action: string | null): boolean { const except = el.getAttribute("flow:target.except"); if ( except && action && except .split(",") .map((s) => s.trim()) .includes(action) ) { return false; } const target = el.getAttribute("flow:target"); if (target) { if (!action) return false; return target .split(",") .map((s) => s.trim()) .includes(action); } return true; } /** * Attribute toggles (`loadingAttr="disabled"`) are applied IMMEDIATELY — a disabled submit * button must guard against a double-click even for a sub-100ms action. */ function _applyLoadingAttr(comp: FlowComponent, loading: boolean, action: string | null): void { _ownedEls(comp, "[flow\\:loading\\.attr]").forEach((el) => { const attr = el.getAttribute("flow:loading.attr"); if (!attr) return; const active = loading && _targetsMatch(el, action); if (active) el.setAttribute(attr, ""); else el.removeAttribute(attr); }); } /** * Visual indicators (targeted show/hide, `loadingClass`) — the flashy ones. Applied only after * the delay on the way in, and cleared immediately on the way out, so a fast action shows none. */ function _applyLoadingVisuals(comp: FlowComponent, loading: boolean, action: string | null): void { _ownedEls( comp, "[flow\\:loading][flow\\:target], [flow\\:loading][flow\\:target\\.except]", ).forEach((el) => { const show = loading && _targetsMatch(el, action); (el as HTMLElement).style.display = show ? el.getAttribute("flow:loading") || "revert" : ""; }); _ownedEls( comp, "[flow\\:loading\\.remove][flow\\:target], [flow\\:loading\\.remove][flow\\:target\\.except]", ).forEach((el) => { const hide = loading && _targetsMatch(el, action); (el as HTMLElement).style.display = hide ? "none" : ""; }); _ownedEls(comp, "[flow\\:loading\\.class]").forEach((el) => { const cls = el.getAttribute("flow:loading.class"); if (!cls) return; const active = loading && _targetsMatch(el, action); cls .split(/\s+/) .filter(Boolean) .forEach((c) => el.classList.toggle(c, active)); }); _ownedEls(comp, "[flow\\:loading\\.class\\.remove]").forEach((el) => { const cls = el.getAttribute("flow:loading.class.remove"); if (!cls) return; const active = loading && _targetsMatch(el, action); cls .split(/\s+/) .filter(Boolean) .forEach((c) => el.classList.toggle(c, !active)); }); } function _setLoading(comp: FlowComponent, loading: boolean, action: string | null = null): void { if (loading) { comp.rootEl.setAttribute("data-flow-loading", ""); // Disable now (guards double-submit); reveal spinners/classes only if the action // outlasts the delay — a quick action never flashes a loading indicator. _applyLoadingAttr(comp, true, action); clearTimeout(_loadingTimers.get(comp.id)); _loadingTimers.set( comp.id, setTimeout(() => { comp.rootEl.setAttribute("data-flow-loading-delayed", ""); _applyLoadingVisuals(comp, true, action); }, LOADING_DELAY_MS), ); } else { comp.rootEl.removeAttribute("data-flow-loading"); comp.rootEl.removeAttribute("data-flow-loading-delayed"); clearTimeout(_loadingTimers.get(comp.id)); _loadingTimers.delete(comp.id); _applyLoadingAttr(comp, false, action); _applyLoadingVisuals(comp, false, action); // clears anything the timer had already applied } } function _dirtyKeys(comp: FlowComponent): Set { const keys = new Set(); for (const [key, val] of Object.entries(comp.ephemeral)) { if (JSON.stringify(comp.canonical[key]) !== JSON.stringify(val)) keys.add(key); } return keys; } function _dirtyMatch(el: Element, dirty: Set): boolean { const target = el.getAttribute("flow:target"); if (target) return target.split(",").some((k) => dirty.has(k.trim())); return dirty.size > 0; } function _updateDirty(comp: FlowComponent): void { const dirty = _dirtyKeys(comp); _ownedEls(comp, "[flow\\:dirty]").forEach((el) => { const show = _dirtyMatch(el, dirty); (el as HTMLElement).style.display = show ? el.getAttribute("flow:dirty") || "revert" : ""; }); _ownedEls(comp, "[flow\\:dirty\\.remove]").forEach((el) => { (el as HTMLElement).style.display = _dirtyMatch(el, dirty) ? "none" : ""; }); _ownedEls(comp, "[flow\\:dirty\\.class]").forEach((el) => { const cls = el.getAttribute("flow:dirty.class"); if (!cls) return; const active = _dirtyMatch(el, dirty); cls .split(/\s+/) .filter(Boolean) .forEach((c) => el.classList.toggle(c, active)); }); _ownedEls(comp, "[flow\\:dirty\\.class\\.remove]").forEach((el) => { const cls = el.getAttribute("flow:dirty.class.remove"); if (!cls) return; const active = _dirtyMatch(el, dirty); cls .split(/\s+/) .filter(Boolean) .forEach((c) => el.classList.toggle(c, !active)); }); _ownedEls(comp, "[flow\\:dirty\\.attr]").forEach((el) => { const attr = el.getAttribute("flow:dirty.attr"); if (!attr) return; if (_dirtyMatch(el, dirty)) el.setAttribute(attr, ""); else el.removeAttribute(attr); }); } let _isOnline = true; function _setConnectionState(online: boolean): void { // The attribute mirrors the *current* state, so it is stamped even when that // state hasn't changed. The bridge starts optimistic (`_isOnline = true`), so // a page whose socket connects on the first attempt — the normal case — would // otherwise never carry the attribute at all, and a stylesheet or a readiness // check keyed on `[data-flow-connection="online"]` would wait forever for a // page that is in fact perfectly connected. document.body.setAttribute("data-flow-connection", online ? "online" : "offline"); if (_isOnline === online) return; _isOnline = online; document.dispatchEvent(new CustomEvent(online ? "flow:online" : "flow:offline")); const offline = !online; document.querySelectorAll("[flow\\:offline\\.class]").forEach((el) => { const cls = el.getAttribute("flow:offline.class"); if (cls) cls .split(/\s+/) .filter(Boolean) .forEach((c) => el.classList.toggle(c, offline)); }); document.querySelectorAll("[flow\\:offline\\.class\\.remove]").forEach((el) => { const cls = el.getAttribute("flow:offline.class.remove"); if (cls) cls .split(/\s+/) .filter(Boolean) .forEach((c) => el.classList.toggle(c, !offline)); }); document.querySelectorAll("[flow\\:offline\\.attr]").forEach((el) => { const attr = el.getAttribute("flow:offline.attr"); if (!attr) return; if (offline) el.setAttribute(attr, ""); else el.removeAttribute(attr); }); } let _ws: WebSocket | null = null; /** Whether this page has ever needed the socket. Latched, never cleared. @see _ensureConnected */ let _socketWanted = false; let _reconnectDelay = 1_000; const MAX_DELAY = 30_000; // ── HTTP fallback (circuit breaker) ───────────────────────────────────────────── // Strict corporate proxies/firewalls often block WebSocket upgrades entirely. After a few failed // handshakes we fall back to sending action frames over plain HTTP POST (/__flow/http), which runs // the identical server pipeline and returns the frames to apply. WS reconnection keeps running in // the background, so if the socket becomes available again we upgrade back automatically. let _httpMode = false; let _wsFailures = 0; const HTTP_FALLBACK_AFTER = 3; // consecutive failed WS handshakes before switching to HTTP /** * The origin guard refused this page, and no retry will change that. * * Reported once, in full, because the alternative is the worst failure this app has: the * HTML renders, the console shows a generic "action failed", and the only symptom is that * nothing a user clicks does anything. The connection state drops to offline too — the app * genuinely cannot act, so actions queue behind `flow:offline` directives instead of being * fired at an endpoint that will refuse every one of them. */ let _originRefused = false; function _reportOriginRefused(body: string): void { if (_originRefused) return; _originRefused = true; console.error( `[Flow] Actions refused: the server rejected this page's origin (${location.origin}).\n` + `Behind a reverse proxy the app's own origin is the loopback address it bound to, so ` + `the public origin has to be configured for it to accept browser-initiated actions.\n` + `Fix: set \`url\` in config/app.ts to ${location.origin} — AppConfig fills ` + `app.allowedOrigins from it.` + (body ? `\nServer said: ${body}` : ""), ); _setConnectionState(false); } function _enableHttpMode(): void { if (_httpMode) return; _httpMode = true; console.warn( "[Flow] WebSocket unavailable — falling back to HTTP requests. If actions also fail, " + "check that the proxy forwards /__flow/* over HTTP/1.1 and does not gate it behind auth.", ); _setConnectionState(true); // HTTP works, so we're "online" for offline directives // The socket that dropped may never come back, so this is the reconnect for anything it // left mid-action — resync over HTTP before replaying, same order as the WS path. _resyncStaleComponents(); // Drain anything queued while we were deciding, now over HTTP. const replay = [..._offlineQueues.values()].flat(); _offlineQueues.clear(); for (const a of replay) _sendCall(a.comp, a.method, a.args, a.updates); } /** POST an action frame to the HTTP fallback endpoint and apply the returned frames. */ function _httpSend(frame: unknown, compId: string): void { fetch("/__flow/http", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(frame), }) .then(async (r) => { if (r.ok) return r.json(); // The status is the diagnosis, and the body carries the server's own words for it. // Both are dropped by a bare `HTTP 403`, which is what made this failure so quiet. const body = await r.text().catch(() => ""); const err = new Error(`HTTP ${r.status}${body ? `: ${body}` : ""}`) as Error & { status?: number; body?: string; }; err.status = r.status; err.body = body; throw err; }) .then((frames: unknown) => { if (Array.isArray(frames)) for (const f of frames) void _handleServerFrame(f as ServerFrame); }) .catch((e: Error & { status?: number; body?: string }) => { if (e.status === 403) _reportOriginRefused(e.body ?? ""); else console.error("[Flow] HTTP action failed:", e); _resolveAck(compId); // never wedge the per-component queue }); } const _queues = new Map>(); // Offline queue: multiple actions per component (FIFO, sent on reconnect) type OfflineAction = { comp: FlowComponent; method: string; args: unknown[]; updates: Record; }; const _offlineQueues = new Map(); const _intervals = new Map[]>(); const _observers = new Map(); const _lazyObservers = new Map(); function _pendingUpdates(comp: FlowComponent): Record { const updates: Record = {}; for (const [key, val] of Object.entries(comp.ephemeral)) { if (JSON.stringify(comp.canonical[key]) !== JSON.stringify(val)) { updates[key] = val; } } return updates; } // Serialize round-trips per component: only ONE action is in flight for a component at a // time. Snapshot deltas are order-dependent — the server diffs the new state against the // snapshot the client SENT, so the client must apply the returned delta to that same base. // Pipelining (sending the next action before the previous patch lands) would apply a delta // to an already-updated snapshot and corrupt it, failing the HMAC on the next action. Each // queued send therefore resolves only when its patch/terminal frame is applied (or a timeout // fires), which gates the next send until comp.snapshot reflects the previous patch. const _pendingAcks = new Map void>(); const ACK_TIMEOUT_MS = 15_000; /** * Components whose last action was released without its patch ever being applied — the * socket closed, or the ack timed out, while the action was in flight. * * The client cannot tell whether such an action ran. The server is stateless per frame, so * a completed action's database write is committed while the client still holds the * pre-action snapshot: the in-memory half is silently reverted, nothing is shown, and the * next action then builds on state that does not match the server's. Rather than guess, * these components are re-synchronised from the server on reconnect — the server's current * state is the truth whether the action ran or not. See {@link _resyncStaleComponents}. */ const _staleComponents = new Set(); function _resolveAck(compId: string): void { const resolve = _pendingAcks.get(compId); if (resolve) { _pendingAcks.delete(compId); resolve(); } } /** * Release an ack whose patch never arrived, and remember that the component's state is now * of unknown accuracy. * * @param compId - The component whose in-flight action was abandoned. */ function _abandonAck(compId: string): void { if (!_pendingAcks.has(compId)) return; _staleComponents.add(compId); _resolveAck(compId); } /** * Ask the server to re-derive the state of every component left mid-action by a dropped * connection or a timed-out ack. * * `$refresh` re-runs `onMount()` server-side and sends back a patch built from that, so a * component whose state comes from the database converges on what was actually committed — * including the effects of an action whose acknowledgement was lost. A `flow:desync` event * is dispatched first so an app can say something about it rather than having the UI change * under the user with no explanation. */ function _resyncStaleComponents(refresh: (id: string) => boolean = _refreshMounted): string[] { if (_staleComponents.size === 0) return []; const ids = [..._staleComponents]; // Cleared before refreshing, not after: `$refresh` goes through the send queue and can // itself be abandoned, which must re-mark the component rather than be wiped by this // drain finishing. _staleComponents.clear(); const resynced = ids.filter((id) => refresh(id)); if (resynced.length > 0 && typeof document !== "undefined") { document.dispatchEvent(new CustomEvent("flow:desync", { detail: { components: resynced } })); } return resynced; } /** Send `$refresh` to a mounted component. Returns false when it is no longer on the page. */ function _refreshMounted(id: string): boolean { const comp = _components.get(id); if (!comp) return false; _callAction(comp, "$refresh", []); return true; } /** * @internal Exported for tests: the bridge's stale-component bookkeeping runs in a browser, * but the state machine — mark on abandon, drain exactly once, re-mark if the resync itself * is abandoned — is what carries the risk and is testable on its own. */ export const _resyncInternals = { markStale: (id: string): void => { _staleComponents.add(id); }, isStale: (id: string): boolean => _staleComponents.has(id), size: (): number => _staleComponents.size, clear: (): void => _staleComponents.clear(), drain: (refresh: (id: string) => boolean): string[] => _resyncStaleComponents(refresh), }; /** * Enqueue a call on the component's serial queue. The returned queue promise resolves when * the action's patch (or a terminal error frame) is applied — not merely when it is sent — * so the next action for this component waits for an up-to-date snapshot base. `fixedUpdates` * is supplied for offline replay (captured at queue time); otherwise updates are read at send. */ function _sendCall( comp: FlowComponent, method: string, args: unknown[], fixedUpdates?: Record, ): void { const compId = comp.id; const prev = _queues.get(compId) ?? Promise.resolve(); _queues.set( compId, prev .then( () => new Promise((resolve) => { const updates = fixedUpdates ?? _pendingUpdates(comp); const frame = { type: "call", component: compId, method, args, updates, snapshot: comp.snapshot, }; // Here is where the client writes riding along with this call are // finally resolved, so it is the only place the panel can learn them. const pending = _pendingActionByComp.get(compId); if (pending && pending.method === method) pending.updates = updates; // Safety net: never wedge the queue if a patch is dropped (server crash / HTTP error). const armSafetyNet = (): void => { setTimeout(() => { // A timed-out ack carries exactly the same ambiguity as a dropped socket: // the action may well have run. Mark the component for resync rather than // carrying on from a snapshot that may no longer match the server. if (_pendingAcks.get(compId) === resolve) _abandonAck(compId); }, ACK_TIMEOUT_MS); }; if (_ws && _ws.readyState === WebSocket.OPEN) { _ws.send(JSON.stringify(frame)); _pendingAcks.set(compId, resolve); armSafetyNet(); } else if (_httpMode && _isOnline) { // HTTP fallback: the returned frames resolve the ack (a patch → _resolveAck). _pendingAcks.set(compId, resolve); _httpSend(frame, compId); armSafetyNet(); } else { const q = _offlineQueues.get(compId) ?? []; q.push({ comp, method, args, updates: fixedUpdates ?? _pendingUpdates(comp) }); _offlineQueues.set(compId, q); resolve(); } }), ) .catch((e) => { console.error(e); _resolveAck(compId); }), ); } function _dispatchFrame(comp: FlowComponent, method: string, args: unknown[]): void { // Fast offline path when the socket is down AND we're not in HTTP fallback; otherwise _sendCall // routes over the socket or HTTP as appropriate (and queues offline itself if truly offline). if (!_httpMode && (!_isOnline || !_ws || _ws.readyState !== WebSocket.OPEN)) { const q = _offlineQueues.get(comp.id) ?? []; q.push({ comp, method, args, updates: _pendingUpdates(comp) }); _offlineQueues.set(comp.id, q); return; } _sendCall(comp, method, args); } /** * Open the socket, but only if the page has anything that could use it. * * Every page used to connect. A page whose components are all * `static interactive = false` — a marketing page, a docs article, a rendered * report — has nothing that can dispatch an action, poll, or receive a patch, * and it still opened `/__flow/ws` and held it. That is a connection per visitor * per page, and a server holding all of them, for a page that will never send a * frame. * * The test is `_components.size`, and it is exact rather than a heuristic: both * paths that write to the socket — {@link _sendCall} and {@link _cancelTask} — * take a `FlowComponent`, so with none registered nothing can send. Navigation * does not need it either; `` fetches the page over HTTP. * * Called again after anything that can register a component — the initial scan, * an SPA navigation, a lazy child arriving — so a page that becomes interactive * connects at that moment rather than never. Already-connected is a no-op, and * reconnection after a drop stays with {@link _connect}, which does not re-ask * the question: a page that has connected once has proved it needs a socket. * * The latch is a flag rather than `_ws !== null`, because `_ws` is nulled on * close while a reconnect is pending — so a scan during that window would have * opened a second socket beside the one already on its way back. */ function _ensureConnected(): void { if (_socketWanted || _components.size === 0) return; _socketWanted = true; _connect(); } function _connect(): void { const proto = location.protocol === "https:" ? "wss:" : "ws:"; _ws = new WebSocket(`${proto}//${location.host}/__flow/ws`); _ws.onopen = () => { _reconnectDelay = 1_000; _wsFailures = 0; if (_httpMode) { _httpMode = false; // socket is back — upgrade out of HTTP fallback console.info("[Flow] WebSocket restored — leaving HTTP fallback."); } _setConnectionState(true); // Replay all queued offline actions in order — through the serial queue, so a // component's actions still go out one-at-a-time (deltas stay aligned to their base). // Resync before replaying: a component left mid-action needs the server's current // state as its base, or the replayed actions build on a snapshot that may already be // out of date. Both go through the per-component serial queue, so the order holds. _resyncStaleComponents(); const replay = [..._offlineQueues.values()].flat(); _offlineQueues.clear(); for (const action of replay) { _sendCall(action.comp, action.method, action.args, action.updates); } }; _ws.onmessage = (e: MessageEvent) => { let frame: ServerFrame & { type: string; scripts?: string[] }; try { frame = JSON.parse(e.data as string) as typeof frame; } catch { return; } void _handleServerFrame(frame); }; _ws.onclose = () => { _ws = null; // Release any in-flight ack waiters and reset the per-component queues so a fresh // action chain starts cleanly on reconnect. Each released ack belonged to an action // that may have completed server-side, so its component is flagged for resync rather // than left silently holding pre-action state. for (const id of [..._pendingAcks.keys()]) _abandonAck(id); for (const [id] of _queues) _queues.set(id, Promise.resolve()); _components.forEach((c) => _setLoading(c, false)); // Trip the circuit breaker after a few failed handshakes — switch to HTTP so the app keeps // working where WebSockets are blocked. WS reconnection continues in the background. _wsFailures++; if (!_httpMode && _wsFailures >= HTTP_FALLBACK_AFTER) _enableHttpMode(); if (!_httpMode) _setConnectionState(false); setTimeout(() => { _reconnectDelay = Math.min(_reconnectDelay * 2, MAX_DELAY); _connect(); }, _reconnectDelay); }; } type AlpineWithEval = AlpineType & { evaluate(el: Element, expression: string): unknown; }; function _executeClientScripts(comp: FlowComponent, scripts: string[]): void { const A = Alpine as unknown as AlpineWithEval; for (const script of scripts) { try { A.evaluate(comp.rootEl, script); } catch (e) { console.error("[Flow] client() script error:", e, "\nScript:", script); } } } // ── Per-component error store ───────────────────────────────────────────────── const _errors = new Map>(); // ── $flow.$watch subscriptions ─────────────────────────────────────────────── type WatchFn = (newVal: unknown, oldVal: unknown) => void; const _watchers = new Map>>(); function _runWatchers(comp: FlowComponent, oldValues: Record): void { const compMap = _watchers.get(comp.id); if (!compMap) return; for (const [prop, fns] of compMap) { const next = comp.reactive[prop]; if (JSON.stringify(oldValues[prop]) !== JSON.stringify(next)) { for (const fn of fns) { try { fn(next, oldValues[prop]); } catch (e) { console.error("[Flow] $watch error:", e); } } } } } function _setErrors(comp: FlowComponent, errors: Record | undefined): void { if (errors && Object.keys(errors).length > 0) { _errors.set(comp.id, errors); } else { _errors.delete(comp.id); } } function _getErrors(compId: string): Record { return _errors.get(compId) ?? {}; } // ── @url sync ───────────────────────────────────────────────────────────────── /** * After a patch, update the browser URL query string for @url-decorated props. * The metadata lives in snapshot.data[key][1].url. */ function _syncUrlParams(comp: FlowComponent): void { const data = comp.snapshot.data; let pushed = false; const params = new URLSearchParams(location.search); for (const [key, [, meta]] of Object.entries(data)) { const urlMeta = meta["url"] as { as: string; history: "push" | "replace" } | undefined; if (!urlMeta) continue; const paramName = urlMeta.as ?? key; const val = comp.reactive[key]; if (val === null || val === undefined || val === "") { params.delete(paramName); } else { params.set(paramName, String(val)); } if (urlMeta.history === "push" && !pushed) pushed = true; } const newSearch = params.toString(); const newUrl = `${location.pathname}${newSearch ? "?" + newSearch : ""}${location.hash}`; if (newUrl !== location.pathname + location.search + location.hash) { if (pushed) { // Bank the scroll offset first: this entry is about to become a Back // target, and the viewport has not moved, so where the user is now is // where Back should return them. _rememberScroll(); _pushHistoryEntry(newUrl); } else { history.replaceState(history.state, "", newUrl); } } } // ── flow:transition ────────────────────────────────────────────────────────── /** Trigger the enter transition for elements with flow:transition. */ function _triggerEnterTransition(el: Element): void { if (!(el instanceof HTMLElement)) return; el.classList.add("flow-entering"); requestAnimationFrame(() => { requestAnimationFrame(() => { el.classList.remove("flow-entering"); }); }); } // ── flow:teleport ──────────────────────────────────────────────────────────── /** * Process `flow:teleport` elements: move them to their target location in the * DOM. The attribute value is a CSS selector for the target parent. * * @example *
...
*/ function _processTeleports(): void { document.querySelectorAll("[flow\\:teleport]").forEach((el) => { const target = el.getAttribute("flow:teleport"); if (!target) return; const dest = document.querySelector(target); if (!dest || el.parentElement === dest) return; // Save the owning component id so findComponentByEl works after the move const owner = findComponentByEl(el); if (owner) (el as HTMLElement).dataset["flowOwner"] = owner.id; // Dedupe: a parent re-render morphs a FRESH copy of this element back into the // root; remove the previously-teleported copy (matched by id) before moving the // new one, so the destination never accumulates duplicates. Give teleported // elements a stable `id` for this to work across re-renders. const id = (el as HTMLElement).id; if (id) { const prev = dest.querySelector(`#${CSS.escape(id)}[data-flow-teleported]`); if (prev && prev !== el) prev.remove(); } // Move the element to the target, keeping original position marker const marker = document.createComment(`flow:teleport ${id || "unknown"}`); el.replaceWith(marker); dest.appendChild(el); // Mark as teleported so we don't re-process (el as HTMLElement).dataset["flowTeleported"] = "1"; }); } // ── Cross-component events ──────────────────────────────────────────────────── /** * Emit a cross-component event: broadcast it on window (so Alpine * `x-on:event-name.window` works) AND trigger any server `@on` listeners on * matching components. Used by both server-dispatched event frames and the * client-side $dispatch / $dispatchTo / $dispatchSelf magics, so a client * dispatch reaches server listeners exactly like a server dispatch does. * * opts.toName → deliver only to components whose class name matches * opts.selfId → deliver only to the component with this id */ function _emitEvent( name: string, data: Record, opts: { toName?: string | undefined; selfId?: string | undefined } = {}, ): void { window.dispatchEvent(new CustomEvent(`flow:${name}`, { detail: data, bubbles: true })); for (const [, comp] of _components) { if (opts.selfId && comp.id !== opts.selfId) continue; if (opts.toName && comp.snapshot.memo.name !== opts.toName) continue; const method = comp.snapshot.memo.listeners?.[name]; if (method) _callAction(comp, method, [data]); } } /** Server-dispatched event frame → emit, honouring optional to/self targeting. */ function _handleEventFrame( name: string, data: Record, to?: string, selfId?: string, ): void { _emitEvent(name, data, { toName: to, selfId }); } // ── File downloads ──────────────────────────────────────────────────────────── function _handleDownloadFrame(filename: string, content: string, mime: string): void { try { const binary = atob(content); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); const blob = new Blob([bytes], { type: mime }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.style.display = "none"; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 60_000); } catch (e) { console.error("[Flow] download error:", e); } } // ── Flash notifications ─────────────────────────────────────────────────────── function _dispatchFlash(detail: Record): void { window.dispatchEvent(new CustomEvent("flow:flash", { detail })); } // ── Server frame handler ────────────────────────────────────────────────────── // One-shot guard for the auto-reload that recovers from an expired/unknown component // (see the "error" frame handling). Persisted across the reload via sessionStorage so the // reload itself can't re-arm it, and cleared once normal patches resume — so each broken // episode triggers at most one reload, while a later, genuine restart can recover again. const _EXPIRY_RELOAD_KEY = "flow:reloaded-for-expired-component"; /** * Why an error frame should be recovered by a reload, if it should. * * Pure and exported so the policy is testable without a DOM — the handler that * calls it needs `window`, `sessionStorage` and a live component map. * * `reload` is set by the server when the snapshot no longer belongs to the * session that is asking, which is what signing out in another tab looks like * from here. The page's state belongs to who you *were*, so no patch can * succeed and reloading is the only honest recovery — the fresh request meets * auth middleware and lands wherever a signed-out visitor belongs. * * Both cases share the one-shot guard: a reload that does not fix the problem * must not reload again. */ export function _reloadReasonFor( frame: { message?: string; reload?: boolean }, alreadyReloaded: boolean, ): "session-changed" | "unknown-component" | null { if (alreadyReloaded) return null; // The server's explicit instruction outranks any inference from the message. if (frame.reload) return "session-changed"; if (frame.message === "Unknown component") return "unknown-component"; return null; } function _reloadedForExpiry(): boolean { try { return sessionStorage.getItem(_EXPIRY_RELOAD_KEY) === "1"; } catch { return false; } } function _markReloadedForExpiry(): void { try { sessionStorage.setItem(_EXPIRY_RELOAD_KEY, "1"); } catch { // sessionStorage unavailable (private mode, etc.) — proceed without the guard. } } function _clearExpiryReloadGuard(): void { try { sessionStorage.removeItem(_EXPIRY_RELOAD_KEY); } catch { // ignore } } /** * Morph a component's DOM to fresh HTML (the shared body of a patch apply and a time-travel * jump), then re-scan/refresh so restored islands and directives are interactive again. */ function _morphComponent(comp: FlowComponent, html: string): void { try { Alpine.morph(comp.rootEl, html, { key: (el: Element) => (el as HTMLElement).dataset?.["flowId"] ?? el.getAttribute("flow:id") ?? el.getAttribute("flow:key") ?? (el as HTMLElement).id ?? null, removing: (el: Node, skip: () => void) => { if (el instanceof Element && el.tagName === "SCRIPT" && el.id.startsWith("flow-state-")) { skip(); } }, updating: (from: Node, to: Node, childrenOnly: () => void, skip: () => void) => { if (!(from instanceof Element)) return; if (from.hasAttribute("flow:ignore")) { skip(); return; } if (from.hasAttribute("flow:ignore.self")) { childrenOnly(); return; } if (from.hasAttribute("flow:replace") && to instanceof Element) { from.innerHTML = to.innerHTML; skip(); return; } if ( from.hasAttribute("data-flow-root") && (from as HTMLElement).dataset["flowId"] !== comp.id ) { // Deliver parent→child reactive/modelable bindings before preserving the island. if (to instanceof Element) _applyChildBindings(from, to); skip(); } }, added: (el: Node) => { if (el instanceof Element && el.hasAttribute("flow:transition")) { _triggerEnterTransition(el); } }, }); } catch (e) { console.error("[Flow] morph error:", e); } _scanComponents(); _ensureConnected(); _cleanupDisconnected(); _refreshComponentFeatures(comp); } // ── Time-travel devtools (dev-only) ──────────────────────────────────────────── // Enabled when the server's `ready` frame reports `dev: true`. Recording lives in the patch // handler and _scanComponents; this wires the DOM side — applying a jumped-to frame, a console // API (window.__flow.timeline), and a minimal scrubbing panel. /** Restore a recorded frame's snapshot + HTML into the live component (client-only). */ function _applyTimelineFrame(frame: _timeline.TimelineFrame): void { const comp = _components.get(frame.compId); if (!comp) return; comp.mergeServerPatch(frame.snapshot); // reactive state +