@zerotal/core 1.0.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 (201) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/LICENSE +21 -0
  3. package/README.md +128 -0
  4. package/package.json +72 -0
  5. package/src/application/Application.ts +1671 -0
  6. package/src/application/BootDoctor.ts +108 -0
  7. package/src/application/DevErrorPage.ts +567 -0
  8. package/src/application/ExceptionHandler.ts +183 -0
  9. package/src/application/currentApp.ts +73 -0
  10. package/src/assets/assets.ts +79 -0
  11. package/src/assets/index.ts +16 -0
  12. package/src/auth/AuthenticatedUser.ts +18 -0
  13. package/src/build/PackageLinter.ts +146 -0
  14. package/src/build/PackageScaffold.ts +127 -0
  15. package/src/build/codemod.ts +64 -0
  16. package/src/build/index.ts +12 -0
  17. package/src/command/Command.ts +254 -0
  18. package/src/command/CommandRunner.ts +593 -0
  19. package/src/command/OutputWriter.ts +61 -0
  20. package/src/command/builtin/CompileCommand.ts +46 -0
  21. package/src/command/builtin/CssBuildCommand.ts +71 -0
  22. package/src/command/builtin/KeyGenerateCommand.ts +58 -0
  23. package/src/command/builtin/LintPackagesCommand.ts +72 -0
  24. package/src/command/builtin/MakeCommandCommand.ts +85 -0
  25. package/src/command/builtin/MakeControllerCommand.ts +95 -0
  26. package/src/command/builtin/MakeEventCommand.ts +85 -0
  27. package/src/command/builtin/MakeJobCommand.ts +53 -0
  28. package/src/command/builtin/MakeListenerCommand.ts +35 -0
  29. package/src/command/builtin/MakeMiddlewareCommand.ts +63 -0
  30. package/src/command/builtin/MakeNotificationCommand.ts +48 -0
  31. package/src/command/builtin/MakeObserverCommand.ts +78 -0
  32. package/src/command/builtin/MakePackageCommand.ts +45 -0
  33. package/src/command/builtin/MakePolicyCommand.ts +66 -0
  34. package/src/command/builtin/MakeProviderCommand.ts +75 -0
  35. package/src/command/builtin/MakeRequestCommand.ts +47 -0
  36. package/src/command/builtin/MakeResourceCommand.ts +61 -0
  37. package/src/command/builtin/MakeTestCommand.ts +120 -0
  38. package/src/command/builtin/ReloadCommand.ts +52 -0
  39. package/src/command/builtin/ReplCommand.ts +174 -0
  40. package/src/command/builtin/RouteListCommand.ts +188 -0
  41. package/src/command/builtin/ServeCommand.ts +321 -0
  42. package/src/command/builtin/StartCommand.ts +3 -0
  43. package/src/command/builtin/StatusCommand.ts +71 -0
  44. package/src/command/builtin/TestCommand.ts +172 -0
  45. package/src/command/builtin/WorkerCommand.ts +27 -0
  46. package/src/command/builtin/index.ts +53 -0
  47. package/src/command/scaffold/worker.ts.txt +12 -0
  48. package/src/command/scaffold/zerotal.ts.txt +26 -0
  49. package/src/command/startZerotal.ts +55 -0
  50. package/src/config/AppConfig.ts +253 -0
  51. package/src/config/ConfigLoader.ts +117 -0
  52. package/src/config/ConfigManager.ts +169 -0
  53. package/src/config/index.ts +46 -0
  54. package/src/config/registry.ts +59 -0
  55. package/src/config/validation.ts +117 -0
  56. package/src/container/Container.ts +606 -0
  57. package/src/container/ContextualBindingBuilder.ts +57 -0
  58. package/src/container/ScopedResolver.ts +117 -0
  59. package/src/container/index.ts +32 -0
  60. package/src/container/inject.ts +55 -0
  61. package/src/container/types.ts +71 -0
  62. package/src/context/RequestContext.ts +91 -0
  63. package/src/contracts/auth.ts +24 -0
  64. package/src/contracts/index.ts +23 -0
  65. package/src/contracts/session.ts +70 -0
  66. package/src/contracts/transaction.ts +26 -0
  67. package/src/conventions/ConventionLoader.ts +128 -0
  68. package/src/conventions/builtinConcerns.ts +131 -0
  69. package/src/crypt/Crypt.ts +141 -0
  70. package/src/crypt/URLSigner.ts +96 -0
  71. package/src/datetime/Carbon.ts +1396 -0
  72. package/src/datetime/CarbonInterval.ts +421 -0
  73. package/src/datetime/clock.ts +28 -0
  74. package/src/datetime/index.ts +23 -0
  75. package/src/datetime/temporal-shim.ts +1 -0
  76. package/src/dev/BuildOutput.ts +131 -0
  77. package/src/dev/CssPlugins.ts +184 -0
  78. package/src/dev/DevBuildHook.ts +74 -0
  79. package/src/dev/DevOrchestrator.ts +213 -0
  80. package/src/dev/DevReloadMiddleware.ts +101 -0
  81. package/src/dev/DevReloadServer.ts +85 -0
  82. package/src/dev/DevWsServer.ts +45 -0
  83. package/src/dev/index.ts +19 -0
  84. package/src/dev/reloadClient.ts +39 -0
  85. package/src/env/Def.ts +232 -0
  86. package/src/env/EnvSchema.ts +105 -0
  87. package/src/env/index.ts +34 -0
  88. package/src/env/t.ts +128 -0
  89. package/src/errors/ConfigError.ts +12 -0
  90. package/src/errors/ContainerErrors.ts +143 -0
  91. package/src/errors/HttpError.ts +127 -0
  92. package/src/errors/ValidationError.ts +19 -0
  93. package/src/errors/ZerotalError.ts +25 -0
  94. package/src/errors/index.ts +46 -0
  95. package/src/events/CallQueuedListener.ts +66 -0
  96. package/src/events/Emitter.ts +280 -0
  97. package/src/events/EventFake.ts +160 -0
  98. package/src/events/FrameworkEvents.ts +252 -0
  99. package/src/facade/Facade.ts +101 -0
  100. package/src/facade/facades/App.ts +155 -0
  101. package/src/facade/facades/Artisan.ts +63 -0
  102. package/src/facade/facades/Config.ts +21 -0
  103. package/src/facade/facades/Events.ts +19 -0
  104. package/src/facade/facades/index.ts +28 -0
  105. package/src/global.d.ts +9 -0
  106. package/src/hash/Hash.ts +60 -0
  107. package/src/health/Health.ts +221 -0
  108. package/src/health/index.ts +27 -0
  109. package/src/helpers/Collection.ts +435 -0
  110. package/src/helpers/config.ts +59 -0
  111. package/src/helpers/fluent.ts +52 -0
  112. package/src/helpers/html.ts +11 -0
  113. package/src/helpers/index.ts +266 -0
  114. package/src/helpers/make.ts +35 -0
  115. package/src/helpers/markdown.ts +73 -0
  116. package/src/helpers/pageElements.ts +27 -0
  117. package/src/helpers/request.ts +62 -0
  118. package/src/helpers/response.ts +411 -0
  119. package/src/helpers/str.ts +208 -0
  120. package/src/http/Http.ts +298 -0
  121. package/src/http/HttpClient.ts +289 -0
  122. package/src/http/Resource.ts +171 -0
  123. package/src/http/UploadedFile.ts +204 -0
  124. package/src/http/Uri.ts +490 -0
  125. package/src/http/index.ts +46 -0
  126. package/src/http/negotiate.ts +213 -0
  127. package/src/http/originGuard.ts +76 -0
  128. package/src/http/sniffContentType.ts +105 -0
  129. package/src/http/url.ts +204 -0
  130. package/src/http/withHeaders.ts +24 -0
  131. package/src/index.ts +250 -0
  132. package/src/lock/LockManager.ts +228 -0
  133. package/src/lock/config.ts +49 -0
  134. package/src/lock/drivers/LockDriver.ts +32 -0
  135. package/src/lock/drivers/MemoryLockDriver.ts +52 -0
  136. package/src/lock/drivers/RedisLockDriver.ts +58 -0
  137. package/src/lock/drivers/SqliteLockDriver.ts +85 -0
  138. package/src/lock/errors.ts +20 -0
  139. package/src/lock/facades/Lock.ts +114 -0
  140. package/src/lock/index.ts +53 -0
  141. package/src/logger/Log.ts +35 -0
  142. package/src/logger/LogManager.ts +430 -0
  143. package/src/logger/LoggerMiddleware.ts +125 -0
  144. package/src/logger/channels/ConsoleChannel.ts +139 -0
  145. package/src/logger/channels/DailyChannel.ts +74 -0
  146. package/src/logger/channels/NullChannel.ts +17 -0
  147. package/src/logger/channels/SingleChannel.ts +34 -0
  148. package/src/logger/channels/StackChannel.ts +29 -0
  149. package/src/logger/config.ts +90 -0
  150. package/src/logger/format.ts +96 -0
  151. package/src/logger/frameworkLog.ts +93 -0
  152. package/src/logger/index.ts +68 -0
  153. package/src/logger/renderTable.ts +111 -0
  154. package/src/logger/types.ts +212 -0
  155. package/src/macros/config.macro.ts +50 -0
  156. package/src/metrics/HttpMetrics.ts +114 -0
  157. package/src/metrics/index.ts +18 -0
  158. package/src/middleware/BaseMiddleware.ts +72 -0
  159. package/src/middleware/CorsMiddleware.ts +152 -0
  160. package/src/middleware/RateLimiter.ts +255 -0
  161. package/src/middleware/SecureHeadersMiddleware.ts +127 -0
  162. package/src/middleware/ThrottleMiddleware.ts +252 -0
  163. package/src/middleware/WebhookMiddleware.ts +204 -0
  164. package/src/pipeline/ContextRegistry.ts +42 -0
  165. package/src/pipeline/HttpContext.ts +865 -0
  166. package/src/pipeline/Pipeline.ts +150 -0
  167. package/src/pipeline/currentPage.ts +46 -0
  168. package/src/pipeline/types.ts +80 -0
  169. package/src/provider/LockProvider.ts +64 -0
  170. package/src/provider/LogProvider.ts +137 -0
  171. package/src/provider/ServiceProvider.ts +84 -0
  172. package/src/provider/StorageProvider.ts +45 -0
  173. package/src/router/FileRouter.ts +526 -0
  174. package/src/router/Route.ts +76 -0
  175. package/src/router/RouteHandler.ts +335 -0
  176. package/src/router/Router.ts +1247 -0
  177. package/src/router/domain.ts +65 -0
  178. package/src/security/index.ts +22 -0
  179. package/src/storage/FakeDisk.ts +233 -0
  180. package/src/storage/StorageFilesMiddleware.ts +150 -0
  181. package/src/storage/StorageManager.ts +173 -0
  182. package/src/storage/config.ts +47 -0
  183. package/src/storage/drivers/LocalDriver.ts +138 -0
  184. package/src/storage/drivers/S3Driver.ts +169 -0
  185. package/src/storage/errors.ts +135 -0
  186. package/src/storage/facades/Storage.ts +3 -0
  187. package/src/storage/global.d.ts +7 -0
  188. package/src/storage/index.ts +22 -0
  189. package/src/storage/root.ts +59 -0
  190. package/src/storage/types.ts +104 -0
  191. package/src/support/appKey.ts +38 -0
  192. package/src/support/cookie.ts +72 -0
  193. package/src/support/crypto.ts +52 -0
  194. package/src/support/deepMerge.ts +117 -0
  195. package/src/support/env.ts +71 -0
  196. package/src/support/network.ts +79 -0
  197. package/src/support/port.ts +197 -0
  198. package/src/support/str.ts +122 -0
  199. package/src/view/FileRouteResolver.ts +59 -0
  200. package/src/view/index.ts +144 -0
  201. package/src/view/jsx-runtime.ts +233 -0
