@zerotal/core 1.4.0 → 1.5.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 +351 -0
- package/package.json +1 -1
- package/src/application/Application.ts +107 -9
- package/src/application/DevErrorPage.ts +82 -0
- package/src/application/diagnostics.ts +111 -0
- package/src/command/CommandRunner.ts +82 -1
- package/src/command/builtin/AssetsBuildCommand.ts +102 -0
- package/src/command/builtin/DeployCommand.ts +315 -0
- package/src/command/builtin/DevCommand.ts +88 -0
- package/src/command/builtin/DoctorCommand.ts +97 -0
- package/src/command/builtin/MakeCommandCommand.ts +2 -0
- package/src/command/builtin/RouteTypesCommand.ts +56 -0
- package/src/command/builtin/ServeCommand.ts +232 -44
- package/src/command/builtin/index.ts +5 -0
- package/src/command/scaffold/zerotal.ts.txt +2 -10
- package/src/config/AppConfig.ts +109 -2
- package/src/config/DeployConfig.ts +71 -0
- package/src/config/index.ts +2 -0
- package/src/config/registry.ts +1 -0
- package/src/container/Container.ts +3 -3
- package/src/container/inject.ts +3 -2
- package/src/context/RequestContext.ts +60 -0
- package/src/contracts/session.ts +18 -3
- package/src/dev/BuildCache.ts +312 -0
- package/src/dev/CssPlugins.ts +93 -7
- package/src/dev/DevBuildHook.ts +14 -1
- package/src/dev/DevDeck.ts +549 -0
- package/src/dev/DevOrchestrator.ts +166 -31
- package/src/dev/DevProcess.ts +221 -0
- package/src/dev/DevReloadMiddleware.ts +1 -1
- package/src/dev/DevSupervisor.ts +363 -0
- package/src/dev/bootBuild.ts +94 -0
- package/src/dev/index.ts +24 -0
- package/src/dev/startDevMode.ts +145 -0
- package/src/doctor/AppDoctor.ts +399 -0
- package/src/doctor/TransportProbe.ts +169 -0
- package/src/events/Emitter.ts +4 -3
- package/src/facade/facades/App.ts +10 -2
- package/src/helpers/index.ts +23 -1
- package/src/helpers/response.ts +18 -8
- package/src/http/Uri.ts +7 -3
- package/src/http/originGuard.ts +1 -1
- package/src/http/url.ts +10 -4
- package/src/index.ts +43 -0
- package/src/lock/LockManager.ts +190 -14
- package/src/lock/drivers/LockDriver.ts +11 -0
- package/src/lock/drivers/MemoryLockDriver.ts +21 -1
- package/src/lock/drivers/RedisLockDriver.ts +64 -8
- package/src/lock/drivers/SqliteLockDriver.ts +13 -0
- package/src/lock/errors.ts +26 -0
- package/src/lock/facades/Lock.ts +30 -5
- package/src/lock/index.ts +2 -2
- package/src/macros/config.macro.ts +2 -0
- package/src/provider/ServiceProvider.ts +40 -0
- package/src/router/Router.ts +111 -13
- package/src/router/registry.ts +123 -0
- package/src/router/routeTypes.ts +132 -0
- package/src/support/classRef.ts +27 -0
- package/src/support/env.ts +69 -2
- package/src/support/unroutedRoutes.ts +37 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,357 @@ follows the Zerotal monorepo's unified versioning.
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [1.5.0] — 2026-08-15
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`bun zt deploy:<env>` — a release that refuses to finish when something is wrong.**
|
|
16
|
+
The pieces already existed: `zt doctor` finds silent misconfigurations, config
|
|
17
|
+
validators refuse an insecure production boot, `assets:build` builds a release,
|
|
18
|
+
`migrate` applies the schema. What was missing was an order, and the order is the
|
|
19
|
+
value: **everything that can refuse runs before anything that mutates.** A bad
|
|
20
|
+
origin list stops the deploy while the old release is still serving, instead of
|
|
21
|
+
after the migration has run and the new process is live and inert.
|
|
22
|
+
|
|
23
|
+
Four phases — preflight (this really is that environment; every config validator
|
|
24
|
+
re-run with production semantics; `doctor`), build, migrate, verify. It exits
|
|
25
|
+
non-zero and **does not restart your service**: systemd or your container runtime
|
|
26
|
+
owns process lifecycle, and this gives it a gate to restart behind.
|
|
27
|
+
|
|
28
|
+
Every environment gets its own command. `production` and `staging` exist without
|
|
29
|
+
configuration; `config/deploy.ts` declares more, each with an optional public URL
|
|
30
|
+
and its own step list. The target name is checked against the deployment the
|
|
31
|
+
process was actually started as, so `deploy:production` on a staging box stops on
|
|
32
|
+
the first line rather than migrating the wrong database.
|
|
33
|
+
|
|
34
|
+
`--dry-run` prints the plan, `--skip-migrations` releases without touching the
|
|
35
|
+
schema, `--probe` runs a real WebSocket handshake against the deployed site.
|
|
36
|
+
|
|
37
|
+
- **Two new `doctor` checks, for the two settings nothing was watching.**
|
|
38
|
+
`app.cors.origin: "*"` lets any website read this app's responses out of a
|
|
39
|
+
visitor's browser — and it was what every scaffolded app shipped with, because the
|
|
40
|
+
templates set it while the framework's own default was the safe empty list.
|
|
41
|
+
`app.secureHeaders.secure` gates HSTS, defaults to false, and had no production
|
|
42
|
+
detection anywhere, so a deployment that never set it sent no
|
|
43
|
+
`Strict-Transport-Security` at all. Both fail on a production-like deployment and
|
|
44
|
+
stay quiet locally.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- **A weak `APP_KEY` never actually refused a production boot.** The check asked
|
|
49
|
+
`isProdLike(Bun.env["APP_ENV"])` — but `setAppEnv()` overwrites that variable with
|
|
50
|
+
the runtime mode (`web`/`console`/`worker`) before the app is created, so the
|
|
51
|
+
answer was always "no" and the refusal this code exists for had never once fired.
|
|
52
|
+
|
|
53
|
+
`APP_ENV` carries two meanings and the second destroys the first. `setAppEnv()`
|
|
54
|
+
now preserves the deployment name, and `deployEnv()` reads it back. Prefer it to
|
|
55
|
+
`Bun.env["APP_ENV"]` for any production decision.
|
|
56
|
+
|
|
57
|
+
- **`staging` was production for some purposes and not others.** `isProdLike`
|
|
58
|
+
accepted it — so config validation refused an insecure staging boot — while
|
|
59
|
+
`App.isProduction()`, the doctor, the boot-build policy and the asset-minify
|
|
60
|
+
default all excluded it. A staging box therefore got production-grade config
|
|
61
|
+
refusal alongside unminified assets and a boot-time asset build, which is the one
|
|
62
|
+
environment where the read-only restart loop was still reachable. All of them now
|
|
63
|
+
agree.
|
|
64
|
+
|
|
65
|
+
- **`app.secureHeaders` could not be configured beyond `frameOptions`.** The
|
|
66
|
+
middleware reads the whole block and layers it over its defaults, so every option
|
|
67
|
+
had always worked — but only `frameOptions` was declared on the type, which made
|
|
68
|
+
the rest a type error to write down. `secure` is the one that mattered: HSTS is
|
|
69
|
+
emitted only when it is true, so an app had no supported way to turn HSTS on.
|
|
70
|
+
|
|
71
|
+
- **`assets:build` and `doctor` killed the process instead of failing.** Both called
|
|
72
|
+
`process.exit(1)` directly, so composing either through `callInProcess` ended the
|
|
73
|
+
caller — and in the doctor's case its buffered report was never flushed, so the
|
|
74
|
+
failure arrived with nothing explaining it. Both throw now; the CLI exit code is
|
|
75
|
+
unchanged.
|
|
76
|
+
|
|
77
|
+
- **The development error page can now say what to do, not just what broke.**
|
|
78
|
+
`no such table: assets` is exact about the failure and useless about the cause:
|
|
79
|
+
every frame in its stack is inside the SQL driver, because that is where the
|
|
80
|
+
error surfaced rather than where it came from.
|
|
81
|
+
|
|
82
|
+
`registerErrorDiagnoser()` lets the package that owns an error class contribute
|
|
83
|
+
a diagnosis — a title, a paragraph, supporting specifics, and optionally a
|
|
84
|
+
button — rendered above the stack. `@zerotal/orm` registers the first one; see
|
|
85
|
+
its changelog. Diagnosers run in order, the first match wins, and one that
|
|
86
|
+
throws is skipped rather than replacing a real stack trace with a stack trace
|
|
87
|
+
about the diagnoser.
|
|
88
|
+
|
|
89
|
+
A diagnosis with an `action` changes server state from a page rendered by a
|
|
90
|
+
GET, so the type carries the values and the _endpoint_ owns the safety. That is
|
|
91
|
+
stated on the type, because the alternative is each implementor rediscovering
|
|
92
|
+
it.
|
|
93
|
+
|
|
94
|
+
- **Typed route names — `bun zt route:types`.** The command boots the app, reads the
|
|
95
|
+
routes it registered, and writes `types/routes.generated.ts`: a name → URL pattern map
|
|
96
|
+
plus a one-line `RouteRegistry` augmentation. With it, `route("psots.show")` and
|
|
97
|
+
`route("posts.show", {})` are compile errors, and the second one names the `slug` it
|
|
98
|
+
wants. Params are derived from the pattern, so adding a segment changes one string and
|
|
99
|
+
every call site updates with it.
|
|
100
|
+
|
|
101
|
+
It boots rather than scanning `routes/` because a route name comes from three places
|
|
102
|
+
and only one is a file path — the file-router's convention, a route file's
|
|
103
|
+
`export const meta = { GET: { name } }`, and programmatic registrations, including a
|
|
104
|
+
package provider's. A scanner sees the first and quietly misses the other two, and a
|
|
105
|
+
second implementation of the naming rules is a second implementation to disagree with
|
|
106
|
+
the first.
|
|
107
|
+
|
|
108
|
+
Freshness has three parts, because a generated file that is only right after someone
|
|
109
|
+
remembers to run a command is wrong in every fresh checkout: `zt dev` rewrites it on
|
|
110
|
+
every restart, the file is committed so editors and CI need no boot, and
|
|
111
|
+
`route:types --check` fails when the tree has drifted from it. Until the file exists,
|
|
112
|
+
the registry is empty and `route()` behaves exactly as before.
|
|
113
|
+
|
|
114
|
+
- **`route.dynamic(name, params?, query?)`** — the escape hatch for a route name that is
|
|
115
|
+
only known at runtime (read from config, chosen by a package). Deliberately a separate
|
|
116
|
+
function rather than a `string` overload on `route()`: an overload that accepts every
|
|
117
|
+
string is matched by every string, which would have made the checked signature
|
|
118
|
+
decorative. Typed names also flow through `redirect().to()`, `redirectTo()`,
|
|
119
|
+
`Url.route()` and `Uri.route()`.
|
|
120
|
+
|
|
121
|
+
- **`app.allowedOrigins` is declared config, and defaults to the origin of `app.url`.**
|
|
122
|
+
WebSocket upgrades and raw routes bypass the middleware pipeline, so each carries its
|
|
123
|
+
own `Origin` check against the app's own origin — which behind a reverse proxy is the
|
|
124
|
+
loopback address it bound to, never the public URL a browser sends. The runtime already
|
|
125
|
+
read `allowedOrigins`, but `AppConfigShape` did not declare it, so the only way to set
|
|
126
|
+
it was to spread it onto the exported config and the type system said nothing. Unset, a
|
|
127
|
+
proxied app renders every page correctly and refuses every credentialed action with a
|
|
128
|
+
403 — quieter than a 500, invisible in the logs, and passing any health check that reads
|
|
129
|
+
a status code.
|
|
130
|
+
|
|
131
|
+
It is now a first-class field, filled from `url`, and unions rather than replaces: an
|
|
132
|
+
app naming a second origin does not mean "and stop trusting my own public URL".
|
|
133
|
+
|
|
134
|
+
- **`bun zt doctor --url=…` probes the deployed transport from the outside.** Every other
|
|
135
|
+
check runs inside the process, and the expensive proxy failures are exactly the ones
|
|
136
|
+
that cannot be seen from there. This sends a real handshake with a real `Origin` through
|
|
137
|
+
the real proxy and reads the status: `101` is healthy, `403` is the origin guard, `401`
|
|
138
|
+
is an auth gate over the transport (browsers do not send basic-auth credentials on a
|
|
139
|
+
handshake), `404` is usually a proxy not forwarding the path.
|
|
140
|
+
|
|
141
|
+
Two static checks come with it: **Transport origins** (empty list, an entry that is not
|
|
142
|
+
an origin, or a production app still pointing at localhost) and **Asset output**.
|
|
143
|
+
|
|
144
|
+
- **`bun zt assets:build`** — build every bundle the app declares as a release step:
|
|
145
|
+
`app.assets` entrypoints plus Flow's conventional `resources/css/app.css` and
|
|
146
|
+
`resources/js/app.js`. `css:build` only ever covered the first half of that.
|
|
147
|
+
|
|
148
|
+
- **`Application.declareWebSocketPath()` / `webSocketPaths()`.** Handlers are only wired in
|
|
149
|
+
the web runtime, so a CLI process could not name the app's own transport — which is what
|
|
150
|
+
`doctor --url` needs. Providers declare the path in `onRegister()`, which runs in every
|
|
151
|
+
mode.
|
|
152
|
+
|
|
153
|
+
- **`RequestContext.remember(key, factory)`** — run something at most once per request.
|
|
154
|
+
The N+1 detector says a query ran too many times; when the answer is the same every
|
|
155
|
+
time, the fix is to ask once, and every app that hits it rebuilds this by hand. Two
|
|
156
|
+
behaviours are the whole point and are the ones a hand-rolled version gets wrong: the
|
|
157
|
+
**promise** is cached rather than the resolved value (cache after the `await` and a
|
|
158
|
+
`Promise.all` of ten readers all miss), and a **rejected promise is evicted** (leave
|
|
159
|
+
it in and one transient failure poisons every later read in the same request).
|
|
160
|
+
Outside a request it is a pass-through — a queue worker has no request to scope to.
|
|
161
|
+
`RequestContext.forget(key)` drops a value when a write invalidates an earlier read.
|
|
162
|
+
|
|
163
|
+
- **Refreshable locks — a lock can now be held across work longer than its TTL.** Sizing
|
|
164
|
+
a TTL was a trade with no good answer: too short and the lock evaporates mid-job, too
|
|
165
|
+
long and a crashed holder blocks the key for however long you guessed. The number was
|
|
166
|
+
being asked two different questions at once.
|
|
167
|
+
|
|
168
|
+
`refresh: true` separates them. The lock is extended in the background for as long as
|
|
169
|
+
the callback runs, so the TTL only has to answer "how long after a crash before someone
|
|
170
|
+
else may take over" — a decision rather than a guess:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
await Lock.block(
|
|
174
|
+
"report:monthly",
|
|
175
|
+
60,
|
|
176
|
+
async (lock, signal) => {
|
|
177
|
+
await buildReport({ signal }); // may take an hour; 60 is fine
|
|
178
|
+
},
|
|
179
|
+
{ refresh: true },
|
|
180
|
+
);
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Refreshes run every `refreshEvery` seconds, defaulting to a third of the TTL so one
|
|
184
|
+
missed beat is survivable. `ManagedLock.refresh()` exposes the same thing by hand for
|
|
185
|
+
flows that span steps, alongside `expiresAt` — a client-side estimate, for deciding
|
|
186
|
+
when to refresh next rather than for deciding whether you still hold the lock.
|
|
187
|
+
|
|
188
|
+
**A lock that is lost anyway is not papered over.** The callback's `AbortSignal` is
|
|
189
|
+
aborted and `LockLostError` is thrown, because work that continues after losing
|
|
190
|
+
exclusivity is exactly the situation the lock existed to prevent. The signal is a
|
|
191
|
+
request, not a guarantee — work that ignores it runs on — so a job that can do damage
|
|
192
|
+
after losing the lock has to check it between steps.
|
|
193
|
+
|
|
194
|
+
`extend` is **optional** on the `LockDriver` contract, so a driver written against 1.x
|
|
195
|
+
still compiles; refreshing falls back to `acquire(key, owner, ttl)`, which is an
|
|
196
|
+
owner-guarded refresh on all three built-ins. Both callback arguments are additive —
|
|
197
|
+
every existing zero-argument call site is untouched.
|
|
198
|
+
|
|
199
|
+
The refresh timer is `unref()`d and cleared in `finally` on all three exits (success,
|
|
200
|
+
throw, and lock lost). An un-unref'd interval in a lock helper is the reason a process
|
|
201
|
+
stops exiting, and nothing about that symptom points back here.
|
|
202
|
+
|
|
203
|
+
- **`bun zt dev` — the server and every companion process in one terminal.** An app with
|
|
204
|
+
a queue needed two terminals and the discipline to restart the right one by hand. Worse,
|
|
205
|
+
the gap was not closeable from a package: every library with a companion process — a
|
|
206
|
+
worker, a listener, a watcher — had the same problem and no way to help, because the dev
|
|
207
|
+
runner only knew about the server.
|
|
208
|
+
|
|
209
|
+
A provider now declares one the way it declares `replContext()`:
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
override devProcesses(): DevProcessDefinition[] {
|
|
213
|
+
return [{ name: "queue", command: "queue:work", enabled: () => this._hasQueue() }];
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
and it appears as its own tab, individually restartable with `r`. `QueueProvider` ships
|
|
218
|
+
the first one; it stays off screen under the `sync` driver and when an in-process worker
|
|
219
|
+
pool is already draining the queue, because a tab with nothing to do is worse than no
|
|
220
|
+
tab. Apps get the last word through `app.dev.processes` and `app.dev.disable` — reusing
|
|
221
|
+
a name replaces the process rather than adding a second one.
|
|
222
|
+
|
|
223
|
+
**A dying process never takes the server with it.** It restarts on its own, three times
|
|
224
|
+
with backoff, and then parks that one tab with instructions rather than tearing dev mode
|
|
225
|
+
down. This is deliberately the opposite of the asset build hook, where a failure aborts
|
|
226
|
+
the reload — different lifetimes, different failure rules.
|
|
227
|
+
|
|
228
|
+
`--only` / `--without` / `--list` / `--stream` / `--force-build` mirror `artisan dev`,
|
|
229
|
+
so there is nothing to translate coming from another framework. `--list` names the provider behind
|
|
230
|
+
every entry, which is the question you actually have when an unfamiliar tab appears.
|
|
231
|
+
`serve --dev` is unchanged in spelling and gains all of it — `dev` is that command with
|
|
232
|
+
a richer flag set, not a second implementation of it.
|
|
233
|
+
|
|
234
|
+
- **The Deck — a tabbed dev UI with no new dependency.** `@zerotal/core` carries exactly
|
|
235
|
+
one external runtime dependency, and a terminal multiplexer off npm would be the second,
|
|
236
|
+
in the package everything else depends on, to draw a box. Bun ships every primitive it
|
|
237
|
+
needs: `Bun.stringWidth` measures what the terminal will actually show, `Bun.sliceAnsi`
|
|
238
|
+
cuts a styled line without severing an escape sequence, and raw stdin gives us keys.
|
|
239
|
+
|
|
240
|
+
Scrollback belongs to the deck rather than the terminal — 5,000 lines per process —
|
|
241
|
+
which is what makes per-tab history and `/` search possible at all. `1`–`9` and the
|
|
242
|
+
arrows select, `r` restarts, `c` clears, `t` toggles timestamps, `q` quits.
|
|
243
|
+
|
|
244
|
+
**Stream mode is the base case, not a fallback.** Interleaved `[label] line` output with
|
|
245
|
+
no escape codes whatsoever, chosen automatically whenever stdout is not a TTY, and what
|
|
246
|
+
you want in a log file or CI. The tab UI is a layer on top of it. Either way the terminal
|
|
247
|
+
is restored on every exit path there is — `q`, a signal, and an uncaught throw — because
|
|
248
|
+
raw mode plus the alternate screen left on makes a shell unusable, and that is the
|
|
249
|
+
classic way a TUI ruins someone's afternoon.
|
|
250
|
+
|
|
251
|
+
- **`doctorChecks()` on `ServiceProvider`.** The declarative counterpart to
|
|
252
|
+
`app.registerDoctorCheck()`: same checks, same report, but asked of the provider rather
|
|
253
|
+
than pushed from inside `onRegister()`, so a package's checks sit next to its other
|
|
254
|
+
contributions and read without tracing a registration call. A provider whose method
|
|
255
|
+
throws contributes nothing rather than failing the doctor for every other package.
|
|
256
|
+
|
|
257
|
+
- **Dev asset builds are skipped when nothing changed.** `serve --dev` rebuilt every bundle
|
|
258
|
+
on every boot and every backend save, including when the project had not been touched.
|
|
259
|
+
A build now records what it consumed and produced, and is skipped when none of it moved.
|
|
260
|
+
The input set comes from two places, because one is not enough: the module graph, read
|
|
261
|
+
back from the external sourcemaps the dev build already emits (lazily-imported chunks
|
|
262
|
+
included), and — for stylesheets — a stat sweep of `app/`, `resources/`, `routes/` and
|
|
263
|
+
`config/`, since Tailwind discovers utility classes by reading templates that appear in
|
|
264
|
+
no sourcemap. Measured on a small app: a Tailwind CSS build of ~740 ms becomes ~1 ms.
|
|
265
|
+
Every uncertainty resolves to _build_: a corrupt cache, an unreadable input, a deleted
|
|
266
|
+
output, a changed config, or a different Bun version all rebuild. Minified (production)
|
|
267
|
+
builds never consult it, and `ZT_NO_BUILD_CACHE=1` disables it everywhere.
|
|
268
|
+
|
|
269
|
+
- **`app/commands/` is auto-discovered.** `make:command` generates into the conventional
|
|
270
|
+
directory, but the runner never read it — a generated command answered
|
|
271
|
+
`Unknown command` until it was hand-registered in a provider, and nothing said so. The
|
|
272
|
+
runner now discovers the directory in console/worker/test environments (after the
|
|
273
|
+
built-ins, so an app command wins a name collision), the path is overridable via
|
|
274
|
+
`app.conventions.paths.commands`, and `make:command` prints the run invocation. The
|
|
275
|
+
scaffolded `zt.ts` comment describing the manual registration dance is gone.
|
|
276
|
+
- **`bun zt doctor`.** One command that runs every static sanity check against the booted
|
|
277
|
+
app and prints each finding with its fix: APP_KEY strength, `database.synchronize`
|
|
278
|
+
colliding with migration files, a `routes/` directory no `routing()` group loads, and
|
|
279
|
+
class directories (`app/schedules`, `app/jobs`, `config/storage.ts`) whose consuming
|
|
280
|
+
provider isn't registered — the family of failures that otherwise fail by doing
|
|
281
|
+
nothing. Packages contribute their own checks via `app.registerDoctorCheck()` in
|
|
282
|
+
`onRegister()`; the scheduler's static-config check is the first. Exits 1 when any
|
|
283
|
+
check fails outright.
|
|
284
|
+
- **Boot warns about an unrouted `routes/` directory.** A conventional `routes/index.ts`
|
|
285
|
+
full of `Router.get(...)` calls registers nothing until `.routing()` loads it, so every
|
|
286
|
+
path in it 404s in a way indistinguishable from a typo'd URL. The web boot now names
|
|
287
|
+
the files and the one-line fix. (`Application.routedFiles` is new, so the check — and
|
|
288
|
+
anything else — can see what the routing groups actually load.)
|
|
289
|
+
|
|
290
|
+
### Changed — BREAKING
|
|
291
|
+
|
|
292
|
+
- **`route()` takes query values as a third argument: `route(name, params, query)`.**
|
|
293
|
+
Previously any param that matched no `:segment` was appended to the query string, which
|
|
294
|
+
meant a typo'd param name silently produced a URL that was wrong rather than an error —
|
|
295
|
+
`route("posts.show", { slugg })` shipped `/posts/:slug?slugg=…`. Params are now exact:
|
|
296
|
+
an unknown key throws, naming the key and pointing at the third argument.
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
route("search", { q: "zerotal", page: 2 }); // before
|
|
300
|
+
route("search", {}, { q: "zerotal", page: 2 }); // now
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Query values may be arrays (`{ tag: ["a", "b"] }` → `?tag=a&tag=b`), and `null` /
|
|
304
|
+
`undefined` entries are dropped rather than serialised as `"null"`. The same applies to
|
|
305
|
+
`redirect().to()` / `redirectTo()`, which take params only — build the URL with
|
|
306
|
+
`route()` when you need a query string.
|
|
307
|
+
|
|
308
|
+
This was a decision between typing the existing behaviour and fixing it. Typing it
|
|
309
|
+
would have made a footgun look safe, which is worse than leaving it alone.
|
|
310
|
+
|
|
311
|
+
- **A catch-all route's value is passed under the `"*"` key.** `[...slug]` compiles to `*`
|
|
312
|
+
in the URL pattern — the segment name is gone by the time routing sees it — and
|
|
313
|
+
`route()` previously left the `*` in the URL untouched, producing a literal
|
|
314
|
+
`/docs/*`. It now substitutes, from either a path or an array of segments:
|
|
315
|
+
`route("docs.show", { "*": "guides/intro" })`, `route("docs.show", { "*": ["guides", "intro"] })`.
|
|
316
|
+
|
|
317
|
+
### Changed
|
|
318
|
+
|
|
319
|
+
- **`serve` no longer rebuilds assets at boot in production when the output directory is
|
|
320
|
+
read-only.** Rebuilding on start is right in development and load-bearing for the wrong
|
|
321
|
+
reason in production: it makes the server process require write access to its own output
|
|
322
|
+
tree, so a properly hardened unit (`ProtectSystem=strict` with a tight `ReadWritePaths`)
|
|
323
|
+
fails at startup with `Read-only file system: writing chunk "./app.css"` and
|
|
324
|
+
restart-loops — with the logs blaming the filesystem rather than the boot-time build that
|
|
325
|
+
made it a problem.
|
|
326
|
+
|
|
327
|
+
A read-only output directory is now read as what it is: a deployment that built its
|
|
328
|
+
assets ahead of time and locked the tree down. It serves what it shipped and logs one
|
|
329
|
+
line. Everywhere else, and anywhere the directory is writable, behaviour is unchanged.
|
|
330
|
+
|
|
331
|
+
- **`SessionContract.get` and `pull` take an optional `<T>`.** The contract's own
|
|
332
|
+
docblock said higher-level surfaces layer a generic on top, but `ctx.session` _is_
|
|
333
|
+
typed as the contract — so `ctx.session.get<number>(k)` was a compile error while
|
|
334
|
+
`ctx.flashed<T>(k)` on the same object was not. `<T>` defaults to `unknown`, so the
|
|
335
|
+
read-then-narrow form is unchanged.
|
|
336
|
+
|
|
337
|
+
### Fixed
|
|
338
|
+
|
|
339
|
+
- **The memory lock driver refreshes on re-acquire.** `ManagedLock.acquire()` documents
|
|
340
|
+
that re-acquiring while this instance already holds the key refreshes it. Redis honoured
|
|
341
|
+
that with `EXPIRE` and SQLite with an `UPDATE`; the memory driver returned `true` for the
|
|
342
|
+
same owner and never touched `expiresAt` — so the driver every app gets by default was
|
|
343
|
+
the only one of the three that quietly refused. A caller re-acquiring to stay alive was
|
|
344
|
+
told it had worked and then lost the lock on the original schedule.
|
|
345
|
+
|
|
346
|
+
- **`setAppEnv("dev")` resolves to `web`, not `console`.** Dev mode's process 1 boots the
|
|
347
|
+
app purely to ask its providers what to run, and a provider is only asked if its
|
|
348
|
+
`static environments` includes the environment it booted under. Falling through to
|
|
349
|
+
`console` would have silently dropped every web-only provider — no error, no empty tab,
|
|
350
|
+
just a process that never appears — and would have left `zt dev` and `serve --dev`
|
|
351
|
+
disagreeing about what dev mode consists of.
|
|
352
|
+
|
|
353
|
+
- **A Flow app built its bundles three times on every `serve --dev`.** `APP_ENV` defaults to
|
|
354
|
+
`"web"`, so the orchestrator process passed the view provider's web-runtime check and ran
|
|
355
|
+
its "build once at startup" pass; the orchestrator then ran the same build hook itself;
|
|
356
|
+
then it spawned a worker that booted the app and built a third time. Every backend save
|
|
357
|
+
paid for two of them. View providers now skip their boot-time build when a
|
|
358
|
+
`DevOrchestrator` owns builds — detected from `argv` for the orchestrator, since providers
|
|
359
|
+
boot before `ServeCommand` can set an environment variable, and from `ZT_DEV` for the
|
|
360
|
+
worker it supervises. A plain `serve` still builds at boot.
|
|
361
|
+
|
|
11
362
|
## [1.1.0] — 2026-08-08
|
|
12
363
|
|
|
13
364
|
### Fixed
|
package/package.json
CHANGED
|
@@ -38,13 +38,16 @@ import { NotFoundError } from "../errors/HttpError.ts";
|
|
|
38
38
|
import type { ContainerBindings } from "../container/types.ts";
|
|
39
39
|
import { dispatchRequest } from "../router/RouteHandler.ts";
|
|
40
40
|
import type { ProviderHooks } from "../router/RouteHandler.ts";
|
|
41
|
-
import { isProdLike } from "../support/env.ts";
|
|
41
|
+
import { isProdLike, deployEnv } from "../support/env.ts";
|
|
42
42
|
import { appKeyStrengthWarning } from "../support/appKey.ts";
|
|
43
43
|
import { runBootDoctor } from "./BootDoctor.ts";
|
|
44
44
|
import { runConfigValidators } from "../config/validation.ts";
|
|
45
45
|
import { pathToFileURL } from "node:url";
|
|
46
|
-
import {
|
|
46
|
+
import { unroutedRoutesWarning } from "../support/unroutedRoutes.ts";
|
|
47
|
+
import type { DoctorCheck } from "../doctor/AppDoctor.ts";
|
|
48
|
+
import { defaultApp, setDefaultApp } from "./currentApp.ts";
|
|
47
49
|
import type { ConfigValidator, RegisteredConfigValidator } from "../config/validation.ts";
|
|
50
|
+
import type { ClassRef } from "../support/classRef.ts";
|
|
48
51
|
|
|
49
52
|
// ── Convention config discovery ───────────────────────────────────────────────
|
|
50
53
|
|
|
@@ -323,6 +326,9 @@ export class Application {
|
|
|
323
326
|
* a `path` is a catch-all. Each connection is tagged with `_wsPath` on upgrade and dispatched
|
|
324
327
|
* to the matching registration.
|
|
325
328
|
*/
|
|
329
|
+
/** Transport paths declared by providers in any runtime. See `declareWebSocketPath`. */
|
|
330
|
+
private _declaredWsPaths = new Set<string>();
|
|
331
|
+
|
|
326
332
|
private _wsRegistrations: Array<{
|
|
327
333
|
path?: string | undefined;
|
|
328
334
|
handlers: WebSocketHandlers;
|
|
@@ -331,16 +337,29 @@ export class Application {
|
|
|
331
337
|
/** Set by ServeCommand --dev-worker to enable the /__dev/ws HMR endpoint. */
|
|
332
338
|
private _devWsEnabled = false;
|
|
333
339
|
/** Tracks provider-auto-registered middleware to prevent double-registration. */
|
|
334
|
-
private readonly _autoMiddlewareSet = new Set<
|
|
340
|
+
private readonly _autoMiddlewareSet = new Set<ClassRef>();
|
|
335
341
|
private _providerHooks: ProviderHooks | undefined = undefined;
|
|
336
342
|
/** @internal The auth user-resolver registered via {@link withUserResolver}; called by AuthMiddleware. */
|
|
337
343
|
_userResolver: ((id: number) => Promise<AuthenticatedUser | null>) | undefined = undefined;
|
|
338
344
|
/** Convention descriptors contributed by providers (models, observers, policies, …). */
|
|
339
345
|
private _concerns: ConcernDescriptor[] = [];
|
|
340
|
-
/**
|
|
341
|
-
|
|
346
|
+
/**
|
|
347
|
+
* Namespace validators contributed by providers; run once at boot (see
|
|
348
|
+
* {@link registerConfigValidator}).
|
|
349
|
+
*
|
|
350
|
+
* Readable, not private, so `zt deploy:<env>` can re-run them against the target
|
|
351
|
+
* environment before a release. Boot already ran them — but on a machine that is
|
|
352
|
+
* not the deployment, where `isProduction` was false and every finding was a
|
|
353
|
+
* warning. Re-running them with production semantics is how a deploy reports the
|
|
354
|
+
* problems that would otherwise refuse the boot after the cutover.
|
|
355
|
+
*
|
|
356
|
+
* @internal
|
|
357
|
+
*/
|
|
358
|
+
readonly _configValidators: RegisteredConfigValidator[] = [];
|
|
342
359
|
/** Bootstrap container-registration callbacks queued via `bind()`; run during boot(). */
|
|
343
360
|
private _bindCallbacks: Array<(container: Container) => void> = [];
|
|
361
|
+
/** Doctor checks contributed by providers (see {@link registerDoctorCheck}). */
|
|
362
|
+
private _doctorChecks: DoctorCheck[] = [];
|
|
344
363
|
|
|
345
364
|
private constructor() {}
|
|
346
365
|
|
|
@@ -355,6 +374,30 @@ export class Application {
|
|
|
355
374
|
return this;
|
|
356
375
|
}
|
|
357
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Contribute a check to `zt doctor`. Providers call this in `onRegister()`;
|
|
379
|
+
* the doctor runs the built-in checks plus everything contributed here.
|
|
380
|
+
*
|
|
381
|
+
* @category Providers
|
|
382
|
+
*/
|
|
383
|
+
registerDoctorCheck(check: DoctorCheck): this {
|
|
384
|
+
this._doctorChecks.push(check);
|
|
385
|
+
return this;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The provider-contributed doctor checks (read by `runDoctor`). */
|
|
389
|
+
get doctorChecks(): readonly DoctorCheck[] {
|
|
390
|
+
return this._doctorChecks;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The files loaded by `routing()` groups — what actually serves explicit routes.
|
|
395
|
+
* Read by the unrouted-`routes/` boot warning and the doctor.
|
|
396
|
+
*/
|
|
397
|
+
get routedFiles(): string[] {
|
|
398
|
+
return this._routeGroups.map((g) => g.file);
|
|
399
|
+
}
|
|
400
|
+
|
|
358
401
|
/**
|
|
359
402
|
* Attach a validator to a config namespace. Providers call this in
|
|
360
403
|
* `onRegister()`; the boot sequence runs every validator once — after
|
|
@@ -719,7 +762,7 @@ export class Application {
|
|
|
719
762
|
const hasSubclass = this._middleware.some(
|
|
720
763
|
(registered) =>
|
|
721
764
|
registered !== middlewareClass &&
|
|
722
|
-
registered.prototype instanceof (middlewareClass as
|
|
765
|
+
registered.prototype instanceof (middlewareClass as ClassRef),
|
|
723
766
|
);
|
|
724
767
|
if (hasSubclass) return;
|
|
725
768
|
this._autoMiddlewareSet.add(middlewareClass);
|
|
@@ -854,6 +897,38 @@ export class Application {
|
|
|
854
897
|
return this;
|
|
855
898
|
}
|
|
856
899
|
|
|
900
|
+
/**
|
|
901
|
+
* Declare a WebSocket path this app serves, without wiring any handlers for it.
|
|
902
|
+
*
|
|
903
|
+
* Handlers are only registered in the web runtime, so a CLI process — which is what
|
|
904
|
+
* `bun zt doctor` is — has no idea the app has a transport at all. Providers call this
|
|
905
|
+
* from `onRegister()`, which runs in every mode, so the paths are knowable from the
|
|
906
|
+
* console even though nothing is listening there.
|
|
907
|
+
*
|
|
908
|
+
* @category Server
|
|
909
|
+
*/
|
|
910
|
+
declareWebSocketPath(path: string): this {
|
|
911
|
+
this._declaredWsPaths.add(path);
|
|
912
|
+
return this;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Every WebSocket path this app serves: those declared via
|
|
917
|
+
* {@link Application.declareWebSocketPath} plus those actually registered. A catch-all
|
|
918
|
+
* registration (no path) is reported as `"*"`.
|
|
919
|
+
*
|
|
920
|
+
* Exposed for tooling that has to reach the transport from outside the process — `bun zt
|
|
921
|
+
* doctor --url=…` probes each of these through the real proxy, because a handshake a
|
|
922
|
+
* browser cannot complete is invisible from in here.
|
|
923
|
+
*
|
|
924
|
+
* @category Server
|
|
925
|
+
*/
|
|
926
|
+
webSocketPaths(): string[] {
|
|
927
|
+
return [
|
|
928
|
+
...new Set([...this._declaredWsPaths, ...this._wsRegistrations.map((r) => r.path ?? "*")]),
|
|
929
|
+
];
|
|
930
|
+
}
|
|
931
|
+
|
|
857
932
|
/** Find the WS registration handling a connection's path (exact match, else a catch-all). */
|
|
858
933
|
private _wsRegFor(wsPath: unknown): (typeof this._wsRegistrations)[number] | undefined {
|
|
859
934
|
return (
|
|
@@ -1016,15 +1091,25 @@ export class Application {
|
|
|
1016
1091
|
await this._loadFileRoutes();
|
|
1017
1092
|
}
|
|
1018
1093
|
|
|
1094
|
+
// A routes/ directory nobody routed is a silent 404 for every path in it — the file
|
|
1095
|
+
// imports cleanly and registers nothing, which looks identical to a typo'd URL.
|
|
1096
|
+
this._warnUnroutedRoutesDir(process.cwd());
|
|
1097
|
+
|
|
1019
1098
|
// Convention phase — worker jobs/schedules + public static files.
|
|
1020
1099
|
await this._bootConventions();
|
|
1021
1100
|
|
|
1022
1101
|
// Fail loud on a weak APP_KEY: in a production-like deployment a short key is
|
|
1023
1102
|
// a refuse-to-boot error; elsewhere (bar the test harness) it's a warning.
|
|
1103
|
+
//
|
|
1104
|
+
// `deployEnv()`, not `Bun.env["APP_ENV"]`. `setAppEnv()` overwrites that
|
|
1105
|
+
// variable with the runtime mode (`web`/`console`/`worker`) before the app is
|
|
1106
|
+
// created, so this read was always `"web"` under the CLI — meaning a weak key
|
|
1107
|
+
// in production only ever warned, and the refusal this block exists for had
|
|
1108
|
+
// never once fired.
|
|
1024
1109
|
if (this._env !== "test") {
|
|
1025
1110
|
const _keyWarning = appKeyStrengthWarning(Bun.env["APP_KEY"]);
|
|
1026
1111
|
if (_keyWarning) {
|
|
1027
|
-
if (isProdLike(
|
|
1112
|
+
if (isProdLike(deployEnv())) throw new Error(_keyWarning);
|
|
1028
1113
|
console.warn(_keyWarning);
|
|
1029
1114
|
}
|
|
1030
1115
|
}
|
|
@@ -1045,6 +1130,20 @@ export class Application {
|
|
|
1045
1130
|
}
|
|
1046
1131
|
}
|
|
1047
1132
|
|
|
1133
|
+
/**
|
|
1134
|
+
* Warn when `routes/` exists on disk but no `routing()` group points inside it.
|
|
1135
|
+
* Web only: workers and the console don't answer HTTP, and the test harness
|
|
1136
|
+
* routinely boots apps with no routes at all.
|
|
1137
|
+
*/
|
|
1138
|
+
private _warnUnroutedRoutesDir(root: string): void {
|
|
1139
|
+
if (this._env !== "web") return;
|
|
1140
|
+
const warning = unroutedRoutesWarning(
|
|
1141
|
+
root,
|
|
1142
|
+
this._routeGroups.map((g) => g.file),
|
|
1143
|
+
);
|
|
1144
|
+
if (warning) frameworkLog("app").warn(warning);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1048
1147
|
private async _loadFileRoutes(): Promise<void> {
|
|
1049
1148
|
for (const { dir, prefix, middleware } of this._fileRouteGroups) {
|
|
1050
1149
|
await Router.groupAsync({ prefix, middleware }, () => scanFileRoutes(dir).then(() => {}));
|
|
@@ -1537,7 +1636,6 @@ export class Application {
|
|
|
1537
1636
|
const appVersion =
|
|
1538
1637
|
configManager?.get<string>("app.version", Bun.env["APP_VERSION"] ?? Bun.version) ??
|
|
1539
1638
|
Bun.version;
|
|
1540
|
-
const app = this;
|
|
1541
1639
|
|
|
1542
1640
|
// Built-in runtime probe — memory, Bun version, in-flight request count.
|
|
1543
1641
|
Health.register("runtime", () => ({
|
|
@@ -1546,7 +1644,7 @@ export class Application {
|
|
|
1546
1644
|
memory: process.memoryUsage(),
|
|
1547
1645
|
bun: Bun.version,
|
|
1548
1646
|
pendingRequests:
|
|
1549
|
-
(
|
|
1647
|
+
(this._static as { pendingRequests?: number } | undefined)?.pendingRequests ?? 0,
|
|
1550
1648
|
},
|
|
1551
1649
|
}));
|
|
1552
1650
|
|