@zerotal/core 1.7.0 → 1.7.3

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
@@ -6,6 +6,137 @@ follows the Zerotal monorepo's unified versioning.
6
6
 
7
7
  **Maturity: `stable`**
8
8
 
9
+ ## [Unreleased]
10
+
11
+ ### Fixed
12
+
13
+ - **`make:request` generates a file that compiles.** The stub imported `@zerotal/validator`,
14
+ which a scaffolded app does not depend on — it depends on the `zerotal` umbrella — so the
15
+ generated file failed to resolve until the import was changed by hand. It also annotated
16
+ `rules(): Record<string, FieldRule>`, the one thing `FormRequest`'s own docblock warns
17
+ against: `validate()` reads the narrow return type through `ReturnType<T['rules']>`, so the
18
+ annotation widened it back and every validated field arrived as `unknown`, silently, with a
19
+ cast somewhere downstream the first sign of it.
20
+
21
+ - **Every other `make:*` stub names a package the app has too.** The same fault ran through
22
+ nine generators: `make:command`, `make:controller`, `make:middleware` and `make:provider`
23
+ named `@zerotal/core`; `make:job` `@zerotal/queue`; `make:observer` `@zerotal/orm`;
24
+ `make:policy` `@zerotal/auth`; `make:test` `@zerotal/testing`. A scaffolded app depends on
25
+ none of them — it has the `zerotal` umbrella — so each wrote a file that did not resolve.
26
+ They now emit `zerotal` and its subpaths.
27
+
28
+ `make:resource` was worse: `Resource`, `ResourceCollection` and `PaginatedData` are not on
29
+ `@zerotal/core`'s root entry at all, so that stub was broken against the scoped name as
30
+ well. It emits `zerotal/http`, where they live.
31
+
32
+ `make:notification` keeps `@zerotal/notifications`: there is no umbrella subpath for it, and
33
+ the `api` template installs it directly.
34
+
35
+ A single test now runs all thirteen generators and fails on any import that is neither the
36
+ umbrella, a Bun/Node builtin, a relative path, nor one of the scoped packages a template
37
+ actually installs. Each generator's own test had only checked that the output mentioned the
38
+ class being made, which is why none of this showed.
39
+
40
+ ### Added
41
+
42
+ - **`@zerotal/core/errors`** — a subpath for the error classes, so a module that can run in a
43
+ browser can import `ZerotalError` without reaching the root entry. The root re-exports
44
+ `CommandRunner`, which reaches the built-in CLI commands and `await import("bun")`, so a single
45
+ root import is enough to make a browser bundle fail at resolution. `@zerotal/core/helpers`
46
+ already covered `deepMerge` the same way.
47
+
48
+ The rule this makes workable: **core's root entry is server-only.** Anything that might be
49
+ bundled for a browser imports from a narrow subpath.
50
+
51
+ ## [1.7.1] — 2026-08-16
52
+
53
+ ### Changed
54
+
55
+ - **`APP_ENV` is the deployment name; the runtime mode moved to `APP_TYPE`.** They shared one
56
+ variable and the mode won: `setAppEnv()` overwrote `APP_ENV` with `web` / `worker` /
57
+ `console` at boot, so an app whose `.env` said `APP_ENV=development` read `"console"` back
58
+ from `env("APP_ENV")` inside every CLI command.
59
+
60
+ The dangerous direction is the one nobody hits in development. A guard written the obvious
61
+ way —
62
+
63
+ ```ts
64
+ if (env("APP_ENV") === "production") refuseToWipe();
65
+ ```
66
+
67
+ — was **inert in every console command**, which is exactly where destructive commands live.
68
+ 1.7.0 patched the framework's own gates by parking a copy that `deployEnv()` read back, but
69
+ application code reading the documented variable the documented way still got the mode.
70
+
71
+ Two questions, two variables. `setAppEnv()` no longer touches `APP_ENV` at all and writes
72
+ the mode to `APP_TYPE`; `runtimeMode()` reads it, and falls back to the legacy location so a
73
+ process started by an older launcher still boots the right providers. An explicit
74
+ `APP_TYPE` wins over the command, which is how `serve --dev` boots its supervised server as
75
+ `web`. `deployEnv()` and `config("app.env")` are unchanged and still correct.
76
+
77
+ No action needed in an app unless it sets `APP_ENV=web` by hand to force web mode — that
78
+ still works, and `APP_TYPE=web` is the spelling to move to.
79
+
80
+ Found seeding the first cookbook app, where a guard fired that should not have.
81
+
82
+ ### Fixed
83
+
84
+ - **`Router.raw()` did not answer `HEAD`.** The pipeline derives a `HEAD` handler from every
85
+ `GET` — its own docblock notes that not doing so gives "every uptime monitor,
86
+ load-balancer probe, CDN origin check and `curl -I`" a 404 — and the raw path was left out
87
+ of it. So `curl -I` against a raw route answered 404 while the `GET` beside it answered 200. This framework's own site serves `/docs/*` and `/blog` from raw routes, so every link
88
+ checker and uptime probe aimed at the documentation was told the page did not exist.
89
+
90
+ Derived from the wrapped handler rather than the bare one, so the security headers below
91
+ ride along and a `HEAD` cannot answer with fewer than the `GET` it mirrors. A `HEAD` the
92
+ app registered itself still wins. Third gap in the same family, after the headers and
93
+ static files: any path that answers a request without running the pipeline needs whatever
94
+ the pipeline was doing for it.
95
+
96
+ - **The dev deck would not scroll.** On the alternate screen a terminal has no scrollback of
97
+ its own, so the wheel and the scrollbar had nothing to move and the deck read as frozen —
98
+ from the moment tabs mode starts, every way of looking at an older line has to come from
99
+ the deck itself, and only Page Up/Down did.
100
+
101
+ It now asks the terminal to send the wheel as cursor keys (`?1007h`, released again on
102
+ exit) and handles `↑`/`↓` and Home/End. Deliberately not mouse tracking, which would give
103
+ real wheel events at the price of the terminal's own text selection.
104
+
105
+ Two things had to change underneath. A read from stdin is not one key: a wheel tick arrives
106
+ as the same arrow repeated once per line, all in one chunk, and two fast keystrokes arrive
107
+ together — so a chunk is split into keys and the frame painted once at the end. And a card
108
+ that has been scrolled up now holds its place: `scroll` counts up from the newest line, so a
109
+ busy process used to drag the window down by a line for every line it printed, sliding the
110
+ text somebody had stopped to read off the top while they read it. A card pinned to the
111
+ bottom still follows its output, which is the one that should.
112
+
113
+ Both of the next two were found by wiring DevTools into this repo's own `apps/docs` and
114
+ driving it in a browser.
115
+
116
+ - **`Router.raw()` responses carried no security headers.** A raw route opts out of the
117
+ _request_ pipeline — CSRF on a transport endpoint, session resolution on a relay — and was
118
+ silently opting its response out of `SecureHeadersMiddleware` too. This framework's own
119
+ documentation site serves every `/docs/*` page from a raw route, so every page of it went
120
+ out with no `X-Content-Type-Options: nosniff`, no `X-Frame-Options`, no
121
+ `Referrer-Policy` and no `Permissions-Policy`. In production the reverse proxy happened
122
+ to add two of them, which is why nothing had noticed.
123
+
124
+ The header set is now applied to raw responses at compile time, **add-if-absent** rather
125
+ than overwrite: a raw route is the one place a handler owns its whole response, and an
126
+ endpoint that deliberately allows framing has a reason the framework cannot see. The
127
+ response is only reconstructed when something is missing, so the hot path — Flow's action
128
+ endpoint is a raw route — pays nothing when it already has them.
129
+
130
+ This is the third surface in the same family, after the pipeline and static files. Any
131
+ path that answers a request without running middleware needs the same treatment.
132
+
133
+ - **`redactGraph` masked booleans.** Sensitivity is judged by key name, by substring, so
134
+ `cors.credentials` matched "credential" and the DevTools Config tab reported
135
+ `‹redacted›` where the answer was `false`. A boolean has two possible values: masking one
136
+ conceals nothing a reader could not guess, and hides the security setting they opened the
137
+ tab to check. Booleans now pass through; numbers still mask, since a number can be a PIN
138
+ or an account. The helper also gained the test file it shipped without.
139
+
9
140
  ## [1.7.0] — 2026-08-16
