@zerotal/flow 1.5.1 → 1.6.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 +22 -0
- package/package.json +5 -5
- package/src/Component.ts +4 -1
- package/src/client/bridge.ts +7 -0
- package/src/client/index.csp.ts +4 -0
- package/src/client/index.ts +4 -0
- package/src/client/routes.ts +40 -0
- package/src/dehydrate.ts +2 -2
- package/src/provider/FlowProvider.ts +27 -3
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,28 @@ change.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [1.6.0] — 2026-08-15
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **`$route()` in Alpine expressions.** Build a URL by route name in the browser, from the
|
|
19
|
+
same table the server rendered with:
|
|
20
|
+
|
|
21
|
+
```html
|
|
22
|
+
<a :href="$route('posts.show', { slug: post.slug })">Read</a>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Nothing to install and nothing to wire up. Inertia apps hand `route()` their table by
|
|
26
|
+
static import, but `/__flow/runtime.js` is built by the framework rather than by your app,
|
|
27
|
+
so it cannot import a file that lives in the application. The runtime handler serialises
|
|
28
|
+
`Router.namedRoutes` onto the bundle it serves instead — at serve time, not at build time,
|
|
29
|
+
because the bundle is built in `onBooting()` when providers registered after Flow have not
|
|
30
|
+
added their routes yet. The bundle is already `no-store`, so there is no cache to
|
|
31
|
+
invalidate and nothing is added to any page's HTML.
|
|
32
|
+
|
|
33
|
+
Same helper as the server's `route()`, sharing one URL builder, so a link built in an
|
|
34
|
+
Alpine expression and one built in JSX cannot disagree about encoding.
|
|
35
|
+
|
|
14
36
|
## [1.5.0] — 2026-08-15
|
|
15
37
|
|
|
16
38
|
### Changed — BREAKING
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/flow",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
@@ -46,14 +46,14 @@
|
|
|
46
46
|
"@alpinejs/morph": "^3.14.9",
|
|
47
47
|
"@alpinejs/persist": "^3.14.9",
|
|
48
48
|
"@alpinejs/resize": "^3.14.9",
|
|
49
|
-
"@zerotal/core": "1.
|
|
50
|
-
"@zerotal/orm": "1.
|
|
51
|
-
"@zerotal/validator": "1.
|
|
49
|
+
"@zerotal/core": "1.6.0",
|
|
50
|
+
"@zerotal/orm": "1.6.0",
|
|
51
|
+
"@zerotal/validator": "1.6.0",
|
|
52
52
|
"alpinejs": "^3.14.9",
|
|
53
53
|
"typescript": "^5.8.0"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
|
-
"@zerotal/broadcasting": "1.
|
|
56
|
+
"@zerotal/broadcasting": "1.6.0",
|
|
57
57
|
"@types/alpinejs": "^3.13.10",
|
|
58
58
|
"@types/alpinejs__anchor": "^3.13.1",
|
|
59
59
|
"@types/alpinejs__collapse": "^3.13.4",
|
package/src/Component.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { _validateEventPayload } from "./events.ts";
|
|
|
30
30
|
import type { FlowEvents, EventName, EventArgs } from "./events.ts";
|
|
31
31
|
import { route, request, safeRedirectPath, HttpContext } from "@zerotal/core";
|
|
32
32
|
import type { RouteParamValues, RouteParamsArg, RouteTarget } from "@zerotal/core";
|
|
33
|
+
import { isProdLike, deployEnv } from "@zerotal/core";
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
36
|
* Convert the validator's one-message-per-field result (`Record<string, string>`) into
|
|
@@ -167,7 +168,9 @@ function _fnv1a(input: string): string {
|
|
|
167
168
|
}
|
|
168
169
|
|
|
169
170
|
function _isProduction(): boolean {
|
|
170
|
-
|
|
171
|
+
// `deployEnv()`, not `APP_ENV` — the latter holds the runtime mode once the app
|
|
172
|
+
// has booted, so this answered "no" on every production server.
|
|
173
|
+
return isProdLike(deployEnv()) || Bun.env["NODE_ENV"] === "production";
|
|
171
174
|
}
|
|
172
175
|
|
|
173
176
|
/** Classes already warned about, so a hundred-row list logs once. */
|
package/src/client/bridge.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { confirmDialog, type ConfirmDialogOptions } from "./confirm.ts";
|
|
|
34
34
|
import { showErrorOverlay } from "./errorOverlay.ts";
|
|
35
35
|
import { applyAppend, applyRemove, type OptOp } from "./optimistic.ts";
|
|
36
36
|
import { clientStore } from "../store.ts";
|
|
37
|
+
import { route } from "@zerotal/core/routes";
|
|
37
38
|
import { URL_ATTRIBUTES, sanitizeUrl } from "../urlSafety.ts";
|
|
38
39
|
import {
|
|
39
40
|
recordFrame as _recordTimelineFrame,
|
|
@@ -3659,6 +3660,12 @@ export function registerFlowMagic(alpine: AlpineType): void {
|
|
|
3659
3660
|
// $store — the "flow" Alpine store (app-wide, client-only UI state). It's a real Alpine
|
|
3660
3661
|
// store, so reads inside a binding track and writes re-render with no round-trip. Declare
|
|
3661
3662
|
// its shape with defineStore(); see store.ts.
|
|
3663
|
+
// $route('posts.show', { slug }) — the same helper the server renders
|
|
3664
|
+
// with, so a link built in an Alpine expression and one built in JSX
|
|
3665
|
+
// resolve identically. The table is installed from window.__zerotalRoutes
|
|
3666
|
+
// by the bundle entry (see installClientRoutes).
|
|
3667
|
+
if (key === "$route") return route;
|
|
3668
|
+
|
|
3662
3669
|
if (key === "$store") return clientStore();
|
|
3663
3670
|
|
|
3664
3671
|
// $dispatch('event', {data}) — fire an event to every component on the page
|
package/src/client/index.csp.ts
CHANGED
|
@@ -20,6 +20,10 @@ import { initBridge, registerFlowMagic, setCspMode } from "./bridge.ts";
|
|
|
20
20
|
import { registerHeadless } from "./headless.ts";
|
|
21
21
|
import { installCspEvaluator } from "./cspAdapter.ts";
|
|
22
22
|
import { initClientStore } from "../store.ts";
|
|
23
|
+
import { installClientRoutes } from "./routes.ts";
|
|
24
|
+
|
|
25
|
+
// Install the route table before anything can evaluate a `$route(...)` binding.
|
|
26
|
+
installClientRoutes();
|
|
23
27
|
|
|
24
28
|
(window as unknown as Record<string, unknown>).Alpine = Alpine;
|
|
25
29
|
|
package/src/client/index.ts
CHANGED
|
@@ -21,6 +21,10 @@ import resize from "@alpinejs/resize";
|
|
|
21
21
|
import { initBridge, registerFlowMagic } from "./bridge.ts";
|
|
22
22
|
import { registerHeadless } from "./headless.ts";
|
|
23
23
|
import { initClientStore } from "../store.ts";
|
|
24
|
+
import { installClientRoutes } from "./routes.ts";
|
|
25
|
+
|
|
26
|
+
// Install the route table before anything can evaluate a `$route(...)` binding.
|
|
27
|
+
installClientRoutes();
|
|
24
28
|
|
|
25
29
|
// Expose Alpine on window so FlowComponent and third-party Alpine plugins
|
|
26
30
|
// can access it via window.Alpine (standard Alpine convention).
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install the route table into the Flow client runtime.
|
|
3
|
+
*
|
|
4
|
+
* Inertia apps hand `route()` their table by static import, because they own
|
|
5
|
+
* their bundle entry. Flow apps don't: `/__flow/runtime.js` is built by the
|
|
6
|
+
* framework from `client/index.ts`, so it cannot import a file that lives in
|
|
7
|
+
* the application. The table is handed over at serve time instead — the runtime
|
|
8
|
+
* handler prepends `window.__zerotalRoutes = {...}` to the bundle it returns
|
|
9
|
+
* (see `FlowProvider`), and this reads it back.
|
|
10
|
+
*
|
|
11
|
+
* Prepending rather than embedding at build time is deliberate: the bundle is
|
|
12
|
+
* built in `onBooting()`, when providers registered after Flow have not added
|
|
13
|
+
* their routes yet. The handler runs per request, long after boot, so the table
|
|
14
|
+
* it serialises is the complete one. The bundle is already `no-store`, so there
|
|
15
|
+
* is no cache to invalidate.
|
|
16
|
+
*/
|
|
17
|
+
import { defineRoutes } from "@zerotal/core/routes";
|
|
18
|
+
|
|
19
|
+
// Declared rather than cast at the call site: `window` genuinely carries this
|
|
20
|
+
// property in a Flow page, and saying so once is what makes reading it type-safe
|
|
21
|
+
// everywhere instead of an assertion each time.
|
|
22
|
+
declare global {
|
|
23
|
+
interface Window {
|
|
24
|
+
/** The route table the runtime handler prepends to the bundle. */
|
|
25
|
+
__zerotalRoutes?: Readonly<Record<string, string>>;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read `window.__zerotalRoutes` and install it, so `$route(...)` in an Alpine
|
|
31
|
+
* expression resolves the same names the server rendered with.
|
|
32
|
+
*
|
|
33
|
+
* Installs an empty table when the global is absent — `route()` then throws
|
|
34
|
+
* "Named route not found" for any name, which is the truthful error. Leaving it
|
|
35
|
+
* uninstalled would instead report a missing `defineRoutes()` call and send the
|
|
36
|
+
* reader looking for app wiring that Flow does not have.
|
|
37
|
+
*/
|
|
38
|
+
export function installClientRoutes(): void {
|
|
39
|
+
defineRoutes(window.__zerotalRoutes ?? {});
|
|
40
|
+
}
|
package/src/dehydrate.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
resolveSharedChannel,
|
|
15
15
|
} from "./decorators.ts";
|
|
16
16
|
import { ZerotalError, safeEqual, hmacHex, RequestContext } from "@zerotal/core";
|
|
17
|
+
import { isProdLike, deployEnv } from "@zerotal/core";
|
|
17
18
|
|
|
18
19
|
// ── Stable JSON stringify ─────────────────────────────────────────────────────
|
|
19
20
|
// Keys are sorted so the HMAC is deterministic regardless of insertion order.
|
|
@@ -316,8 +317,7 @@ export function applySnapshotDelta(prev: Snapshot, delta: SnapshotDelta): Snapsh
|
|
|
316
317
|
const SNAPSHOT_WARN_BYTES = 100 * 1024; // 100 KB
|
|
317
318
|
|
|
318
319
|
export function warnIfLarge(snapshot: Snapshot, name: string): void {
|
|
319
|
-
|
|
320
|
-
if (env === "production" || env === "prod") return;
|
|
320
|
+
if (isProdLike(deployEnv())) return;
|
|
321
321
|
|
|
322
322
|
// Buffer.byteLength is a native call that avoids allocating a typed array.
|
|
323
323
|
const size = Buffer.byteLength(JSON.stringify(snapshot), "utf8");
|
|
@@ -19,6 +19,7 @@ import { installFlowObservability } from "../observability.ts";
|
|
|
19
19
|
import { DEFAULT_PERSISTENT_MIDDLEWARE } from "../config.ts";
|
|
20
20
|
import type { AppEnvironment, Container, MiddlewareClass, NextFn } from "@zerotal/core";
|
|
21
21
|
import type { WebSocketHandlers } from "@zerotal/core";
|
|
22
|
+
import { isProdLike, deployEnv } from "@zerotal/core";
|
|
22
23
|
import type { CallFrame, Snapshot } from "../types.ts";
|
|
23
24
|
import { getPage, getAllowedMethods, getRenderlessMethods, BUILTIN_ACTIONS } from "../registry.ts";
|
|
24
25
|
import {
|
|
@@ -44,6 +45,21 @@ import { isAllowedOrigin } from "@zerotal/core/http";
|
|
|
44
45
|
|
|
45
46
|
// ── In-memory runtime.js bundle (built once at boot) ─────────────────────────
|
|
46
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The `window.__zerotalRoutes = {...}` line prepended to the served runtime
|
|
50
|
+
* bundle, which `installClientRoutes()` reads back on load.
|
|
51
|
+
*
|
|
52
|
+
* Served as JavaScript, not embedded in HTML, so the `</script>` hazard the
|
|
53
|
+
* state island guards against does not apply here — this response is never
|
|
54
|
+
* parsed as markup. Plain `JSON.stringify` is enough.
|
|
55
|
+
*
|
|
56
|
+
* @internal exported for tests.
|
|
57
|
+
*/
|
|
58
|
+
export function _routeTablePrelude(): string {
|
|
59
|
+
const table = Object.fromEntries(Router.namedRoutes);
|
|
60
|
+
return `window.__zerotalRoutes=${JSON.stringify(table)};\n`;
|
|
61
|
+
}
|
|
62
|
+
|
|
47
63
|
/** True when this process is the `serve --dev-worker` (dev fast refresh gates on it). */
|
|
48
64
|
const _isDevWorker = Bun.argv.includes("--dev-worker");
|
|
49
65
|
|
|
@@ -998,7 +1014,9 @@ export class FlowProvider extends ServiceProvider {
|
|
|
998
1014
|
const result = await Bun.build({
|
|
999
1015
|
entrypoints: [entry],
|
|
1000
1016
|
target: "browser",
|
|
1001
|
-
|
|
1017
|
+
// `deployEnv()` — comparing APP_ENV ran the check against the runtime mode
|
|
1018
|
+
// ("web"), so the client bundle shipped UNMINIFIED to every production visitor.
|
|
1019
|
+
minify: isProdLike(deployEnv()),
|
|
1002
1020
|
format: "esm",
|
|
1003
1021
|
});
|
|
1004
1022
|
|
|
@@ -1023,7 +1041,13 @@ export class FlowProvider extends ServiceProvider {
|
|
|
1023
1041
|
});
|
|
1024
1042
|
return;
|
|
1025
1043
|
}
|
|
1026
|
-
|
|
1044
|
+
// Prepend the named-route table so `$route(...)` resolves the same
|
|
1045
|
+
// names the server renders with. Serialised here rather than baked
|
|
1046
|
+
// into the bundle because the bundle is built in onBooting(), before
|
|
1047
|
+
// providers registered after Flow have added their routes; this
|
|
1048
|
+
// handler runs per request, so the table is complete. `no-store`
|
|
1049
|
+
// above means there is no cached copy to go stale.
|
|
1050
|
+
http.response = new Response(`${_routeTablePrelude()}${_runtimeBundle}`, {
|
|
1027
1051
|
status: 200,
|
|
1028
1052
|
headers: {
|
|
1029
1053
|
"Content-Type": "text/javascript; charset=utf-8",
|
|
@@ -1292,7 +1316,7 @@ export class FlowProvider extends ServiceProvider {
|
|
|
1292
1316
|
// unit into a restart loop. See @zerotal/core/dev/bootBuild.ts.
|
|
1293
1317
|
const decision = await bootBuildDecision(
|
|
1294
1318
|
[...(cssExists ? [cssOutdir] : []), ...(jsExists ? [jsOutdir] : [])],
|
|
1295
|
-
config.safe<string>("app.env",
|
|
1319
|
+
config.safe<string>("app.env", deployEnv() || "development"),
|
|
1296
1320
|
);
|
|
1297
1321
|
if (!decision.build) {
|
|
1298
1322
|
console.info(`[Flow] ${decision.reason}`);
|