@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 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.5.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.5.1",
50
- "@zerotal/orm": "1.5.1",
51
- "@zerotal/validator": "1.5.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.5.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
- return (Bun.env["APP_ENV"] ?? Bun.env["NODE_ENV"]) === "production";
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. */
@@ -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
@@ -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
 
@@ -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
- const env = Bun.env["APP_ENV"] ?? "development";
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
- minify: Bun.env["APP_ENV"] === "production",
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
- http.response = new Response(_runtimeBundle, {
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", Bun.env["APP_ENV"] ?? "development"),
1319
+ config.safe<string>("app.env", deployEnv() || "development"),
1296
1320
  );
1297
1321
  if (!decision.build) {
1298
1322
  console.info(`[Flow] ${decision.reason}`);