@zerotal/core 1.5.0 → 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
@@ -8,6 +8,96 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.6.0] — 2026-08-15
12
+
13
+ ### Added
14
+
15
+ - **`route()` in the browser — `@zerotal/core/routes`.** The typed `route()` helper now has a
16
+ browser twin. Hand it the table `bun zt route:types` already generates, once, at your
17
+ entry point:
18
+
19
+ ```ts
20
+ import { defineRoutes } from "zerotal/routes";
21
+ import { ROUTES } from "../../types/routes.generated";
22
+
23
+ defineRoutes(ROUTES);
24
+ ```
25
+
26
+ and `route("posts.show", { slug })` works in a component exactly as it does in a
27
+ controller. The table is a build-time constant, so it costs one static import, ships
28
+ nothing per response, and needs no fetch before the first link renders. `hasRoute(name)`
29
+ answers the conditional-link question without a try/catch, and `resetRoutes()` clears the
30
+ table for tests.
31
+
32
+ The two helpers are one implementation, not two that agree today. Everything a caller can
33
+ observe — param encoding, catch-all handling, which mistakes throw and what they say —
34
+ moved into a shared builder; only the table lookup differs (the live router on the server,
35
+ the generated map in the browser). Both are typed as the same `RouteBuilder` interface, and
36
+ both read the same `RouteRegistry`, so a name that type-checks in a controller type-checks
37
+ in a component and a missing `:param` fails the build on either side. A parity test asserts
38
+ the two produce byte-identical URLs and identical error messages.
39
+
40
+ The entry point is browser-safe by construction: it imports nothing that touches `Bun`,
41
+ the container, or request state.
42
+
43
+ ### Fixed
44
+
45
+ - **Ten more places asked `APP_ENV` a question it cannot answer.** 1.5.0 and 1.5.1 each
46
+ fixed the instances that had been noticed; this is the audit of every remaining reader,
47
+ and it found more than either. `APP_ENV` holds the runtime mode once `setAppEnv()` has
48
+ run, so any check comparing it against a deployment name was asking whether `"web"` is
49
+ production. In core and its packages that meant:
50
+
51
+ - **auto-`synchronize` was never hard-off in production**, so the only thing between a
52
+ production database and boot-time schema sync was the config default. The comment above
53
+ the guard claimed `APP_ENV` still held the deployment name, which is precisely the
54
+ mistake;
55
+ - **the Flow client bundle was never minified in production**, shipping ~183 KB
56
+ unminified to every visitor;
57
+ - **`forceState()`** did not refuse to run on live data despite the throw written for it;
58
+ - **environment-scoped scheduled tasks never matched**, so `.environments(["production"])`
59
+ silently never ran;
60
+ - the N+1 detector's own gate, the monitor's reported environment, the admin environment
61
+ badge, the large-snapshot warning, and four config-first reads whose fallback was the
62
+ runtime mode.
63
+
64
+ All of them read `deployEnv()` now. Everything still fails closed: production, staging and
65
+ an unset environment behave exactly as before.
66
+
67
+ - **`useOnce()` demanded a cast from every caller.** `PipeClass` used `unknown[]` for its
68
+ constructor arguments, which fails parameter contravariance for any middleware class with
69
+ a typed constructor — so all eight packages that register middleware wrote
70
+ `useOnce(Middleware as never)`, twelve times over. It uses the codebase's standard
71
+ `any[]` constructor shape now, the same reasoning `container/types.ts` already documented
72
+ for container tokens, and all twelve casts are gone.
73
+
74
+ ### Changed
75
+
76
+ - **Reading `Bun.env["APP_ENV"]` directly is now a lint error.** Fourteen instances of one
77
+ mistake across seven packages were each found separately; the rule is so the fifteenth is
78
+ found by CI. Tests are exempt — pinning the variable is how a test reproduces what a
79
+ server sees — and the handful of genuine runtime-mode reads carry an inline disable saying
80
+ which of the two meanings they want.
81
+
82
+ ## [1.5.1] — 2026-08-15
83
+
84
+ ### Fixed
85
+
86
+ - **Every development-only surface switched itself off under `zt serve`.**
87
+ `devSurfacesEnabled()` asked `Bun.env["APP_ENV"]` whether this was a development
88
+ environment — but `setAppEnv()` replaces that with the runtime mode before the app is
89
+ created, so it was asking whether `"web"` is development. The answer is no. An app with
90
+ `APP_ENV=development` in its `.env` therefore got **production error pages** from a plain
91
+ `bun zt serve`: no stack trace, no dev overlay.
92
+
93
+ It reads `deployEnv()` now, which is where the deployment name survives. Production and
94
+ staging are unaffected — both still fail closed, and an unset value still fails closed.
95
+
96
+ This is the third instance of one mistake. The weak-`APP_KEY` refusal and the ORM's N+1
97
+ detector had exactly the same bug, both fixed in 1.5.0. Reading `APP_ENV` to decide
98
+ anything about the _deployment_ is wrong once the app has booted; `deployEnv()` is the
99
+ answer.
100
+
11
101
  ## [1.5.0] — 2026-08-15
