@zerotal/flow 1.3.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.
@@ -59,6 +59,7 @@ interface FlowDirectives {
59
59
 
60
60
  navigate?: boolean;
61
61
  navigateHover?: boolean;
62
+ navigatePreserveScroll?: boolean;
62
63
  current?: string;
63
64
  currentExact?: boolean;
64
65
  currentStrict?: boolean;
@@ -162,6 +163,9 @@ interface FlowDirectives {
162
163
  [key: `flow:${string}`]: any;
163
164
  }
164
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
165
169
  export namespace JSX {
166
170
  export type Element = HtmlNode;
167
171
 
@@ -183,6 +187,12 @@ export namespace JSX {
183
187
  lazy?: boolean;
184
188
  /** Mount a child component right after the first paint (non-blocking). */
185
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;
186
196
  /**
187
197
  * Named slot content for a child component: `slots={{ header: <h2>…</h2>, footer: … }}`.
188
198
  * The child places each with `this.slot("header")`; its plain children are the default slot.
@@ -265,9 +275,25 @@ function escapeAttr(s: string): string {
265
275
  // (e.g. two simultaneous GET requests each awaiting DB calls) cannot
266
276
  // cross-contaminate their page reference or child-thunk lists.
267
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
+
268
294
  interface _RenderCtx {
269
295
  page: Record<string, unknown>;
270
- thunks: Array<{ token: string; run: () => Promise<HtmlNode> }>;
296
+ thunks: _Thunk[];
271
297
  seq: number;
272
298
  /**
273
299
  * Per-render random suffix on the child-placeholder token.
@@ -437,9 +463,17 @@ export async function _renderFlowPage(
437
463
  try {
438
464
  const node = await render();
439
465
  let html = node.html;
440
- for (const { token, run } of ctx.thunks) {
441
- const childNode = await run();
442
- 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);
443
477
  }
444
478
  return html;
445
479
  } finally {
@@ -704,6 +738,10 @@ const PULSE_PROP_MAP: Record<string, string> = {
704
738
  dirtyTarget: "flow:target",
705
739
  navigate: "flow:navigate",
706
740
  navigateHover: "flow:navigate.hover",
741
+ // One word on purpose: toFlowHtmlAttr() rewrites every `-` in a flow: attribute
742
+ // to a `.`, so a hyphenated modifier would reach the DOM under a different name
743
+ // here than the compiler emits.
744
+ navigatePreserveScroll: "flow:navigate.preserve",
707
745
  current: "flow:current",
708
746
  currentExact: "flow:current.exact",
709
747
  currentStrict: "flow:current.strict",
@@ -754,6 +792,42 @@ function toFlowHtmlAttr(k: string): string {
754
792
  return "flow:" + local.replace(/-/g, ".");
755
793
  }
756
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
+
757
831
  function renderChild(child: unknown): string {
758
832
  if (child === null || child === undefined || child === false) return "";
759
833
  if (child === true) return "";
@@ -877,6 +951,7 @@ export function jsx(
877
951
  slots?: Record<string, string>;
878
952
  lazy?: boolean;
879
953
  defer?: boolean;
954
+ stream?: boolean;
880
955
  },
881
956
  ): Promise<HtmlNode>;
882
957
  }
@@ -897,6 +972,7 @@ export function jsx(
897
972
  slots: _namedSlots,
898
973
  lazy: _lazy,
899
974
  defer: _defer,
975
+ stream: _stream,
900
976
  ...componentProps
901
977
  } = p;
902
978
  const slots: Record<string, string> = {};
@@ -919,6 +995,7 @@ export function jsx(
919
995
  props: componentProps,
920
996
  ...(_lazy ? { lazy: true } : {}),
921
997
  ...(_defer ? { defer: true } : {}),
998
+ ...(_stream ? { stream: true } : {}),
922
999
  ...(Object.keys(slots).length ? { slots } : {}),
923
1000
  }),
924
1001
  });
@@ -1011,7 +1088,7 @@ export function jsx(
1011
1088
  // Emitted as flow:{event} so the bridge detects the $flow prefix and
1012
1089
  // evaluates it via Alpine instead of sending a WebSocket action.
1013
1090
  if (typeof target === "function" && !target.name && k.startsWith("on")) {
1014
- const body = _clientExprFromFn(target as Function);
1091
+ const body = _clientExprFromFn(target);
1015
1092
  const event = k.slice(2).toLowerCase();
1016
1093
  const base = `flow:${event}`;
1017
1094
  const attr = modifiers ? `${base}.${modifiers}` : base;
@@ -1154,8 +1231,14 @@ export function jsx(
1154
1231
  continue;
1155
1232
  }
1156
1233
 
1157
- let htmlKey = PULSE_PROP_MAP[k] || k;
1158
- htmlKey = toFlowHtmlAttr(htmlKey);
1234
+ // A mapped value is the attribute name the compiler emits and the bridge queries
1235
+ // for, so it goes out verbatim. Running it through toFlowHtmlAttr() rewrote the
1236
+ // hyphen in `flow:focus-error` to a dot, and the runtime path then emitted an
1237
+ // attribute nothing listens for: `focusOnError` worked on a compiled page and did
1238
+ // nothing at all on one that fell back to the runtime, with no error either way.
1239
+ // The kebab→dot rewrite is for hand-written `flow:*` attributes only.
1240
+ const mapped = PULSE_PROP_MAP[k];
1241
+ const htmlKey = mapped ?? toFlowHtmlAttr(k);
1159
1242
 
1160
1243
  // Special object formatting for `confirm={{ message, title?, confirm?, cancel?,
1161
1244
  // variant?, danger?, icon?, prompt? }}`. The bare {message} and {message,prompt}
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)