@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 +131 -0
- package/api-surface.md +226 -1
- package/package.json +2 -1
- package/src/application/Application.ts +53 -3
- package/src/command/builtin/MakeCommandCommand.ts +2 -2
- package/src/command/builtin/MakeControllerCommand.ts +2 -2
- package/src/command/builtin/MakeJobCommand.ts +1 -1
- package/src/command/builtin/MakeMiddlewareCommand.ts +1 -1
- package/src/command/builtin/MakeObserverCommand.ts +1 -1
- package/src/command/builtin/MakePolicyCommand.ts +1 -1
- package/src/command/builtin/MakeProviderCommand.ts +1 -1
- package/src/command/builtin/MakeRequestCommand.ts +14 -4
- package/src/command/builtin/MakeResourceCommand.ts +2 -2
- package/src/command/builtin/MakeTestCommand.ts +2 -2
- package/src/command/builtin/RouteTypesCommand.ts +1 -0
- package/src/dev/DevDeck.ts +144 -20
- package/src/dev/DevOrchestrator.ts +1 -1
- package/src/helpers/index.ts +43 -28
- package/src/router/Router.ts +54 -1
- package/src/router/routeTypes.ts +52 -11
- package/src/router/routes.ts +125 -0
- package/src/security/redactGraph.ts +10 -1
- package/src/support/env.ts +48 -8
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.
|
|
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
|
-
//
|
|
505
|
-
|
|
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 '
|
|
18
|
-
import type { ArgDef, FlagDef } from '
|
|
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 '
|
|
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 '
|
|
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 '
|
|
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 '
|
|
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 '
|
|
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 '
|
|
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 '
|
|
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 '
|
|
36
|
-
import type { RuleBuilder }
|
|
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)
|
|
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 '
|
|
39
|
-
import type { PaginatedData } from '
|
|
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 '
|
|
15
|
-
import type { TestApp } from '
|
|
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;
|