@zerotal/flow 1.4.0 → 1.5.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/src/decorators.ts CHANGED
@@ -189,13 +189,26 @@ function _scanChain(startProto: object | null): void {
189
189
  interface PendingField {
190
190
  name: string;
191
191
  apply: (proto: object) => void;
192
+ /**
193
+ * `context.metadata` — one object per **file**, not per class (Bun shares it across
194
+ * every class defined in the same module). It is the only link back to where a
195
+ * buffered entry came from, because `Symbol.metadata` never reaches the class, so
196
+ * there is nothing to read at drain time.
197
+ *
198
+ * It is enough for the case that matters: a class may only claim registrations
199
+ * enqueued by its own file. Without it, a component that declares `count` and is
200
+ * never read leaves that entry in the buffer forever, and the next class in a
201
+ * *different* file with an own field called `count` claims it — silently getting
202
+ * the wrong decorator, and never getting its own.
203
+ */
204
+ group: object | undefined;
192
205
  }
193
206
  const _pendingFields: PendingField[] = [];
194
207
  const _drainedProtos = new WeakSet<object>();
195
208
 
196
209
  /** Enqueue a field registration captured (with the correct name) in the decorator body. */
197
- function _enqueueField(name: string, apply: (proto: object) => void): void {
198
- _pendingFields.push({ name, apply });
210
+ function _enqueueField(name: string, apply: (proto: object) => void, group?: object): void {
211
+ _pendingFields.push({ name, apply, group });
199
212
  }
200
213
 
201
214
  /**
@@ -206,6 +219,11 @@ function _enqueueField(name: string, apply: (proto: object) => void): void {
206
219
  * decorators run consecutively at definition time (including a subclass's mixin-supplied fields)
207
220
  * and in declaration order, so its block is exactly such a run.
208
221
  *
222
+ * The search is scoped to one FILE's entries at a time, newest first — see
223
+ * {@link PendingField.group}. Scanning the whole buffer let a class claim a same-named
224
+ * entry left behind by an unrelated file, which is invisible: it gets the wrong
225
+ * decorator and never gets its own.
226
+ *
209
227
  * The strictly-increasing constraint is what makes this robust when two classes share field
210
228
  * names AND their blocks are adjacent in the buffer (e.g. AddTransactionModal and TransferModal
211
229
  * both declare `accountId`/`open`/`fAmount`). A plain "longest run ⊆ own" would greedily extend
@@ -222,40 +240,109 @@ function _drainFields(ctor: unknown): void {
222
240
  _drainedProtos.add(proto); // mark first to stay re-entrancy-safe during the probe
223
241
  if (_pendingFields.length === 0) return;
224
242
 
243
+ // The fields THIS class declares — not the ones it inherits. Instance fields all
244
+ // land on the instance whoever declared them, so `Object.keys(new Derived())` also
245
+ // lists the base's; subtracting the base's own probe leaves this class's block.
246
+ //
247
+ // The difference is load-bearing, not tidiness. `PaginatedPage extends
248
+ // Pagination(Component)` inherits `page`, so an undrained `[page, page]` group left
249
+ // by *another* `Pagination(...)` application matched it — and being two entries long
250
+ // it beat `PaginatedPage`'s own one-entry `[rows]`. The subclass silently ended up
251
+ // with the mixin's decorators and none of its own.
252
+ //
253
+ // If the base cannot be constructed without arguments there is nothing to subtract,
254
+ // and the full key set is the same answer this used to give.
225
255
  let ownIndex: Map<string, number>;
226
256
  try {
227
257
  const keys = Object.keys(new (ctor as new () => object)());
228
- ownIndex = new Map(keys.map((k, i) => [k, i]));
258
+ const base = Object.getPrototypeOf(ctor) as unknown;
259
+ let inherited: Set<string> | undefined;
260
+ if (typeof base === "function" && base !== Function.prototype) {
261
+ try {
262
+ inherited = new Set(Object.keys(new (base as new () => object)()));
263
+ } catch {
264
+ inherited = undefined;
265
+ }
266
+ }
267
+ const declared = inherited ? keys.filter((k) => !inherited.has(k)) : keys;
268
+ ownIndex = new Map(declared.map((k, i) => [k, i]));
229
269
  } catch {
230
270
  return; // not constructible without args — nothing we can match
231
271
  }
232
272
 
233
- // Longest contiguous run whose names map to strictly-increasing declaration-order indices
234
- // (equal allowed only for an identical repeated name — multiple decorators on one field).
235
- let bestStart = -1;
236
- let bestLen = 0;
273
+ // Match within one class's entries at a time — see {@link PendingField.group}.
274
+ //
275
+ // Scanning the buffer as one flat list let a class claim a same-named entry left
276
+ // behind by an unrelated class, and that is invisible when it happens: the class
277
+ // silently gets the wrong decorator and never gets its own. It is not
278
+ // hypothetical — a component that declares a field and is never read leaves its
279
+ // entry buffered for the life of the process, and names like `count`, `open` and
280
+ // `page` are declared all over a codebase.
281
+ //
282
+ // Groups are keyed by identity rather than by adjacency, because a class's entries
283
+ // need not be contiguous: an import evaluated part-way through a module interleaves
284
+ // another file's.
285
+ const groups: object[] = [];
286
+ const byGroup = new Map<object, number[]>();
287
+ const NO_GROUP = {}; // stand-in key, so entries without metadata still group together
237
288
  for (let i = 0; i < _pendingFields.length; i++) {
238
- if (!ownIndex.has(_pendingFields[i]!.name)) continue;
239
- let last = -1;
240
- let lastName = "";
241
- let j = i;
242
- while (j < _pendingFields.length) {
243
- const name = _pendingFields[j]!.name;
244
- const idx = ownIndex.get(name);
245
- if (idx === undefined) break;
246
- if (idx < last || (idx === last && name !== lastName)) break;
247
- last = idx;
248
- lastName = name;
249
- j++;
289
+ const key = _pendingFields[i]!.group ?? NO_GROUP;
290
+ let list = byGroup.get(key);
291
+ if (!list) {
292
+ list = [];
293
+ byGroup.set(key, list);
294
+ groups.push(key);
250
295
  }
251
- if (j - i > bestLen) {
252
- bestLen = j - i;
253
- bestStart = i;
296
+ list.push(i);
297
+ }
298
+
299
+ // Best run across every group, NOT the first group that matches anything. A class
300
+ // whose own group is a two-field block must beat an unrelated group that happens to
301
+ // share one of those names, and only the length says so. Ties go to the most
302
+ // recently enqueued group: a class is drained after its own definition, so among
303
+ // equal candidates the newest is the one that just defined it, and older equals are
304
+ // leftovers by construction.
305
+ let bestIdxs: number[] | null = null;
306
+ let bestLen = 0;
307
+ let bestGroup = -1;
308
+ for (let g = 0; g < groups.length; g++) {
309
+ const idxs = byGroup.get(groups[g]!)!;
310
+
311
+ // Longest contiguous run whose names map to strictly-increasing declaration-order
312
+ // indices (equal allowed only for an identical repeated name — multiple decorators
313
+ // on one field). "Contiguous" is within this group's entries.
314
+ for (let i = 0; i < idxs.length; i++) {
315
+ if (!ownIndex.has(_pendingFields[idxs[i]!]!.name)) continue;
316
+ let last = -1;
317
+ let lastName = "";
318
+ let j = i;
319
+ while (j < idxs.length) {
320
+ const name = _pendingFields[idxs[j]!]!.name;
321
+ const idx = ownIndex.get(name);
322
+ if (idx === undefined) break;
323
+ if (idx < last || (idx === last && name !== lastName)) break;
324
+ last = idx;
325
+ lastName = name;
326
+ j++;
327
+ }
328
+ // Longer always wins. On a tie, a LATER group wins (newest definition), but an
329
+ // earlier run within the SAME group is kept — which is the previous behaviour.
330
+ const len = j - i;
331
+ if (len > bestLen || (len === bestLen && len > 0 && g > bestGroup)) {
332
+ bestLen = len;
333
+ bestIdxs = idxs.slice(i, j);
334
+ bestGroup = g;
335
+ }
254
336
  }
255
337
  }
256
- if (bestStart === -1) return;
257
- const claimed = _pendingFields.splice(bestStart, bestLen);
258
- for (const e of claimed) e.apply(proto);
338
+ if (!bestIdxs || bestLen === 0) return;
339
+
340
+ // Apply, then remove the claimed entries. Removal is by descending buffer index so
341
+ // the earlier positions stay valid while splicing.
342
+ for (const bufIdx of bestIdxs) _pendingFields[bufIdx]!.apply(proto);
343
+ for (let k = bestIdxs.length - 1; k >= 0; k--) {
344
+ _pendingFields.splice(bestIdxs[k]!, 1);
345
+ }
259
346
  }
260
347
 
261
348
  /** Drain via an instance or a prototype before reading. */
@@ -291,7 +378,7 @@ function _fieldDecorator(
291
378
  return (_value, context) => {
292
379
  const name = String(context.name);
293
380
  _assertField(name, context);
294
- _enqueueField(name, (proto) => register(proto, name));
381
+ _enqueueField(name, (proto) => register(proto, name), context.metadata);
295
382
  };
296
383
  }
297
384
 
@@ -415,7 +502,7 @@ function _memoizeGetter(orig: () => unknown, name: string): () => unknown {
415
502
  * @remarks
416
503
  * A computed getter is not serialized and is not client-writable; it renders as static
417
504
  * server-produced text. The result is memoized for the duration of a single render pass
418
- * (Livewire-style per-request caching): an expensive getter read several times in one template
505
+ * — an expensive getter read several times in one template
419
506
  * runs once. The cache is scoped to the active render (see `_renderFlowPage`), so reads
420
507
  * outside rendering always recompute and never go stale. Apply only to a getter — a field-style
421
508
  * decorator on it is rejected; conversely `@computed` on a non-getter is a TypeScript error.
@@ -512,7 +599,7 @@ export function expose(_value: unknown, context: ExposeContext): void {
512
599
  }
513
600
  if (context.kind === "field") {
514
601
  // Field: capture in the body and enqueue (see _fieldDecorator / _drainFields).
515
- _enqueueField(name, (proto) => _register(_exposedProps, proto, name));
602
+ _enqueueField(name, (proto) => _register(_exposedProps, proto, name), context.metadata);
516
603
  return;
517
604
  }
518
605
  // Method: tag the function so `_scanProto` finds it on the DECLARING prototype (see header);
@@ -671,7 +758,7 @@ export function resolveSharedChannel(channel: SharedChannel, instance: object):
671
758
  // A child-component prop the PARENT can push new values into on every re-render.
672
759
  // Included in the snapshot and client-writable (so the framework can deliver
673
760
  // parent updates), and re-emitted by the parent so the child re-renders when the
674
- // bound value changes. Mirrors Livewire's #[Reactive].
761
+ // bound value changes.
675
762
 
676
763
  /**
677
764
  * Marks a child-component prop as reactive — the parent re-pushes new values into it on every
@@ -680,8 +767,8 @@ export function resolveSharedChannel(channel: SharedChannel, instance: object):
680
767
  * @remarks
681
768
  * The prop is included in the snapshot and is client-writable (so the framework can deliver the
682
769
  * parent's updates), and the parent re-emits it so the child re-renders when the bound value
683
- * changes. This is a one-way parent→child binding; use `@modelable` for two-way. Mirrors
684
- * Livewire's `#[Reactive]`. Applies to a field only.
770
+ * changes. This is a one-way parent→child binding; use `@modelable` for two-way.
771
+ * Applies to a field only.
685
772
  *
686
773
  * @example
687
774
  * ```tsx
@@ -700,8 +787,8 @@ export const reactive = _fieldDecorator((proto, name) => {
700
787
 
701
788
  // ── @modelable ────────────────────────────────────────────────────────────────
702
789
  // A reactive prop that ALSO syncs back to the parent: `value={this.x}` on a child
703
- // component binds the parent's `x` to the child's modelable prop both ways. Mirrors
704
- // Livewire's #[Modelable]. A modelable prop is implicitly reactive.
790
+ // component binds the parent's `x` to the child's modelable prop both ways.
791
+ // A modelable prop is implicitly reactive.
705
792
 
706
793
  /**
707
794
  * Marks a child-component prop as two-way bound to the parent (parent↔child).
@@ -709,8 +796,8 @@ export const reactive = _fieldDecorator((proto, name) => {
709
796
  * @remarks
710
797
  * A modelable prop is reactive (the parent pushes new values in on re-render) AND syncs back:
711
798
  * `value={this.x}` on a child component binds the parent's `x` to the child's modelable prop in
712
- * both directions. A modelable prop is implicitly reactive (it registers as both). Mirrors
713
- * Livewire's `#[Modelable]`. Applies to a field only.
799
+ * both directions. A modelable prop is implicitly reactive (it registers as both).
800
+ * Applies to a field only.
714
801
  *
715
802
  * @example
716
803
  * ```tsx
package/src/dehydrate.ts CHANGED
@@ -73,7 +73,7 @@ function currentSubject(): string | undefined {
73
73
 
74
74
  // ── Signing payload ───────────────────────────────────────────────────────────
75
75
  // children and listeners are excluded from signing:
76
- // - children: Livewire pattern — ids are derived server-side, never trusted from client
76
+ // - children: ids are derived server-side, never trusted from the client
77
77
  // - listeners: server-derived from class decorators, cannot meaningfully be tampered
78
78
  // with (@on methods are still validated against @expose allowlist on every call)
79
79
 
@@ -155,7 +155,7 @@ export function dehydrate(
155
155
  : undefined;
156
156
 
157
157
  // Children collected during the render that preceded this dehydrate call.
158
- // Excluded from HMAC (Livewire pattern).
158
+ // Excluded from HMAC — see the signing-payload note above.
159
159
  const snapshotMemo: Snapshot["memo"] = {
160
160
  ...memo,
161
161
  children: [...page._childIds],
package/src/events.ts CHANGED
@@ -40,7 +40,7 @@
40
40
  *
41
41
  * @category Realtime
42
42
  */
43
- // eslint-disable-next-line @typescript-eslint/no-empty-interface
43
+
44
44
  export interface FlowEvents {}
45
45
 
46
46
  /**
package/src/index.ts CHANGED
@@ -105,6 +105,10 @@ export {
105
105
  Alert,
106
106
  Table,
107
107
  Drawer,
108
+ ErrorBoundary,
109
+ SectionContent,
110
+ SectionOutlet,
111
+ Virtualize,
108
112
  } from "./components.ts";
109
113
  export type {
110
114
  LinkProps,
@@ -126,6 +130,10 @@ export type {
126
130
  TableProps,
127
131
  TableColumn,
128
132
  DrawerProps,
133
+ ErrorBoundaryProps,
134
+ SectionContentProps,
135
+ SectionOutletProps,
136
+ VirtualizeProps,
129
137
  } from "./components.ts";
130
138
 
131
139
  // Headless (unstyled, accessible) primitives — data-* state, Tailwind-friendly.
@@ -221,8 +229,6 @@ import "./synths/CarbonIntervalSynth.ts";
221
229
  import "./synths/CollectionSynth.ts";
222
230
  import "./synths/DateSynth.ts";
223
231
  import "./synths/TemporaryUploadedFileSynth.ts";
224
- import type { MiddlewareClass, RouteRegistration } from "@zerotal/core";
225
- import type { PageClassWithMeta } from "./registry.ts";
226
232
 
227
233
  // File uploads
228
234
  export { TemporaryUploadedFile } from "./uploads/TemporaryUploadedFile.ts";
@@ -163,6 +163,9 @@ interface FlowDirectives {
163
163
  [key: `flow:${string}`]: any;
164
164
  }
165
165
 
166
+ // TypeScript resolves JSX element/attribute types by looking up a JSX namespace —
167
+ // there is no module-shaped way to provide it.
168
+ // eslint-disable-next-line @typescript-eslint/no-namespace
166
169
  export namespace JSX {
167
170
  export type Element = HtmlNode;
168
171
 
@@ -184,6 +187,12 @@ export namespace JSX {
184
187
  lazy?: boolean;
185
188
  /** Mount a child component right after the first paint (non-blocking). */
186
189
  defer?: boolean;
190
+ /**
191
+ * Render a child component's placeholder now and its real markup later on the
192
+ * same response — no second round trip. For content that is definitely needed
193
+ * and merely slow; use `lazy`/`defer` for content that may never be needed.
194
+ */
195
+ stream?: boolean;
187
196
  /**
188
197
  * Named slot content for a child component: `slots={{ header: <h2>…</h2>, footer: … }}`.
189
198
  * The child places each with `this.slot("header")`; its plain children are the default slot.
@@ -266,9 +275,25 @@ function escapeAttr(s: string): string {
266
275
  // (e.g. two simultaneous GET requests each awaiting DB calls) cannot
267
276
  // cross-contaminate their page reference or child-thunk lists.
268
277
 
278
+ /**
279
+ * How a failed child render is rendered instead of propagating.
280
+ *
281
+ * Installed by `<ErrorBoundary>` onto the thunks whose tokens appear in its
282
+ * children — see `_protectThunks`.
283
+ */
284
+ export interface _ThunkBoundary {
285
+ fallback: (error: unknown) => string;
286
+ }
287
+
288
+ interface _Thunk {
289
+ token: string;
290
+ run: () => Promise<HtmlNode>;
291
+ boundary?: _ThunkBoundary;
292
+ }
293
+
269
294
  interface _RenderCtx {
270
295
  page: Record<string, unknown>;
271
- thunks: Array<{ token: string; run: () => Promise<HtmlNode> }>;
296
+ thunks: _Thunk[];
272
297
  seq: number;
273
298
  /**
274
299
  * Per-render random suffix on the child-placeholder token.
@@ -438,9 +463,17 @@ export async function _renderFlowPage(
438
463
  try {
439
464
  const node = await render();
440
465
  let html = node.html;
441
- for (const { token, run } of ctx.thunks) {
442
- const childNode = await run();
443
- html = html.split(token).join(childNode.html);
466
+ for (const { token, run, boundary } of ctx.thunks) {
467
+ let childHtml: string;
468
+ try {
469
+ childHtml = (await run()).html;
470
+ } catch (error) {
471
+ // Unprotected children still take the page down — a boundary is the
472
+ // opt-in, and swallowing errors by default would hide real bugs.
473
+ if (!boundary) throw error;
474
+ childHtml = boundary.fallback(error);
475
+ }
476
+ html = html.split(token).join(childHtml);
444
477
  }
445
478
  return html;
446
479
  } finally {
@@ -759,6 +792,42 @@ function toFlowHtmlAttr(k: string): string {
759
792
  return "flow:" + local.replace(/-/g, ".");
760
793
  }
761
794
 
795
+ /**
796
+ * Render a JSX child to its HTML string, escaping anything that is not already
797
+ * an {@link HtmlNode}.
798
+ *
799
+ * @internal Exported for built-in components that need their children as markup
800
+ * before the wrapper element is built — `<ErrorBoundary>` inspects the result
801
+ * for child tokens, `<SectionContent>` publishes it elsewhere entirely.
802
+ */
803
+ export function _renderChild(child: unknown): string {
804
+ return renderChild(child);
805
+ }
806
+
807
+ /**
808
+ * Route failures of the child components in `html` through `fallback`.
809
+ *
810
+ * `jsx()` has already queued a thunk for every `<Component />` encountered while
811
+ * building `html`, but none of them have run yet — they resolve after the parent
812
+ * render finishes. Matching on the tokens embedded in `html` is therefore enough
813
+ * to say which pending children sit inside this boundary, without the boundary
814
+ * needing to be told about them.
815
+ *
816
+ * A thunk already claimed by an inner boundary keeps it, so the nearest boundary
817
+ * wins.
818
+ *
819
+ * @internal
820
+ */
821
+ export function _protectThunks(html: string, fallback: (error: unknown) => string): void {
822
+ const ctx = _getRenderCtx();
823
+ if (!ctx) return;
824
+ for (const thunk of ctx.thunks) {
825
+ if (thunk.boundary === undefined && html.includes(thunk.token)) {
826
+ thunk.boundary = { fallback };
827
+ }
828
+ }
829
+ }
830
+
762
831
  function renderChild(child: unknown): string {
763
832
  if (child === null || child === undefined || child === false) return "";
764
833
  if (child === true) return "";
@@ -882,6 +951,7 @@ export function jsx(
882
951
  slots?: Record<string, string>;
883
952
  lazy?: boolean;
884
953
  defer?: boolean;
954
+ stream?: boolean;
885
955
  },
886
956
  ): Promise<HtmlNode>;
887
957
  }
@@ -902,6 +972,7 @@ export function jsx(
902
972
  slots: _namedSlots,
903
973
  lazy: _lazy,
904
974
  defer: _defer,
975
+ stream: _stream,
905
976
  ...componentProps
906
977
  } = p;
907
978
  const slots: Record<string, string> = {};
@@ -924,6 +995,7 @@ export function jsx(
924
995
  props: componentProps,
925
996
  ...(_lazy ? { lazy: true } : {}),
926
997
  ...(_defer ? { defer: true } : {}),
998
+ ...(_stream ? { stream: true } : {}),
927
999
  ...(Object.keys(slots).length ? { slots } : {}),
928
1000
  }),
929
1001
  });
