@zerotal/core 1.4.0 → 1.5.1

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 +370 -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 +24 -4
  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 +99 -3
  60. package/src/support/unroutedRoutes.ts +37 -0
package/CHANGELOG.md CHANGED
@@ -8,6 +8,376 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.5.1] — 2026-08-15
12
+
13
+ ### Fixed
14
+
15
+ - **Every development-only surface switched itself off under `zt serve`.**
16
+ `devSurfacesEnabled()` asked `Bun.env["APP_ENV"]` whether this was a development
17
+ environment — but `setAppEnv()` replaces that with the runtime mode before the app is
18
+ created, so it was asking whether `"web"` is development. The answer is no. An app with
19
+ `APP_ENV=development` in its `.env` therefore got **production error pages** from a plain
20
+ `bun zt serve`: no stack trace, no dev overlay.
21
+
22
+ It reads `deployEnv()` now, which is where the deployment name survives. Production and
23
+ staging are unaffected — both still fail closed, and an unset value still fails closed.
24
+
25
+ This is the third instance of one mistake. The weak-`APP_KEY` refusal and the ORM's N+1
26
+ detector had exactly the same bug, both fixed in 1.5.0. Reading `APP_ENV` to decide
27
+ anything about the _deployment_ is wrong once the app has booted; `deployEnv()` is the
28
+ answer.
29
+
30
+ ## [1.5.0] — 2026-08-15
31
+
32
+ ### Added
33
+
34
+ - **`bun zt deploy:<env>` — a release that refuses to finish when something is wrong.**
35
+ The pieces already existed: `zt doctor` finds silent misconfigurations, config
36
+ validators refuse an insecure production boot, `assets:build` builds a release,
37
+ `migrate` applies the schema. What was missing was an order, and the order is the
38
+ value: **everything that can refuse runs before anything that mutates.** A bad
39
+ origin list stops the deploy while the old release is still serving, instead of
40
+ after the migration has run and the new process is live and inert.
41
+
42
+ Four phases — preflight (this really is that environment; every config validator
43
+ re-run with production semantics; `doctor`), build, migrate, verify. It exits
44
+ non-zero and **does not restart your service**: systemd or your container runtime
45
+ owns process lifecycle, and this gives it a gate to restart behind.
46
+
47
+ Every environment gets its own command. `production` and `staging` exist without
48
+ configuration; `config/deploy.ts` declares more, each with an optional public URL
49
+ and its own step list. The target name is checked against the deployment the
50
+ process was actually started as, so `deploy:production` on a staging box stops on
51
+ the first line rather than migrating the wrong database.
52
+
53
+ `--dry-run` prints the plan, `--skip-migrations` releases without touching the
54
+ schema, `--probe` runs a real WebSocket handshake against the deployed site.
55
+
56
+ - **Two new `doctor` checks, for the two settings nothing was watching.**
57
+ `app.cors.origin: "*"` lets any website read this app's responses out of a
58
+ visitor's browser — and it was what every scaffolded app shipped with, because the
59
+ templates set it while the framework's own default was the safe empty list.
60
+ `app.secureHeaders.secure` gates HSTS, defaults to false, and had no production
61
+ detection anywhere, so a deployment that never set it sent no
62
+ `Strict-Transport-Security` at all. Both fail on a production-like deployment and
63
+ stay quiet locally.
64
+
65
+ ### Fixed
66
+
67
+ - **A weak `APP_KEY` never actually refused a production boot.** The check asked
68
+ `isProdLike(Bun.env["APP_ENV"])` — but `setAppEnv()` overwrites that variable with
69
+ the runtime mode (`web`/`console`/`worker`) before the app is created, so the
70
+ answer was always "no" and the refusal this code exists for had never once fired.
71
+
72
+ `APP_ENV` carries two meanings and the second destroys the first. `setAppEnv()`
73
+ now preserves the deployment name, and `deployEnv()` reads it back. Prefer it to
74
+ `Bun.env["APP_ENV"]` for any production decision.
75
+
76
+ - **`staging` was production for some purposes and not others.** `isProdLike`
77
+ accepted it — so config validation refused an insecure staging boot — while
78
+ `App.isProduction()`, the doctor, the boot-build policy and the asset-minify
79
+ default all excluded it. A staging box therefore got production-grade config
80
+ refusal alongside unminified assets and a boot-time asset build, which is the one
81
+ environment where the read-only restart loop was still reachable. All of them now
82
+ agree.
83
+
84
+ - **`app.secureHeaders` could not be configured beyond `frameOptions`.** The
85
+ middleware reads the whole block and layers it over its defaults, so every option
86
+ had always worked — but only `frameOptions` was declared on the type, which made
87
+ the rest a type error to write down. `secure` is the one that mattered: HSTS is
88
+ emitted only when it is true, so an app had no supported way to turn HSTS on.
89
+
90
+ - **`assets:build` and `doctor` killed the process instead of failing.** Both called
91
+ `process.exit(1)` directly, so composing either through `callInProcess` ended the
92
+ caller — and in the doctor's case its buffered report was never flushed, so the
93
+ failure arrived with nothing explaining it. Both throw now; the CLI exit code is
94
+ unchanged.
95
+
96
+ - **The development error page can now say what to do, not just what broke.**
97
+ `no such table: assets` is exact about the failure and useless about the cause:
98
+ every frame in its stack is inside the SQL driver, because that is where the
99
+ error surfaced rather than where it came from.
100
+
101
+ `registerErrorDiagnoser()` lets the package that owns an error class contribute
102
+ a diagnosis — a title, a paragraph, supporting specifics, and optionally a
103
+ button — rendered above the stack. `@zerotal/orm` registers the first one; see
104
+ its changelog. Diagnosers run in order, the first match wins, and one that
105
+ throws is skipped rather than replacing a real stack trace with a stack trace
106
+ about the diagnoser.
107
+
108
+ A diagnosis with an `action` changes server state from a page rendered by a
109
+ GET, so the type carries the values and the _endpoint_ owns the safety. That is
110
+ stated on the type, because the alternative is each implementor rediscovering
111
+ it.
112
+
113
+ - **Typed route names — `bun zt route:types`.** The command boots the app, reads the
114
+ routes it registered, and writes `types/routes.generated.ts`: a name → URL pattern map
115
+ plus a one-line `RouteRegistry` augmentation. With it, `route("psots.show")` and
116
+ `route("posts.show", {})` are compile errors, and the second one names the `slug` it
117
+ wants. Params are derived from the pattern, so adding a segment changes one string and
118
+ every call site updates with it.
119
+
120
+ It boots rather than scanning `routes/` because a route name comes from three places
121
+ and only one is a file path — the file-router's convention, a route file's
122
+ `export const meta = { GET: { name } }`, and programmatic registrations, including a
123
+ package provider's. A scanner sees the first and quietly misses the other two, and a
124
+ second implementation of the naming rules is a second implementation to disagree with
125
+ the first.
126
+
127
+ Freshness has three parts, because a generated file that is only right after someone
128
+ remembers to run a command is wrong in every fresh checkout: `zt dev` rewrites it on
129
+ every restart, the file is committed so editors and CI need no boot, and
130
+ `route:types --check` fails when the tree has drifted from it. Until the file exists,
131
+ the registry is empty and `route()` behaves exactly as before.
132
+
133
+ - **`route.dynamic(name, params?, query?)`** — the escape hatch for a route name that is
134
+ only known at runtime (read from config, chosen by a package). Deliberately a separate
135
+ function rather than a `string` overload on `route()`: an overload that accepts every
136
+ string is matched by every string, which would have made the checked signature
137
+ decorative. Typed names also flow through `redirect().to()`, `redirectTo()`,
138
+ `Url.route()` and `Uri.route()`.
139
+
140
+ - **`app.allowedOrigins` is declared config, and defaults to the origin of `app.url`.**
141
+ WebSocket upgrades and raw routes bypass the middleware pipeline, so each carries its
142
+ own `Origin` check against the app's own origin — which behind a reverse proxy is the
143
+ loopback address it bound to, never the public URL a browser sends. The runtime already
144
+ read `allowedOrigins`, but `AppConfigShape` did not declare it, so the only way to set
145
+ it was to spread it onto the exported config and the type system said nothing. Unset, a
146
+ proxied app renders every page correctly and refuses every credentialed action with a
147
+ 403 — quieter than a 500, invisible in the logs, and passing any health check that reads
148
+ a status code.
149
+
150
+ It is now a first-class field, filled from `url`, and unions rather than replaces: an
151
+ app naming a second origin does not mean "and stop trusting my own public URL".
152
+
153
+ - **`bun zt doctor --url=…` probes the deployed transport from the outside.** Every other
154
+ check runs inside the process, and the expensive proxy failures are exactly the ones
155
+ that cannot be seen from there. This sends a real handshake with a real `Origin` through
156
+ the real proxy and reads the status: `101` is healthy, `403` is the origin guard, `401`
157
+ is an auth gate over the transport (browsers do not send basic-auth credentials on a
158
+ handshake), `404` is usually a proxy not forwarding the path.
159
+
160
+ Two static checks come with it: **Transport origins** (empty list, an entry that is not
161
+ an origin, or a production app still pointing at localhost) and **Asset output**.
162
+
163
+ - **`bun zt assets:build`** — build every bundle the app declares as a release step:
164
+ `app.assets` entrypoints plus Flow's conventional `resources/css/app.css` and
165
+ `resources/js/app.js`. `css:build` only ever covered the first half of that.
166
+
167
+ - **`Application.declareWebSocketPath()` / `webSocketPaths()`.** Handlers are only wired in
168
+ the web runtime, so a CLI process could not name the app's own transport — which is what
169
+ `doctor --url` needs. Providers declare the path in `onRegister()`, which runs in every
170
+ mode.
171
+
172
+ - **`RequestContext.remember(key, factory)`** — run something at most once per request.
173
+ The N+1 detector says a query ran too many times; when the answer is the same every
174
+ time, the fix is to ask once, and every app that hits it rebuilds this by hand. Two
175
+ behaviours are the whole point and are the ones a hand-rolled version gets wrong: the
176
+ **promise** is cached rather than the resolved value (cache after the `await` and a
177
+ `Promise.all` of ten readers all miss), and a **rejected promise is evicted** (leave
178
+ it in and one transient failure poisons every later read in the same request).
179
+ Outside a request it is a pass-through — a queue worker has no request to scope to.
180
+ `RequestContext.forget(key)` drops a value when a write invalidates an earlier read.
181
+
182
+ - **Refreshable locks — a lock can now be held across work longer than its TTL.** Sizing
183
+ a TTL was a trade with no good answer: too short and the lock evaporates mid-job, too
184
+ long and a crashed holder blocks the key for however long you guessed. The number was
185
+ being asked two different questions at once.
186
+
187
+ `refresh: true` separates them. The lock is extended in the background for as long as
188
+ the callback runs, so the TTL only has to answer "how long after a crash before someone
189
+ else may take over" — a decision rather than a guess:
190
+
191
+ ```ts
192
+ await Lock.block(
193
+ "report:monthly",
194
+ 60,
195
+ async (lock, signal) => {
196
+ await buildReport({ signal }); // may take an hour; 60 is fine
197
+ },
198
+ { refresh: true },
199
+ );
200
+ ```
201
+
202
+ Refreshes run every `refreshEvery` seconds, defaulting to a third of the TTL so one
203
+ missed beat is survivable. `ManagedLock.refresh()` exposes the same thing by hand for
204
+ flows that span steps, alongside `expiresAt` — a client-side estimate, for deciding
205
+ when to refresh next rather than for deciding whether you still hold the lock.
206
+
207
+ **A lock that is lost anyway is not papered over.** The callback's `AbortSignal` is
208
+ aborted and `LockLostError` is thrown, because work that continues after losing
209
+ exclusivity is exactly the situation the lock existed to prevent. The signal is a
210
+ request, not a guarantee — work that ignores it runs on — so a job that can do damage
211
+ after losing the lock has to check it between steps.
212
+
213
+ `extend` is **optional** on the `LockDriver` contract, so a driver written against 1.x
214
+ still compiles; refreshing falls back to `acquire(key, owner, ttl)`, which is an
215
+ owner-guarded refresh on all three built-ins. Both callback arguments are additive —
216
+ every existing zero-argument call site is untouched.
217
+
218
+ The refresh timer is `unref()`d and cleared in `finally` on all three exits (success,
219
+ throw, and lock lost). An un-unref'd interval in a lock helper is the reason a process
220
+ stops exiting, and nothing about that symptom points back here.
221
+
222
+ - **`bun zt dev` — the server and every companion process in one terminal.** An app with
223
+ a queue needed two terminals and the discipline to restart the right one by hand. Worse,
224
+ the gap was not closeable from a package: every library with a companion process — a
225
+ worker, a listener, a watcher — had the same problem and no way to help, because the dev
226
+ runner only knew about the server.
227
+
228
+ A provider now declares one the way it declares `replContext()`:
229
+
230
+ ```ts
231
+ override devProcesses(): DevProcessDefinition[] {
232
+ return [{ name: "queue", command: "queue:work", enabled: () => this._hasQueue() }];
233
+ }
234
+ ```
235
+
236
+ and it appears as its own tab, individually restartable with `r`. `QueueProvider` ships
237
+ the first one; it stays off screen under the `sync` driver and when an in-process worker
238
+ pool is already draining the queue, because a tab with nothing to do is worse than no
239
+ tab. Apps get the last word through `app.dev.processes` and `app.dev.disable` — reusing
240
+ a name replaces the process rather than adding a second one.
241
+
242
+ **A dying process never takes the server with it.** It restarts on its own, three times
243
+ with backoff, and then parks that one tab with instructions rather than tearing dev mode
244
+ down. This is deliberately the opposite of the asset build hook, where a failure aborts
245
+ the reload — different lifetimes, different failure rules.
246
+
247
+ `--only` / `--without` / `--list` / `--stream` / `--force-build` mirror `artisan dev`,
248
+ so there is nothing to translate coming from another framework. `--list` names the provider behind
249
+ every entry, which is the question you actually have when an unfamiliar tab appears.
250
+ `serve --dev` is unchanged in spelling and gains all of it — `dev` is that command with
251
+ a richer flag set, not a second implementation of it.
252
+
253
+ - **The Deck — a tabbed dev UI with no new dependency.** `@zerotal/core` carries exactly
254
+ one external runtime dependency, and a terminal multiplexer off npm would be the second,
255
+ in the package everything else depends on, to draw a box. Bun ships every primitive it
256
+ needs: `Bun.stringWidth` measures what the terminal will actually show, `Bun.sliceAnsi`
257
+ cuts a styled line without severing an escape sequence, and raw stdin gives us keys.
258
+
259
+ Scrollback belongs to the deck rather than the terminal — 5,000 lines per process —
260
+ which is what makes per-tab history and `/` search possible at all. `1`–`9` and the
261
+ arrows select, `r` restarts, `c` clears, `t` toggles timestamps, `q` quits.
262
+
263
+ **Stream mode is the base case, not a fallback.** Interleaved `[label] line` output with
264
+ no escape codes whatsoever, chosen automatically whenever stdout is not a TTY, and what
265
+ you want in a log file or CI. The tab UI is a layer on top of it. Either way the terminal
266
+ is restored on every exit path there is — `q`, a signal, and an uncaught throw — because
267
+ raw mode plus the alternate screen left on makes a shell unusable, and that is the
268
+ classic way a TUI ruins someone's afternoon.
269
+
270
+ - **`doctorChecks()` on `ServiceProvider`.** The declarative counterpart to
271
+ `app.registerDoctorCheck()`: same checks, same report, but asked of the provider rather
272
+ than pushed from inside `onRegister()`, so a package's checks sit next to its other
273
+ contributions and read without tracing a registration call. A provider whose method
274
+ throws contributes nothing rather than failing the doctor for every other package.
275
+
276
+ - **Dev asset builds are skipped when nothing changed.** `serve --dev` rebuilt every bundle
277
+ on every boot and every backend save, including when the project had not been touched.
278
+ A build now records what it consumed and produced, and is skipped when none of it moved.
279
+ The input set comes from two places, because one is not enough: the module graph, read
280
+ back from the external sourcemaps the dev build already emits (lazily-imported chunks
281
+ included), and — for stylesheets — a stat sweep of `app/`, `resources/`, `routes/` and
282
+ `config/`, since Tailwind discovers utility classes by reading templates that appear in
283
+ no sourcemap. Measured on a small app: a Tailwind CSS build of ~740 ms becomes ~1 ms.
284
+ Every uncertainty resolves to _build_: a corrupt cache, an unreadable input, a deleted
285
+ output, a changed config, or a different Bun version all rebuild. Minified (production)
286
+ builds never consult it, and `ZT_NO_BUILD_CACHE=1` disables it everywhere.
287
+
288
+ - **`app/commands/` is auto-discovered.** `make:command` generates into the conventional
289
+ directory, but the runner never read it — a generated command answered
290
+ `Unknown command` until it was hand-registered in a provider, and nothing said so. The
291
+ runner now discovers the directory in console/worker/test environments (after the
292
+ built-ins, so an app command wins a name collision), the path is overridable via
293
+ `app.conventions.paths.commands`, and `make:command` prints the run invocation. The
294
+ scaffolded `zt.ts` comment describing the manual registration dance is gone.
295
+ - **`bun zt doctor`.** One command that runs every static sanity check against the booted
296
+ app and prints each finding with its fix: APP_KEY strength, `database.synchronize`
297
+ colliding with migration files, a `routes/` directory no `routing()` group loads, and
298
+ class directories (`app/schedules`, `app/jobs`, `config/storage.ts`) whose consuming
299
+ provider isn't registered — the family of failures that otherwise fail by doing
300
+ nothing. Packages contribute their own checks via `app.registerDoctorCheck()` in
301
+ `onRegister()`; the scheduler's static-config check is the first. Exits 1 when any
302
+ check fails outright.
303
+ - **Boot warns about an unrouted `routes/` directory.** A conventional `routes/index.ts`
304
+ full of `Router.get(...)` calls registers nothing until `.routing()` loads it, so every
305
+ path in it 404s in a way indistinguishable from a typo'd URL. The web boot now names
306
+ the files and the one-line fix. (`Application.routedFiles` is new, so the check — and
307
+ anything else — can see what the routing groups actually load.)
308
+
309
+ ### Changed — BREAKING
310
+
311
+ - **`route()` takes query values as a third argument: `route(name, params, query)`.**
312
+ Previously any param that matched no `:segment` was appended to the query string, which
313
+ meant a typo'd param name silently produced a URL that was wrong rather than an error —
314
+ `route("posts.show", { slugg })` shipped `/posts/:slug?slugg=…`. Params are now exact:
315
+ an unknown key throws, naming the key and pointing at the third argument.
316
+
317
+ ```ts
318
+ route("search", { q: "zerotal", page: 2 }); // before
319
+ route("search", {}, { q: "zerotal", page: 2 }); // now
320
+ ```
321
+
322
+ Query values may be arrays (`{ tag: ["a", "b"] }` → `?tag=a&tag=b`), and `null` /
323
+ `undefined` entries are dropped rather than serialised as `"null"`. The same applies to
324
+ `redirect().to()` / `redirectTo()`, which take params only — build the URL with
325
+ `route()` when you need a query string.
326
+
327
+ This was a decision between typing the existing behaviour and fixing it. Typing it
328
+ would have made a footgun look safe, which is worse than leaving it alone.
329
+
330
+ - **A catch-all route's value is passed under the `"*"` key.** `[...slug]` compiles to `*`
331
+ in the URL pattern — the segment name is gone by the time routing sees it — and
332
+ `route()` previously left the `*` in the URL untouched, producing a literal
333
+ `/docs/*`. It now substitutes, from either a path or an array of segments:
334
+ `route("docs.show", { "*": "guides/intro" })`, `route("docs.show", { "*": ["guides", "intro"] })`.
335
+
336
+ ### Changed
337
+
338
+ - **`serve` no longer rebuilds assets at boot in production when the output directory is
339
+ read-only.** Rebuilding on start is right in development and load-bearing for the wrong
340
+ reason in production: it makes the server process require write access to its own output
341
+ tree, so a properly hardened unit (`ProtectSystem=strict` with a tight `ReadWritePaths`)
342
+ fails at startup with `Read-only file system: writing chunk "./app.css"` and
343
+ restart-loops — with the logs blaming the filesystem rather than the boot-time build that
344
+ made it a problem.
345
+
346
+ A read-only output directory is now read as what it is: a deployment that built its
347
+ assets ahead of time and locked the tree down. It serves what it shipped and logs one
348
+ line. Everywhere else, and anywhere the directory is writable, behaviour is unchanged.
349
+
350
+ - **`SessionContract.get` and `pull` take an optional `<T>`.** The contract's own
351
+ docblock said higher-level surfaces layer a generic on top, but `ctx.session` _is_
352
+ typed as the contract — so `ctx.session.get<number>(k)` was a compile error while
353
+ `ctx.flashed<T>(k)` on the same object was not. `<T>` defaults to `unknown`, so the
354
+ read-then-narrow form is unchanged.
355
+
356
+ ### Fixed
357
+
358
+ - **The memory lock driver refreshes on re-acquire.** `ManagedLock.acquire()` documents
359
+ that re-acquiring while this instance already holds the key refreshes it. Redis honoured
360
+ that with `EXPIRE` and SQLite with an `UPDATE`; the memory driver returned `true` for the
361
+ same owner and never touched `expiresAt` — so the driver every app gets by default was
362
+ the only one of the three that quietly refused. A caller re-acquiring to stay alive was
363
+ told it had worked and then lost the lock on the original schedule.
364
+
365
+ - **`setAppEnv("dev")` resolves to `web`, not `console`.** Dev mode's process 1 boots the
366
+ app purely to ask its providers what to run, and a provider is only asked if its
367
+ `static environments` includes the environment it booted under. Falling through to
368
+ `console` would have silently dropped every web-only provider — no error, no empty tab,
369
+ just a process that never appears — and would have left `zt dev` and `serve --dev`
370
+ disagreeing about what dev mode consists of.
371
+
372
+ - **A Flow app built its bundles three times on every `serve --dev`.** `APP_ENV` defaults to
373
+ `"web"`, so the orchestrator process passed the view provider's web-runtime check and ran
374
+ its "build once at startup" pass; the orchestrator then ran the same build hook itself;
375
+ then it spawned a worker that booted the app and built a third time. Every backend save
376
+ paid for two of them. View providers now skip their boot-time build when a
377
+ `DevOrchestrator` owns builds — detected from `argv` for the orchestrator, since providers
378
+ boot before `ServeCommand` can set an environment variable, and from `ZT_DEV` for the
379
+ worker it supervises. A plain `serve` still builds at boot.
380
+
11
381
  ## [1.1.0] — 2026-08-08
12
382
 
13
383
  ### 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.1",
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