@@ -0,0 +1,1671 @@
1
+ /**
2
+ * The application kernel: the central object that wires the IoC container,
3
+ * registers and boots service providers through the framework lifecycle, loads
4
+ * configuration and routes, and starts the HTTP/worker server.
5
+ */
6
+ import { Container } from "../container/Container.ts";
7
+ import { frameworkLog } from "../logger/frameworkLog.ts";
8
+ import { ServiceProvider } from "../provider/ServiceProvider.ts";
9
+ import type { AuthenticatedUser } from "../auth/AuthenticatedUser.ts";
10
+ import * as DevWsServer from "../dev/DevWsServer.ts";
11
+ import { DevReloadMiddleware, setDevReloadClientActive } from "../dev/DevReloadMiddleware.ts";
12
+ import type { HttpContext } from "../pipeline/HttpContext.ts";
13
+ import { Pipeline } from "../pipeline/Pipeline.ts";
14
+ import { ExceptionHandler } from "./ExceptionHandler.ts";
15
+ import { Router, RouterState } from "../router/Router.ts";
16
+ import type { StaticOptions } from "../router/Router.ts";
17
+ import { Health, resolveHealthConfig, checkHealthAccess } from "../health/Health.ts";
18
+ import type { HealthConfigShape } from "../health/Health.ts";
19
+ import { scanFileRoutes } from "../router/FileRouter.ts";
20
+ import {
21
+ runConventions,
22
+ importConventionModules,
23
+ type ConcernDescriptor,
24
+ type ConcernContext,
25
+ } from "../conventions/ConventionLoader.ts";
26
+ import { builtinConcerns } from "../conventions/builtinConcerns.ts";
27
+ import { ConfigManager } from "../config/ConfigManager.ts";
28
+ import { DEFAULT_MAX_REQUEST_BODY_SIZE } from "../config/AppConfig.ts";
29
+ import { SecureHeadersMiddleware } from "../middleware/SecureHeadersMiddleware.ts";
30
+ import { isAllowedOrigin, allowedOriginsFrom } from "../http/originGuard.ts";
31
+ import { rescueSync } from "../helpers/index.ts";
32
+ import { configureAssets, setAssetVersion, assetVersion } from "../assets/assets.ts";
33
+ import { ConfigLoader, type ConfigMap } from "../config/ConfigLoader.ts";
34
+ import { Emitter } from "../events/Emitter.ts";
35
+ import { FrameworkEvents, AppBooted } from "../events/FrameworkEvents.ts";
36
+ import type { Pipe, NextFn } from "../pipeline/types.ts";
37
+ import { NotFoundError } from "../errors/HttpError.ts";
38
+ import type { ContainerBindings } from "../container/types.ts";
39
+ import { dispatchRequest } from "../router/RouteHandler.ts";
40
+ import type { ProviderHooks } from "../router/RouteHandler.ts";
41
+ import { isProdLike } from "../support/env.ts";
42
+ import { appKeyStrengthWarning } from "../support/appKey.ts";
43
+ import { runBootDoctor } from "./BootDoctor.ts";
44
+ import { runConfigValidators } from "../config/validation.ts";
45
+ import { currentApp, defaultApp, setDefaultApp } from "./currentApp.ts";
46
+ import type { ConfigValidator, RegisteredConfigValidator } from "../config/validation.ts";
47
+
48
+ // ── Convention config discovery ───────────────────────────────────────────────
49
+
50
+ async function _discoverConfig(dir: string): Promise<Record<string, Record<string, unknown>>> {
51
+ const configDir = `${dir}/config`;
52
+ const map: Record<string, Record<string, unknown>> = {};
53
+ try {
54
+ const glob = new Bun.Glob("*.{ts,js}");
55
+ for await (const file of glob.scan({ cwd: configDir, onlyFiles: true })) {
56
+ const key = file.replace(/\.(ts|js)$/, "");
57
+ try {
58
+ const module = await import(`${configDir}/${file}`);
59
+ const value = module.default ?? module;
60
+ if (value && typeof value === "object" && !Array.isArray(value)) {
61
+ map[key] = value as Record<string, unknown>;
62
+ }
63
+ } catch {
64
+ // Skip files that error on import (missing env vars, etc.)
65
+ }
66
+ }
67
+ } catch {
68
+ // config/ directory doesn't exist — fine for minimal apps
69
+ }
70
+ return map;
71
+ }
72
+
73
+ // ── Routing config types ──────────────────────────────────────────────────────
74
+
75
+ /**
76
+ * A single entry in a `routing()` config map.
77
+ *
78
+ * Short form (string): just the file path — defaults apply for "web" and "api" keys.
79
+ * Long form (object): explicit file + optional prefix/middleware overrides.
80
+ *
81
+ * Built-in defaults:
82
+ * "web" → prefix: "", middleware: ["web"]
83
+ * "api" → prefix: "/api", middleware: ["api"]
84
+ * Custom keys must declare both `prefix` and `middleware` explicitly.
85
+ */
86
+ export type RoutingEntry = string | { file: string; prefix?: string; middleware?: MiddlewareInput };
87
+
88
+ /** Map of named route groups to their source files. */
89
+ export type RoutingConfig = Record<string, RoutingEntry>;
90
+
91
+ /**
92
+ * A single entry in a `fileBasedRouting()` config map.
93
+ * Same semantics as `RoutingEntry` but points to a directory instead of a file.
94
+ */
95
+ export type FileRoutingEntry =
96
+ string | { dir: string; prefix?: string; middleware?: MiddlewareInput };
97
+
98
+ /** Map of named route groups to their file-route directories. */
99
+ export type FileRoutingConfig = Record<string, FileRoutingEntry>;
100
+
101
+ /**
102
+ * Middleware accepted by a routing entry: a named middleware group (string), an
103
+ * array of names, a middleware class, or an array mixing names and classes.
104
+ * Mirrors `Router.group({ middleware })`, so e.g. `[TenancyMiddleware]` works.
105
+ */
106
+ export type MiddlewareInput = string | string[] | PipeClass | PipeClass[];
107
+
108
+ // ── Route group resolver ──────────────────────────────────────────────────────
109
+
110
+ /**
111
+ * Resolve the prefix/middleware for a named route group. "web" and "api" have
112
+ * built-in defaults; any other key must declare both explicitly. `label` names
113
+ * the calling API (`routing` / `fileBasedRouting`) in error messages.
114
+ */
115
+ function _resolveGroupOptions(
116
+ label: "routing" | "fileBasedRouting",
117
+ key: string,
118
+ explicit: { prefix?: string; middleware?: MiddlewareInput },
119
+ ): { prefix: string; middleware: MiddlewareInput } {
120
+ if (key === "web" || key === "api") {
121
+ return {
122
+ prefix: explicit.prefix ?? (key === "web" ? "" : "/api"),
123
+ middleware: explicit.middleware != null ? explicit.middleware : [key],
124
+ };
125
+ }
126
+ if (explicit.prefix === undefined)
127
+ throw new Error(`[Zerotal] ${label}() group "${key}" must declare an explicit prefix`);
128
+ if (explicit.middleware == null)
129
+ throw new Error(`[Zerotal] ${label}() group "${key}" must declare explicit middleware`);
130
+ return { prefix: explicit.prefix, middleware: explicit.middleware };
131
+ }
132
+
133
+ function _resolveRouteGroup(
134
+ key: string,
135
+ entry: RoutingEntry,
136
+ ): { file: string; prefix: string; middleware: MiddlewareInput } {
137
+ const file = typeof entry === "string" ? entry : entry.file;
138
+ const explicit = typeof entry === "object" ? entry : {};
139
+ return { file, ..._resolveGroupOptions("routing", key, explicit) };
140
+ }
141
+
142
+ function _resolveFileRouteGroup(
143
+ key: string,
144
+ entry: FileRoutingEntry,
145
+ ): { dir: string; prefix: string; middleware: MiddlewareInput } {
146
+ const dir = typeof entry === "string" ? entry : entry.dir;
147
+ const explicit = typeof entry === "object" ? entry : {};
148
+ return { dir, ..._resolveGroupOptions("fileBasedRouting", key, explicit) };
149
+ }
150
+
151
+ type ProviderClass = new (app: Application) => ServiceProvider;
152
+ type DeferrableProviderClass = ProviderClass & {
153
+ provides: readonly (keyof ContainerBindings)[];
154
+ };
155
+ type Environment = "web" | "console" | "worker" | "test" | "repl";
156
+ type PipeClass = new (...args: unknown[]) => Pipe<HttpContext>;
157
+
158
+ /** Options form for `Application.create({ ... })`. */
159
+ export interface CreateOptions {
160
+ providers?: ProviderClass[];
161
+ env?: Environment;
162
+ config?: ConfigLoader | ConfigMap;
163
+ /**
164
+ * Drop {@link SecureHeadersMiddleware} from the front of the pipeline.
165
+ *
166
+ * It is registered by default because a freshly scaffolded app otherwise ships with no
167
+ * clickjacking protection, no MIME-sniffing protection and no referrer policy — headers
168
+ * whose absence nothing surfaces and which cost nothing to send. Configure it through
169
+ * `config('app.secureHeaders')`; turn it off only when a proxy in front of the app is
170
+ * already emitting the same headers.
171
+ */
172
+ secureHeaders?: false;
173
+ }
174
+
175
+ function _normaliseEnv(raw: string): Environment {
176
+ switch (raw.toLowerCase()) {
177
+ case "local":
178
+ case "development":
179
+ case "dev":
180
+ case "production":
181
+ case "staging":
182
+ // Deployment-environment names: the app is still a web server.
183
+ return "web";
184
+ default:
185
+ return raw as Environment;
186
+ }
187
+ }
188
+
189
+ /** A static directory mount, as recorded by {@link Router.static}. */
190
+ type StaticMount = { prefix: string; rootDir: string; options?: StaticOptions | undefined };
191
+
192
+ /**
193
+ * @internal Exported for tests.
194
+ *
195
+ * Look a GET up against the directories served by per-request disk lookup, and
196
+ * return the file when one of them has it.
197
+ *
198
+ * Every dir whose prefix matches is tried, not just the first: the conventional
199
+ * `public → /` mount matches every path, so stopping there would make a second
200
+ * mount (an app with a custom asset outDir) unreachable.
201
+ */
202
+ export async function _lazyStaticResponse(
203
+ url: string,
204
+ dirs: ReadonlyArray<StaticMount>,
205
+ ): Promise<Response | undefined> {
206
+ let pathname: string;
207
+ try {
208
+ // Decode so `/assets/my%20app.js` finds `my app.js` on disk. A malformed
209
+ // escape throws and belongs to no file.
210
+ pathname = decodeURIComponent(new URL(url).pathname);
211
+ } catch {
212
+ return undefined;
213
+ }
214
+
215
+ // The URL parser resolves `..` segments, so one surviving here arrived
216
+ // percent-encoded — someone reaching outside the served directory.
217
+ if (pathname.split("/").includes("..")) return undefined;
218
+
219
+ for (const { prefix, rootDir, options } of dirs) {
220
+ const trimmedPrefix = prefix.replace(/\/$/, "");
221
+ const underPrefix =
222
+ pathname.startsWith(trimmedPrefix + "/") || (trimmedPrefix === "" && pathname !== "/");
223
+ if (!underPrefix) continue;
224
+
225
+ const relative = pathname.slice(trimmedPrefix.length).replace(/^\//, "");
226
+ const file = Bun.file(`${rootDir}/${relative}`);
227
+ if (await file.exists()) {
228
+ return new Response(
229
+ file as unknown as BodyInit,
230
+ options?.headers ? { headers: options.headers } : undefined,
231
+ );
232
+ }
233
+ }
234
+ return undefined;
235
+ }
236
+
237
+ /** Minimal WebSocket handler shape accepted by Bun.serve(). */
238
+ export interface WebSocketHandlers {
239
+ open?(ws: unknown): void;
240
+ message(ws: unknown, message: string | Uint8Array): void;
241
+ close?(ws: unknown, code: number, reason: string): void;
242
+ drain?(ws: unknown): void;
243
+ }
244
+
245
+ /**
246
+ * Installs application-scoped state and returns a teardown function that
247
+ * restores the previous state when the application is reset.
248
+ */
249
+ export type AppScopeInstaller = () => () => void;
250
+
251
+ const _appScopeInstallers: AppScopeInstaller[] = [];
252
+
253
+ /**
254
+ * @internal Register an installer run when an {@link Application} is created and
255
+ * torn down on reset — used by framework packages to bind per-app global state.
256
+ */
257
+ export function registerAppScope(installer: AppScopeInstaller): void {
258
+ _appScopeInstallers.push(installer);
259
+ }
260
+
261
+ /**
262
+ * The application kernel: container, provider lifecycle, routing, and server.
263
+ *
264
+ * `Application` is the process-wide singleton that owns the IoC {@link Container},
265
+ * registers and boots {@link ServiceProvider}s through the framework lifecycle,
266
+ * loads configuration and routes, and binds the Bun HTTP server. Build one with
267
+ * the fluent {@link Application.create} factory, chain configuration/routing
268
+ * calls, then {@link start} (web) or {@link bootAsWorker} (queue worker).
269
+ *
270
+ * @remarks
271
+ * The full lifecycle is: `boot()` runs phases REGISTERING → BOOTING → BOOTED
272
+ * (config, provider graph, routes, conventions); `start()` then runs STARTING →
273
+ * STARTED and binds `Bun.serve()`; `stop()`/`close()` run STOPPING → STOPPED in
274
+ * LIFO order. `start()` boots first if it has not already.
275
+ *
276
+ * @example
277
+ * ```ts
278
+ * // bootstrap/app.ts
279
+ * import { Application } from "@zerotal/core";
280
+ *
281
+ * export const app = Application.create({ providers: [AppServiceProvider] })
282
+ * .useConfig(configLoader("./config"))
283
+ * .routing({ web: "./routes/web.ts", api: "./routes/api.ts" })
284
+ * .use([CorsMiddleware]);
285
+ *
286
+ * await app.start(3000); // boots, then serves on :3000
287
+ * ```
288
+ */
289
+ export class Application {
290
+ /**
291
+ * The application's IoC {@link Container}. Providers bind services here in
292
+ * `onRegister()`, and code resolves them with `container.make(token)`.
293
+ *
294
+ * @category Container
295
+ */
296
+ readonly container = new Container();
297
+
298
+ private _providers: ProviderClass[] = [];
299
+ /** @internal The resolved, ordered provider instances driving the lifecycle after boot. */
300
+ _activeProviders: ServiceProvider[] = [];
301
+ /** Middleware auto-registered by providers via useOnce() — runs first. */
302
+ private _autoMiddleware: PipeClass[] = [];
303
+ /** Middleware registered via explicit .use() calls — runs after provider middleware. */
304
+ private _middleware: PipeClass[] = [];
305
+ /** @internal The runtime environment; read via the {@link environment} getter. */
306
+ _env: Environment = "web";
307
+ private _booted = false;
308
+ private _bootDurationMs: number | undefined = undefined;
309
+ private _static?: ReturnType<typeof Bun.serve>;
310
+ private _configMap: Record<string, Record<string, unknown>> | undefined = undefined;
311
+ /** Tracks where config came from, to reject conflicting overrides via useConfig(). */
312
+ private _configSource: "create" | "useConfig" | undefined = undefined;
313
+ /** Resolved explicit-route groups, accumulated across `routing()` calls. */
314
+ private _routeGroups: Array<{ file: string; prefix: string; middleware: MiddlewareInput }> = [];
315
+ /** Resolved file-route groups, accumulated across `fileBasedRouting()` calls. */
316
+ private _fileRouteGroups: Array<{ dir: string; prefix: string; middleware: MiddlewareInput }> =
317
+ [];
318
+ private _exceptionHandler: ExceptionHandler | undefined = undefined;
319
+ /**
320
+ * WebSocket handlers registered by providers, multiplexed by path so several endpoints
321
+ * coexist (e.g. flow's `/__flow/ws` and broadcasting's `/app/ws`). A registration without
322
+ * a `path` is a catch-all. Each connection is tagged with `_wsPath` on upgrade and dispatched
323
+ * to the matching registration.
324
+ */
325
+ private _wsRegistrations: Array<{
326
+ path?: string | undefined;
327
+ handlers: WebSocketHandlers;
328
+ upgradeData?: ((req: Request, server?: unknown) => Record<string, unknown>) | undefined;
329
+ }> = [];
330
+ /** Set by ServeCommand --dev-worker to enable the /__dev/ws HMR endpoint. */
331
+ private _devWsEnabled = false;
332
+ /** Tracks provider-auto-registered middleware to prevent double-registration. */
333
+ private readonly _autoMiddlewareSet = new Set<Function>();
334
+ private _providerHooks: ProviderHooks | undefined = undefined;
335
+ /** @internal The auth user-resolver registered via {@link withUserResolver}; called by AuthMiddleware. */
336
+ _userResolver: ((id: number) => Promise<AuthenticatedUser | null>) | undefined = undefined;
337
+ /** Convention descriptors contributed by providers (models, observers, policies, …). */
338
+ private _concerns: ConcernDescriptor[] = [];
339
+ /** Namespace validators contributed by providers; run once at boot (see {@link registerConfigValidator}). */
340
+ private _configValidators: RegisteredConfigValidator[] = [];
341
+ /** Bootstrap container-registration callbacks queued via `bind()`; run during boot(). */
342
+ private _bindCallbacks: Array<(container: Container) => void> = [];
343
+
344
+ private constructor() {}
345
+
346
+ /**
347
+ * Register a convention descriptor for auto-discovery at boot. Providers call this in
348
+ * `onRegister()`/`onBooting()`; the loader runs all descriptors during the convention phase.
349
+ *
350
+ * @category Providers
351
+ */
352
+ registerConcern(descriptor: ConcernDescriptor): this {
353
+ this._concerns.push(descriptor);
354
+ return this;
355
+ }
356
+
357
+ /**
358
+ * Attach a validator to a config namespace. Providers call this in
359
+ * `onRegister()`; the boot sequence runs every validator once — after
360
+ * providers register, before they boot. In a production-like deployment an
361
+ * `error`-level issue refuses boot; elsewhere issues are logged as warnings.
362
+ *
363
+ * @example
364
+ * // In a provider's onRegister():
365
+ * this.app.registerConfigValidator("session", (value, { isProduction }) => {
366
+ * const cfg = value as SessionConfigShape | undefined;
367
+ * return isProduction && cfg?.secure !== true
368
+ * ? [{ level: "error", message: "session.secure must be true in production." }]
369
+ * : [];
370
+ * });
371
+ *
372
+ * @category Configuration
373
+ */
374
+ registerConfigValidator(namespace: string, validate: ConfigValidator): this {
375
+ this._configValidators.push({ namespace, validate });
376
+ return this;
377
+ }
378
+
379
+ /** @internal This application's isolated router state, swapped in as the active state on create. */
380
+ readonly routerState = new RouterState();
381
+ private _scopeRestores: Array<() => void> = [];
382
+
383
+ // ── Static factory / singleton ────────────────────────────────────────
384
+ // The process-default application is owned by `currentApp.ts`, so there is
385
+ // exactly one ambient "current app" in the framework.
386
+
387
+ /**
388
+ * Create the process's application.
389
+ *
390
+ * There is exactly one application per process. Calling `create()` a second
391
+ * time is an error — retrieve the existing app with {@link currentApp}, and
392
+ * call {@link Application._resetInstance} before creating another (tests). The
393
+ * environment defaults to `APP_ENV`.
394
+ *
395
+ * @param options - `{ providers?, env?, config? }`.
396
+ * @throws {Error} When an application already exists in this process.
397
+ * @category Lifecycle
398
+ * @example
399
+ * ```ts
400
+ * const app = Application.create({ providers: [AppServiceProvider], env: "web" });
401
+ * ```
402
+ */
403
+ static create(options: CreateOptions = {}): Application {
404
+ if (defaultApp()) {
405
+ throw new Error(
406
+ "[Zerotal] An application already exists in this process. Use currentApp() to retrieve it; " +
407
+ "call Application._resetInstance() before creating another.",
408
+ );
409
+ }
410
+
411
+ const rawEnv = options.env ?? Bun.env["APP_ENV"] ?? "web";
412
+ const resolvedEnv: Environment = _normaliseEnv(rawEnv);
413
+
414
+ const app = new Application();
415
+ app._env = resolvedEnv;
416
+ if (options.secureHeaders === false) app._kernelMiddleware = [];
417
+ if (options.providers) for (const provider of options.providers) app._addProvider(provider);
418
+ if (options.config) {
419
+ app._configMap =
420
+ options.config instanceof ConfigLoader ? options.config.all() : options.config;
421
+ app._configSource = "create";
422
+ }
423
+ app._scopeRestores = _appScopeInstallers.map((install) => install());
424
+ setDefaultApp(app);
425
+ return app;
426
+ }
427
+
428
+ /**
429
+ * Pre-load config into the container before providers boot. Accepts a raw namespace map, the
430
+ * generated configs barrel, or a {@link ConfigLoader} from `configLoader("./config")`.
431
+ *
432
+ * If config was already provided via `Application.create({ config })`, this call is **ignored**
433
+ * (create() wins). That lets the framework-managed `zerotal.ts` always call `useConfig(...)`
434
+ * without conflicting when an app chose to pass config to `create()` instead.
435
+ *
436
+ * @category Configuration
437
+ * @example
438
+ * Application.create().useConfig(configLoader("./config")).register([...]);
439
+ */
440
+ useConfig(input: ConfigMap | ConfigLoader): this {
441
+ if (this._configSource === "create") {
442
+ // Config came from Application.create({ config }) — that's the source of truth; ignore.
443
+ return this;
444
+ }
445
+ this._configMap = input instanceof ConfigLoader ? input.all() : input;
446
+ this._configSource = "useConfig";
447
+ return this;
448
+ }
449
+
450
+ /**
451
+ * Declare explicit route files for one or more named groups.
452
+ *
453
+ * Each key is a group name; the value is the route file path (or an object
454
+ * with explicit `prefix` and `middleware` overrides).
455
+ *
456
+ * Built-in defaults (no overrides required):
457
+ * "web" → prefix: "", middleware: ["web"]
458
+ * "api" → prefix: "/api", middleware: ["api"]
459
+ *
460
+ * Custom group names must declare both `prefix` and `middleware` explicitly
461
+ * or an error is thrown at boot time.
462
+ *
463
+ * Routes are loaded after all providers have finished `onRegister()`, so
464
+ * middleware groups are always available when route files run.
465
+ *
466
+ * Three forms, and the call is **additive** — each invocation appends more
467
+ * groups, so you can chain calls to register several route sources:
468
+ *
469
+ * .routing('./routes/web.ts') // bare file → "web" group
470
+ * .routing({ file: './routes/api.ts', prefix: '/api', middleware: ['api'] }) // single group
471
+ * .routing({ web: './routes/web.ts', api: './routes/api.ts' }) // named map
472
+ *
473
+ * @example
474
+ * // bootstrap/app.ts
475
+ * Application.create({ providers })
476
+ * .routing({
477
+ * web: './routes/web.ts',
478
+ * api: './routes/api.ts',
479
+ * });
480
+ *
481
+ * @category Routing
482
+ * @throws {Error} At call time when a custom (non web/api) group omits an explicit `prefix` or `middleware`.
483
+ */
484
+ routing(config: string | RoutingConfig | (RoutingEntry & { file: string })): this {
485
+ if (typeof config === "string" || typeof (config as { file?: unknown }).file === "string") {
486
+ this._routeGroups.push(_resolveRouteGroup("web", config as RoutingEntry));
487
+ } else {
488
+ for (const [key, entry] of Object.entries(config as RoutingConfig)) {
489
+ this._routeGroups.push(_resolveRouteGroup(key, entry));
490
+ }
491
+ }
492
+ return this;
493
+ }
494
+
495
+ /**
496
+ * Declare file-based route directories for one or more named groups.
497
+ *
498
+ * Same key semantics as `routing()` (built-in defaults for "web" / "api").
499
+ * Each directory is scanned at boot time and every exported HTTP-method
500
+ * function is registered as a route.
501
+ *
502
+ * Three forms, and the call is **additive** — each invocation appends more
503
+ * groups, so a tenant-scoped app can chain a plain surface group with a
504
+ * prefixed tenant group:
505
+ *
506
+ * .fileBasedRouting(basePath('app/flow/pages/surface')) // bare dir → "web" group
507
+ * .fileBasedRouting({ // single group, explicit
508
+ * dir: basePath('app/flow/pages/[tenancy]'),
509
+ * prefix: '/:tenancy',
510
+ * middleware: [TenancyMiddleware],
511
+ * })
512
+ * .fileBasedRouting({ web: './app/routes' }) // named map
513
+ *
514
+ * @example
515
+ * // bootstrap/app.ts
516
+ * Application.create({ providers })
517
+ * .fileBasedRouting(basePath('app/flow/pages/surface'))
518
+ * .fileBasedRouting({
519
+ * dir: basePath('app/flow/pages/[tenancy]'),
520
+ * prefix: '/:tenancy',
521
+ * middleware: [TenancyMiddleware],
522
+ * });
523
+ *
524
+ * @category Routing
525
+ * @throws {Error} At call time when a custom (non web/api) group omits an explicit `prefix` or `middleware`.
526
+ */
527
+ fileBasedRouting(
528
+ config: string | FileRoutingConfig | (FileRoutingEntry & { dir: string }),
529
+ ): this {
530
+ if (typeof config === "string" || typeof (config as { dir?: unknown }).dir === "string") {
531
+ this._fileRouteGroups.push(_resolveFileRouteGroup("web", config as FileRoutingEntry));
532
+ } else {
533
+ for (const [key, entry] of Object.entries(config as FileRoutingConfig)) {
534
+ this._fileRouteGroups.push(_resolveFileRouteGroup(key, entry));
535
+ }
536
+ }
537
+ return this;
538
+ }
539
+
540
+ /**
541
+ * Whether `boot()` has completed. Once booted the container is considered
542
+ * locked for app-level registration (see {@link ContainerLockedError}).
543
+ *
544
+ * @category Lifecycle
545
+ */
546
+ get booted(): boolean {
547
+ return this._booted;
548
+ }
549
+
550
+ /**
551
+ * Wall-clock time the application took to boot, in milliseconds (undefined until booted).
552
+ *
553
+ * @category Lifecycle
554
+ */
555
+ get bootDurationMs(): number | undefined {
556
+ return this._bootDurationMs;
557
+ }
558
+
559
+ /**
560
+ * The runtime environment this application is running in.
561
+ *
562
+ * @category Environment
563
+ */
564
+ get environment(): Environment {
565
+ return this._env;
566
+ }
567
+
568
+ /** @internal Reset the process-default app — use in tests between cases. */
569
+ static _resetInstance(): void {
570
+ const instance = defaultApp();
571
+ if (instance) {
572
+ for (const restore of [...instance._scopeRestores].reverse()) restore();
573
+ instance._scopeRestores = [];
574
+ }
575
+ setDefaultApp(undefined);
576
+ }
577
+
578
+ /**
579
+ * @internal Re-establish this already-created app as the singleton and
580
+ * reinstall its app scope (router state + facade resolution).
581
+ *
582
+ * `create()` self-adopts, so this is a no-op for a fresh app. It exists for
583
+ * the case where a module-cached `bootstrap/app.ts` returns its top-level
584
+ * app after the singleton was torn down — e.g. a second `createTestApp()` in
585
+ * the same process (multiple test files). Without it, facades resolve against
586
+ * no current instance and throw `E_FACADE_BEFORE_BOOT`.
587
+ */
588
+ adoptAsCurrent(): this {
589
+ if (defaultApp() === this) return this;
590
+ Application._resetInstance();
591
+ this._scopeRestores = _appScopeInstallers.map((install) => install());
592
+ setDefaultApp(this);
593
+ return this;
594
+ }
595
+
596
+ // ── Provider registration ─────────────────────────────────────────────
597
+
598
+ /**
599
+ * Register service providers to boot through the full lifecycle.
600
+ *
601
+ * Idempotent by class identity: a provider already registered (here, in
602
+ * `create()`, discovered from `app/providers/*`, or pulled in via another
603
+ * provider's `static dependsOn`) is not registered again, so explicit and
604
+ * automatic registration can safely overlap. The first registration wins its
605
+ * position; ordering across dependencies is resolved at boot.
606
+ *
607
+ * @category Providers
608
+ * @example
609
+ * ```ts
610
+ * Application.create()
611
+ * .register([DatabaseProvider, AuthProvider])
612
+ * .register([AppServiceProvider]); // additive; duplicates are ignored
613
+ * ```
614
+ */
615
+ register(providers: ProviderClass[]): this {
616
+ for (const provider of providers) this._addProvider(provider);
617
+ return this;
618
+ }
619
+
620
+ /** Append a provider unless it is already registered (idempotent by class identity). */
621
+ private _addProvider(provider: ProviderClass): void {
622
+ if (!this._providers.includes(provider)) this._providers.push(provider);
623
+ }
624
+
625
+ /**
626
+ * Register container bindings without authoring a {@link ServiceProvider}.
627
+ *
628
+ * The callback receives the live {@link Container} and runs once during
629
+ * `boot()` — after the core singletons are registered and before any
630
+ * provider's `onRegister()`, so providers can still override these bindings.
631
+ * Ideal for app-level singletons, interface→implementation bindings, and
632
+ * contextual bindings that don't warrant a full provider.
633
+ *
634
+ * @example
635
+ * // bootstrap/app.ts
636
+ * Application.create({ providers })
637
+ * .bind((c) => {
638
+ * c.singleton(Clock, () => new SystemClock());
639
+ * c.for(ReportService).give(Clock, () => new FixedClock());
640
+ * })
641
+ * .fileBasedRouting({ web: basePath("app/flow/pages") });
642
+ *
643
+ * @category Container
644
+ */
645
+ bind(callback: (container: Container) => void): this {
646
+ this._bindCallbacks.push(callback);
647
+ return this;
648
+ }
649
+
650
+ /**
651
+ * Register one or more ServiceProviders as deferred — each boots only
652
+ * the first time one of its bindings is resolved.
653
+ *
654
+ * @example
655
+ * // Single token:
656
+ * app.defer('cache', CacheProvider);
657
+ *
658
+ * // Object map:
659
+ * app.defer({ cache: CacheProvider, mail: MailProvider });
660
+ *
661
+ * // Array — each provider must declare static provides = ['token'] as const:
662
+ * app.defer([CacheProvider, AuthProvider, QueueProvider]);
663
+ *
664
+ * @category Providers
665
+ */
666
+ defer(token: keyof ContainerBindings, Provider: ProviderClass): this;
667
+ defer(map: Partial<Record<keyof ContainerBindings, ProviderClass>>): this;
668
+ defer(providers: DeferrableProviderClass[]): this;
669
+ defer(
670
+ tokenOrMap:
671
+ | keyof ContainerBindings
672
+ | Partial<Record<keyof ContainerBindings, ProviderClass>>
673
+ | DeferrableProviderClass[],
674
+ Provider?: ProviderClass,
675
+ ): this {
676
+ if (Array.isArray(tokenOrMap)) {
677
+ for (const DeferredProvider of tokenOrMap) {
678
+ for (const token of DeferredProvider.provides) {
679
+ this.container.defer(token, DeferredProvider as new (app: unknown) => unknown);
680
+ }
681
+ }
682
+ } else if (typeof tokenOrMap === "string") {
683
+ if (Provider) this.container.defer(tokenOrMap, Provider as new (app: unknown) => unknown);
684
+ } else {
685
+ for (const [alias, deferredProvider] of Object.entries(tokenOrMap)) {
686
+ if (deferredProvider)
687
+ this.container.defer(alias as never, deferredProvider as new (app: unknown) => unknown);
688
+ }
689
+ }
690
+ return this;
691
+ }
692
+
693
+ /**
694
+ * Register one or more global middleware classes.
695
+ * Middleware runs in array order on every request.
696
+ *
697
+ * @category Server
698
+ * @example
699
+ * app.use([CorsMiddleware.with({ origin: '*' }), InertiaMiddleware]);
700
+ */
701
+ use(middleware: PipeClass | PipeClass[]): this {
702
+ if (Array.isArray(middleware)) {
703
+ this._middleware.push(...middleware);
704
+ } else {
705
+ this._middleware.push(middleware);
706
+ }
707
+ return this;
708
+ }
709
+
710
+ /**
711
+ * Register middleware exactly once — idempotent.
712
+ * Used by providers in onBooting() to auto-register their required middleware.
713
+ *
714
+ * @internal
715
+ */
716
+ useOnce(middlewareClass: PipeClass): void {
717
+ if (this._autoMiddlewareSet.has(middlewareClass)) return;
718
+ const hasSubclass = this._middleware.some(
719
+ (registered) =>
720
+ registered !== middlewareClass &&
721
+ registered.prototype instanceof (middlewareClass as Function),
722
+ );
723
+ if (hasSubclass) return;
724
+ this._autoMiddlewareSet.add(middlewareClass);
725
+ this._autoMiddleware.push(middlewareClass);
726
+ }
727
+
728
+ /**
729
+ * Middleware the framework installs ahead of everything else.
730
+ *
731
+ * `SecureHeadersMiddleware` is here rather than left to each app because its defaults
732
+ * are right for essentially every app, its absence is invisible, and the alternative —
733
+ * documenting it and hoping — shipped every scaffold with no clickjacking protection and
734
+ * no MIME-sniffing protection. Cleared by `Application.create({ secureHeaders: false })`.
735
+ */
736
+ private _kernelMiddleware: PipeClass[] = [SecureHeadersMiddleware];
737
+
738
+ /** Resolved middleware pipeline: kernel → provider auto → explicit .use() */
739
+ private get _pipeline(): PipeClass[] {
740
+ // An app that registers its own SecureHeadersMiddleware — usually `.with({ secure: true })`
741
+ // or a subclass — replaces the kernel copy rather than stacking a second one on top.
742
+ const overridden = [...this._autoMiddleware, ...this._middleware].some(
743
+ (registered) =>
744
+ registered === SecureHeadersMiddleware ||
745
+ registered.prototype instanceof SecureHeadersMiddleware,
746
+ );
747
+ const kernel = overridden ? [] : this._kernelMiddleware;
748
+ return [...kernel, ...this._autoMiddleware, ...this._middleware];
749
+ }
750
+
751
+ /**
752
+ * Read-only copy of the resolved global middleware pipeline
753
+ * (kernel → provider auto → explicit .use(), in execution order).
754
+ *
755
+ * @category Server
756
+ */
757
+ get globalMiddleware(): PipeClass[] {
758
+ return [...this._pipeline];
759
+ }
760
+
761
+ /**
762
+ * Register the callback used to load an authenticated user from their session ID.
763
+ * Called by AuthMiddleware on every request that has a `user_id` in the session.
764
+ *
765
+ * @example
766
+ * // bootstrap/app.ts
767
+ * Application.create({ providers })
768
+ * .withUserResolver((id) => User.find(id));
769
+ *
770
+ * @category Configuration
771
+ */
772
+ withUserResolver(fn: (id: number) => Promise<AuthenticatedUser | null>): this {
773
+ this._userResolver = fn;
774
+ return this;
775
+ }
776
+
777
+ /**
778
+ * Register a custom exception handler for all unhandled route errors.
779
+ *
780
+ * @example
781
+ * // bootstrap/app.ts
782
+ * import { Handler } from './app/exceptions/Handler.ts';
783
+ * app.withExceptionHandler(Handler);
784
+ *
785
+ * @category Configuration
786
+ */
787
+ withExceptionHandler(Handler: new () => ExceptionHandler): this {
788
+ this._exceptionHandler = new Handler();
789
+ return this;
790
+ }
791
+
792
+ /**
793
+ * Swap in an already-constructed exception handler and return the one it
794
+ * replaced.
795
+ *
796
+ * {@link withExceptionHandler} constructs the handler itself, which is right
797
+ * for an application naming its own class but leaves no way to install a
798
+ * specific instance or to wrap the existing one. `@zerotal/testing` needs
799
+ * both, so a request's exception stays reachable from the test that made it.
800
+ *
801
+ * @internal
802
+ */
803
+ _swapExceptionHandler(handler: ExceptionHandler | undefined): ExceptionHandler | undefined {
804
+ const previous = this._exceptionHandler;
805
+ this._exceptionHandler = handler;
806
+ return previous;
807
+ }
808
+
809
+ /**
810
+ * Enable the /__dev/ws WebSocket HMR endpoint.
811
+ * Called by ServeCommand when running as --dev-worker.
812
+ *
813
+ * @category Server
814
+ */
815
+ enableDevWs(): this {
816
+ this._devWsEnabled = true;
817
+ // Inject the live-reload client (and any registered dev snippets) into HTML
818
+ // responses, so auto-reload works for every view layer, not just Inertia.
819
+ setDevReloadClientActive(true);
820
+ this.useOnce(DevReloadMiddleware as never);
821
+ return this;
822
+ }
823
+
824
+ /**
825
+ * Live server concurrency straight from Bun: HTTP requests currently being
826
+ * processed and open WebSocket connections. Zeroes when no server is bound
827
+ * (e.g. console/test runs). See https://bun.com/docs/runtime/http/metrics.
828
+ *
829
+ * @category Server
830
+ */
831
+ serverMetrics(): { pendingRequests: number; pendingWebSockets: number } {
832
+ const s = this._static as { pendingRequests?: number; pendingWebSockets?: number } | undefined;
833
+ return {
834
+ pendingRequests: s?.pendingRequests ?? 0,
835
+ pendingWebSockets: s?.pendingWebSockets ?? 0,
836
+ };
837
+ }
838
+
839
+ /**
840
+ * Register WebSocket handlers so Bun.serve() enables the WS protocol. Multiple providers may
841
+ * register — each for its own `path` (e.g. `/__flow/ws`, `/app/ws`) — and connections are
842
+ * routed to the matching handler by request path. Omit `path` for a catch-all. Called by
843
+ * FlowProvider and BroadcastProvider before the server binds.
844
+ *
845
+ * @category Server
846
+ */
847
+ withWebSocket(
848
+ handlers: WebSocketHandlers,
849
+ upgradeData?: (req: Request, server?: unknown) => Record<string, unknown>,
850
+ path?: string,
851
+ ): this {
852
+ this._wsRegistrations.push({ path, handlers, upgradeData });
853
+ return this;
854
+ }
855
+
856
+ /** Find the WS registration handling a connection's path (exact match, else a catch-all). */
857
+ private _wsRegFor(wsPath: unknown): (typeof this._wsRegistrations)[number] | undefined {
858
+ return (
859
+ this._wsRegistrations.find((r) => r.path === wsPath) ??
860
+ this._wsRegistrations.find((r) => r.path === undefined)
861
+ );
862
+ }
863
+
864
+ /**
865
+ * Extra origins accepted for credentialed endpoints that bypass the middleware pipeline
866
+ * (WebSocket upgrades and raw routes). Configured as `app.allowedOrigins`; empty means
867
+ * same-origin only.
868
+ *
869
+ * @internal
870
+ */
871
+ _allowedOrigins(): string[] {
872
+ return rescueSync(
873
+ () => allowedOriginsFrom((this.container.makeSync("config") as ConfigManager).get("app")),
874
+ [] as string[],
875
+ );
876
+ }
877
+
878
+ // ── Internals ─────────────────────────────────────────────────────────
879
+
880
+ private _buildWsConfig(): WebSocketHandlers | undefined {
881
+ const devEnabled = this._devWsEnabled;
882
+
883
+ if (!devEnabled && this._wsRegistrations.length === 0) return undefined;
884
+
885
+ type AnyWS = {
886
+ data: { _dev?: boolean; _wsPath?: string; [key: string]: unknown };
887
+ send(message: string): void;
888
+ };
889
+
890
+ return {
891
+ open: (ws: unknown) => {
892
+ if ((ws as AnyWS).data._dev) {
893
+ DevWsServer.open(ws as AnyWS);
894
+ // Tell the tab which build this worker is serving. A tab that
895
+ // reconnects after a backend restart compares this against the token
896
+ // it saw on its first connect and reloads itself when they differ —
897
+ // the rebuild that came with the restart has no other way to reach it,
898
+ // because the push arrives while the socket is down.
899
+ try {
900
+ (ws as AnyWS).send(`version:${assetVersion()}`);
901
+ } catch {
902
+ /* socket closed between upgrade and open — the tab will reconnect */
903
+ }
904
+ } else this._wsRegFor((ws as AnyWS).data._wsPath)?.handlers.open?.(ws);
905
+ },
906
+ message: (ws: unknown, message: string | Uint8Array) => {
907
+ if (!(ws as AnyWS).data._dev)
908
+ this._wsRegFor((ws as AnyWS).data._wsPath)?.handlers.message(ws, message);
909
+ },
910
+ close: (ws: unknown, code: number, reason: string) => {
911
+ if ((ws as AnyWS).data._dev) DevWsServer.close(ws as AnyWS);
912
+ else this._wsRegFor((ws as AnyWS).data._wsPath)?.handlers.close?.(ws, code, reason);
913
+ },
914
+ };
915
+ }
916
+
917
+ // ── Lifecycle ─────────────────────────────────────────────────────────
918
+
919
+ /**
920
+ * Run the boot sequence — phases 1–3: REGISTERING → BOOTING → BOOTED.
921
+ *
922
+ * @remarks
923
+ * Discovers config and `app/providers/*`, resolves the provider dependency
924
+ * graph, runs every provider's `onRegister`/`onBooting`/`onBooted`, loads
925
+ * routes, and runs the convention loader. Idempotent — a second call after
926
+ * booting returns immediately. Called automatically by {@link start} and
927
+ * {@link bootAsWorker} if not already booted.
928
+ *
929
+ * @throws {Error} When a provider dependency cycle is detected, or in a production-like deployment when `APP_KEY` is too weak.
930
+ * @category Lifecycle
931
+ */
932
+ async boot(): Promise<void> {
933
+ if (this._booted) return;
934
+ const _bootStart = performance.now();
935
+ this.container._app = this;
936
+
937
+ // Auto-discover config when useConfig() was not called explicitly.
938
+ // Scans <cwd>/config/*.ts and loads each file's default export.
939
+ // Gracefully skips missing or unreadable files.
940
+ if (this._configMap === undefined) {
941
+ this._configMap = await _discoverConfig(process.cwd());
942
+ }
943
+
944
+ // Built-in core singletons (overridable by providers via last-write-wins)
945
+ this.container.singleton("config", () => new ConfigManager());
946
+ this.container.singleton("events", () => new Emitter(this.container));
947
+
948
+ if (this._configMap !== undefined) {
949
+ const configManager = new ConfigManager();
950
+ for (const [key, value] of Object.entries(this._configMap)) {
951
+ configManager.load(key, value);
952
+ }
953
+ this.container.value("config", configManager);
954
+ }
955
+
956
+ // Auto-discover app/providers/* BEFORE the register phase so they run the full lifecycle.
957
+ await this._discoverProviders(process.cwd());
958
+
959
+ // Expand each provider's `static dependsOn` (transitively, de-duped) and topo-sort so
960
+ // dependencies boot before their dependents. Providers excluded by `static environments`
961
+ // — and any dependency only those needed — are dropped. Cycles throw with the path.
962
+ this._activeProviders = this._resolveProviderGraph(this._providers).map(
963
+ (ProviderClassEntry) => new ProviderClassEntry(this),
964
+ );
965
+
966
+ // Build per-request provider hooks now that _activeProviders is populated.
967
+ this._providerHooks = this._buildProviderHooks();
968
+
969
+ // Bootstrap bindings registered via app.bind(cb) — run before providers'
970
+ // onRegister() so providers can still override (last-write-wins).
971
+ for (const callback of this._bindCallbacks) callback(this.container);
972
+
973
+ // Phase 1 — synchronous, binds into container.
974
+ for (const provider of this._activeProviders) provider.onRegister();
975
+
976
+ // Config validation — providers have registered their namespace validators
977
+ // in onRegister; run them before anything boots. In a production-like
978
+ // deployment an insecure/invalid value refuses boot (named culprits);
979
+ // elsewhere it warns. Skipped in the test harness to keep output clean.
980
+ if (this._env !== "test" && this._configValidators.length > 0) {
981
+ const configManager = this.container.makeSync("config") as ConfigManager;
982
+ const appEnv = configManager.get<string>("app.env", Bun.env["APP_ENV"] ?? "development");
983
+ runConfigValidators(this._configValidators, configManager, isProdLike(appEnv));
984
+ }
985
+
986
+ // Phase 2 — sequential in registration order.
987
+ for (const provider of this._activeProviders) await provider.onBooting();
988
+
989
+ // Phase 3 — async, all providers have finished booting.
990
+ await Promise.all(this._activeProviders.map((provider) => provider.onBooted()));
991
+
992
+ // Ensure config and events are resolved so makeSync() works below.
993
+ await this.container.make("config");
994
+ await this.container.make("events");
995
+
996
+ // Boot-time doctor — verify every provider's declared `provides` is wired,
997
+ // and in real runtimes eager-resolve them so facades work at first access and
998
+ // any construction error surfaces here (a named boot failure) rather than
999
+ // mid-request. Skipped for eager-resolution in test/repl to avoid side
1000
+ // effects; the cheap "is it bound?" check still runs in every environment.
1001
+ await runBootDoctor(this._activeProviders, this.container, {
1002
+ eagerResolve: this._env !== "test" && this._env !== "repl",
1003
+ });
1004
+
1005
+ // Auto-discover app/middleware/* as named groups before routes can reference them.
1006
+ await this._discoverMiddleware(process.cwd());
1007
+
1008
+ // Load explicit route files (after all providers have registered middleware groups).
1009
+ if (this._routeGroups.length) {
1010
+ await this._loadRoutes();
1011
+ }
1012
+
1013
+ // Load file-based route directories.
1014
+ if (this._fileRouteGroups.length) {
1015
+ await this._loadFileRoutes();
1016
+ }
1017
+
1018
+ // Convention phase — worker jobs/schedules + public static files.
1019
+ await this._bootConventions();
1020
+
1021
+ // Fail loud on a weak APP_KEY: in a production-like deployment a short key is
1022
+ // a refuse-to-boot error; elsewhere (bar the test harness) it's a warning.
1023
+ if (this._env !== "test") {
1024
+ const _keyWarning = appKeyStrengthWarning(Bun.env["APP_KEY"]);
1025
+ if (_keyWarning) {
1026
+ if (isProdLike(Bun.env["APP_ENV"] ?? "")) throw new Error(_keyWarning);
1027
+ console.warn(_keyWarning);
1028
+ }
1029
+ }
1030
+
1031
+ this._booted = true;
1032
+ this._bootDurationMs = performance.now() - _bootStart;
1033
+
1034
+ // Announce boot completion so observers (Health page, telemetry, logger) can
1035
+ // surface startup cost. Fired once per application boot.
1036
+ FrameworkEvents.emit(
1037
+ new AppBooted(this._bootDurationMs, this._env, this._activeProviders.length),
1038
+ );
1039
+ }
1040
+
1041
+ private async _loadRoutes(): Promise<void> {
1042
+ for (const { file, prefix, middleware } of this._routeGroups) {
1043
+ await Router.groupAsync({ prefix, middleware }, () => import(file));
1044
+ }
1045
+ }
1046
+
1047
+ private async _loadFileRoutes(): Promise<void> {
1048
+ for (const { dir, prefix, middleware } of this._fileRouteGroups) {
1049
+ await Router.groupAsync({ prefix, middleware }, () => scanFileRoutes(dir).then(() => {}));
1050
+ }
1051
+ }
1052
+
1053
+ /**
1054
+ * Cache-Control for statically-served files.
1055
+ *
1056
+ * In dev (the `--dev-worker` child spawned by `serve --dev`) we send
1057
+ * `no-cache` so the browser always revalidates `app.js` / `app.css` and never
1058
+ * serves a stale bundle after a rebuild + HMR reload — the same effect Vite
1059
+ * achieves with hashed URLs in dev. In production the files are served without
1060
+ * an explicit header (browser heuristic caching).
1061
+ */
1062
+ private _staticCacheOptions(): { headers: Record<string, string> } | undefined {
1063
+ if (!process.argv.includes("--dev-worker")) return undefined;
1064
+ return { headers: { "Cache-Control": "no-cache" } };
1065
+ }
1066
+
1067
+ private async _bootConventions(): Promise<void> {
1068
+ const dir = process.cwd();
1069
+
1070
+ if (this._env === "web") {
1071
+ const staticOptions = this._staticCacheOptions();
1072
+
1073
+ // Serve entire public/ directory at /
1074
+ Router.static("/", `${dir}/public`, staticOptions);
1075
+
1076
+ // Serve a custom asset output dir / prefix when it isn't already covered
1077
+ // by the public → / mount above (e.g. outDir: "build", prefix: "/assets").
1078
+ const assets = this._assetsConfig();
1079
+ if (assets && (assets.outDir !== "public" || assets.prefix !== "/")) {
1080
+ Router.static(assets.prefix, `${dir}/${assets.outDir}`, staticOptions);
1081
+ }
1082
+
1083
+ // Configure the asset() URL helper: honour the configured prefix, and in
1084
+ // dev (the --dev-worker child) append ?v=<version> for cache-busting. The
1085
+ // initial version comes from the orchestrator via ZT_ASSET_VERSION;
1086
+ // each rebuild ships a fresh token over the reload channel (see ServeCommand).
1087
+ const isDevWorker = process.argv.includes("--dev-worker");
1088
+ configureAssets({ prefix: assets?.prefix ?? "/", dev: isDevWorker });
1089
+ if (isDevWorker) setAssetVersion(Bun.env["ZT_ASSET_VERSION"] ?? "");
1090
+ }
1091
+
1092
+ // Convention phase — discovers app/schedules (class-based), models, observers, jobs, etc.
1093
+ // (The `jobs` concern imports every app/jobs/*.ts, so job classes self-register here in
1094
+ // every environment, including the worker process — no generated barrel needed.)
1095
+ await this._runConventions(dir);
1096
+ }
1097
+
1098
+ /** Read `config/app.ts` → `assets` (front-end bundling), or undefined when not configured. */
1099
+ private _assetsConfig(): { outDir: string; prefix: string } | undefined {
1100
+ try {
1101
+ const config = this.container.makeSync("config") as ConfigManager;
1102
+ return config.get("app.assets") as { outDir: string; prefix: string } | undefined;
1103
+ } catch {
1104
+ return undefined;
1105
+ }
1106
+ }
1107
+
1108
+ /** Read `config/app.ts` → `conventions: { enabled, paths }` (auto-discovery settings). */
1109
+ private _conventionsConfig(): { enabled: boolean; paths: Record<string, string> } {
1110
+ let raw: { enabled?: boolean; paths?: Record<string, string> } = {};
1111
+ try {
1112
+ const config = this.container.makeSync("config") as ConfigManager;
1113
+ raw = (config.get("app.conventions") ?? {}) as typeof raw;
1114
+ } catch {
1115
+ /* config not resolvable yet — use defaults */
1116
+ }
1117
+ return { enabled: raw.enabled !== false, paths: raw.paths ?? {} };
1118
+ }
1119
+
1120
+ /**
1121
+ * Auto-discover and register convention-based classes (models, observers, policies,
1122
+ * listeners, jobs, …) from the `app/*` directories. Built-in concerns plus any contributed
1123
+ * by providers via `registerConcern()`. Gated by `app.conventions.enabled` (default on).
1124
+ */
1125
+ private async _runConventions(root: string): Promise<void> {
1126
+ const { enabled, paths } = this._conventionsConfig();
1127
+ if (!enabled) return;
1128
+
1129
+ const concerns = [...builtinConcerns, ...this._concerns];
1130
+ if (concerns.length === 0) return;
1131
+
1132
+ const ctx: ConcernContext = {
1133
+ app: this,
1134
+ env: this._env,
1135
+ resolve: <T>(token: string): T | undefined =>
1136
+ this.container.tryMake(token as keyof ContainerBindings) as T | undefined,
1137
+ };
1138
+
1139
+ await runConventions(concerns, { root, env: this._env, paths, ctx });
1140
+ }
1141
+
1142
+ /**
1143
+ * Auto-discover `app/providers/*` BEFORE the register phase, so discovered providers run the
1144
+ * full lifecycle (onRegister/onBooting/onBooted) like explicitly-registered ones. Appended
1145
+ * after explicit providers (so app providers boot after framework providers) and de-duplicated.
1146
+ */
1147
+ private async _discoverProviders(root: string): Promise<void> {
1148
+ const { enabled, paths } = this._conventionsConfig();
1149
+ if (!enabled) return;
1150
+ const dir = `${root}/${paths["providers"] ?? "app/providers"}`;
1151
+ await importConventionModules(dir, "providers", (module) => {
1152
+ for (const exported of Object.values(module)) {
1153
+ if (
1154
+ typeof exported === "function" &&
1155
+ exported !== ServiceProvider &&
1156
+ (exported as { prototype?: unknown }).prototype instanceof ServiceProvider
1157
+ ) {
1158
+ this._addProvider(exported as ProviderClass);
1159
+ }
1160
+ }
1161
+ });
1162
+ }
1163
+
1164
+ /** Whether a provider runs in the current environment (`static environments`, default all). */
1165
+ private _isActiveInEnv(provider: ProviderClass): boolean {
1166
+ const environments = (provider as unknown as { environments?: Environment[] }).environments;
1167
+ return !environments || environments.includes(this._env);
1168
+ }
1169
+
1170
+ /**
1171
+ * Resolve the final, ordered provider list for boot. Each provider's `static dependsOn`
1172
+ * is pulled in transitively (a provider only has to register the feature it wants, not the
1173
+ * plumbing it needs), de-duplicated by class identity. Providers excluded by their
1174
+ * `static environments` are dropped — and a dependency is only included if it is itself
1175
+ * active in this environment, so a `web`-only provider is never dragged into a CLI boot.
1176
+ *
1177
+ * The result is topologically sorted so every provider boots after its dependencies; among
1178
+ * providers with no dependency relationship, order is `static priority` (lower first) then
1179
+ * first-registered. A dependency cycle throws here, at boot, with the offending path.
1180
+ */
1181
+ private _resolveProviderGraph(roots: ProviderClass[]): ProviderClass[] {
1182
+ const depsOf = (p: ProviderClass): ProviderClass[] =>
1183
+ (p as unknown as { dependsOn?: ProviderClass[] }).dependsOn ?? [];
1184
+ const priorityOf = (p: ProviderClass): number =>
1185
+ (p as unknown as { priority?: number }).priority ?? 0;
1186
+
1187
+ // Transitive closure of env-active providers, remembering first-seen order.
1188
+ const included = new Set<ProviderClass>();
1189
+ const seenAt = new Map<ProviderClass, number>();
1190
+ let seq = 0;
1191
+ const collect = (p: ProviderClass): void => {
1192
+ if (included.has(p) || !this._isActiveInEnv(p)) return;
1193
+ included.add(p);
1194
+ seenAt.set(p, seq++);
1195
+ for (const dep of depsOf(p)) collect(dep);
1196
+ };
1197
+ for (const root of roots) collect(root);
1198
+
1199
+ // Deterministic order for independent providers: priority asc, then registration order.
1200
+ const byRank = (a: ProviderClass, b: ProviderClass): number =>
1201
+ priorityOf(a) - priorityOf(b) || (seenAt.get(a) ?? 0) - (seenAt.get(b) ?? 0);
1202
+
1203
+ const sorted: ProviderClass[] = [];
1204
+ const done = new Set<ProviderClass>();
1205
+ const onStack = new Set<ProviderClass>();
1206
+ const stack: ProviderClass[] = [];
1207
+ const visit = (p: ProviderClass): void => {
1208
+ if (done.has(p)) return;
1209
+ if (onStack.has(p)) {
1210
+ const cycle = [...stack.slice(stack.indexOf(p)), p].map((c) => c.name).join(" → ");
1211
+ throw new Error(`Circular provider dependency: ${cycle}`);
1212
+ }
1213
+ onStack.add(p);
1214
+ stack.push(p);
1215
+ for (const dep of depsOf(p)
1216
+ .filter((d) => included.has(d))
1217
+ .sort(byRank))
1218
+ visit(dep);
1219
+ onStack.delete(p);
1220
+ stack.pop();
1221
+ done.add(p);
1222
+ sorted.push(p);
1223
+ };
1224
+ for (const p of [...included].sort(byRank)) visit(p);
1225
+
1226
+ return sorted;
1227
+ }
1228
+
1229
+ /**
1230
+ * Auto-discover `app/middleware/*` before routes load. Each middleware class is registered as a
1231
+ * single-class named group (referenceable by its class name in `Router.group({ middleware })`),
1232
+ * so it isn't applied globally by default. A class with `static global = true` is added to the
1233
+ * global pipeline via `use()`.
1234
+ */
1235
+ private async _discoverMiddleware(root: string): Promise<void> {
1236
+ const { enabled, paths } = this._conventionsConfig();
1237
+ if (!enabled) return;
1238
+ const dir = `${root}/${paths["middleware"] ?? "app/middleware"}`;
1239
+ await importConventionModules(dir, "middleware", (module) => {
1240
+ for (const exported of Object.values(module)) {
1241
+ if (typeof exported !== "function") continue;
1242
+ const middlewareClass = exported as PipeClass & { global?: boolean };
1243
+ const prototype = (middlewareClass as { prototype?: { handle?: unknown } }).prototype;
1244
+ if (!prototype || typeof prototype.handle !== "function") continue; // Not a middleware.
1245
+ Router.middlewareGroup(middlewareClass.name, [middlewareClass] as never);
1246
+ if (middlewareClass.global === true) this.use(middlewareClass);
1247
+ }
1248
+ });
1249
+ }
1250
+
1251
+ /**
1252
+ * Boot (if needed) and start the HTTP server — phases 4–5: STARTING → STARTED.
1253
+ *
1254
+ * @remarks
1255
+ * Boots first when not yet booted, runs providers' `onStarting`, binds
1256
+ * `Bun.serve()` on `port` (registering the health endpoint, compiled routes,
1257
+ * and any WebSocket handlers), then runs `onStarted`. Installs SIGTERM/SIGINT
1258
+ * handlers that call {@link stop} and a SIGUSR2 handler that hot-reloads routes.
1259
+ *
1260
+ * @param port - TCP port to bind; pass `0` to let the OS choose a free port.
1261
+ * @category Lifecycle
1262
+ * @example
1263
+ * ```ts
1264
+ * const app = Application.create({ providers }).routing({ web: "./routes/web.ts" });
1265
+ * await app.start(3000);
1266
+ * ```
1267
+ */
1268
+ async start(port = 3000): Promise<void> {
1269
+ if (!this._booted) await this.boot();
1270
+
1271
+ // Phase 4 — STARTING (before server binds)
1272
+ await Promise.all(this._activeProviders.map((p) => p.onStarting()));
1273
+
1274
+ this._registerHealthEndpoint();
1275
+
1276
+ const configManager = this.container.makeSync("config") as ConfigManager;
1277
+ const http3 = configManager.get<boolean>("app.http3", false);
1278
+ const tlsConfig = configManager.get<{ cert?: unknown; key?: unknown } | undefined>("app.tls");
1279
+
1280
+ const compiledRoutes = Router.compile(
1281
+ this.container,
1282
+ this._pipeline,
1283
+ this._exceptionHandler,
1284
+ this._providerHooks,
1285
+ );
1286
+ const wsConfig = this._buildWsConfig();
1287
+
1288
+ // Eager static dirs are already pre-registered by Router.compile() as native
1289
+ // `Response(Bun.file)` routes, so Bun serves them without ever entering JS.
1290
+ // Only dirs that opted out with `eager: false` need the per-request lookup
1291
+ // below — for the default (eager) case this loop is skipped entirely, so an
1292
+ // unmatched GET no longer pays a wasted `exists()` stat before its 404.
1293
+ //
1294
+ // Under the dev worker every dir gets the fallback as well. That route table
1295
+ // is a snapshot of the directory taken when Bun.serve() started, and a dev
1296
+ // rebuild writes files it has never heard of: bundlers name code-split
1297
+ // chunks after their content, so each rebuild emits a fresh set of
1298
+ // `chunk-<hash>.js`. Without the fallback the running server 404s every one
1299
+ // of them until it restarts, and the page dies on its first dynamic import.
1300
+ // Bun still answers pre-registered paths natively, so this costs a stat only
1301
+ // on the files that are genuinely new.
1302
+ const devStatic = process.argv.includes("--dev-worker");
1303
+ const lazyStaticDirs = Router.staticDirs.filter(
1304
+ (dir) => devStatic || dir.options?.eager === false,
1305
+ );
1306
+
1307
+ const extraOptions: Record<string, unknown> = {};
1308
+ if (http3) extraOptions.http3 = true;
1309
+ if (tlsConfig?.cert && tlsConfig?.key) extraOptions.tls = tlsConfig;
1310
+
1311
+ // Bodies are fully buffered before a handler sees them, so this is the ceiling on
1312
+ // memory one request can claim. Bun's default is 128 MiB per request on every route,
1313
+ // authenticated or not — twenty concurrent POSTs is 2.5 GB resident.
1314
+ const maxRequestBodySize = configManager.get<number>(
1315
+ "app.maxRequestBodySize",
1316
+ DEFAULT_MAX_REQUEST_BODY_SIZE,
1317
+ );
1318
+
1319
+ this._static = Bun.serve({
1320
+ port,
1321
+ maxRequestBodySize,
1322
+ routes: compiledRoutes,
1323
+ ...(Object.keys(extraOptions).length
1324
+ ? (extraOptions as unknown as Parameters<typeof Bun.serve>[0])
1325
+ : {}),
1326
+ fetch: async (req: Request, server: unknown): Promise<Response | undefined> => {
1327
+ // Lazy static file serving — for dirs registered with `eager: false`,
1328
+ // plus every dir under the dev worker (see `lazyStaticDirs` above).
1329
+ // Files already pre-registered are served by Bun as native routes and
1330
+ // never reach this fallback.
1331
+ if (req.method === "GET" && lazyStaticDirs.length) {
1332
+ const staticFile = await _lazyStaticResponse(req.url, lazyStaticDirs);
1333
+ if (staticFile) return staticFile;
1334
+ }
1335
+
1336
+ if (req.headers.get("Upgrade") === "websocket") {
1337
+ const bunServer = server as {
1338
+ upgrade(req: Request, opts: { data: unknown }): boolean;
1339
+ };
1340
+ const pathname = new URL(req.url).pathname;
1341
+
1342
+ if (this._devWsEnabled && pathname === "/__dev/ws") {
1343
+ const upgraded = bunServer.upgrade(req, {
1344
+ data: { _dev: true, id: crypto.randomUUID() },
1345
+ });
1346
+ if (upgraded) return undefined;
1347
+ }
1348
+
1349
+ const wsReg = this._wsRegistrations.length ? this._wsRegFor(pathname) : undefined;
1350
+ if (wsReg) {
1351
+ // Cross-site WebSocket hijacking guard. The WS handshake is exempt from the
1352
+ // same-origin policy but still carries cookies, so without this any site the user
1353
+ // visits could open an authenticated socket to this server and drive every action
1354
+ // the session permits. Browsers always send Origin on a handshake and script
1355
+ // cannot forge it. Non-browser clients omit it and are allowed through.
1356
+ if (!isAllowedOrigin(req, this._allowedOrigins())) {
1357
+ return new Response("Forbidden origin.", { status: 403 });
1358
+ }
1359
+ const extraData = wsReg.upgradeData?.(req, bunServer) ?? {};
1360
+ // Tag the connection with its path so open/message/close route to this handler.
1361
+ const data = { id: crypto.randomUUID(), _wsPath: pathname, ...extraData };
1362
+ const upgraded = bunServer.upgrade(req, { data });
1363
+ if (upgraded) return undefined;
1364
+ }
1365
+ }
1366
+
1367
+ // Shared request lifecycle (scoping, HttpContext, in-flight gauge,
1368
+ // events, exception handling, metrics) — same dispatcher as matched
1369
+ // routes, so instrumentation is applied uniformly.
1370
+ return dispatchRequest(req, server, {
1371
+ container: this.container,
1372
+ providerHooks: this._providerHooks,
1373
+ renderError: (error, ctx) => this._handleErrorAsync(error, ctx),
1374
+ execute: async (ctx) => {
1375
+ // An unmatched URL is routed through the same handler as a thrown
1376
+ // NotFoundError, so an app that registers its own ExceptionHandler
1377
+ // renders its own 404 page instead of the framework's default one.
1378
+ const renderNotFound = (innerCtx: HttpContext): Promise<Response> =>
1379
+ this._handleErrorAsync(new NotFoundError(), innerCtx);
1380
+
1381
+ const notFoundCatcher = class implements Pipe<HttpContext> {
1382
+ async handle(innerCtx: HttpContext, next: NextFn): Promise<Response | void> {
1383
+ await next();
1384
+ if (!innerCtx.response) {
1385
+ innerCtx.response = await renderNotFound(innerCtx);
1386
+ }
1387
+ return innerCtx.response;
1388
+ }
1389
+ };
1390
+
1391
+ const finalCtx = await Pipeline.send<HttpContext>(ctx)
1392
+ .through([...this._pipeline, notFoundCatcher])
1393
+ .via(this.container)
1394
+ .thenReturn();
1395
+
1396
+ return finalCtx.response!;
1397
+ },
1398
+ });
1399
+ },
1400
+ error: (_error: Error): Response =>
1401
+ Response.json({ message: "Internal Server Error" }, { status: 500 }),
1402
+ ...(wsConfig ? { websocket: wsConfig } : {}),
1403
+ } as Parameters<typeof Bun.serve>[0]);
1404
+
1405
+ // Phase 5 — STARTED (server is accepting connections)
1406
+ await Promise.all(this._activeProviders.map((p) => p.onStarted()));
1407
+ frameworkLog("app").info(`Server listening on http://localhost:${port}`, { port });
1408
+
1409
+ await this._writePidFile();
1410
+
1411
+ for (const signal of ["SIGTERM", "SIGINT"] as const) {
1412
+ process.on(signal, () => void this.stop());
1413
+ }
1414
+
1415
+ process.on("SIGUSR2", () => void this._reloadRoutes());
1416
+ }
1417
+
1418
+ /**
1419
+ * Boot the application in the `worker` environment and block for queue/worker use.
1420
+ *
1421
+ * @remarks
1422
+ * Sets the environment to `worker`, boots if needed, and runs providers'
1423
+ * `onStarting`/`onStarted` without binding an HTTP server. Installs
1424
+ * SIGTERM/SIGINT handlers that drain providers (LIFO `onStopping`/`onStopped`)
1425
+ * and then `process.exit(0)`.
1426
+ *
1427
+ * @category Lifecycle
1428
+ */
1429
+ async bootAsWorker(): Promise<void> {
1430
+ this._env = "worker";
1431
+
1432
+ if (!this._booted) await this.boot();
1433
+
1434
+ // Phase 4: STARTING
1435
+ await Promise.all(this._activeProviders.map((p) => p.onStarting()));
1436
+
1437
+ // Phase 5: STARTED
1438
+ await Promise.all(this._activeProviders.map((p) => p.onStarted()));
1439
+
1440
+ let stopping = false;
1441
+ const shutdown = async (signal: string): Promise<void> => {
1442
+ if (stopping) return;
1443
+ stopping = true;
1444
+ frameworkLog("worker").info(`${signal} received — draining`);
1445
+
1446
+ const reversed = [...this._activeProviders].reverse();
1447
+ for (const provider of reversed) {
1448
+ await provider.onStopping();
1449
+ }
1450
+
1451
+ for (const provider of reversed) {
1452
+ provider.onStopped();
1453
+ }
1454
+
1455
+ frameworkLog("worker").info("Shutdown complete");
1456
+ process.exit(0);
1457
+ };
1458
+
1459
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
1460
+ process.on("SIGINT", () => void shutdown("SIGINT"));
1461
+ }
1462
+
1463
+ /**
1464
+ * Phases 6–7: STOPPING → STOPPED (LIFO order).
1465
+ *
1466
+ * By default the process exits once shutdown completes (CLI behaviour).
1467
+ * Pass `{ exit: false }` for embedded use or in-process test teardown, where
1468
+ * the caller owns the process lifetime.
1469
+ *
1470
+ * @example
1471
+ * await app.stop(); // CLI: stops providers, then process.exit(0)
1472
+ * await app.stop({ exit: false }); // embedded/tests: stops without exiting
1473
+ *
1474
+ * @category Lifecycle
1475
+ */
1476
+ async stop(options: { exit?: boolean } = {}): Promise<void> {
1477
+ this._static?.stop(false);
1478
+ const reversed = [...this._activeProviders].reverse();
1479
+ for (const provider of reversed) await provider.onStopping();
1480
+
1481
+ for (const provider of reversed) await provider.onStopped();
1482
+ await this._removePidFile();
1483
+ frameworkLog("app").info("Server stopped");
1484
+ if (options.exit !== false) process.exit(0);
1485
+ }
1486
+
1487
+ /**
1488
+ * Gracefully shut the application down **without** exiting the process.
1489
+ *
1490
+ * The embedded/test-friendly counterpart to {@link stop}: it runs the same
1491
+ * STOPPING → STOPPED teardown (server + providers, LIFO) but never calls
1492
+ * `process.exit`, so the caller keeps ownership of the process lifetime. This
1493
+ * is the method to reach for in in-process tests, REPLs, and when hosting the
1494
+ * app inside a larger program. Equivalent to `stop({ exit: false })`.
1495
+ *
1496
+ * @example
1497
+ * const app = await Application.create().register([...]).start(0);
1498
+ * // …exercise the app…
1499
+ * await app.close(); // tears down, process stays alive
1500
+ *
1501
+ * @category Lifecycle
1502
+ */
1503
+ async close(): Promise<void> {
1504
+ await this.stop({ exit: false });
1505
+ }
1506
+
1507
+ // ── Internal helpers ──────────────────────────────────────────────────
1508
+
1509
+ private _registerHealthEndpoint(): void {
1510
+ if (this._env !== "web" && this._env !== "worker") return;
1511
+
1512
+ const configManager = (() => {
1513
+ try {
1514
+ return this.container.makeSync("config") as ConfigManager;
1515
+ } catch {
1516
+ return undefined;
1517
+ }
1518
+ })();
1519
+ const appEnv = configManager?.get<string>("app.env", "development") ?? "development";
1520
+ const isProduction = isProdLike(appEnv);
1521
+
1522
+ // Health config lives under `app.health` (an object). For back-compat we also
1523
+ // honour a legacy `health` config namespace (config/health.ts) and a bare
1524
+ // `app.health` boolean enable-flag. Precedence: app.health object → health
1525
+ // namespace → app.health boolean → environment default.
1526
+ const appHealth = configManager?.get<boolean | HealthConfigShape>("app.health");
1527
+ const namespacedHealth = configManager?.get<HealthConfigShape>("health", {}) ?? {};
1528
+ const healthConfigRaw =
1529
+ appHealth && typeof appHealth === "object" ? appHealth : namespacedHealth;
1530
+ const legacyHealthFlag = typeof appHealth === "boolean" ? appHealth : undefined;
1531
+ const config = resolveHealthConfig(healthConfigRaw, isProduction, legacyHealthFlag);
1532
+ if (!config.enabled) return;
1533
+
1534
+ const appName =
1535
+ configManager?.get<string>("app.name", Bun.env["APP_NAME"] ?? "zerotal-app") ?? "zerotal-app";
1536
+ const appVersion =
1537
+ configManager?.get<string>("app.version", Bun.env["APP_VERSION"] ?? Bun.version) ??
1538
+ Bun.version;
1539
+ const app = this;
1540
+
1541
+ // Built-in runtime probe — memory, Bun version, in-flight request count.
1542
+ Health.register("runtime", () => ({
1543
+ status: "ok",
1544
+ meta: {
1545
+ memory: process.memoryUsage(),
1546
+ bun: Bun.version,
1547
+ pendingRequests:
1548
+ (app._static as { pendingRequests?: number } | undefined)?.pendingRequests ?? 0,
1549
+ },
1550
+ }));
1551
+
1552
+ Router._registerFileHandler(
1553
+ "GET",
1554
+ config.path,
1555
+ async (http: HttpContext) => {
1556
+ const access = checkHealthAccess(http.request, config, isProduction);
1557
+ if (!access.allowed) {
1558
+ http.json({ status: "down", message: access.reason }, access.code ?? 403);
1559
+ return;
1560
+ }
1561
+ const report = await Health.run({
1562
+ name: appName,
1563
+ version: appVersion,
1564
+ environment: appEnv,
1565
+ uptime: Math.floor(process.uptime()),
1566
+ ...(this._bootDurationMs !== undefined
1567
+ ? { bootMs: Math.round(this._bootDurationMs) }
1568
+ : {}),
1569
+ });
1570
+ const code = report.status === "down" ? 503 : 200;
1571
+ http.json(config.showDetails ? report : { status: report.status }, code);
1572
+ },
1573
+ [],
1574
+ );
1575
+ }
1576
+
1577
+ private get _pidFilePath(): string {
1578
+ return `${process.cwd()}/.zerotal/server.pid`;
1579
+ }
1580
+
1581
+ private async _writePidFile(): Promise<void> {
1582
+ try {
1583
+ await Bun.write(this._pidFilePath, String(process.pid));
1584
+ } catch {
1585
+ // Non-fatal
1586
+ }
1587
+ }
1588
+
1589
+ private async _removePidFile(): Promise<void> {
1590
+ try {
1591
+ const { unlink } = await import("node:fs/promises");
1592
+ await unlink(this._pidFilePath);
1593
+ } catch {
1594
+ // Already removed or never written
1595
+ }
1596
+ }
1597
+
1598
+ /**
1599
+ * Hot-reload route table triggered by SIGUSR2.
1600
+ */
1601
+ private async _reloadRoutes(): Promise<void> {
1602
+ try {
1603
+ frameworkLog("app").info("Reloading routes");
1604
+
1605
+ Router.reset();
1606
+ const reloadId = String(Date.now());
1607
+
1608
+ this._registerHealthEndpoint();
1609
+
1610
+ // Re-run explicit route files with cache-busting imports
1611
+ for (const { file, prefix, middleware } of this._routeGroups) {
1612
+ await Router.groupAsync({ prefix, middleware }, () => import(`${file}?t=${reloadId}`));
1613
+ }
1614
+
1615
+ // Re-run file-based routes with cache-busting imports
1616
+ for (const { dir, prefix, middleware } of this._fileRouteGroups) {
1617
+ await Router.groupAsync({ prefix, middleware }, () =>
1618
+ scanFileRoutes(dir, reloadId).then(() => {}),
1619
+ );
1620
+ }
1621
+
1622
+ // Re-register public static files
1623
+ const dir = process.cwd();
1624
+ if (this._env === "web") {
1625
+ Router.static("/", `${dir}/public`, this._staticCacheOptions());
1626
+ }
1627
+
1628
+ const newRoutes = Router.compile(
1629
+ this.container,
1630
+ this._pipeline,
1631
+ this._exceptionHandler,
1632
+ this._providerHooks,
1633
+ );
1634
+ this._static?.reload({ routes: newRoutes });
1635
+
1636
+ frameworkLog("app").info("Routes reloaded (zero downtime)");
1637
+ } catch (error) {
1638
+ frameworkLog("app").error("Route reload failed", undefined, error);
1639
+ }
1640
+ }
1641
+
1642
+ private async _handleErrorAsync(error: unknown, ctx: HttpContext): Promise<Response> {
1643
+ const handler = this._exceptionHandler;
1644
+ if (handler) {
1645
+ await handler.report(error, ctx);
1646
+ return handler.render(error, ctx);
1647
+ }
1648
+ frameworkLog("app").error("Unhandled error", undefined, error);
1649
+ return ExceptionHandler.defaultRender(error, ctx);
1650
+ }
1651
+
1652
+ private _buildProviderHooks(): ProviderHooks {
1653
+ const providers = this._activeProviders;
1654
+ return {
1655
+ async onRequestReceived(ctx: HttpContext): Promise<void> {
1656
+ if (providers.length > 0)
1657
+ await Promise.all(providers.map((provider) => provider.onRequestReceived(ctx)));
1658
+ },
1659
+ async onRequestProcessed(ctx: HttpContext): Promise<void> {
1660
+ if (providers.length > 0)
1661
+ await Promise.all(providers.map((provider) => provider.onRequestProcessed(ctx)));
1662
+ },
1663
+ scheduleResponseSent(ctx: HttpContext): void {
1664
+ if (providers.length === 0) return;
1665
+ ctx.afterResponse(async () => {
1666
+ await Promise.all(providers.map((provider) => provider.onResponseSent(ctx)));
1667
+ });
1668
+ },
1669
+ };
1670
+ }
1671
+ }