@@ -1016,7 +1088,7 @@ export function jsx(
1016
1088
  // Emitted as flow:{event} so the bridge detects the $flow prefix and
1017
1089
  // evaluates it via Alpine instead of sending a WebSocket action.
1018
1090
  if (typeof target === "function" && !target.name && k.startsWith("on")) {
1019
- const body = _clientExprFromFn(target as Function);
1091
+ const body = _clientExprFromFn(target);
1020
1092
  const event = k.slice(2).toLowerCase();
1021
1093
  const base = `flow:${event}`;
1022
1094
  const attr = modifiers ? `${base}.${modifiers}` : base;
package/src/pagination.ts CHANGED
@@ -128,8 +128,7 @@ export function paginate<T>(items: T[], page = 1, perPage = 15): Paginator<T> {
128
128
 
129
129
  // ── Pagination mixin ──────────────────────────────────────────────────────────
130
130
  //
131
- // The TypeScript equivalent of Livewire's `WithPagination` trait: compose it onto a
132
- // component to add a URL-synced `page` plus page-navigation methods (`nextPage`,
131
+ // A mixin: compose it onto a component to add a URL-synced `page` plus page-navigation methods (`nextPage`,
133
132
  // `previousPage`, `gotoPage`, `resetPage`), WITHOUT putting any of that on
134
133
  // the base Component.
135
134
  //
@@ -147,12 +146,12 @@ export function paginate<T>(items: T[], page = 1, perPage = 15): Paginator<T> {
147
146
  // applied here register on the mixin's prototype, which the component's prototype-chain
148
147
  // walk already traverses — so it "just works".
149
148
  //
150
- // Named paginators (Livewire's `pageName`): every nav method accepts an optional page
149
+ // Named paginators: every nav method accepts an optional page
151
150
  // name so a single page can drive more than one independent paginator —
152
151
  // `this.nextPage("invoices")`, `Invoice.paginate(10, undefined, "invoices")`. The default
153
152
  // paginator ("page") is the URL-synced one; named paginators live in the snapshot.
154
153
  //
155
- // Optional update hooks fire around a page change, mirroring Livewire — define any of
154
+ // Optional update hooks fire around a page change — define any of
156
155
  // `updatingPage(page, name)` / `updatedPage(page, name)` (default paginator) or the
157
156
  // generic `updatingPaginators(page, name)` / `updatedPaginators(page, name)`.
158
157
 
@@ -174,8 +173,7 @@ interface PaginationHooks {
174
173
  const DEFAULT_PAGE = "page";
175
174
 
176
175
  /**
177
- * Component mixin that adds URL-synced pagination state and navigation actions —
178
- * flow's equivalent of Livewire's `WithPagination` trait.
176
+ * Component mixin that adds URL-synced pagination state and navigation actions.
179
177
  *
180
178
  * @remarks
181
179
  * Composing this onto a component adds:
@@ -221,7 +219,7 @@ export function Pagination<TBase extends AbstractComponentCtor>(Base: TBase) {
221
219
  /** Current page of the default paginator, synced to the `?page=` query string. */
222
220
  @expose @url page = 1;
223
221
 
224
- /** Current page of each NAMED paginator (Livewire `pageName`), keyed by name. Snapshot-only. */
222
+ /** Current page of each NAMED paginator, keyed by name. Snapshot-only. */
225
223
  @expose paginators: Record<string, number> = {};
226
224
 
227
225
  /**
@@ -97,7 +97,7 @@ export function _runtimeJs(): string | null {
97
97
  return _runtimeBundle;
98
98
  }
99
99
 
100
- // ── Rate limiter for HMAC failures (Livewire pattern) ─────────────────────────
100
+ // ── Rate limiter for HMAC failures ───────────────────────────────────────────
101
101
 
102
102
  const _checksumFailures = new Map<string, { count: number; since: number }>();
103
103
  const CHECKSUM_MAX = 10;
@@ -260,7 +260,7 @@ async function _handleFlowMessage(ws: FlowBunWs, raw: string, deps: FlowWsDeps):
260
260
  // with no user at all, so every action on an authenticated page is rejected
261
261
  // as somebody else's.
262
262
 
263
- // ── Persistent middleware (Livewire-style) ──────────────────────────────────
263
+ // ── Persistent middleware ───────────────────────────────────────────────────
264
264
  const pagePath = frame.snapshot.memo.path;
265
265
  const middleware = [
266
266
  ...deps.globalMiddleware().filter(_isPersistentMiddleware),
@@ -785,7 +785,7 @@ async function _dispatchFrame(
785
785
  ws.send(JSON.stringify({ type: "flash", component: canonicalId, ...f }));
786
786
  }
787
787
 
788
- // Dispatch cross-component events (to/self targeting mirrors Livewire's dispatch()->to()/->self()).
788
+ // Dispatch cross-component events (to/self targeting).
789
789
  for (const event of effects.events) {
790
790
  ws.send(
791
791
  JSON.stringify({
@@ -922,10 +922,9 @@ export class FlowProvider extends ServiceProvider {
922
922
  }
923
923
 
924
924
  /**
925
- * Global middleware re-applied on every WebSocket update (Livewire-style
926
- * persistent middleware). Matched against the app's global pipeline by
927
- * class reference or class name. Route middleware always re-runs and does
928
- * not need to be listed here.
925
+ * Global middleware re-applied on every WebSocket update. Matched against the
926
+ * app's global pipeline by class reference or class name. Route middleware
927
+ * always re-runs and does not need to be listed here.
929
928
  */
930
929
  static persistentMiddleware: (string | MiddlewareClass)[] = [...DEFAULT_PERSISTENT_MIDDLEWARE];
931
930
 
@@ -966,6 +965,11 @@ export class FlowProvider extends ServiceProvider {
966
965
  this._applyPersistentMiddlewareConfig();
967
966
  Router.macro("flow", flowRoute);
968
967
 
968
+ // Declared here rather than alongside the handlers below, because the handlers are
969
+ // only wired in the web runtime and `bun zt doctor --url=…` runs in a console one.
970
+ // The socket is the whole application, so a CLI that cannot name it cannot check it.
971
+ this.app.declareWebSocketPath("/__flow/ws");
972
+
969
973
  registerFileRouteResolver(({ urlPath, module, middleware, name, filePath }) => {
970
974
  const PageClass = _findPageExport(module);
971
975
  if (!PageClass) return false;
@@ -1244,8 +1248,13 @@ export class FlowProvider extends ServiceProvider {
1244
1248
 
1245
1249
  if (!cssExists && !jsExists) return;
1246
1250
 
1247
- const { registerDevBuildHook, buildCssBundle, buildJsBundle } =
1248
- await import("@zerotal/core/dev");
1251
+ const {
1252
+ registerDevBuildHook,
1253
+ buildCssBundle,
1254
+ buildJsBundle,
1255
+ isDevOrchestrated,
1256
+ bootBuildDecision,
1257
+ } = await import("@zerotal/core/dev");
1249
1258
 
1250
1259
  // Combined hook — DevOrchestrator calls this on any frontend file change.
1251
1260
  // Registered under Flow's own name so it sits alongside, rather than on
@@ -1256,16 +1265,40 @@ export class FlowProvider extends ServiceProvider {
1256
1265
  const results = await Promise.all([
1257
1266
  cssExists
1258
1267
  ? buildCssBundle(cssEntry, cssOutdir, false)
1259
- : Promise.resolve({ success: true, logs: [] as unknown[] }),
1268
+ : Promise.resolve({ success: true, logs: [] as unknown[], skipped: true }),
1260
1269
  jsExists
1261
1270
  ? buildJsBundle(jsEntry, jsOutdir, false)
1262
- : Promise.resolve({ success: true, logs: [] as unknown[] }),
1271
+ : Promise.resolve({ success: true, logs: [] as unknown[], skipped: true }),
1263
1272
  ]);
1264
1273
  const failed = results.flatMap((r) => (r.success ? [] : (r.logs ?? [])));
1265
- return { success: failed.length === 0, logs: failed };
1274
+ return {
1275
+ success: failed.length === 0,
1276
+ logs: failed,
1277
+ // A bundle with no entry point counts as skipped: it did no work either.
1278
+ skipped: results.every((r) => r.skipped === true),
1279
+ };
1266
1280
  });
1267
1281
 
1268
- // Build once at startup so assets are ready before the first request.
1282
+ // Build once at startup so assets are ready before the first request —
1283
+ // except under `serve --dev`, where the orchestrator owns builds and has
1284
+ // already run the hook above before this process could serve anything.
1285
+ // Without this guard a Flow app built its bundles three times per boot (once
1286
+ // here in the orchestrator process, once in the orchestrator's own initial
1287
+ // build, once here again in the spawned worker) and twice per backend save.
1288
+ if (isDevOrchestrated()) return;
1289
+
1290
+ // Nor in a production deployment whose output directory is read-only: those assets
1291
+ // were built at deploy time, and rebuilding them is what turns a hardened systemd
1292
+ // unit into a restart loop. See @zerotal/core/dev/bootBuild.ts.
1293
+ const decision = await bootBuildDecision(
1294
+ [...(cssExists ? [cssOutdir] : []), ...(jsExists ? [jsOutdir] : [])],
1295
+ config.safe<string>("app.env", Bun.env["APP_ENV"] ?? "development"),
1296
+ );
1297
+ if (!decision.build) {
1298
+ console.info(`[Flow] ${decision.reason}`);
1299
+ return;
1300
+ }
1301
+
1269
1302
  const [cssResult, jsResult] = await Promise.all([
1270
1303
  cssExists
1271
1304
  ? buildCssBundle(cssEntry, cssOutdir, false)