12
102
 
13
103
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/core",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -9,6 +9,7 @@
9
9
  "types": "./src/index.ts",
10
10
  "exports": {
11
11
  ".": "./src/index.ts",
12
+ "./routes": "./src/router/routes.ts",
12
13
  "./contracts": "./src/contracts/index.ts",
13
14
  "./lock": "./src/lock/index.ts",
14
15
  "./logger": "./src/logger/index.ts",
@@ -157,7 +157,14 @@ type DeferrableProviderClass = ProviderClass & {
157
157
  provides: readonly (keyof ContainerBindings)[];
158
158
  };
159
159
  type Environment = "web" | "console" | "worker" | "test" | "repl";
160
- type PipeClass = new (...args: unknown[]) => Pipe<HttpContext>;
160
+ // `args: any[]`, the codebase's standard constructor shape — the same reasoning
161
+ // `container/types.ts` already writes down for `ClassToken`: `unknown[]` fails
162
+ // constructor-parameter contravariance at the call site, so any middleware class
163
+ // with a typed constructor is rejected. Every provider that registers one worked
164
+ // around it the same way, `useOnce(Middleware)`, eleven times across eight
165
+ // packages — a cast the framework was asking for rather than one anybody wanted.
166
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- middleware classes carry their own constructor shapes
167
+ type PipeClass = new (...args: any[]) => Pipe<HttpContext>;
161
168
 
162
169
  /** Options form for `Application.create({ ... })`. */
163
170
  export interface CreateOptions {
@@ -452,6 +459,7 @@ export class Application {
452
459
  );
453
460
  }
454
461
 
462
+ // eslint-disable-next-line no-restricted-syntax -- runtime mode is exactly what Application.create() wants; _normaliseEnv maps deployment names onto it
455
463
  const rawEnv = options.env ?? Bun.env["APP_ENV"] ?? "web";
456
464
  const resolvedEnv: Environment = _normaliseEnv(rawEnv);
457
465
 
@@ -861,7 +869,7 @@ export class Application {
861
869
  // Inject the live-reload client (and any registered dev snippets) into HTML
862
870
  // responses, so auto-reload works for every view layer, not just Inertia.
863
871
  setDevReloadClientActive(true);
864
- this.useOnce(DevReloadMiddleware as never);
872
+ this.useOnce(DevReloadMiddleware);
865
873
  return this;
866
874
  }
867
875
 
@@ -1055,7 +1063,7 @@ export class Application {
1055
1063
  // elsewhere it warns. Skipped in the test harness to keep output clean.
1056
1064
  if (this._env !== "test" && this._configValidators.length > 0) {
1057
1065
  const configManager = this.container.makeSync("config") as ConfigManager;
1058
- const appEnv = configManager.get<string>("app.env", Bun.env["APP_ENV"] ?? "development");
1066
+ const appEnv = configManager.get<string>("app.env", deployEnv() || "development");
1059
1067
  runConfigValidators(this._configValidators, configManager, isProdLike(appEnv));
1060
1068
  }
1061
1069
 
@@ -7,6 +7,7 @@ import type { FlagDef } from "../Command.ts";
7
7
  import { hasDevBuildHooks, runDevBuildHooks } from "../../dev/DevBuildHook.ts";
8
8
  import { buildConfiguredAssets, type AssetBuildConfig } from "../../dev/CssPlugins.ts";
9
9
  import { bootBuildDecision } from "../../dev/bootBuild.ts";
10
+ import { deployEnv } from "../../support/env.ts";
10
11
  import { collectDevProcesses, type ResolvedDevProcess } from "../../dev/DevProcess.ts";
11
12
  import { startDevMode, SERVER_PROCESS_NAME } from "../../dev/startDevMode.ts";
12
13
  import type { ConfigManager } from "../../config/ConfigManager.ts";
@@ -456,7 +457,7 @@ export class ServeCommand extends Command {
456
457
  // down is doing the right thing; rebuilding at boot would only fail. See bootBuild.ts.
457
458
  const decision = await bootBuildDecision(
458
459
  [`${process.cwd()}/${assets.outDir}`],
459
- (this._appEnv() ?? Bun.env["APP_ENV"]) as string | undefined,
460
+ (this._appEnv() ?? deployEnv()) as string | undefined,
460
461
  );
461
462
  if (!decision.build) {
462
463
  this.info(decision.reason ?? "Skipping the boot-time asset build.");
@@ -346,6 +346,7 @@ export function AppConfig(options: {
346
346
  // overrides (e.g. `conventions.paths.models`) keep every other default in place.
347
347
  const defaults: AppConfigShape = {
348
348
  name: "Zerotal App",
349
+ // eslint-disable-next-line no-restricted-syntax -- config is loaded before setAppEnv() runs, so APP_ENV still holds the deployment name here
349
350
  env: Bun.env["APP_ENV"] ?? "development",
350
351
  key: Bun.env["APP_KEY"] ?? "",
351
352
  debug: Bun.env["APP_DEBUG"] !== "false",
@@ -22,7 +22,7 @@
22
22
  */
23
23
  import { join } from "node:path";
24
24
  import { ConfigError } from "../errors/ConfigError.ts";
25
- import { DEPLOY_ENV_VAR } from "../support/env.ts";
25
+ import { DEPLOY_ENV_VAR, RUNTIME_MODES as _RUNTIME_MODES } from "../support/env.ts";
26
26
 
27
27
  // ── basePath() ────────────────────────────────────────────────────────────────
28
28
 
@@ -65,11 +65,9 @@ export function basePath(...segments: string[]): string {
65
65
  * setAppEnv(process.argv[2]);
66
66
  * const { default: app } = await import('./bootstrap/app.ts');
67
67
  */
68
- /** Runtime-mode values that Application understands natively — never remapped. */
69
- const _RUNTIME_MODES = new Set(["web", "worker", "console", "test", "testing", "repl"]);
70
-
71
68
  export function setAppEnv(command?: string): void {
72
69
  const normalizedCommand = (command ?? "").toLowerCase();
70
+ // eslint-disable-next-line no-restricted-syntax -- this IS setAppEnv — reading the pre-overwrite value is the whole job
73
71
  const current = Bun.env["APP_ENV"];
74
72
  const environment = Bun.env as Record<string, string>;
75
73
 
@@ -79,10 +77,11 @@ export function setAppEnv(command?: string): void {
79
77
  // quietly answering no — including the weak-`APP_KEY` refusal and the ORM's
80
78
  // N+1 detector. `deployEnv()` reads this back; see {@link DEPLOY_ENV_VAR}.
81
79
  //
82
- // `??=` so the first caller wins: a re-entrant `setAppEnv` (dev mode boots the
83
- // app twice) must not stamp the runtime mode over the real deployment name.
80
+ // The guard is what protects a re-entrant call: `current` is only ever written
81
+ // when it is a genuine deployment name, so a second `setAppEnv` which sees the
82
+ // runtime mode this one just wrote — cannot stamp `"web"` over `"production"`.
84
83
  if (current && !_RUNTIME_MODES.has(current.toLowerCase())) {
85
- environment[DEPLOY_ENV_VAR] ??= current;
84
+ environment[DEPLOY_ENV_VAR] = current;
86
85
  }
87
86
 
88
87
  if (["serve", "start", "s", "dev", "d"].includes(normalizedCommand)) {
@@ -21,6 +21,7 @@ export const DEFAULT_LOG_RETENTION_DAYS = 14;
21
21
  * A test that wants the trail asks for it explicitly.
22
22
  */
23
23
  function _fileDefault(): FileSinkConfig {
24
+ // eslint-disable-next-line no-restricted-syntax -- asks about the test/testing runtime modes, not the deployment
24
25
  const env = (Bun.env["APP_ENV"] ?? "").trim().toLowerCase();
25
26
  if (env === "test" || env === "testing") return false;
26
27
  return { path: DEFAULT_LOG_PATH, days: DEFAULT_LOG_RETENTION_DAYS };
@@ -1,6 +1,7 @@
1
1
  import { ServiceProvider } from "./ServiceProvider.ts";
2
2
  import type { AppEnvironment } from "./ServiceProvider.ts";
3
3
  import { FrameworkEvents } from "../events/FrameworkEvents.ts";
4
+ import { deployEnv } from "../support/env.ts";
4
5
  import type {
5
6
  // Application lifecycle
6
7
  AppBooted,
@@ -60,7 +61,7 @@ export class LogProvider extends ServiceProvider {
60
61
  const cfg = (await c.make("config")) as { get<T>(path: string): T | undefined };
61
62
  const logging = cfg.get<LoggingConfigShape>("logging") ?? _defaultConfig();
62
63
  const appName = cfg.get<string>("app.name");
63
- const appEnv = cfg.get<string>("app.env") ?? Bun.env["APP_ENV"];
64
+ const appEnv = cfg.get<string>("app.env") ?? deployEnv();
64
65
 
65
66
  return new LogManager(logging, { app: appName, env: appEnv });
66
67
  });
@@ -75,7 +76,7 @@ export class LogProvider extends ServiceProvider {
75
76
  get<T>(path: string): T | undefined;
76
77
  };
77
78
  const requests = cfg.get<LoggingConfigShape>("logging")?.requests ?? true;
78
- if (requests) this.app.useOnce(LoggerMiddleware as never);
79
+ if (requests) this.app.useOnce(LoggerMiddleware);
79
80
  }
80
81
 
81
82
  override async onBooted(): Promise<void> {
@@ -17,11 +17,12 @@ import type {
17
17
  } from "./Route.ts";
18
18
  import type {
19
19
  RouteArgs,
20
- RouteParamValue,
20
+ RouteBuilder,
21
21
  RouteParamValues,
22
22
  RouteQuery,
23
23
  RouteTarget,
24
24
  } from "./registry.ts";
25
+ import { buildRouteUrl, unknownRouteError } from "./buildRoute.ts";
25
26
  import type { HttpContext } from "../pipeline/HttpContext.ts";
26
27
  import type { ExceptionHandler } from "../application/ExceptionHandler.ts";
27
28
  import { createRouteHandler } from "./RouteHandler.ts";
@@ -279,58 +280,12 @@ export interface ViewRegistration {
279
280
  withLayout(layout: ViewLayout): ViewRegistration;
280
281
  }
281
282
 
282
- /** Encode one catch-all value: `'guides/intro'` and `['guides','intro']` both give `guides/intro`. */
283
- function _encodeWildcard(value: RouteParamValue | readonly RouteParamValue[]): string {
284
- const segments = Array.isArray(value) ? value : String(value).split("/");
285
- return (segments as readonly RouteParamValue[])
286
- .map((segment) => encodeURIComponent(String(segment)))
287
- .filter((segment) => segment.length > 0)
288
- .join("/");
289
- }
290
-
291
- /** Serialise the query bag: `null`/`undefined` drop out, arrays repeat the key. */
292
- function _encodeQuery(query: RouteQuery): string {
293
- const pairs: string[] = [];
294
- for (const [key, value] of Object.entries(query)) {
295
- if (value === null || value === undefined) continue;
296
- const values = Array.isArray(value) ? value : [value];
297
- for (const entry of values as readonly (string | number | boolean)[]) {
298
- pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(entry))}`);
299
- }
300
- }
301
- return pairs.join("&");
302
- }
303
-
304
- /** The {@link route} helper: a checked call signature plus its `dynamic` escape hatch. */
305
- export interface RouteBuilder {
306
- /**
307
- * Generate a URL for a named route, substituting `:param` segments.
308
- *
309
- * @param name - The registered route name, e.g. `'posts.show'`.
310
- * @param args - `params` (one value per `:param`; a catch-all takes the `"*"` key and accepts an array of segments) then optional `query` values.
311
- * @throws {Error} when the route name is unknown, a required `:param` is missing, or a param key matches no segment.
312
- */
313
- <N extends RouteTarget>(name: N, ...args: RouteArgs<N>): string;
314
-
315
- /**
316
- * Build a URL for a route name that isn't known at compile time — a name from
317
- * config, a database row, or a package that registers routes conditionally.
318
- *
319
- * The escape hatch is a separate function rather than an overload on purpose:
320
- * an overload taking `string` is matched by every string, which would let
321
- * every typo through the front door. This one is greppable, and reads as the
322
- * exception it is.
323
- *
324
- * @param name - The route name, resolved at runtime.
325
- * @param params - One value per `:param` in the pattern.
326
- * @param query - Optional query-string values.
327
- * @throws {Error} on the same conditions as {@link route} — an unknown name still throws.
328
- *
329
- * @example
330
- * route.dynamic(config('app.home_route'), { id })
331
- */
332
- dynamic(name: string, params?: RouteParamValues, query?: RouteQuery): string;
333
- }
283
+ /**
284
+ * The {@link route} helper's call signature. Declared in `registry.ts` and
285
+ * shared with the browser's `route()` (`@zerotal/core/routes`) so the two are
286
+ * the same signature, not two that happen to look alike.
287
+ */
288
+ export type { RouteBuilder } from "./registry.ts";
334
289
 
335
290
  /**
336
291
  * Generate a URL for a named route, substituting `:param` segments.
@@ -364,45 +319,17 @@ export const route: RouteBuilder = Object.assign(
364
319
  },
365
320
  );
366
321
 
322
+ /**
323
+ * Look the name up in the live router, then hand off to the shared builder.
324
+ *
325
+ * The lookup is the *only* server-specific part of `route()`: it reads
326
+ * `_s().namedRoutes`, which is per-application and resets between tests. The
327
+ * URL semantics live in `buildRoute.ts`, shared with the browser build.
328
+ */
367
329
  function _buildRoute(name: string, params: RouteParamValues, query: RouteQuery): string {
368
330
  const pattern = _s().namedRoutes.get(name);
369
- if (pattern === undefined) {
370
- throw new Error(`[Zerotal] Named route not found: "${name}"`);
371
- }
372
-
373
- const usedKeys = new Set<string>();
374
- let url = pattern.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, key: string) => {
375
- const value = params[key];
376
- if (value === undefined) {
377
- throw new Error(`[Zerotal] Missing parameter "${key}" for route "${name}"`);
378
- }
379
- usedKeys.add(key);
380
- // Encode path params so values containing `/ ? #` cannot mangle the URL.
381
- return encodeURIComponent(String(value));
382
- });
383
-
384
- // A catch-all segment reaches the router as `*` — the `[...slug]` name is gone
385
- // by then — so the wildcard is its own param key.
386
- if (url.includes("*")) {
387
- const value = params["*"];
388
- if (value === undefined) {
389
- throw new Error(`[Zerotal] Missing catch-all parameter "*" for route "${name}"`);
390
- }
391
- usedKeys.add("*");
392
- url = url.replace("*", _encodeWildcard(value));
393
- }
394
-
395
- const unknown = Object.keys(params).filter((key) => !usedKeys.has(key));
396
- if (unknown.length > 0) {
397
- throw new Error(
398
- `[Zerotal] Unknown parameter${unknown.length > 1 ? "s" : ""} ` +
399
- `${unknown.map((key) => `"${key}"`).join(", ")} for route "${name}" (${pattern}). ` +
400
- `Query-string values go in the third argument: route(name, params, query).`,
401
- );
402
- }
403
-
404
- const search = _encodeQuery(query);
405
- return search ? `${url}?${search}` : url;
331
+ if (pattern === undefined) throw unknownRouteError(name);
332
+ return buildRouteUrl(name, pattern, params, query);
406
333
  }
407
334
 
408
335
  /**
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The URL-building half of `route()`: pattern in, URL out. No table, no router,
3
+ * no request — which is the point.
4
+ *
5
+ * `route()` exists twice, once against the server's live `Router.namedRoutes`
6
+ * and once against the generated `ROUTES` table in the browser bundle. Only the
7
+ * *lookup* differs; everything a caller can actually observe — how a `:param` is
8
+ * encoded, what a catch-all accepts, which mistakes throw and what they say —
9
+ * lives here so the two cannot drift. A client `route()` that encoded params
10
+ * differently from the server's would be worse than no client `route()` at all:
11
+ * the links would be wrong only for the values nobody tests with.
12
+ *
13
+ * Nothing in this file may import anything that touches `Bun`, `process`, the
14
+ * container, or request state. It is bundled into browsers.
15
+ */
16
+ import type { RouteParamValue, RouteParamValues, RouteQuery } from "./registry.ts";
17
+
18
+ /** Encode one catch-all value: `'guides/intro'` and `['guides','intro']` both give `guides/intro`. */
19
+ function encodeWildcard(value: RouteParamValue | readonly RouteParamValue[]): string {
20
+ const segments = Array.isArray(value) ? value : String(value).split("/");
21
+ return (segments as readonly RouteParamValue[])
22
+ .map((segment) => encodeURIComponent(String(segment)))
23
+ .filter((segment) => segment.length > 0)
24
+ .join("/");
25
+ }
26
+
27
+ /** Serialise the query bag: `null`/`undefined` drop out, arrays repeat the key. */
28
+ export function encodeRouteQuery(query: RouteQuery): string {
29
+ const pairs: string[] = [];
30
+ for (const [key, value] of Object.entries(query)) {
31
+ if (value === null || value === undefined) continue;
32
+ const values = Array.isArray(value) ? value : [value];
33
+ for (const entry of values as readonly (string | number | boolean)[]) {
34
+ pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(entry))}`);
35
+ }
36
+ }
37
+ return pairs.join("&");
38
+ }
39
+
40
+ /**
41
+ * The error both `route()` implementations throw for a name that isn't registered.
42
+ *
43
+ * Shared so the server and the browser report an unknown name identically — a
44
+ * name that resolves in one and throws in the other is the exact confusion this
45
+ * module exists to prevent.
46
+ *
47
+ * @param name - The name that was looked up.
48
+ * @returns The error to throw; the caller throws it so the stack starts at the call site.
49
+ */
50
+ export function unknownRouteError(name: string): Error {
51
+ return new Error(`[Zerotal] Named route not found: "${name}"`);
52
+ }
53
+
54
+ /**
55
+ * Substitute `params` into a URL pattern and append `query`.
56
+ *
57
+ * Params are **exact**: a key the pattern has no segment for throws rather than
58
+ * quietly becoming a query-string entry. That rule is the reason the typed
59
+ * signature is worth having — a typo'd param name that silently became
60
+ * `?slugg=hello` is the bug the whole feature exists to catch — so it is
61
+ * enforced at runtime too, for callers on the untyped path (`route.dynamic`, or
62
+ * an app that has never run `zt route:types`).
63
+ *
64
+ * @param name - The route name, used only in error messages.
65
+ * @param pattern - The URL pattern the name resolved to, e.g. `/posts/:slug`.
66
+ * @param params - One value per `:param`; a catch-all takes the `"*"` key.
67
+ * @param query - Query-string values, appended after the path.
68
+ * @returns The built URL path (with query string when there is one).
69
+ * @throws {Error} when a required `:param` is missing or a param key matches no segment.
70
+ */
71
+ export function buildRouteUrl(
72
+ name: string,
73
+ pattern: string,
74
+ params: RouteParamValues,
75
+ query: RouteQuery,
76
+ ): string {
77
+ const usedKeys = new Set<string>();
78
+ let url = pattern.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, key: string) => {
79
+ const value = params[key];
80
+ if (value === undefined) {
81
+ throw new Error(`[Zerotal] Missing parameter "${key}" for route "${name}"`);
82
+ }
83
+ usedKeys.add(key);
84
+ // Encode path params so values containing `/ ? #` cannot mangle the URL.
85
+ return encodeURIComponent(String(value));
86
+ });
87
+
88
+ // A catch-all segment reaches the router as `*` — the `[...slug]` name is gone
89
+ // by then — so the wildcard is its own param key.
90
+ if (url.includes("*")) {
91
+ const value = params["*"];
92
+ if (value === undefined) {
93
+ throw new Error(`[Zerotal] Missing catch-all parameter "*" for route "${name}"`);
94
+ }
95
+ usedKeys.add("*");
96
+ url = url.replace("*", encodeWildcard(value));
97
+ }
98
+
99
+ const unknown = Object.keys(params).filter((key) => !usedKeys.has(key));
100
+ if (unknown.length > 0) {
101
+ throw new Error(
102
+ `[Zerotal] Unknown parameter${unknown.length > 1 ? "s" : ""} ` +
103
+ `${unknown.map((key) => `"${key}"`).join(", ")} for route "${name}" (${pattern}). ` +
104
+ `Query-string values go in the third argument: route(name, params, query).`,
105
+ );
106
+ }
107
+
108
+ const search = encodeRouteQuery(query);
109
+ return search ? `${url}?${search}` : url;
110
+ }
@@ -121,3 +121,45 @@ export type RouteArgs<N extends string> = [RouteName] extends [never]
121
121
  // any literal, so a static route would quietly swallow `{ page: 2 }`.
122
122
  [params?: Record<string, never>, query?: RouteQuery]
123
123
  : [params: RouteParams<N>, query?: RouteQuery];
124
+
125
+ /**
126
+ * The `route()` helper: a checked call signature plus its `dynamic` escape hatch.
127
+ *
128
+ * Declared here, next to the types it is built from, because `route()` is
129
+ * implemented twice — once on the server against `Router.namedRoutes`, once in
130
+ * the browser against the generated `ROUTES` table. Both are typed as this
131
+ * interface, so the two call sites cannot drift apart in what they accept: a
132
+ * component that renders on the server and hydrates in the browser sees one
133
+ * signature either way.
134
+ *
135
+ * @category Naming & URLs
136
+ */
137
+ export interface RouteBuilder {
138
+ /**
139
+ * Generate a URL for a named route, substituting `:param` segments.
140
+ *
141
+ * @param name - The registered route name, e.g. `'posts.show'`.
142
+ * @param args - `params` (one value per `:param`; a catch-all takes the `"*"` key and accepts an array of segments) then optional `query` values.
143
+ * @throws {Error} when the route name is unknown, a required `:param` is missing, or a param key matches no segment.
144
+ */
145
+ <N extends RouteTarget>(name: N, ...args: RouteArgs<N>): string;
146
+
147
+ /**
148
+ * Build a URL for a route name that isn't known at compile time — a name from
149
+ * config, a database row, or a package that registers routes conditionally.
150
+ *
151
+ * The escape hatch is a separate function rather than an overload on purpose:
152
+ * an overload taking `string` is matched by every string, which would let
153
+ * every typo through the front door. This one is greppable, and reads as the
154
+ * exception it is.
155
+ *
156
+ * @param name - The route name, resolved at runtime.
157
+ * @param params - One value per `:param` in the pattern.
158
+ * @param query - Optional query-string values.
159
+ * @throws {Error} on the same conditions as `route()` — an unknown name still throws.
160
+ *
161
+ * @example
162
+ * route.dynamic(config('app.home_route'), { id })
163
+ */
164
+ dynamic(name: string, params?: RouteParamValues, query?: RouteQuery): string;
165
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `route()` for the browser — the `@zerotal/core/routes` entry point.
3
+ *
4
+ * The server's `route()` (from `@zerotal/core`) reads the live router, which
5
+ * only exists in the server process. This one reads a table you hand it at
6
+ * boot: the `ROUTES` object that `bun zt route:types` already generates and
7
+ * commits.
8
+ *
9
+ * ```ts
10
+ * // resources/js/app.js — once, before anything renders
11
+ * import { defineRoutes } from "@zerotal/core/routes";
12
+ * import { ROUTES } from "../../types/routes.generated";
13
+ *
14
+ * defineRoutes(ROUTES);
15
+ * ```
16
+ *
17
+ * ```ts
18
+ * // anywhere in a page or component
19
+ * import { route } from "@zerotal/core/routes";
20
+ *
21
+ * route("posts.show", { slug }); // "/posts/hello"
22
+ * route("posts.index", {}, { page: 2 }); // "/posts?page=2"
23
+ * ```
24
+ *
25
+ * The table is a build-time constant, so it costs one static import and
26
+ * tree-shakes to the routes you keep — nothing is shipped per response and
27
+ * nothing has to be fetched before the first link renders.
28
+ *
29
+ * **Types come from the same place as the server's.** The generated file
30
+ * augments `RouteRegistry` in `@zerotal/core`, and both `route()`s are typed as
31
+ * the one `RouteBuilder` interface, so a name that type-checks in a controller
32
+ * type-checks in a component and a missing `:param` fails the build on either
33
+ * side.
34
+ *
35
+ * **This module is isomorphic.** Inertia renders pages twice — once in the SSR
36
+ * process, once in the browser — so a component importing `route` from here
37
+ * runs in both. Call {@link defineRoutes} in each entry (`app.js` and `ssr.js`);
38
+ * it is the same static import, and the same table.
39
+ *
40
+ * @module
41
+ */
42
+ import type {
43
+ RouteArgs,
44
+ RouteBuilder,
45
+ RouteParamValues,
46
+ RouteQuery,
47
+ RouteTarget,
48
+ } from "./registry.ts";
49
+ import { buildRouteUrl, unknownRouteError } from "./buildRoute.ts";
50
+
51
+ /**
52
+ * The name → pattern map `route()` resolves against. A plain object is what
53
+ * `types/routes.generated.ts` exports; a `Map` is accepted so a server-side
54
+ * caller can pass `Router.namedRoutes` straight through.
55
+ */
56
+ export type RouteTable = Readonly<Record<string, string>> | ReadonlyMap<string, string>;
57
+
58
+ /** `null` until `defineRoutes()` runs — distinct from "defined but empty", which is a valid state. */
59
+ let _table: ReadonlyMap<string, string> | null = null;
60
+
61
+ /**
62
+ * Install the route table `route()` resolves against.
63
+ *
64
+ * Call it once per entry point, before the first render. Calling it again
65
+ * replaces the table, which is what makes the dev server's hot reload work —
66
+ * the entry re-runs and the new table wins.
67
+ *
68
+ * @param table - The generated `ROUTES` object, or any name → pattern map.
69
+ *
70
+ * @example
71
+ * import { ROUTES } from "../../types/routes.generated";
72
+ * defineRoutes(ROUTES);
73
+ */
74
+ export function defineRoutes(table: RouteTable): void {
75
+ _table = table instanceof Map ? table : new Map(Object.entries(table));
76
+ }
77
+
78
+ /**
79
+ * Forget the installed table, putting `route()` back into its "not configured"
80
+ * state. For tests that assert on the unconfigured error — application code
81
+ * wants {@link defineRoutes} instead.
82
+ */
83
+ export function resetRoutes(): void {
84
+ _table = null;
85
+ }
86
+
87
+ /**
88
+ * Whether `name` is in the installed table.
89
+ *
90
+ * Useful for the conditional links a client bundle cannot resolve by other
91
+ * means: a nav item for a route that only exists when a package is installed,
92
+ * or an admin link a public build never registers. Returns `false` — rather
93
+ * than throwing — when no table has been installed.
94
+ *
95
+ * @param name - The route name to look for.
96
+ *
97
+ * @example
98
+ * {hasRoute("admin.index") && <a href={route("admin.index")}>Admin</a>}
99
+ */
100
+ export function hasRoute(name: string): boolean {
101
+ return _table?.has(name) ?? false;
102
+ }
103
+
104
+ /** Resolve a pattern or throw the message that tells the caller what to fix. */
105
+ function _pattern(name: string): string {
106
+ if (_table === null) {
107
+ throw new Error(
108
+ `[Zerotal] route("${name}") was called before the route table was installed. ` +
109
+ `Add this to your client entry (and your SSR entry, if you have one):\n` +
110
+ ` import { defineRoutes } from "@zerotal/core/routes";\n` +
111
+ ` import { ROUTES } from "./types/routes.generated";\n` +
112
+ ` defineRoutes(ROUTES);\n` +
113
+ `Generate the table with: bun zt route:types`,
114
+ );
115
+ }
116
+ const pattern = _table.get(name);
117
+ if (pattern === undefined) throw unknownRouteError(name);
118
+ return pattern;
119
+ }
120
+
121
+ /**
122
+ * Generate a URL for a named route, substituting `:param` segments.
123
+ *
124
+ * The browser twin of the server's `route()` — same signature, same encoding,
125
+ * same errors, resolved against the table {@link defineRoutes} installed
126
+ * instead of against the live router.
127
+ *
128
+ * @example
129
+ * route("posts.show", { slug: "hello" }) // "/posts/hello"
130
+ * route("search", {}, { q: "reno", page: 2 }) // "/search?q=reno&page=2"
131
+ * route("docs.show", { "*": "guides/intro" }) // "/docs/guides/intro"
132
+ *
133
+ * @category Naming & URLs
134
+ */
135
+ export const route: RouteBuilder = Object.assign(
136
+ <N extends RouteTarget>(name: N, ...args: RouteArgs<N>): string => {
137
+ const [params = {}, query = {}] = args as [RouteParamValues?, RouteQuery?];
138
+ return buildRouteUrl(name, _pattern(name), params, query);
139
+ },
140
+ {
141
+ dynamic: (name: string, params: RouteParamValues = {}, query: RouteQuery = {}): string =>
142
+ buildRouteUrl(name, _pattern(name), params, query),
143
+ },
144
+ );
145
+
146
+ // The route *types* are deliberately not re-exported here. They erase at
147
+ // compile time, so `import type { RouteName } from "@zerotal/core"` costs a
148
+ // browser bundle nothing — and a second export path for the same names is a
149
+ // second entry in every surface report, forever, for no runtime benefit.
@@ -53,6 +53,22 @@ export const DEV_WORKER_ENV_VAR = "ZT_DEV";
53
53
  */
54
54
  export const DEPLOY_ENV_VAR = "ZT_APP_ENV";
55
55
 
56
+ /**
57
+ * The values of `APP_ENV` that name a runtime *mode* rather than a deployment.
58
+ * `setAppEnv()` writes these; {@link deployEnv} recognises them to know whether
59
+ * `APP_ENV` still holds the deployment name.
60
+ *
61
+ * @internal
62
+ */
63
+ export const RUNTIME_MODES: ReadonlySet<string> = new Set([
64
+ "web",
65
+ "worker",
66
+ "console",
67
+ "test",
68
+ "testing",
69
+ "repl",
70
+ ]);
71
+
56
72
  /**
57
73
  * The deployment name this process was started with — `production`, `staging`,
58
74
  * `local`, whatever the operator set — as opposed to the runtime *mode*.
@@ -72,7 +88,17 @@ export const DEPLOY_ENV_VAR = "ZT_APP_ENV";
72
88
  * @internal
73
89
  */
74
90
  export function deployEnv(): string {
75
- return Bun.env[DEPLOY_ENV_VAR] ?? Bun.env["APP_ENV"] ?? "";
91
+ // eslint-disable-next-line no-restricted-syntax -- this IS deployEnv — it decides whether APP_ENV still holds the deployment name
92
+ const current = Bun.env["APP_ENV"] ?? "";
93
+ // If `APP_ENV` still holds a deployment name, it has not been overwritten yet —
94
+ // or something set it deliberately since — so it is the freshest answer. Only
95
+ // once it holds a runtime mode is the preserved copy the better one.
96
+ //
97
+ // Preferring the preserved copy unconditionally made it sticky for the life of
98
+ // the process: anything that set `APP_ENV` afterwards was ignored, which is
99
+ // wrong in itself and which leaked between test files sharing one process.
100
+ if (current && !RUNTIME_MODES.has(current.toLowerCase())) return current;
101
+ return Bun.env[DEPLOY_ENV_VAR] ?? current;
76
102
  }
77
103
 
78
104
  /**
@@ -101,7 +127,11 @@ export function deployEnv(): string {
101
127
  */
102
128
  export function devSurfacesEnabled(): boolean {
103
129
  if (Bun.env[DEV_WORKER_ENV_VAR] === "1") return true;
104
- return isDevSurfaceAllowed(Bun.env["APP_ENV"] ?? "");
130
+ // `deployEnv()`, not `Bun.env["APP_ENV"]` by the time anything asks, `setAppEnv()`
131
+ // has replaced that with the runtime mode, and `isDevSurfaceAllowed("web")` is false.
132
+ // An app with `APP_ENV=development` in its `.env` was therefore getting production
133
+ // error pages from a plain `zt serve`.
134
+ return isDevSurfaceAllowed(deployEnv());
105
135
  }
106
136
 
107
137
  /**