10
141
 
11
142
  ### Fixed
package/api-surface.md CHANGED
@@ -316,6 +316,7 @@ class HttpContext = {
316
316
  new <TParams extends Record<string, unknown> = Record<string, string>>(request: Request, container: ScopedResolver): HttpContext<TParams>
317
317
  static fake: (url?: string, init?: RequestInit, container?: ScopedResolver) => HttpContext
318
318
  static tryGet: () => HttpContext | undefined
319
+ __: (key: string, replacements?: Replacements, locale?: string) => string
319
320
  _afterResponseCallbacks: (() => Promise<void>)[]
320
321
  _pageResolver?: (pageName: string) => number | undefined
321
322
  _primeBody: (data: Record<string, unknown>) => void
@@ -368,7 +369,6 @@ class HttpContext = {
368
369
  string: (key: string, fallback?: string) => string | undefined
369
370
  subdomain: (name: string) => string | null
370
371
  subdomains: Record<string, string>
371
- t: (key: string, replacements?: Replacements, locale?: string) => string
372
372
  took: number
373
373
  user?: UserModel | undefined
374
374
  view: { (markup: ViewMarkup, status?: number): void; <P extends Record<string, unknown> = Record<string, never>>(component: (ctx: HttpContext, props: P) => ViewMarkup | Promise<ViewMarkup>, props?: P | undefined, status?: number): void | Promise<void>;}
@@ -1101,6 +1101,7 @@ interface WebhookOptions = {
1101
1101
  interface WebSocketHandlers = {
1102
1102
  close?: (ws: unknown, code: number, reason: string) => void
1103
1103
  drain?: (ws: unknown) => void
1104
+ idleTimeout?: number
1104
1105
  message: (ws: unknown, message: string | Uint8Array) => void
1105
1106
  open?: (ws: unknown) => void
1106
1107
  }
@@ -2649,6 +2650,207 @@ type FieldType = 'string' | 'number' | 'boolean' | 'url' | 'enum' | 'port'
2649
2650
 
2650
2651
  type InferDef = D extends Def<infer T> ? T : never
2651
2652
 
2653
+ ## ./errors `(./src/errors/index.ts)`
2654
+
2655
+ class BadRequestError = {
2656
+ new (message?: string): BadRequestError
2657
+ headers?: Record<string, string>
2658
+ readonly code: string
2659
+ readonly context?: Record<string, unknown> | undefined
2660
+ readonly status: number
2661
+ }
2662
+
2663
+ class BindingNotFoundError = {
2664
+ new (token: string): BindingNotFoundError
2665
+ readonly code: string
2666
+ readonly context?: Record<string, unknown> | undefined
2667
+ readonly status: number
2668
+ }
2669
+
2670
+ class BootCheckError = {
2671
+ new (failures: BootCheckFailure[]): BootCheckError
2672
+ readonly code: string
2673
+ readonly context?: Record<string, unknown> | undefined
2674
+ readonly failures: BootCheckFailure[]
2675
+ readonly status: number
2676
+ }
2677
+
2678
+ class CircularDependencyError = {
2679
+ new (chain: string[]): CircularDependencyError
2680
+ readonly code: string
2681
+ readonly context?: Record<string, unknown> | undefined
2682
+ readonly status: number
2683
+ }
2684
+
2685
+ class ConfigError = {
2686
+ new (message: string): ConfigError
2687
+ readonly code: string
2688
+ readonly context?: Record<string, unknown> | undefined
2689
+ readonly status: number
2690
+ }
2691
+
2692
+ class ConfigValidationError = {
2693
+ new (issues: Array<{ namespace: string; message: string; }>): ConfigValidationError
2694
+ readonly code: string
2695
+ readonly context?: Record<string, unknown> | undefined
2696
+ readonly issues: { namespace: string; message: string;}[]
2697
+ readonly status: number
2698
+ }
2699
+
2700
+ class ConflictError = {
2701
+ new (message?: string): ConflictError
2702
+ headers?: Record<string, string>
2703
+ readonly code: string
2704
+ readonly context?: Record<string, unknown> | undefined
2705
+ readonly status: number
2706
+ }
2707
+
2708
+ class ContainerLockedError = {
2709
+ new (method: string): ContainerLockedError
2710
+ readonly code: string
2711
+ readonly context?: Record<string, unknown> | undefined
2712
+ readonly status: number
2713
+ }
2714
+
2715
+ class ContextOutsideRequestError = {
2716
+ new (): ContextOutsideRequestError
2717
+ headers?: Record<string, string>
2718
+ readonly code: string
2719
+ readonly context?: Record<string, unknown> | undefined
2720
+ readonly status: number
2721
+ }
2722
+
2723
+ class FacadeAccessedBeforeBootError = {
2724
+ new (facadeKey: string): FacadeAccessedBeforeBootError
2725
+ readonly code: string
2726
+ readonly context?: Record<string, unknown> | undefined
2727
+ readonly status: number
2728
+ }
2729
+
2730
+ class FacadeBindingMissingError = {
2731
+ new (facadeKey: string): FacadeBindingMissingError
2732
+ readonly code: string
2733
+ readonly context?: Record<string, unknown> | undefined
2734
+ readonly status: number
2735
+ }
2736
+
2737
+ class ForbiddenError = {
2738
+ new (message?: string): ForbiddenError
2739
+ headers?: Record<string, string>
2740
+ readonly code: string
2741
+ readonly context?: Record<string, unknown> | undefined
2742
+ readonly status: number
2743
+ }
2744
+
2745
+ class GoneError = {
2746
+ new (message?: string): GoneError
2747
+ headers?: Record<string, string>
2748
+ readonly code: string
2749
+ readonly context?: Record<string, unknown> | undefined
2750
+ readonly status: number
2751
+ }
2752
+
2753
+ class HttpError = {
2754
+ new (message: string, status: number, code?: string, headers?: Record<string, string>): HttpError
2755
+ headers?: Record<string, string>
2756
+ readonly code: string
2757
+ readonly context?: Record<string, unknown> | undefined
2758
+ readonly status: number
2759
+ }
2760
+
2761
+ class MethodNotAllowedError = {
2762
+ new (allowed?: string[], message?: string): MethodNotAllowedError
2763
+ headers?: Record<string, string>
2764
+ readonly code: string
2765
+ readonly context?: Record<string, unknown> | undefined
2766
+ readonly status: number
2767
+ }
2768
+
2769
+ class NotFoundError = {
2770
+ new (message?: string): NotFoundError
2771
+ headers?: Record<string, string>
2772
+ readonly code: string
2773
+ readonly context?: Record<string, unknown> | undefined
2774
+ readonly status: number
2775
+ }
2776
+
2777
+ class ScopedAfterFlushError = {
2778
+ new (message: string): ScopedAfterFlushError
2779
+ readonly code: string
2780
+ readonly context?: Record<string, unknown> | undefined
2781
+ readonly status: number
2782
+ }
2783
+
2784
+ class ScopedOutsideRequestError = {
2785
+ new (message: string): ScopedOutsideRequestError
2786
+ readonly code: string
2787
+ readonly context?: Record<string, unknown> | undefined
2788
+ readonly status: number
2789
+ }
2790
+
2791
+ class ServiceUnavailableError = {
2792
+ new (reason?: string, retryAfter?: number): ServiceUnavailableError
2793
+ headers?: Record<string, string>
2794
+ readonly code: string
2795
+ readonly context?: Record<string, unknown> | undefined
2796
+ readonly retryAfter?: number | undefined
2797
+ readonly status: number
2798
+ }
2799
+
2800
+ class SyncResolutionError = {
2801
+ new (message: string): SyncResolutionError
2802
+ readonly code: string
2803
+ readonly context?: Record<string, unknown> | undefined
2804
+ readonly status: number
2805
+ }
2806
+
2807
+ class TooManyRequestsError = {
2808
+ new (retryAfter?: number, message?: string): TooManyRequestsError
2809
+ headers?: Record<string, string>
2810
+ readonly code: string
2811
+ readonly context?: Record<string, unknown> | undefined
2812
+ readonly retryAfter?: number | undefined
2813
+ readonly status: number
2814
+ }
2815
+
2816
+ class UnauthorizedError = {
2817
+ new (message?: string): UnauthorizedError
2818
+ headers?: Record<string, string>
2819
+ readonly code: string
2820
+ readonly context?: Record<string, unknown> | undefined
2821
+ readonly status: number
2822
+ }
2823
+
2824
+ class UnprocessableEntityError = {
2825
+ new (message?: string): UnprocessableEntityError
2826
+ headers?: Record<string, string>
2827
+ readonly code: string
2828
+ readonly context?: Record<string, unknown> | undefined
2829
+ readonly status: number
2830
+ }
2831
+
2832
+ class ValidationError = {
2833
+ new (message: string, errors: Record<string, string[]>): ValidationError
2834
+ headers?: Record<string, string>
2835
+ readonly code: string
2836
+ readonly context?: Record<string, unknown> | undefined
2837
+ readonly errors: Record<string, string[]>
2838
+ readonly status: number
2839
+ }
2840
+
2841
+ class ZerotalError = {
2842
+ new (message: string, code: string, status?: number, context?: Record<string, unknown> | undefined): ZerotalError
2843
+ readonly code: string
2844
+ readonly context?: Record<string, unknown> | undefined
2845
+ readonly status: number
2846
+ }
2847
+
2848
+ interface BootCheckFailure = {
2849
+ provider: string
2850
+ reason: string
2851
+ token: string
2852
+ }
2853
+
2652
2854
  ## ./facades `(./src/facade/facades/index.ts)`
2653
2855
 
2654
2856
  const App = { readonly container: Container; readonly instance: () => Application; readonly environment: () => Application['environment']; readonly isProduction: () => boolean; readonly isLocal: () => boolean; readonly make: <T>(token: BindingToken<T>, consumer?: unknown) => Promise<T>; readonly makeSync: <T>(token: BindingToken<T>) => T; readonly build: <T>(ctor: new (...args: unknown[]) => T) => Promise<T>; readonly tryMake: <K extends keyof ContainerBindings>(token: K) => ContainerBindings[K] | undefined; readonly bound: (token: BindingToken) => boolean; readonly bind: <T>(token: BindingToken<T>, factory: Factory<T>) => Container; readonly singleton: <T>(token: BindingToken<T>, factory: Factory<T>) => Container; readonly scoped: <T>(token: BindingToken<T>, factory: Factory<T>) => Container; readonly value: <T>(token: BindingToken<T>, instance: T) => Container; readonly alias: (from: unknown, to: unknown) => Container; readonly forget: (token: BindingToken) => boolean;}
@@ -3294,14 +3496,37 @@ interface HttpMetricsSnapshot = {
3294
3496
 
3295
3497
  const route = RouteBuilder
3296
3498
 
3499
+ function action = <N extends RouteTarget>(name: N, params?: RouteParamValues | undefined, query?: RouteQuery | undefined) => RouteAction
3500
+
3501
+ function defineRouteMethods = (table: Readonly<Record<string, string>>) => void
3502
+
3297
3503
  function defineRoutes = (table: RouteTable) => void
3298
3504
 
3299
3505
  function hasRoute = (name: string) => boolean
3300
3506
 
3301
3507
  function resetRoutes = () => void
3302
3508
 
3509
+ function routeMethod = (name: string) => string | undefined
3510
+
3511
+ interface RouteAction = {
3512
+ method: string
3513
+ url: string
3514
+ }
3515
+
3516
+ interface RouteMethodRegistry = {}
3517
+
3518
+ type MethodedRouteName = never
3519
+
3520
+ type RouteArgs = [params?: RouteParamValues, query?: RouteQuery]
3521
+
3522
+ type RouteParamValues = { [x: string]: RouteParamValue | readonly RouteParamValue[];}
3523
+
3524
+ type RouteQuery = { [x: string]: string | number | boolean | readonly (string | number | boolean)[] | null | undefined;}
3525
+
3303
3526
  type RouteTable = Readonly<Record<string, string>> | ReadonlyMap<string, string>
3304
3527
 
3528
+ type RouteTarget = string
3529
+
3305
3530
  ## ./security `(./src/security/index.ts)`
3306
3531
 
3307
3532
  class CryptKeyMissingError = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/core",
3
- "version": "1.7.0",
3
+ "version": "1.7.3",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -11,6 +11,7 @@
11
11
  ".": "./src/index.ts",
12
12
  "./routes": "./src/router/routes.ts",
13
13
  "./contracts": "./src/contracts/index.ts",
14
+ "./errors": "./src/errors/index.ts",
14
15
  "./lock": "./src/lock/index.ts",
15
16
  "./logger": "./src/logger/index.ts",
16
17
  "./commands": "./src/command/builtin/index.ts",
@@ -14,6 +14,7 @@ import type { HttpContext } from "../pipeline/HttpContext.ts";
14
14
  import { Pipeline } from "../pipeline/Pipeline.ts";
15
15
  import { ExceptionHandler } from "./ExceptionHandler.ts";
16
16
  import { Router, RouterState } from "../router/Router.ts";
17
+ import { defineRouteMethods, defineRoutes } from "../router/routes.ts";
17
18
  import type { StaticOptions } from "../router/Router.ts";
18
19
  import { Health, resolveHealthConfig, checkHealthAccess } from "../health/Health.ts";
19
20
  import type { HealthConfigShape } from "../health/Health.ts";
@@ -42,7 +43,7 @@ import { NotFoundError } from "../errors/HttpError.ts";
42
43
  import type { ContainerBindings } from "../container/types.ts";
43
44
  import { dispatchRequest } from "../router/RouteHandler.ts";
44
45
  import type { ProviderHooks } from "../router/RouteHandler.ts";
45
- import { isProdLike, deployEnv } from "../support/env.ts";
46
+ import { isProdLike, deployEnv, runtimeMode } from "../support/env.ts";
46
47
  import { appKeyStrengthWarning } from "../support/appKey.ts";
47
48
  import { runBootDoctor } from "./BootDoctor.ts";
48
49
  import { runConfigValidators } from "../config/validation.ts";
@@ -254,6 +255,8 @@ export async function _lazyStaticResponse(
254
255
 
255
256
  /** Minimal WebSocket handler shape accepted by Bun.serve(). */
256
257
  export interface WebSocketHandlers {
258
+ /** Seconds a connection may go quiet before Bun closes it. Bun's default is 10. */
259
+ idleTimeout?: number;
257
260
  open?(ws: unknown): void;
258
261
  message(ws: unknown, message: string | Uint8Array): void;
259
262
  close?(ws: unknown, code: number, reason: string): void;
@@ -501,8 +504,12 @@ export class Application {
501
504
  );
502
505
  }
503
506
 
504
- // eslint-disable-next-line no-restricted-syntax -- runtime mode is exactly what Application.create() wants; _normaliseEnv maps deployment names onto it
505
- const rawEnv = options.env ?? Bun.env["APP_ENV"] ?? "web";
507
+ // The runtime mode, which is what provider filtering is keyed on. Reading
508
+ // `APP_ENV` here used to be right only because `setAppEnv()` had overwritten
509
+ // it with the mode; now the mode has its own variable and this asks for it
510
+ // directly. `_normaliseEnv` still maps a deployment name onto a mode, for an
511
+ // explicit `options.env`.
512
+ const rawEnv = options.env ?? runtimeMode("web");
506
513
  const resolvedEnv: Environment = _normaliseEnv(rawEnv);
507
514
 
508
515
  const app = new Application();
@@ -1068,6 +1075,17 @@ export class Application {
1068
1075
  };
1069
1076
 
1070
1077
  return {
1078
+ // Bun closes an idle WebSocket after 10 seconds by default, and the client
1079
+ // pings every 30 — so a connection that is merely *quiet* was being cut
1080
+ // before it ever had reason to speak, taking its channel subscriptions
1081
+ // with it. Nothing surfaced: the page stayed rendered, the client kept its
1082
+ // channel objects, and broadcasts simply stopped arriving for anyone who
1083
+ // had been reading for more than ten seconds.
1084
+ //
1085
+ // 120s leaves room for four missed pings before a genuinely dead socket is
1086
+ // reaped, which is the direction to err: a stale connection costs memory,
1087
+ // a reaped live one costs the feature.
1088
+ idleTimeout: 120,
1071
1089
  open: (ws: unknown) => {
1072
1090
  if ((ws as AnyWS).data._dev) {
1073
1091
  DevWsServer.open(ws as AnyWS);
@@ -1213,6 +1231,21 @@ export class Application {
1213
1231
  await this._loadFileRoutes();
1214
1232
  }
1215
1233
 
1234
+ // Install the route table for `zerotal/routes`, now that every route is
1235
+ // registered.
1236
+ //
1237
+ // That module is the standalone URL builder a browser bundle imports, so it
1238
+ // cannot reach for `Router` itself — importing the router would drag the
1239
+ // server into every client bundle. The dependency therefore points this way:
1240
+ // the server, which already has both, pushes the table in.
1241
+ //
1242
+ // Without this, `route()` threw on the server for any app that renders its
1243
+ // own markup — a `view` build produces every href and form action there —
1244
+ // and the fix was a `defineRoutes()` call each app had to know to write.
1245
+ // A browser entry still calls it; that is a different process with no router
1246
+ // to read. See T24.
1247
+ this._installRouteTable();
1248
+
1216
1249
  // A routes/ directory nobody routed is a silent 404 for every path in it — the file
1217
1250
  // imports cleanly and registers nothing, which looks identical to a typo'd URL.
1218
1251
  this._warnUnroutedRoutesDir(process.cwd());
@@ -1266,6 +1299,23 @@ export class Application {
1266
1299
  if (warning) frameworkLog("app").warn(warning);
1267
1300
  }
1268
1301
 
1302
+ /**
1303
+ * Hand the registered routes to the standalone `route()` builder.
1304
+ *
1305
+ * Name → pattern for the URLs, and name → verb for `action()`. `RouteDefinition`
1306
+ * carries its own `name` beside `method`, so the pair comes from one record —
1307
+ * a path join would be wrong, since `GET /login` and `POST /login` share a path.
1308
+ */
1309
+ private _installRouteTable(): void {
1310
+ const methods = new Map<string, string>();
1311
+ for (const definition of Router.routes.values()) {
1312
+ if (definition.name) methods.set(definition.name, definition.method);
1313
+ }
1314
+
1315
+ defineRoutes(Router.namedRoutes);
1316
+ defineRouteMethods(Object.fromEntries(methods));
1317
+ }
1318
+
1269
1319
  private async _loadFileRoutes(): Promise<void> {
1270
1320
  for (const { dir, prefix, middleware } of this._fileRouteGroups) {
1271
1321
  await Router.groupAsync({ prefix, middleware }, () => scanFileRoutes(dir).then(() => {}));
@@ -14,8 +14,8 @@ export function toKebab(name: string): string {
14
14
 
15
15
  /** Source for a new CLI command class extending `Command`. */
16
16
  export function commandStub(name: string): string {
17
- return `import { Command } from '@zerotal/core';
18
- import type { ArgDef, FlagDef } from '@zerotal/core';
17
+ return `import { Command } from 'zerotal';
18
+ import type { ArgDef, FlagDef } from 'zerotal';
19
19
 
20
20
  export class ${name} extends Command {
21
21
  static commandName = '${toKebab(name)}';
@@ -5,7 +5,7 @@ import { Command } from "../Command.ts";
5
5
 
6
6
  /** Source for a minimal controller with a single `index` action. */
7
7
  export function basicStub(name: string): string {
8
- return `import type { HttpContext } from '@zerotal/core';
8
+ return `import type { HttpContext } from 'zerotal';
9
9
 
10
10
  export class ${name} {
11
11
  async index(ctx: HttpContext): Promise<void> {
@@ -17,7 +17,7 @@ export class ${name} {
17
17
 
18
18
  /** Source for a resourceful controller with full CRUD action stubs. */
19
19
  export function resourceStub(name: string): string {
20
- return `import type { HttpContext } from '@zerotal/core';
20
+ return `import type { HttpContext } from 'zerotal';
21
21
 
22
22
  export class ${name} {
23
23
  async index(ctx: HttpContext): Promise<void> {
@@ -27,7 +27,7 @@ export class MakeJobCommand extends Command {
27
27
  }
28
28
  await Bun.write(
29
29
  path,
30
- `import { Job, JobRegistry } from '@zerotal/queue';
30
+ `import { Job, JobRegistry } from 'zerotal/queue';
31
31
 
32
32
  export class ${name} extends Job {
33
33
  readonly queue = 'default';
@@ -5,7 +5,7 @@ import { Command } from "../Command.ts";
5
5
 
6
6
  /** Source for a pass-through middleware class implementing `Pipe<HttpContext>`. */
7
7
  export function middlewareStub(name: string): string {
8
- return `import type { HttpContext, Pipe, NextFn } from '@zerotal/core';
8
+ return `import type { HttpContext, Pipe, NextFn } from 'zerotal';
9
9
 
10
10
  export class ${name} implements Pipe<HttpContext> {
11
11
  async handle(ctx: HttpContext, next: NextFn): Promise<Response | void> {
@@ -47,7 +47,7 @@ export class MakeObserverCommand extends Command {
47
47
  }
48
48
 
49
49
  function _stub(name: string, model: string): string {
50
- return `import type { ModelObserver } from '@zerotal/orm';
50
+ return `import type { ModelObserver } from 'zerotal/orm';
51
51
 
52
52
  export class ${name} implements ModelObserver {
53
53
  creating(${model.toLowerCase()}: Record<string, unknown>): void {
@@ -41,7 +41,7 @@ export class MakePolicyCommand extends Command {
41
41
 
42
42
  /** Source for a new authorization policy class extending `Policy`. */
43
43
  export function policyStub(name: string, model: string): string {
44
- return `import { Policy } from '@zerotal/auth';
44
+ return `import { Policy } from 'zerotal/auth';
45
45
  // import type { ${model} } from '../models/${model}.ts';
46
46
  // import type { User } from '../models/User.ts';
47
47
 
@@ -6,7 +6,7 @@ import { Command } from "../Command.ts";
6
6
  import { registerProvider } from "../../build/codemod.ts";
7
7
 
8
8
  function providerStub(name: string): string {
9
- return `import { ServiceProvider } from '@zerotal/core';
9
+ return `import { ServiceProvider } from 'zerotal';
10
10
 
11
11
  export class ${name} extends ServiceProvider {
12
12
  override onRegister(): void {
@@ -31,13 +31,23 @@ export class MakeRequestCommand extends Command {
31
31
  }
32
32
  }
33
33
 
34
+ // Two things this stub deliberately does not do.
35
+ //
36
+ // It imports from `zerotal/validator`, not `@zerotal/validator`: a scaffolded app
37
+ // depends on the umbrella, so the scoped name resolves to nothing and the file it
38
+ // just generated does not compile.
39
+ //
40
+ // And it leaves `rules()` unannotated. `validate()` reads the narrow return type
41
+ // through `ReturnType<T['rules']>`, so writing `Record<string, FieldRule>` there
42
+ // widens it back and every validated field arrives as `unknown` — silently, with
43
+ // the first sign a cast somewhere downstream. `FormRequest`'s own docblock says
44
+ // so; the generator used to emit exactly what it warns against.
34
45
  function stub(name: string): string {
35
- return `import { FormRequest } from '@zerotal/validator';
36
- import type { RuleBuilder } from '@zerotal/validator';
37
- import type { FieldRule } from '@zerotal/validator';
46
+ return `import { FormRequest } from 'zerotal/validator';
47
+ import type { RuleBuilder } from 'zerotal/validator';
38
48
 
39
49
  export class ${name} extends FormRequest {
40
- rules(r: RuleBuilder): Record<string, FieldRule> {
50
+ rules(r: RuleBuilder) {
41
51
  return {
42
52
  // example: title: r.string().min(3).max(255),
43
53
  };
@@ -35,8 +35,8 @@ export class MakeResourceCommand extends Command {
35
35
  function _stub(name: string): string {
36
36
  const model = name.replace(/Resource$/, "");
37
37
  const snake = model.replace(/([A-Z])/g, (char, index) => (index ? "-" : "") + char.toLowerCase());
38
- return `import { Resource, ResourceCollection } from '@zerotal/core';
39
- import type { PaginatedData } from '@zerotal/core';
38
+ return `import { Resource, ResourceCollection } from 'zerotal/http';
39
+ import type { PaginatedData } from 'zerotal/http';
40
40
 
41
41
  export class ${name} extends Resource<${model}> {
42
42
  toArray(): Record<string, unknown> {
@@ -11,8 +11,8 @@ export function featureTestStub(name: string): string {
11
11
  // edit rather than one you have to rewrite.
12
12
  const resource = pluralize(subject.toLowerCase());
13
13
  return `import { describe, it, beforeAll, afterAll } from 'bun:test';
14
- import { migrateDatabase, refreshDatabase, assertDatabaseHas } from '@zerotal/testing';
15
- import type { TestApp } from '@zerotal/testing';
14
+ import { migrateDatabase, refreshDatabase, assertDatabaseHas } from 'zerotal/testing';
15
+ import type { TestApp } from 'zerotal/testing';
16
16
  import { createApp } from '../helpers.ts';
17
17
 
18
18
  let app: TestApp;
@@ -34,6 +34,7 @@ export class RouteTypesCommand extends Command {
34
34
 
35
35
  async run(): Promise<void> {
36
36
  const check = this.flags["check"] as boolean;
37
+
37
38
  const result = await writeRouteTypes(Router.namedRoutes, { check });
38
39
 
39
40
  if (check) {