@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.
- package/CHANGELOG.md +79 -0
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/package.json +72 -0
- package/src/application/Application.ts +1671 -0
- package/src/application/BootDoctor.ts +108 -0
- package/src/application/DevErrorPage.ts +567 -0
- package/src/application/ExceptionHandler.ts +183 -0
- package/src/application/currentApp.ts +73 -0
- package/src/assets/assets.ts +79 -0
- package/src/assets/index.ts +16 -0
- package/src/auth/AuthenticatedUser.ts +18 -0
- package/src/build/PackageLinter.ts +146 -0
- package/src/build/PackageScaffold.ts +127 -0
- package/src/build/codemod.ts +64 -0
- package/src/build/index.ts +12 -0
- package/src/command/Command.ts +254 -0
- package/src/command/CommandRunner.ts +593 -0
- package/src/command/OutputWriter.ts +61 -0
- package/src/command/builtin/CompileCommand.ts +46 -0
- package/src/command/builtin/CssBuildCommand.ts +71 -0
- package/src/command/builtin/KeyGenerateCommand.ts +58 -0
- package/src/command/builtin/LintPackagesCommand.ts +72 -0
- package/src/command/builtin/MakeCommandCommand.ts +85 -0
- package/src/command/builtin/MakeControllerCommand.ts +95 -0
- package/src/command/builtin/MakeEventCommand.ts +85 -0
- package/src/command/builtin/MakeJobCommand.ts +53 -0
- package/src/command/builtin/MakeListenerCommand.ts +35 -0
- package/src/command/builtin/MakeMiddlewareCommand.ts +63 -0
- package/src/command/builtin/MakeNotificationCommand.ts +48 -0
- package/src/command/builtin/MakeObserverCommand.ts +78 -0
- package/src/command/builtin/MakePackageCommand.ts +45 -0
- package/src/command/builtin/MakePolicyCommand.ts +66 -0
- package/src/command/builtin/MakeProviderCommand.ts +75 -0
- package/src/command/builtin/MakeRequestCommand.ts +47 -0
- package/src/command/builtin/MakeResourceCommand.ts +61 -0
- package/src/command/builtin/MakeTestCommand.ts +120 -0
- package/src/command/builtin/ReloadCommand.ts +52 -0
- package/src/command/builtin/ReplCommand.ts +174 -0
- package/src/command/builtin/RouteListCommand.ts +188 -0
- package/src/command/builtin/ServeCommand.ts +321 -0
- package/src/command/builtin/StartCommand.ts +3 -0
- package/src/command/builtin/StatusCommand.ts +71 -0
- package/src/command/builtin/TestCommand.ts +172 -0
- package/src/command/builtin/WorkerCommand.ts +27 -0
- package/src/command/builtin/index.ts +53 -0
- package/src/command/scaffold/worker.ts.txt +12 -0
- package/src/command/scaffold/zerotal.ts.txt +26 -0
- package/src/command/startZerotal.ts +55 -0
- package/src/config/AppConfig.ts +253 -0
- package/src/config/ConfigLoader.ts +117 -0
- package/src/config/ConfigManager.ts +169 -0
- package/src/config/index.ts +46 -0
- package/src/config/registry.ts +59 -0
- package/src/config/validation.ts +117 -0
- package/src/container/Container.ts +606 -0
- package/src/container/ContextualBindingBuilder.ts +57 -0
- package/src/container/ScopedResolver.ts +117 -0
- package/src/container/index.ts +32 -0
- package/src/container/inject.ts +55 -0
- package/src/container/types.ts +71 -0
- package/src/context/RequestContext.ts +91 -0
- package/src/contracts/auth.ts +24 -0
- package/src/contracts/index.ts +23 -0
- package/src/contracts/session.ts +70 -0
- package/src/contracts/transaction.ts +26 -0
- package/src/conventions/ConventionLoader.ts +128 -0
- package/src/conventions/builtinConcerns.ts +131 -0
- package/src/crypt/Crypt.ts +141 -0
- package/src/crypt/URLSigner.ts +96 -0
- package/src/datetime/Carbon.ts +1396 -0
- package/src/datetime/CarbonInterval.ts +421 -0
- package/src/datetime/clock.ts +28 -0
- package/src/datetime/index.ts +23 -0
- package/src/datetime/temporal-shim.ts +1 -0
- package/src/dev/BuildOutput.ts +131 -0
- package/src/dev/CssPlugins.ts +184 -0
- package/src/dev/DevBuildHook.ts +74 -0
- package/src/dev/DevOrchestrator.ts +213 -0
- package/src/dev/DevReloadMiddleware.ts +101 -0
- package/src/dev/DevReloadServer.ts +85 -0
- package/src/dev/DevWsServer.ts +45 -0
- package/src/dev/index.ts +19 -0
- package/src/dev/reloadClient.ts +39 -0
- package/src/env/Def.ts +232 -0
- package/src/env/EnvSchema.ts +105 -0
- package/src/env/index.ts +34 -0
- package/src/env/t.ts +128 -0
- package/src/errors/ConfigError.ts +12 -0
- package/src/errors/ContainerErrors.ts +143 -0
- package/src/errors/HttpError.ts +127 -0
- package/src/errors/ValidationError.ts +19 -0
- package/src/errors/ZerotalError.ts +25 -0
- package/src/errors/index.ts +46 -0
- package/src/events/CallQueuedListener.ts +66 -0
- package/src/events/Emitter.ts +280 -0
- package/src/events/EventFake.ts +160 -0
- package/src/events/FrameworkEvents.ts +252 -0
- package/src/facade/Facade.ts +101 -0
- package/src/facade/facades/App.ts +155 -0
- package/src/facade/facades/Artisan.ts +63 -0
- package/src/facade/facades/Config.ts +21 -0
- package/src/facade/facades/Events.ts +19 -0
- package/src/facade/facades/index.ts +28 -0
- package/src/global.d.ts +9 -0
- package/src/hash/Hash.ts +60 -0
- package/src/health/Health.ts +221 -0
- package/src/health/index.ts +27 -0
- package/src/helpers/Collection.ts +435 -0
- package/src/helpers/config.ts +59 -0
- package/src/helpers/fluent.ts +52 -0
- package/src/helpers/html.ts +11 -0
- package/src/helpers/index.ts +266 -0
- package/src/helpers/make.ts +35 -0
- package/src/helpers/markdown.ts +73 -0
- package/src/helpers/pageElements.ts +27 -0
- package/src/helpers/request.ts +62 -0
- package/src/helpers/response.ts +411 -0
- package/src/helpers/str.ts +208 -0
- package/src/http/Http.ts +298 -0
- package/src/http/HttpClient.ts +289 -0
- package/src/http/Resource.ts +171 -0
- package/src/http/UploadedFile.ts +204 -0
- package/src/http/Uri.ts +490 -0
- package/src/http/index.ts +46 -0
- package/src/http/negotiate.ts +213 -0
- package/src/http/originGuard.ts +76 -0
- package/src/http/sniffContentType.ts +105 -0
- package/src/http/url.ts +204 -0
- package/src/http/withHeaders.ts +24 -0
- package/src/index.ts +250 -0
- package/src/lock/LockManager.ts +228 -0
- package/src/lock/config.ts +49 -0
- package/src/lock/drivers/LockDriver.ts +32 -0
- package/src/lock/drivers/MemoryLockDriver.ts +52 -0
- package/src/lock/drivers/RedisLockDriver.ts +58 -0
- package/src/lock/drivers/SqliteLockDriver.ts +85 -0
- package/src/lock/errors.ts +20 -0
- package/src/lock/facades/Lock.ts +114 -0
- package/src/lock/index.ts +53 -0
- package/src/logger/Log.ts +35 -0
- package/src/logger/LogManager.ts +430 -0
- package/src/logger/LoggerMiddleware.ts +125 -0
- package/src/logger/channels/ConsoleChannel.ts +139 -0
- package/src/logger/channels/DailyChannel.ts +74 -0
- package/src/logger/channels/NullChannel.ts +17 -0
- package/src/logger/channels/SingleChannel.ts +34 -0
- package/src/logger/channels/StackChannel.ts +29 -0
- package/src/logger/config.ts +90 -0
- package/src/logger/format.ts +96 -0
- package/src/logger/frameworkLog.ts +93 -0
- package/src/logger/index.ts +68 -0
- package/src/logger/renderTable.ts +111 -0
- package/src/logger/types.ts +212 -0
- package/src/macros/config.macro.ts +50 -0
- package/src/metrics/HttpMetrics.ts +114 -0
- package/src/metrics/index.ts +18 -0
- package/src/middleware/BaseMiddleware.ts +72 -0
- package/src/middleware/CorsMiddleware.ts +152 -0
- package/src/middleware/RateLimiter.ts +255 -0
- package/src/middleware/SecureHeadersMiddleware.ts +127 -0
- package/src/middleware/ThrottleMiddleware.ts +252 -0
- package/src/middleware/WebhookMiddleware.ts +204 -0
- package/src/pipeline/ContextRegistry.ts +42 -0
- package/src/pipeline/HttpContext.ts +865 -0
- package/src/pipeline/Pipeline.ts +150 -0
- package/src/pipeline/currentPage.ts +46 -0
- package/src/pipeline/types.ts +80 -0
- package/src/provider/LockProvider.ts +64 -0
- package/src/provider/LogProvider.ts +137 -0
- package/src/provider/ServiceProvider.ts +84 -0
- package/src/provider/StorageProvider.ts +45 -0
- package/src/router/FileRouter.ts +526 -0
- package/src/router/Route.ts +76 -0
- package/src/router/RouteHandler.ts +335 -0
- package/src/router/Router.ts +1247 -0
- package/src/router/domain.ts +65 -0
- package/src/security/index.ts +22 -0
- package/src/storage/FakeDisk.ts +233 -0
- package/src/storage/StorageFilesMiddleware.ts +150 -0
- package/src/storage/StorageManager.ts +173 -0
- package/src/storage/config.ts +47 -0
- package/src/storage/drivers/LocalDriver.ts +138 -0
- package/src/storage/drivers/S3Driver.ts +169 -0
- package/src/storage/errors.ts +135 -0
- package/src/storage/facades/Storage.ts +3 -0
- package/src/storage/global.d.ts +7 -0
- package/src/storage/index.ts +22 -0
- package/src/storage/root.ts +59 -0
- package/src/storage/types.ts +104 -0
- package/src/support/appKey.ts +38 -0
- package/src/support/cookie.ts +72 -0
- package/src/support/crypto.ts +52 -0
- package/src/support/deepMerge.ts +117 -0
- package/src/support/env.ts +71 -0
- package/src/support/network.ts +79 -0
- package/src/support/port.ts +197 -0
- package/src/support/str.ts +122 -0
- package/src/view/FileRouteResolver.ts +59 -0
- package/src/view/index.ts +144 -0
- 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
|
+
}
|