@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.
- package/CHANGELOG.md +317 -4
- package/README.md +1 -1
- package/package.json +8 -9
- package/src/Component.ts +214 -41
- package/src/browser/FlowBrowser.ts +270 -0
- package/src/browser/cdp.ts +273 -0
- package/src/browser/index.ts +19 -0
- package/src/client/FlowComponent.ts +1 -1
- package/src/client/bridge.ts +345 -38
- package/src/client/headless.ts +2 -1
- package/src/compiler/directives.ts +1 -0
- package/src/compiler/transform.ts +4 -0
- package/src/components.ts +286 -4
- package/src/config.ts +2 -2
- package/src/decorators.ts +122 -35
- package/src/dehydrate.ts +2 -2
- package/src/events.ts +1 -1
- package/src/index.ts +8 -2
- package/src/jsx-runtime.ts +90 -7
- package/src/pagination.ts +5 -7
- package/src/provider/FlowProvider.ts +46 -13
- package/src/router.ts +186 -116
- package/src/sections.ts +121 -0
- package/src/store.ts +0 -1
- package/src/streaming.ts +92 -0
- package/src/synths/index.ts +2 -2
- package/src/testing.ts +1 -1
- package/src/types.ts +1 -1
- package/src/uploads/FileUploads.ts +3 -4
- package/src/uploads/TemporaryUploadedFile.ts +1 -1
package/src/jsx-runtime.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
442
|
-
|
|
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
|
|
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
|
-
|
|
1158
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
926
|
-
*
|
|
927
|
-
*
|
|
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 {
|
|
1248
|
-
|
|
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 {
|
|
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)
|