@zerotal/core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/LICENSE +21 -0
  3. package/README.md +128 -0
  4. package/package.json +72 -0
  5. package/src/application/Application.ts +1671 -0
  6. package/src/application/BootDoctor.ts +108 -0
  7. package/src/application/DevErrorPage.ts +567 -0
  8. package/src/application/ExceptionHandler.ts +183 -0
  9. package/src/application/currentApp.ts +73 -0
  10. package/src/assets/assets.ts +79 -0
  11. package/src/assets/index.ts +16 -0
  12. package/src/auth/AuthenticatedUser.ts +18 -0
  13. package/src/build/PackageLinter.ts +146 -0
  14. package/src/build/PackageScaffold.ts +127 -0
  15. package/src/build/codemod.ts +64 -0
  16. package/src/build/index.ts +12 -0
  17. package/src/command/Command.ts +254 -0
  18. package/src/command/CommandRunner.ts +593 -0
  19. package/src/command/OutputWriter.ts +61 -0
  20. package/src/command/builtin/CompileCommand.ts +46 -0
  21. package/src/command/builtin/CssBuildCommand.ts +71 -0
  22. package/src/command/builtin/KeyGenerateCommand.ts +58 -0
  23. package/src/command/builtin/LintPackagesCommand.ts +72 -0
  24. package/src/command/builtin/MakeCommandCommand.ts +85 -0
  25. package/src/command/builtin/MakeControllerCommand.ts +95 -0
  26. package/src/command/builtin/MakeEventCommand.ts +85 -0
  27. package/src/command/builtin/MakeJobCommand.ts +53 -0
  28. package/src/command/builtin/MakeListenerCommand.ts +35 -0
  29. package/src/command/builtin/MakeMiddlewareCommand.ts +63 -0
  30. package/src/command/builtin/MakeNotificationCommand.ts +48 -0
  31. package/src/command/builtin/MakeObserverCommand.ts +78 -0
  32. package/src/command/builtin/MakePackageCommand.ts +45 -0
  33. package/src/command/builtin/MakePolicyCommand.ts +66 -0
  34. package/src/command/builtin/MakeProviderCommand.ts +75 -0
  35. package/src/command/builtin/MakeRequestCommand.ts +47 -0
  36. package/src/command/builtin/MakeResourceCommand.ts +61 -0
  37. package/src/command/builtin/MakeTestCommand.ts +120 -0
  38. package/src/command/builtin/ReloadCommand.ts +52 -0
  39. package/src/command/builtin/ReplCommand.ts +174 -0
  40. package/src/command/builtin/RouteListCommand.ts +188 -0
  41. package/src/command/builtin/ServeCommand.ts +321 -0
  42. package/src/command/builtin/StartCommand.ts +3 -0
  43. package/src/command/builtin/StatusCommand.ts +71 -0
  44. package/src/command/builtin/TestCommand.ts +172 -0
  45. package/src/command/builtin/WorkerCommand.ts +27 -0
  46. package/src/command/builtin/index.ts +53 -0
  47. package/src/command/scaffold/worker.ts.txt +12 -0
  48. package/src/command/scaffold/zerotal.ts.txt +26 -0
  49. package/src/command/startZerotal.ts +55 -0
  50. package/src/config/AppConfig.ts +253 -0
  51. package/src/config/ConfigLoader.ts +117 -0
  52. package/src/config/ConfigManager.ts +169 -0
  53. package/src/config/index.ts +46 -0
  54. package/src/config/registry.ts +59 -0
  55. package/src/config/validation.ts +117 -0
  56. package/src/container/Container.ts +606 -0
  57. package/src/container/ContextualBindingBuilder.ts +57 -0
  58. package/src/container/ScopedResolver.ts +117 -0
  59. package/src/container/index.ts +32 -0
  60. package/src/container/inject.ts +55 -0
  61. package/src/container/types.ts +71 -0
  62. package/src/context/RequestContext.ts +91 -0
  63. package/src/contracts/auth.ts +24 -0
  64. package/src/contracts/index.ts +23 -0
  65. package/src/contracts/session.ts +70 -0
  66. package/src/contracts/transaction.ts +26 -0
  67. package/src/conventions/ConventionLoader.ts +128 -0
  68. package/src/conventions/builtinConcerns.ts +131 -0
  69. package/src/crypt/Crypt.ts +141 -0
  70. package/src/crypt/URLSigner.ts +96 -0
  71. package/src/datetime/Carbon.ts +1396 -0
  72. package/src/datetime/CarbonInterval.ts +421 -0
  73. package/src/datetime/clock.ts +28 -0
  74. package/src/datetime/index.ts +23 -0
  75. package/src/datetime/temporal-shim.ts +1 -0
  76. package/src/dev/BuildOutput.ts +131 -0
  77. package/src/dev/CssPlugins.ts +184 -0
  78. package/src/dev/DevBuildHook.ts +74 -0
  79. package/src/dev/DevOrchestrator.ts +213 -0
  80. package/src/dev/DevReloadMiddleware.ts +101 -0
  81. package/src/dev/DevReloadServer.ts +85 -0
  82. package/src/dev/DevWsServer.ts +45 -0
  83. package/src/dev/index.ts +19 -0
  84. package/src/dev/reloadClient.ts +39 -0
  85. package/src/env/Def.ts +232 -0
  86. package/src/env/EnvSchema.ts +105 -0
  87. package/src/env/index.ts +34 -0
  88. package/src/env/t.ts +128 -0
  89. package/src/errors/ConfigError.ts +12 -0
  90. package/src/errors/ContainerErrors.ts +143 -0
  91. package/src/errors/HttpError.ts +127 -0
  92. package/src/errors/ValidationError.ts +19 -0
  93. package/src/errors/ZerotalError.ts +25 -0
  94. package/src/errors/index.ts +46 -0
  95. package/src/events/CallQueuedListener.ts +66 -0
  96. package/src/events/Emitter.ts +280 -0
  97. package/src/events/EventFake.ts +160 -0
  98. package/src/events/FrameworkEvents.ts +252 -0
  99. package/src/facade/Facade.ts +101 -0
  100. package/src/facade/facades/App.ts +155 -0
  101. package/src/facade/facades/Artisan.ts +63 -0
  102. package/src/facade/facades/Config.ts +21 -0
  103. package/src/facade/facades/Events.ts +19 -0
  104. package/src/facade/facades/index.ts +28 -0
  105. package/src/global.d.ts +9 -0
  106. package/src/hash/Hash.ts +60 -0
  107. package/src/health/Health.ts +221 -0
  108. package/src/health/index.ts +27 -0
  109. package/src/helpers/Collection.ts +435 -0
  110. package/src/helpers/config.ts +59 -0
  111. package/src/helpers/fluent.ts +52 -0
  112. package/src/helpers/html.ts +11 -0
  113. package/src/helpers/index.ts +266 -0
  114. package/src/helpers/make.ts +35 -0
  115. package/src/helpers/markdown.ts +73 -0
  116. package/src/helpers/pageElements.ts +27 -0
  117. package/src/helpers/request.ts +62 -0
  118. package/src/helpers/response.ts +411 -0
  119. package/src/helpers/str.ts +208 -0
  120. package/src/http/Http.ts +298 -0
  121. package/src/http/HttpClient.ts +289 -0
  122. package/src/http/Resource.ts +171 -0
  123. package/src/http/UploadedFile.ts +204 -0
  124. package/src/http/Uri.ts +490 -0
  125. package/src/http/index.ts +46 -0
  126. package/src/http/negotiate.ts +213 -0
  127. package/src/http/originGuard.ts +76 -0
  128. package/src/http/sniffContentType.ts +105 -0
  129. package/src/http/url.ts +204 -0
  130. package/src/http/withHeaders.ts +24 -0
  131. package/src/index.ts +250 -0
  132. package/src/lock/LockManager.ts +228 -0
  133. package/src/lock/config.ts +49 -0
  134. package/src/lock/drivers/LockDriver.ts +32 -0
  135. package/src/lock/drivers/MemoryLockDriver.ts +52 -0
  136. package/src/lock/drivers/RedisLockDriver.ts +58 -0
  137. package/src/lock/drivers/SqliteLockDriver.ts +85 -0
  138. package/src/lock/errors.ts +20 -0
  139. package/src/lock/facades/Lock.ts +114 -0
  140. package/src/lock/index.ts +53 -0
  141. package/src/logger/Log.ts +35 -0
  142. package/src/logger/LogManager.ts +430 -0
  143. package/src/logger/LoggerMiddleware.ts +125 -0
  144. package/src/logger/channels/ConsoleChannel.ts +139 -0
  145. package/src/logger/channels/DailyChannel.ts +74 -0
  146. package/src/logger/channels/NullChannel.ts +17 -0
  147. package/src/logger/channels/SingleChannel.ts +34 -0
  148. package/src/logger/channels/StackChannel.ts +29 -0
  149. package/src/logger/config.ts +90 -0
  150. package/src/logger/format.ts +96 -0
  151. package/src/logger/frameworkLog.ts +93 -0
  152. package/src/logger/index.ts +68 -0
  153. package/src/logger/renderTable.ts +111 -0
  154. package/src/logger/types.ts +212 -0
  155. package/src/macros/config.macro.ts +50 -0
  156. package/src/metrics/HttpMetrics.ts +114 -0
  157. package/src/metrics/index.ts +18 -0
  158. package/src/middleware/BaseMiddleware.ts +72 -0
  159. package/src/middleware/CorsMiddleware.ts +152 -0
  160. package/src/middleware/RateLimiter.ts +255 -0
  161. package/src/middleware/SecureHeadersMiddleware.ts +127 -0
  162. package/src/middleware/ThrottleMiddleware.ts +252 -0
  163. package/src/middleware/WebhookMiddleware.ts +204 -0
  164. package/src/pipeline/ContextRegistry.ts +42 -0
  165. package/src/pipeline/HttpContext.ts +865 -0
  166. package/src/pipeline/Pipeline.ts +150 -0
  167. package/src/pipeline/currentPage.ts +46 -0
  168. package/src/pipeline/types.ts +80 -0
  169. package/src/provider/LockProvider.ts +64 -0
  170. package/src/provider/LogProvider.ts +137 -0
  171. package/src/provider/ServiceProvider.ts +84 -0
  172. package/src/provider/StorageProvider.ts +45 -0
  173. package/src/router/FileRouter.ts +526 -0
  174. package/src/router/Route.ts +76 -0
  175. package/src/router/RouteHandler.ts +335 -0
  176. package/src/router/Router.ts +1247 -0
  177. package/src/router/domain.ts +65 -0
  178. package/src/security/index.ts +22 -0
  179. package/src/storage/FakeDisk.ts +233 -0
  180. package/src/storage/StorageFilesMiddleware.ts +150 -0
  181. package/src/storage/StorageManager.ts +173 -0
  182. package/src/storage/config.ts +47 -0
  183. package/src/storage/drivers/LocalDriver.ts +138 -0
  184. package/src/storage/drivers/S3Driver.ts +169 -0
  185. package/src/storage/errors.ts +135 -0
  186. package/src/storage/facades/Storage.ts +3 -0
  187. package/src/storage/global.d.ts +7 -0
  188. package/src/storage/index.ts +22 -0
  189. package/src/storage/root.ts +59 -0
  190. package/src/storage/types.ts +104 -0
  191. package/src/support/appKey.ts +38 -0
  192. package/src/support/cookie.ts +72 -0
  193. package/src/support/crypto.ts +52 -0
  194. package/src/support/deepMerge.ts +117 -0
  195. package/src/support/env.ts +71 -0
  196. package/src/support/network.ts +79 -0
  197. package/src/support/port.ts +197 -0
  198. package/src/support/str.ts +122 -0
  199. package/src/view/FileRouteResolver.ts +59 -0
  200. package/src/view/index.ts +144 -0
  201. package/src/view/jsx-runtime.ts +233 -0
@@ -0,0 +1,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
+ }