@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +351 -0
  2. package/package.json +1 -1
  3. package/src/application/Application.ts +107 -9
  4. package/src/application/DevErrorPage.ts +82 -0
  5. package/src/application/diagnostics.ts +111 -0
  6. package/src/command/CommandRunner.ts +82 -1
  7. package/src/command/builtin/AssetsBuildCommand.ts +102 -0
  8. package/src/command/builtin/DeployCommand.ts +315 -0
  9. package/src/command/builtin/DevCommand.ts +88 -0
  10. package/src/command/builtin/DoctorCommand.ts +97 -0
  11. package/src/command/builtin/MakeCommandCommand.ts +2 -0
  12. package/src/command/builtin/RouteTypesCommand.ts +56 -0
  13. package/src/command/builtin/ServeCommand.ts +232 -44
  14. package/src/command/builtin/index.ts +5 -0
  15. package/src/command/scaffold/zerotal.ts.txt +2 -10
  16. package/src/config/AppConfig.ts +109 -2
  17. package/src/config/DeployConfig.ts +71 -0
  18. package/src/config/index.ts +2 -0
  19. package/src/config/registry.ts +1 -0
  20. package/src/container/Container.ts +3 -3
  21. package/src/container/inject.ts +3 -2
  22. package/src/context/RequestContext.ts +60 -0
  23. package/src/contracts/session.ts +18 -3
  24. package/src/dev/BuildCache.ts +312 -0
  25. package/src/dev/CssPlugins.ts +93 -7
  26. package/src/dev/DevBuildHook.ts +14 -1
  27. package/src/dev/DevDeck.ts +549 -0
  28. package/src/dev/DevOrchestrator.ts +166 -31
  29. package/src/dev/DevProcess.ts +221 -0
  30. package/src/dev/DevReloadMiddleware.ts +1 -1
  31. package/src/dev/DevSupervisor.ts +363 -0
  32. package/src/dev/bootBuild.ts +94 -0
  33. package/src/dev/index.ts +24 -0
  34. package/src/dev/startDevMode.ts +145 -0
  35. package/src/doctor/AppDoctor.ts +399 -0
  36. package/src/doctor/TransportProbe.ts +169 -0
  37. package/src/events/Emitter.ts +4 -3
  38. package/src/facade/facades/App.ts +10 -2
  39. package/src/helpers/index.ts +23 -1
  40. package/src/helpers/response.ts +18 -8
  41. package/src/http/Uri.ts +7 -3
  42. package/src/http/originGuard.ts +1 -1
  43. package/src/http/url.ts +10 -4
  44. package/src/index.ts +43 -0
  45. package/src/lock/LockManager.ts +190 -14
  46. package/src/lock/drivers/LockDriver.ts +11 -0
  47. package/src/lock/drivers/MemoryLockDriver.ts +21 -1
  48. package/src/lock/drivers/RedisLockDriver.ts +64 -8
  49. package/src/lock/drivers/SqliteLockDriver.ts +13 -0
  50. package/src/lock/errors.ts +26 -0
  51. package/src/lock/facades/Lock.ts +30 -5
  52. package/src/lock/index.ts +2 -2
  53. package/src/macros/config.macro.ts +2 -0
  54. package/src/provider/ServiceProvider.ts +40 -0
  55. package/src/router/Router.ts +111 -13
  56. package/src/router/registry.ts +123 -0
  57. package/src/router/routeTypes.ts +132 -0
  58. package/src/support/classRef.ts +27 -0
  59. package/src/support/env.ts +69 -2
  60. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/core",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -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 { currentApp, defaultApp, setDefaultApp } from "./currentApp.ts";
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<Function>();
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
- /** Namespace validators contributed by providers; run once at boot (see {@link registerConfigValidator}). */
341
- private _configValidators: RegisteredConfigValidator[] = [];
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 Function),
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(Bun.env["APP_ENV"] ?? "")) throw new Error(_keyWarning);
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
- (app._static as { pendingRequests?: number } | undefined)?.pendingRequests ?? 0,
1647
+ (this._static as { pendingRequests?: number } | undefined)?.pendingRequests ?? 0,
1550
1648
  },
1551
1649
  }));
1552
1650