@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,212 @@
|
|
|
1
|
+
import type { TableData } from "./renderTable.ts";
|
|
2
|
+
|
|
3
|
+
export type { TableData };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Severity of a log entry, from lowest to highest: `debug`, `info`, `warn`,
|
|
7
|
+
* `error`, `fatal`. A channel's configured minimum level suppresses any entry
|
|
8
|
+
* below it.
|
|
9
|
+
*
|
|
10
|
+
* @category Logging
|
|
11
|
+
*/
|
|
12
|
+
export type LogLevel = "debug" | "info" | "warn" | "error" | "fatal";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Numeric rank of each {@link LogLevel}, used to compare an entry's level
|
|
16
|
+
* against a channel's minimum. Higher means more severe.
|
|
17
|
+
*
|
|
18
|
+
* @internal
|
|
19
|
+
*/
|
|
20
|
+
export const LEVEL_ORDER: Record<LogLevel, number> = {
|
|
21
|
+
debug: 0,
|
|
22
|
+
info: 1,
|
|
23
|
+
warn: 2,
|
|
24
|
+
error: 3,
|
|
25
|
+
fatal: 4,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A single fully-enriched log record as it is handed to a {@link LogChannel}.
|
|
30
|
+
*
|
|
31
|
+
* The {@link LogManager} populates `timestamp`, `hostname`, and `pid` on every
|
|
32
|
+
* entry; `app`, `env`, `requestId`, `context`, `error`, and `stack` appear only
|
|
33
|
+
* when the corresponding data is available.
|
|
34
|
+
*
|
|
35
|
+
* @category Logging
|
|
36
|
+
*/
|
|
37
|
+
export interface LogEntry {
|
|
38
|
+
/** Severity the entry was emitted at. */
|
|
39
|
+
level: LogLevel;
|
|
40
|
+
/** Name of the channel the entry was routed to. */
|
|
41
|
+
channel: string;
|
|
42
|
+
/**
|
|
43
|
+
* Subsystem that emitted the entry — `app`, `flow`, `inertia`, `queue`.
|
|
44
|
+
* Rendered as a `[SCOPE]` tag by the console channel and kept as a field by
|
|
45
|
+
* the file/JSON ones, so a log file can be filtered by source.
|
|
46
|
+
*/
|
|
47
|
+
scope?: string | undefined;
|
|
48
|
+
/** Human-readable log message. */
|
|
49
|
+
message: string;
|
|
50
|
+
/** ISO-8601 timestamp of when the entry was created. */
|
|
51
|
+
timestamp: string;
|
|
52
|
+
/** Application name, from `app.name` config, when configured. */
|
|
53
|
+
app?: string | undefined;
|
|
54
|
+
/** Application environment, from `app.env` config, when configured. */
|
|
55
|
+
env?: string | undefined;
|
|
56
|
+
/** Host machine name (`os.hostname()`). */
|
|
57
|
+
hostname: string;
|
|
58
|
+
/** Process id of the emitting process. */
|
|
59
|
+
pid: number;
|
|
60
|
+
/** Id of the active HTTP request, when logging inside a request context. */
|
|
61
|
+
requestId?: string | undefined;
|
|
62
|
+
/** Arbitrary structured context passed at the call site (omitted when empty). */
|
|
63
|
+
context?: Record<string, unknown> | undefined;
|
|
64
|
+
/**
|
|
65
|
+
* How a human-facing channel should present {@link LogEntry.context}. `"table"`
|
|
66
|
+
* asks the console for box-drawn columns; file and JSON channels ignore it and
|
|
67
|
+
* keep writing the same structured data. Purely a rendering hint — never a
|
|
68
|
+
* change to what the entry contains.
|
|
69
|
+
*/
|
|
70
|
+
display?: "table" | undefined;
|
|
71
|
+
/** Error message, when an error/value was passed to the log call. */
|
|
72
|
+
error?: string | undefined;
|
|
73
|
+
/** Stack trace, when the passed value was an `Error` with a stack. */
|
|
74
|
+
stack?: string | undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Contract every log destination implements: a single async `write` that
|
|
79
|
+
* persists or displays one {@link LogEntry}. Implementations include
|
|
80
|
+
* {@link ConsoleChannel}, {@link SingleChannel}, {@link DailyChannel},
|
|
81
|
+
* {@link StackChannel}, and {@link NullChannel}. Implement this to add a custom
|
|
82
|
+
* destination.
|
|
83
|
+
*
|
|
84
|
+
* @category Channels
|
|
85
|
+
*/
|
|
86
|
+
export interface LogChannel {
|
|
87
|
+
write(entry: LogEntry): Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A logger pinned to a specific channel and/or a fixed context bag.
|
|
92
|
+
*
|
|
93
|
+
* Returned by {@link LogManager.channel} and {@link LogManager.withContext}
|
|
94
|
+
* (and implemented by {@link LogManager} itself for the default channel). Each
|
|
95
|
+
* level method takes a `message`, optional structured `context`, and an
|
|
96
|
+
* optional error/value whose message and stack are captured onto the entry.
|
|
97
|
+
*
|
|
98
|
+
* @category Context
|
|
99
|
+
*/
|
|
100
|
+
export interface BoundLogger {
|
|
101
|
+
debug(message: string, context?: Record<string, unknown>, err?: unknown): void;
|
|
102
|
+
info(message: string, context?: Record<string, unknown>, err?: unknown): void;
|
|
103
|
+
warn(message: string, context?: Record<string, unknown>, err?: unknown): void;
|
|
104
|
+
error(message: string, context?: Record<string, unknown>, err?: unknown): void;
|
|
105
|
+
fatal(message: string, context?: Record<string, unknown>, err?: unknown): void;
|
|
106
|
+
/**
|
|
107
|
+
* Log `message` with `rows` attached, asking human-facing channels to render
|
|
108
|
+
* them as a table. The data is ordinary context, so a JSON or file channel
|
|
109
|
+
* records exactly what the other level methods would have recorded.
|
|
110
|
+
*
|
|
111
|
+
* @param rows One object (key/value rows) or a list of objects (columns).
|
|
112
|
+
* @param level Severity to emit at. Defaults to `info`.
|
|
113
|
+
*/
|
|
114
|
+
table(message: string, rows: TableData, level?: LogLevel): void;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Discriminated union describing one channel's configuration, keyed by
|
|
119
|
+
* `driver`. Each variant maps to a concrete {@link LogChannel}:
|
|
120
|
+
*
|
|
121
|
+
* - `console` — {@link ConsoleChannel}; `format` is `"pretty"` (default) or `"json"`.
|
|
122
|
+
* - `single` — {@link SingleChannel}; appends every entry to `path`.
|
|
123
|
+
* - `daily` — {@link DailyChannel}; date-rotated files under `path`, pruned after `days`.
|
|
124
|
+
* - `stack` — {@link StackChannel}; fans out to the named `channels`.
|
|
125
|
+
* - `null` — {@link NullChannel}; discards everything.
|
|
126
|
+
*
|
|
127
|
+
* @category Channels
|
|
128
|
+
*/
|
|
129
|
+
export type ChannelConfig =
|
|
130
|
+
| { driver: "console"; level?: LogLevel; format?: "json" | "pretty" }
|
|
131
|
+
| { driver: "single"; level?: LogLevel; path: string }
|
|
132
|
+
| { driver: "daily"; level?: LogLevel; path: string; days?: number }
|
|
133
|
+
| { driver: "stack"; level?: LogLevel; channels: string[] }
|
|
134
|
+
| { driver: "null" };
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The always-on terminal sink. Every entry is printed unless this is `false`.
|
|
138
|
+
*
|
|
139
|
+
* Console output is a property of the logger rather than a channel you route
|
|
140
|
+
* to, so pointing `default` at a file channel no longer costs you the terminal.
|
|
141
|
+
*
|
|
142
|
+
* @category Configuration
|
|
143
|
+
*/
|
|
144
|
+
export type ConsoleSinkConfig =
|
|
145
|
+
| false
|
|
146
|
+
| {
|
|
147
|
+
/** Minimum level printed. Defaults to `debug`. */
|
|
148
|
+
level?: LogLevel;
|
|
149
|
+
/** `"pretty"` (default) or `"json"`. */
|
|
150
|
+
format?: "json" | "pretty";
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The always-on file trail. Every entry is appended to a date-rotated file
|
|
155
|
+
* unless this is `false`.
|
|
156
|
+
*
|
|
157
|
+
* This is the record you read when the terminal is gone: after the process
|
|
158
|
+
* exited, after the scrollback rolled over, on a machine you were not watching.
|
|
159
|
+
* It is deliberately independent of {@link ConsoleSinkConfig} — quietening the
|
|
160
|
+
* terminal must not cost you the trail.
|
|
161
|
+
*
|
|
162
|
+
* @category Configuration
|
|
163
|
+
*/
|
|
164
|
+
export type FileSinkConfig =
|
|
165
|
+
| false
|
|
166
|
+
| {
|
|
167
|
+
/** Directory holding the per-day files. Defaults to `./storage/logs`. */
|
|
168
|
+
path?: string;
|
|
169
|
+
/** Days a file survives before being pruned. Defaults to `14`. */
|
|
170
|
+
days?: number;
|
|
171
|
+
/**
|
|
172
|
+
* Minimum level written. Defaults to `debug` — the trail records
|
|
173
|
+
* everything, and the console threshold controls what you actually watch.
|
|
174
|
+
*/
|
|
175
|
+
level?: LogLevel;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Full shape of the `logging` config namespace, as produced by
|
|
180
|
+
* {@link LoggingConfig}. Defines the two always-on sinks, the channel map,
|
|
181
|
+
* which channel is the default, and framework logging thresholds.
|
|
182
|
+
*
|
|
183
|
+
* @category Configuration
|
|
184
|
+
*/
|
|
185
|
+
export interface LoggingConfigShape {
|
|
186
|
+
/**
|
|
187
|
+
* The terminal sink, on unless `false`. See {@link ConsoleSinkConfig}.
|
|
188
|
+
*/
|
|
189
|
+
console?: ConsoleSinkConfig;
|
|
190
|
+
/**
|
|
191
|
+
* The durable file trail, on unless `false`. See {@link FileSinkConfig}.
|
|
192
|
+
*/
|
|
193
|
+
file?: FileSinkConfig;
|
|
194
|
+
/** Name of the channel used by unqualified `Log.*` calls. */
|
|
195
|
+
default: string;
|
|
196
|
+
/**
|
|
197
|
+
* Named channel definitions; see {@link ChannelConfig}.
|
|
198
|
+
*
|
|
199
|
+
* Channels are *additional* destinations, not replacements — an entry routed
|
|
200
|
+
* to one still reaches the console and the file trail. A channel that already
|
|
201
|
+
* covers a sink (a `console` driver, or `single`/`daily`) suppresses that
|
|
202
|
+
* baseline for its own entries, so nothing is written twice.
|
|
203
|
+
*/
|
|
204
|
+
channels: Record<string, ChannelConfig>;
|
|
205
|
+
/** Threshold, in ms, above which ORM queries are logged as slow (default 1000). */
|
|
206
|
+
slowQueryMs?: number;
|
|
207
|
+
/**
|
|
208
|
+
* Log every HTTP request (method, path, status, duration) via `LoggerMiddleware`.
|
|
209
|
+
* On by default; set `false` to silence the per-request access log.
|
|
210
|
+
*/
|
|
211
|
+
requests?: boolean;
|
|
212
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Bun build-time macro that bakes a config directory into a static object,
|
|
3
|
+
* for embedding configuration into a compiled binary (`bun build --compile`).
|
|
4
|
+
*/
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Bun macro — scan a config directory and return all configs as a static object.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ USE ONLY WITH `bun build --compile`.
|
|
11
|
+
* This macro is evaluated at bundle time and is primarily intended for baking
|
|
12
|
+
* config values into a self-contained binary. Do NOT use it in zt.ts for
|
|
13
|
+
* development or test workflows — it fails on Windows when the argument
|
|
14
|
+
* contains a runtime expression like `${process.cwd()}`.
|
|
15
|
+
*
|
|
16
|
+
* For development (and for all apps not compiled to a binary), import configs
|
|
17
|
+
* directly instead:
|
|
18
|
+
*
|
|
19
|
+
* ```ts
|
|
20
|
+
* // zt.ts — preferred approach (works everywhere)
|
|
21
|
+
* import appConfig from './config/app.ts';
|
|
22
|
+
* import databaseConfig from './config/database.ts';
|
|
23
|
+
* app.useConfig({ app: appConfig, database: databaseConfig, ... });
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* For compiled binaries only:
|
|
27
|
+
*
|
|
28
|
+
* ```ts
|
|
29
|
+
* import { loadConfigsSync } from '@zerotal/core/macros/config' with { type: 'macro' };
|
|
30
|
+
* const configs = loadConfigsSync('/absolute/path/to/config');
|
|
31
|
+
* app.useConfig(configs);
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* Note: config files that read `Bun.env` capture their values from the
|
|
35
|
+
* environment at macro-evaluation time. For bun build --compile this is the
|
|
36
|
+
* CI/CD environment — the same intended behaviour as any immutable artifact.
|
|
37
|
+
*/
|
|
38
|
+
export function loadConfigsSync(configDir: string): Record<string, Record<string, unknown>> {
|
|
39
|
+
const glob = new Bun.Glob("*.ts");
|
|
40
|
+
const result: Record<string, Record<string, unknown>> = {};
|
|
41
|
+
|
|
42
|
+
for (const file of glob.scanSync({ cwd: configDir })) {
|
|
43
|
+
if (file === "index.ts") continue;
|
|
44
|
+
const key = file.replace(/\.ts$/, "");
|
|
45
|
+
const loadedModule = require(join(configDir, file)) as Record<string, unknown>;
|
|
46
|
+
result[key] = (loadedModule["default"] ?? loadedModule) as Record<string, unknown>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process HTTP request metrics — request counts by status class, latency
|
|
3
|
+
* (avg / p95 / max), and short-window throughput. Recorded once per request by
|
|
4
|
+
* the server and surfaced on the admin Health page. Best-effort and memory-safe
|
|
5
|
+
* (a bounded rolling window), reset on process restart.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
interface Sample {
|
|
9
|
+
t: number;
|
|
10
|
+
status: number;
|
|
11
|
+
ms: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const WINDOW_MS = 60 * 60 * 1000; // keep ~1h of samples for percentiles/rates
|
|
15
|
+
const MAX_SAMPLES = 10_000; // hard cap regardless of window
|
|
16
|
+
const FIVE_MINUTES_MS = 5 * 60 * 1000; // the recent window for p95, error rate, and throughput
|
|
17
|
+
|
|
18
|
+
const _samples: Sample[] = [];
|
|
19
|
+
let _total = 0;
|
|
20
|
+
let _inFlight = 0;
|
|
21
|
+
let _2xx = 0,
|
|
22
|
+
_3xx = 0,
|
|
23
|
+
_4xx = 0,
|
|
24
|
+
_5xx = 0;
|
|
25
|
+
let _sumMs = 0,
|
|
26
|
+
_maxMs = 0;
|
|
27
|
+
|
|
28
|
+
/** Mark a request as started — increments the in-flight (currently-processing) gauge. */
|
|
29
|
+
export function beginHttp(): void {
|
|
30
|
+
_inFlight++;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Mark a request as finished — decrements the in-flight gauge (floored at 0). */
|
|
34
|
+
export function endHttp(): void {
|
|
35
|
+
if (_inFlight > 0) _inFlight--;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Record one completed request. Called by the server for every pipeline response. */
|
|
39
|
+
export function recordHttp(status: number, ms: number): void {
|
|
40
|
+
_total++;
|
|
41
|
+
_sumMs += ms;
|
|
42
|
+
if (ms > _maxMs) _maxMs = ms;
|
|
43
|
+
if (status >= 500) _5xx++;
|
|
44
|
+
else if (status >= 400) _4xx++;
|
|
45
|
+
else if (status >= 300) _3xx++;
|
|
46
|
+
else _2xx++;
|
|
47
|
+
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
_samples.push({ t: now, status, ms });
|
|
50
|
+
const cutoff = now - WINDOW_MS;
|
|
51
|
+
while (_samples.length && _samples[0]!.t < cutoff) _samples.shift();
|
|
52
|
+
if (_samples.length > MAX_SAMPLES) _samples.splice(0, _samples.length - MAX_SAMPLES);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A point-in-time view of the request metrics, as shown on the Health page. */
|
|
56
|
+
export interface HttpMetricsSnapshot {
|
|
57
|
+
/** Requests since boot. */
|
|
58
|
+
total: number;
|
|
59
|
+
/** Requests currently being processed (in-flight concurrency). */
|
|
60
|
+
inFlight: number;
|
|
61
|
+
/** 2xx + 3xx responses. */
|
|
62
|
+
success: number;
|
|
63
|
+
/** 4xx responses. */
|
|
64
|
+
clientErrors: number;
|
|
65
|
+
/** 5xx responses. */
|
|
66
|
+
serverErrors: number;
|
|
67
|
+
/** Success percentage (0–100). */
|
|
68
|
+
successRate: number;
|
|
69
|
+
/** Mean response time since boot, ms. */
|
|
70
|
+
avgMs: number;
|
|
71
|
+
/** 95th-percentile response time over the last 5 minutes, ms. */
|
|
72
|
+
p95Ms: number;
|
|
73
|
+
/** Slowest response since boot, ms. */
|
|
74
|
+
maxMs: number;
|
|
75
|
+
/** Requests per minute over the last 5 minutes. */
|
|
76
|
+
perMinute: number;
|
|
77
|
+
/** Last-5-minute totals. */
|
|
78
|
+
last5m: { total: number; errors: number };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Snapshot the current metrics. */
|
|
82
|
+
export function httpMetrics(): HttpMetricsSnapshot {
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
const recentSamples = _samples.filter((sample) => sample.t >= now - FIVE_MINUTES_MS);
|
|
85
|
+
const sortedLatencies = recentSamples
|
|
86
|
+
.map((sample) => sample.ms)
|
|
87
|
+
.sort((first, second) => first - second);
|
|
88
|
+
const p95 = sortedLatencies.length
|
|
89
|
+
? sortedLatencies[
|
|
90
|
+
Math.min(sortedLatencies.length - 1, Math.floor(sortedLatencies.length * 0.95))
|
|
91
|
+
]!
|
|
92
|
+
: 0;
|
|
93
|
+
const errors = recentSamples.filter((sample) => sample.status >= 400).length;
|
|
94
|
+
const success = _2xx + _3xx;
|
|
95
|
+
return {
|
|
96
|
+
total: _total,
|
|
97
|
+
inFlight: _inFlight,
|
|
98
|
+
success,
|
|
99
|
+
clientErrors: _4xx,
|
|
100
|
+
serverErrors: _5xx,
|
|
101
|
+
successRate: _total ? Math.round((success / _total) * 100) : 100,
|
|
102
|
+
avgMs: _total ? Math.round(_sumMs / _total) : 0,
|
|
103
|
+
p95Ms: Math.round(p95),
|
|
104
|
+
maxMs: Math.round(_maxMs),
|
|
105
|
+
perMinute: Math.round(recentSamples.length / 5),
|
|
106
|
+
last5m: { total: recentSamples.length, errors },
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @internal — test reset. */
|
|
111
|
+
export function _resetHttpMetrics(): void {
|
|
112
|
+
_samples.length = 0;
|
|
113
|
+
_total = _inFlight = _2xx = _3xx = _4xx = _5xx = _sumMs = _maxMs = 0;
|
|
114
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process HTTP request metrics (the `@zerotal/core/metrics` subpath) —
|
|
3
|
+
* request counts by status class, latency (avg / p95 / max), and short-window
|
|
4
|
+
* throughput. Recorded once per request by the server and surfaced on the admin
|
|
5
|
+
* Health page; best-effort, memory-safe, and reset on process restart.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { httpMetrics } from "@zerotal/core/metrics";
|
|
10
|
+
*
|
|
11
|
+
* const snapshot = httpMetrics();
|
|
12
|
+
* console.log(snapshot.total, snapshot.successRate, snapshot.p95Ms);
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* @packageDocumentation
|
|
16
|
+
*/
|
|
17
|
+
export { recordHttp, beginHttp, endHttp, httpMetrics } from "./HttpMetrics.ts";
|
|
18
|
+
export type { HttpMetricsSnapshot } from "./HttpMetrics.ts";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The base class every Zerotal middleware extends, providing the `Pipe`
|
|
3
|
+
* contract plus the static `with()` helper that bakes options into a
|
|
4
|
+
* zero-argument middleware class usable directly in `app.use([...])`.
|
|
5
|
+
*/
|
|
6
|
+
import type { Pipe, NextFn } from "../pipeline/types.ts";
|
|
7
|
+
import type { HttpContext } from "../pipeline/HttpContext.ts";
|
|
8
|
+
// Canonical implementation now lives in support/deepMerge.ts; re-exported here so existing
|
|
9
|
+
// `import { deepMerge } from "./BaseMiddleware.ts"` call sites keep working.
|
|
10
|
+
import { deepMerge } from "../support/deepMerge.ts";
|
|
11
|
+
|
|
12
|
+
export { deepMerge };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Base class for all Zerotal middlewares.
|
|
16
|
+
*
|
|
17
|
+
* Subclasses must declare `protected options` with their default values.
|
|
18
|
+
* Use `MyMiddleware.with({ ... })` to produce a zero-arg constructor with
|
|
19
|
+
* options deep-merged on top of the subclass defaults.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* class MyMiddleware extends BaseMiddleware<MyOptions> {
|
|
23
|
+
* protected options: MyOptions = { timeout: 5000 };
|
|
24
|
+
*
|
|
25
|
+
* async handle(ctx: HttpContext, next: NextFn): Promise<Response | void> {
|
|
26
|
+
* // ...read/write ctx.* ...
|
|
27
|
+
* return next();
|
|
28
|
+
* }
|
|
29
|
+
* }
|
|
30
|
+
*
|
|
31
|
+
* app.use([MyMiddleware.with({ timeout: 1000 })]);
|
|
32
|
+
*/
|
|
33
|
+
export abstract class BaseMiddleware<O extends object = object> implements Pipe<HttpContext> {
|
|
34
|
+
// Explicit constructor so JSC's function-coverage counter attributes
|
|
35
|
+
// the super() call from concrete subclasses to this entry.
|
|
36
|
+
constructor() {}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Subclasses must declare this with their default option values.
|
|
40
|
+
* TypeScript enforces this at compile time — forgetting it is a type error.
|
|
41
|
+
*/
|
|
42
|
+
protected abstract options: O;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Returns a zero-arg subclass with the given options deep-merged on top of
|
|
46
|
+
* the subclass defaults, usable directly in app.use([...]).
|
|
47
|
+
*/
|
|
48
|
+
static with<
|
|
49
|
+
// 1. Constrain T to be a concrete class (not abstract) that extends BaseMiddleware
|
|
50
|
+
T extends new (...args: any[]) => BaseMiddleware<any>,
|
|
51
|
+
// 2. Dynamically infer the specific options type (U) from that concrete class
|
|
52
|
+
Opts = T extends new (...args: any[]) => BaseMiddleware<infer U> ? U : object,
|
|
53
|
+
>(this: T, options: Partial<Opts>): new () => InstanceType<T> {
|
|
54
|
+
const configured = class extends (this as any) {
|
|
55
|
+
constructor() {
|
|
56
|
+
super();
|
|
57
|
+
(this as any).options = deepMerge((this as any).options ?? {}, options);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
// A class expression is anonymous, so a configured middleware used to appear
|
|
61
|
+
// as `""` everywhere a name is read — the pipeline listing, `route:list`,
|
|
62
|
+
// error messages. Carrying the base name across keeps a middleware
|
|
63
|
+
// identifiable after it has been configured.
|
|
64
|
+
Object.defineProperty(configured, "name", { value: this.name, configurable: true });
|
|
65
|
+
return configured as any;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
abstract handle(ctx: HttpContext, next: NextFn): Promise<Response | void>;
|
|
69
|
+
|
|
70
|
+
afterResponse?(ctx: HttpContext): Promise<void>;
|
|
71
|
+
onError?(ctx: HttpContext, error: Error): Promise<void>;
|
|
72
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CORS middleware: applies `Access-Control-*` headers to responses and answers
|
|
3
|
+
* preflight `OPTIONS` requests, configurable per-origin and via app config.
|
|
4
|
+
*/
|
|
5
|
+
import type { NextFn } from "../pipeline/types.ts";
|
|
6
|
+
import type { HttpContext } from "../pipeline/HttpContext.ts";
|
|
7
|
+
import { BaseMiddleware, deepMerge } from "./BaseMiddleware.ts";
|
|
8
|
+
import { withHeaders } from "../http/withHeaders.ts";
|
|
9
|
+
import { config } from "../helpers/config.ts";
|
|
10
|
+
|
|
11
|
+
export interface CorsOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Allowed origins. Default: `[]` — same-origin only, so nothing is shared until an app
|
|
14
|
+
* names what it means to share with.
|
|
15
|
+
*
|
|
16
|
+
* A **string** or **array** is matched exactly against the request's `Origin`, scheme
|
|
17
|
+
* and port included.
|
|
18
|
+
*
|
|
19
|
+
* `'*'` allows any origin. It cannot be combined with `credentials: true` (the browser
|
|
20
|
+
* rejects that pairing outright), and it means every page on the internet can read any
|
|
21
|
+
* response this middleware covers that is not separately credential-gated.
|
|
22
|
+
*
|
|
23
|
+
* A **function** receives the raw `Origin` header and returns whether to allow it. Match
|
|
24
|
+
* the whole origin, not a suffix: `o.endsWith('.example.com')` also matches
|
|
25
|
+
* `https://evil.example.com.attacker.test` and `http://x.example.com` — write
|
|
26
|
+
* `new URL(o).hostname.endsWith('.example.com') && o.startsWith('https://')`, or just
|
|
27
|
+
* list the origins.
|
|
28
|
+
*/
|
|
29
|
+
origin?: string | string[] | ((origin: string) => boolean);
|
|
30
|
+
/** Allowed HTTP methods. Default: `['GET','POST','PUT','PATCH','DELETE','OPTIONS']`. */
|
|
31
|
+
methods?: string[];
|
|
32
|
+
/** Allowed request headers. Default: `['Content-Type','Authorization','X-Requested-With']`. */
|
|
33
|
+
allowedHeaders?: string[];
|
|
34
|
+
/** Headers the browser may expose to JS. Default: `[]`. */
|
|
35
|
+
exposedHeaders?: string[];
|
|
36
|
+
/** Allow cookies / auth headers in cross-origin requests. Default: `false`. */
|
|
37
|
+
credentials?: boolean;
|
|
38
|
+
/** Preflight cache duration in seconds. Default: `600`. */
|
|
39
|
+
maxAge?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* CORS middleware — adds Access-Control-* headers to every response and
|
|
44
|
+
* short-circuits HTTP OPTIONS preflight requests with 204.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* // Global (most common):
|
|
48
|
+
* app.use(new CorsMiddleware());
|
|
49
|
+
*
|
|
50
|
+
* // Restrict to specific origins (the usual case):
|
|
51
|
+
* app.use(CorsMiddleware.with({ origin: 'https://app.example.com', credentials: true }));
|
|
52
|
+
*
|
|
53
|
+
* // Dynamic per-origin check — compare the whole origin, never a suffix:
|
|
54
|
+
* app.use(CorsMiddleware.with({
|
|
55
|
+
* origin: (o) => o.startsWith('https://') && new URL(o).hostname.endsWith('.example.com'),
|
|
56
|
+
* }));
|
|
57
|
+
*/
|
|
58
|
+
export class CorsMiddleware extends BaseMiddleware<CorsOptions> {
|
|
59
|
+
protected options: CorsOptions = {
|
|
60
|
+
origin: [],
|
|
61
|
+
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
62
|
+
allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"],
|
|
63
|
+
exposedHeaders: [],
|
|
64
|
+
credentials: false,
|
|
65
|
+
maxAge: 600,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
constructor(options: CorsOptions = {}) {
|
|
69
|
+
super();
|
|
70
|
+
// App-level defaults from config('app.cors') layer over the built-ins; explicit
|
|
71
|
+
// options (constructor arg or .with(...)) win over both.
|
|
72
|
+
this.options = deepMerge(this.options, config.safe("app.cors", {} as Partial<CorsOptions>));
|
|
73
|
+
this.options = deepMerge(this.options, options);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async handle(http: HttpContext, next: NextFn): Promise<Response | void> {
|
|
77
|
+
const requestOrigin = http.request.headers.get("Origin") ?? "";
|
|
78
|
+
const allowedOrigin = this._resolveOrigin(requestOrigin);
|
|
79
|
+
|
|
80
|
+
// Preflight: respond immediately without going deeper into the pipeline
|
|
81
|
+
if (http.request.method === "OPTIONS") {
|
|
82
|
+
return new Response(null, {
|
|
83
|
+
status: 204,
|
|
84
|
+
headers: this._buildHeaders(allowedOrigin, http),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const response = await next();
|
|
89
|
+
|
|
90
|
+
// Attach CORS headers to the actual response
|
|
91
|
+
if (response && allowedOrigin) {
|
|
92
|
+
return withHeaders(response, this._buildHeaders(allowedOrigin, http));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private _resolveOrigin(requestOrigin: string): string {
|
|
97
|
+
const origin = this.options.origin ?? [];
|
|
98
|
+
if (origin === "*") {
|
|
99
|
+
// `Access-Control-Allow-Origin: *` and `Allow-Credentials: true` is a combination
|
|
100
|
+
// browsers reject, so an app that sets both has a misconfiguration rather than a
|
|
101
|
+
// wildcard. Reflecting the caller's origin there would quietly turn it into
|
|
102
|
+
// "any origin, with cookies" — refuse instead.
|
|
103
|
+
if (this.options.credentials) return "";
|
|
104
|
+
return "*";
|
|
105
|
+
}
|
|
106
|
+
if (typeof origin === "function") {
|
|
107
|
+
return origin(requestOrigin) ? requestOrigin : "";
|
|
108
|
+
}
|
|
109
|
+
if (Array.isArray(origin)) {
|
|
110
|
+
return origin.includes(requestOrigin) ? requestOrigin : "";
|
|
111
|
+
}
|
|
112
|
+
return origin === requestOrigin ? requestOrigin : "";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private _buildHeaders(allowedOrigin: string, ctx: HttpContext): Record<string, string> {
|
|
116
|
+
if (!allowedOrigin) return {};
|
|
117
|
+
|
|
118
|
+
const methods = this.options.methods ?? ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
|
119
|
+
const allowedHeaders = this.options.allowedHeaders ?? [
|
|
120
|
+
"Content-Type",
|
|
121
|
+
"Authorization",
|
|
122
|
+
"X-Requested-With",
|
|
123
|
+
];
|
|
124
|
+
const exposedHeaders = this.options.exposedHeaders ?? [];
|
|
125
|
+
const credentials = this.options.credentials ?? false;
|
|
126
|
+
const maxAge = this.options.maxAge ?? 600;
|
|
127
|
+
|
|
128
|
+
const headers: Record<string, string> = {
|
|
129
|
+
"Access-Control-Allow-Origin": allowedOrigin,
|
|
130
|
+
"Access-Control-Allow-Methods": methods.join(", "),
|
|
131
|
+
"Access-Control-Allow-Headers": allowedHeaders.join(", "),
|
|
132
|
+
"Access-Control-Max-Age": String(maxAge),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
if (credentials) {
|
|
136
|
+
headers["Access-Control-Allow-Credentials"] = "true";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (exposedHeaders.length > 0) {
|
|
140
|
+
headers["Access-Control-Expose-Headers"] = exposedHeaders.join(", ");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Vary by Origin for non-wildcard responses so shared caches don't serve
|
|
144
|
+
// one origin's CORS headers to another.
|
|
145
|
+
if (allowedOrigin !== "*") {
|
|
146
|
+
const existing = ctx.response?.headers.get("Vary") ?? "";
|
|
147
|
+
headers["Vary"] = existing ? `${existing}, Origin` : "Origin";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return headers;
|
|
151
|
+
}
|
|
152
|
+
}
|