@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,430 @@
1
+ import os from "node:os";
2
+ import { RequestContext } from "../context/RequestContext.ts";
3
+ import type {
4
+ LogChannel,
5
+ LogEntry,
6
+ LogLevel,
7
+ LoggingConfigShape,
8
+ ChannelConfig,
9
+ BoundLogger,
10
+ } from "./types.ts";
11
+ import { LEVEL_ORDER } from "./types.ts";
12
+ import type { TableData } from "./renderTable.ts";
13
+ import { ConsoleChannel } from "./channels/ConsoleChannel.ts";
14
+ import { DailyChannel } from "./channels/DailyChannel.ts";
15
+ import { SingleChannel } from "./channels/SingleChannel.ts";
16
+ import { StackChannel } from "./channels/StackChannel.ts";
17
+ import { NullChannel } from "./channels/NullChannel.ts";
18
+ import { DEFAULT_LOG_PATH, DEFAULT_LOG_RETENTION_DAYS } from "./config.ts";
19
+
20
+ function levelNum(l: LogLevel): number {
21
+ return LEVEL_ORDER[l] ?? 1;
22
+ }
23
+
24
+ function configLevel(cfg: ChannelConfig | undefined): LogLevel | undefined {
25
+ return cfg && "level" in cfg ? cfg.level : undefined;
26
+ }
27
+
28
+ /**
29
+ * Which always-on sinks a channel already covers.
30
+ *
31
+ * A channel that prints to the console, or writes to a file, would otherwise
32
+ * double up with the baseline sink doing the same thing — so for entries routed
33
+ * through it, the baseline it covers stands down.
34
+ */
35
+ interface _Covers {
36
+ console: boolean;
37
+ file: boolean;
38
+ }
39
+
40
+ interface _Resolved {
41
+ impl: LogChannel;
42
+ minLevel: LogLevel;
43
+ covers: _Covers;
44
+ }
45
+
46
+ /** One always-on destination and the level it accepts from. */
47
+ interface _Sink {
48
+ impl: LogChannel;
49
+ minLevel: LogLevel;
50
+ }
51
+ interface _Enrich {
52
+ app?: string | undefined;
53
+ env?: string | undefined;
54
+ hostname: string;
55
+ pid: number;
56
+ }
57
+
58
+ /**
59
+ * The engine behind Zerotal's logging: builds a {@link LogChannel} for every
60
+ * entry in {@link LoggingConfigShape.channels}, enriches each
61
+ * {@link LogEntry} (timestamp, hostname, pid, app/env, request id), filters by
62
+ * each channel's minimum {@link LogLevel}, and dispatches the write.
63
+ *
64
+ * Implements {@link BoundLogger}, so the five level methods log to the default
65
+ * channel; use {@link LogManager.channel | channel} to target another and
66
+ * {@link LogManager.withContext | withContext} for contextual logging. Normally
67
+ * resolved via the {@link Log} facade rather than constructed directly.
68
+ *
69
+ * Channel `write` failures are swallowed (reported to stderr) so logging can
70
+ * never throw into application code.
71
+ *
72
+ * @category Logging
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * const log = new LogManager({
77
+ * default: "console",
78
+ * channels: { console: { driver: "console", format: "pretty" } },
79
+ * });
80
+ * log.info("Server started", { port: 3000 });
81
+ * log.channel("console").error("Boom", {}, new Error("kaboom"));
82
+ * ```
83
+ */
84
+ export class LogManager implements BoundLogger {
85
+ private readonly _resolved: Map<string, _Resolved> = new Map();
86
+ private readonly _default: string;
87
+ private readonly _enrich: _Enrich;
88
+ /** The terminal, unless `logging.console` is `false`. */
89
+ private _console: _Sink | null = null;
90
+ /** The durable file trail, unless `logging.file` is `false`. */
91
+ private _file: _Sink | null = null;
92
+
93
+ /**
94
+ * @param config - Channel map and default-channel selection.
95
+ * @param enrich - Overrides for the enrichment fields (`app`, `env`,
96
+ * `hostname`, `pid`); `hostname` and `pid` default to the current host/process.
97
+ * @param override - When supplied, replaces the default channel's
98
+ * implementation with this {@link LogChannel} and skips building the rest —
99
+ * primarily used in tests.
100
+ * @throws {Error} If a `stack` channel references an unknown channel name, or
101
+ * a channel declares an unknown `driver`.
102
+ */
103
+ constructor(config: LoggingConfigShape, enrich?: Partial<_Enrich>, override?: LogChannel) {
104
+ this._default = config.default;
105
+ this._enrich = {
106
+ app: enrich?.app,
107
+ env: enrich?.env,
108
+ hostname: enrich?.hostname ?? os.hostname(),
109
+ pid: enrich?.pid ?? process.pid,
110
+ };
111
+
112
+ if (override) {
113
+ // A test substituting one channel wants exactly that channel, not the
114
+ // terminal and a directory of files alongside it.
115
+ this._resolved.set(config.default, {
116
+ impl: override,
117
+ minLevel: configLevel(config.channels[config.default]) ?? "debug",
118
+ covers: { console: true, file: true },
119
+ });
120
+ return;
121
+ }
122
+
123
+ // Absent means off, not "on with defaults": the defaults live in
124
+ // `LoggingConfig()`, which is what an application's config goes through. A
125
+ // hand-built config — a test, a bespoke embedding — gets exactly the sinks it
126
+ // asked for and never starts writing files somewhere it was not told to.
127
+ if (config.console) {
128
+ this._console = {
129
+ impl: new ConsoleChannel(config.console.format ?? "pretty"),
130
+ minLevel: config.console.level ?? "debug",
131
+ };
132
+ }
133
+
134
+ if (config.file) {
135
+ this._file = {
136
+ impl: new DailyChannel(
137
+ config.file.path ?? DEFAULT_LOG_PATH,
138
+ config.file.days ?? DEFAULT_LOG_RETENTION_DAYS,
139
+ ),
140
+ minLevel: config.file.level ?? "debug",
141
+ };
142
+ }
143
+
144
+ for (const [name, cfg] of Object.entries(config.channels)) {
145
+ this._resolved.set(name, {
146
+ impl: this._make(name, cfg, config.channels),
147
+ minLevel: configLevel(cfg) ?? "debug",
148
+ covers: this._coverage(cfg, config.channels),
149
+ });
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Which baseline sinks `cfg` already writes to, walking a `stack` into its
155
+ * members so a stack containing a console channel still suppresses the
156
+ * baseline console.
157
+ */
158
+ private _coverage(
159
+ cfg: ChannelConfig,
160
+ all: Record<string, ChannelConfig>,
161
+ seen = new Set<string>(),
162
+ ): _Covers {
163
+ switch (cfg.driver) {
164
+ case "console":
165
+ return { console: true, file: false };
166
+ case "single":
167
+ case "daily":
168
+ return { console: false, file: true };
169
+ case "stack": {
170
+ const covers: _Covers = { console: false, file: false };
171
+ for (const name of cfg.channels) {
172
+ // A stack cannot cover a member twice, and a cycle must not hang the
173
+ // constructor — `_make` rejects unknown names, so absence is enough here.
174
+ if (seen.has(name)) continue;
175
+ seen.add(name);
176
+ const child = all[name];
177
+ if (!child) continue;
178
+ const childCovers = this._coverage(child, all, seen);
179
+ covers.console ||= childCovers.console;
180
+ covers.file ||= childCovers.file;
181
+ }
182
+ return covers;
183
+ }
184
+ default:
185
+ return { console: false, file: false };
186
+ }
187
+ }
188
+
189
+ /** Log a `debug`-level message to the default channel. @category Logging */
190
+ debug(msg: string, ctx?: Record<string, unknown>, err?: unknown): void {
191
+ this._emit(this._default, "debug", msg, ctx, err);
192
+ }
193
+ /** Log an `info`-level message to the default channel. @category Logging */
194
+ info(msg: string, ctx?: Record<string, unknown>, err?: unknown): void {
195
+ this._emit(this._default, "info", msg, ctx, err);
196
+ }
197
+ /** Log a `warn`-level message to the default channel. @category Logging */
198
+ warn(msg: string, ctx?: Record<string, unknown>, err?: unknown): void {
199
+ this._emit(this._default, "warn", msg, ctx, err);
200
+ }
201
+ /** Log an `error`-level message to the default channel. @category Logging */
202
+ error(msg: string, ctx?: Record<string, unknown>, err?: unknown): void {
203
+ this._emit(this._default, "error", msg, ctx, err);
204
+ }
205
+ /** Log a `fatal`-level message to the default channel. @category Logging */
206
+ fatal(msg: string, ctx?: Record<string, unknown>, err?: unknown): void {
207
+ this._emit(this._default, "fatal", msg, ctx, err);
208
+ }
209
+
210
+ /**
211
+ * Log `msg` with `rows` attached, rendered as a box-drawn table by the console
212
+ * channel. Past three or four keys an inline JSON blob stops being readable;
213
+ * the same data in columns can be scanned.
214
+ *
215
+ * The rows are ordinary context, so file and JSON channels record what any
216
+ * other level method would have recorded — only the presentation differs.
217
+ *
218
+ * @param rows One object (rendered as key/value rows) or a list of objects
219
+ * (rendered as a column per key, with a header).
220
+ * @param level Severity to emit at. Defaults to `info`.
221
+ * @category Logging
222
+ *
223
+ * @example
224
+ * ```ts
225
+ * Log.table("Compile summary", { compiled: 4, cached: 2, runtime: 8 });
226
+ * Log.table("Slow routes", [
227
+ * { route: "/posts", ms: 812 },
228
+ * { route: "/search", ms: 1204 },
229
+ * ], "warn");
230
+ * ```
231
+ */
232
+ table(msg: string, rows: TableData, level: LogLevel = "info"): void {
233
+ this._emitTable(this._default, level, msg, rows, undefined);
234
+ }
235
+
236
+ /**
237
+ * Return a {@link BoundLogger} that writes to the named channel instead of the
238
+ * default. Unknown channel names are silently ignored (nothing is emitted).
239
+ *
240
+ * @param name - Name of a channel from the config.
241
+ * @category Channels
242
+ *
243
+ * @example
244
+ * ```ts
245
+ * log.channel("daily").info("Written to today's rotating file");
246
+ * ```
247
+ */
248
+ channel(name: string): BoundLogger {
249
+ return this._bound(name, undefined);
250
+ }
251
+
252
+ /**
253
+ * Return a {@link BoundLogger} on the default channel that merges `extra` into
254
+ * the context of every entry it emits. Per-call context keys override the
255
+ * shared ones.
256
+ *
257
+ * @param extra - Context fields shared across all subsequent log calls.
258
+ * @category Context
259
+ *
260
+ * @example
261
+ * ```ts
262
+ * const scoped = log.withContext({ orderId: 99 });
263
+ * scoped.info("Charged"); // context: { orderId: 99 }
264
+ * scoped.warn("Retry", { attempt: 2 }); // context: { orderId: 99, attempt: 2 }
265
+ * ```
266
+ */
267
+ withContext(extra: Record<string, unknown>): BoundLogger {
268
+ return this._bound(this._default, extra);
269
+ }
270
+
271
+ /**
272
+ * Return a {@link BoundLogger} that tags every entry with the subsystem it
273
+ * came from. The console channel renders the tag as `[FLOW]`; file and JSON
274
+ * channels keep it as a `scope` field, so a log file can be filtered by source.
275
+ *
276
+ * @param name - Subsystem name, e.g. `"flow"`, `"queue"`.
277
+ * @category Context
278
+ *
279
+ * @example
280
+ * ```ts
281
+ * const log = Log.scope("queue");
282
+ * log.info("Worker started", { queues: ["default"] });
283
+ * // 01:08:35.224 INFO [QUEUE] Worker started {"queues":["default"]}
284
+ * ```
285
+ */
286
+ scope(name: string): BoundLogger {
287
+ return this._bound(this._default, undefined, name);
288
+ }
289
+
290
+ private _bound(
291
+ ch: string,
292
+ extra: Record<string, unknown> | undefined,
293
+ scope?: string,
294
+ ): BoundLogger {
295
+ const merge = (ctx?: Record<string, unknown>): Record<string, unknown> | undefined =>
296
+ extra ? { ...extra, ...ctx } : ctx;
297
+ return {
298
+ debug: (msg, ctx, err) => this._emit(ch, "debug", msg, merge(ctx), err, scope),
299
+ info: (msg, ctx, err) => this._emit(ch, "info", msg, merge(ctx), err, scope),
300
+ warn: (msg, ctx, err) => this._emit(ch, "warn", msg, merge(ctx), err, scope),
301
+ error: (msg, ctx, err) => this._emit(ch, "error", msg, merge(ctx), err, scope),
302
+ fatal: (msg, ctx, err) => this._emit(ch, "fatal", msg, merge(ctx), err, scope),
303
+ table: (msg, rows, level = "info") => this._emitTable(ch, level, msg, rows, scope, extra),
304
+ };
305
+ }
306
+
307
+ /**
308
+ * Attach `rows` as context and mark the entry for table rendering. A list of
309
+ * rows is nested under `rows` so the entry's context stays an object, which is
310
+ * what every channel and collector expects.
311
+ */
312
+ private _emitTable(
313
+ ch: string,
314
+ level: LogLevel,
315
+ msg: string,
316
+ rows: TableData,
317
+ scope?: string,
318
+ extra?: Record<string, unknown>,
319
+ ): void {
320
+ const data = Array.isArray(rows) ? { rows } : (rows as Record<string, unknown>);
321
+ this._emit(ch, level, msg, { ...extra, ...data }, undefined, scope, "table");
322
+ }
323
+
324
+ private _emit(
325
+ ch: string,
326
+ level: LogLevel,
327
+ msg: string,
328
+ ctx?: Record<string, unknown>,
329
+ err?: unknown,
330
+ scope?: string,
331
+ display?: "table",
332
+ ): void {
333
+ const resolved = this._resolved.get(ch);
334
+
335
+ // Which destinations want this entry. The routed channel is one of them, not
336
+ // the gatekeeper: a channel filtered to `warn` must not also hide the entry
337
+ // from the file trail or from monitor.
338
+ const toChannel = resolved !== undefined && levelNum(level) >= levelNum(resolved.minLevel);
339
+ const toConsole =
340
+ this._console !== null &&
341
+ !resolved?.covers.console &&
342
+ levelNum(level) >= levelNum(this._console.minLevel);
343
+ const toFile =
344
+ this._file !== null &&
345
+ !resolved?.covers.file &&
346
+ levelNum(level) >= levelNum(this._file.minLevel);
347
+
348
+ if (!toChannel && !toConsole && !toFile && LogManager._taps.length === 0) return;
349
+
350
+ const requestId = RequestContext.tryGet()?.requestId;
351
+
352
+ const entry: LogEntry = {
353
+ level,
354
+ channel: ch,
355
+ message: msg,
356
+ timestamp: new Date().toISOString(),
357
+ ...this._enrich,
358
+ ...(scope ? { scope } : {}),
359
+ ...(display ? { display } : {}),
360
+ ...(requestId ? { requestId } : {}),
361
+ ...(ctx && Object.keys(ctx).length > 0 ? { context: ctx } : {}),
362
+ };
363
+
364
+ if (err !== undefined) {
365
+ if (err instanceof Error) {
366
+ entry.error = err.message;
367
+ if (err.stack) entry.stack = err.stack;
368
+ } else {
369
+ entry.error = String(err);
370
+ }
371
+ }
372
+
373
+ for (const tap of LogManager._taps) {
374
+ try {
375
+ tap(entry);
376
+ } catch {
377
+ /* a tap must never break logging */
378
+ }
379
+ }
380
+
381
+ if (toConsole) this._dispatch(this._console!.impl, entry);
382
+ if (toFile) this._dispatch(this._file!.impl, entry);
383
+ if (toChannel) this._dispatch(resolved!.impl, entry);
384
+ }
385
+
386
+ /** Write to one destination, never letting its failure reach the caller. */
387
+ private _dispatch(channel: LogChannel, entry: LogEntry): void {
388
+ channel.write(entry).catch((e: unknown) => {
389
+ process.stderr.write(`[Zerotal/Log] channel write failed: ${String(e)}\n`);
390
+ });
391
+ }
392
+
393
+ private static _taps: Array<(entry: LogEntry) => void> = [];
394
+
395
+ /**
396
+ * Register a sink invoked for every log entry, after enrichment and before the
397
+ * channel write. Used by `@zerotal/monitor` to surface logs in the panel.
398
+ * Returns an unsubscribe function.
399
+ */
400
+ static tap(fn: (entry: LogEntry) => void): () => void {
401
+ LogManager._taps.push(fn);
402
+ return () => {
403
+ const i = LogManager._taps.indexOf(fn);
404
+ if (i >= 0) LogManager._taps.splice(i, 1);
405
+ };
406
+ }
407
+
408
+ private _make(name: string, cfg: ChannelConfig, all: Record<string, ChannelConfig>): LogChannel {
409
+ switch (cfg.driver) {
410
+ case "console":
411
+ return new ConsoleChannel(cfg.format);
412
+ case "single":
413
+ return new SingleChannel(cfg.path);
414
+ case "daily":
415
+ return new DailyChannel(cfg.path, cfg.days);
416
+ case "stack":
417
+ return new StackChannel(
418
+ cfg.channels.map((c) => {
419
+ const child = all[c];
420
+ if (!child) throw new Error(`Log stack references unknown channel "${c}"`);
421
+ return this._make(c, child, all);
422
+ }),
423
+ );
424
+ case "null":
425
+ return new NullChannel();
426
+ default:
427
+ throw new Error(`Unknown log driver: ${(cfg as any).driver} on channel "${name}"`);
428
+ }
429
+ }
430
+ }
@@ -0,0 +1,125 @@
1
+ import type { NextFn } from "../pipeline/types.ts";
2
+ import type { HttpContext } from "../pipeline/HttpContext.ts";
3
+ import { BaseMiddleware } from "../middleware/BaseMiddleware.ts";
4
+ import type { LogManager } from "./LogManager.ts";
5
+
6
+ // ANSI escape codes
7
+ const R = "\x1b[0m";
8
+ const DIM = "\x1b[2m";
9
+ const BOLD = "\x1b[1m";
10
+ const CYAN = "\x1b[36m";
11
+ const GREEN = "\x1b[32m";
12
+ const YELLOW = "\x1b[33m";
13
+ const RED = "\x1b[31m";
14
+ const BLUE = "\x1b[34m";
15
+ const MAGENTA = "\x1b[35m";
16
+ const WHITE = "\x1b[97m";
17
+
18
+ const METHOD_COLOR: Record<string, string> = {
19
+ GET: BLUE,
20
+ POST: GREEN,
21
+ PUT: YELLOW,
22
+ PATCH: MAGENTA,
23
+ DELETE: RED,
24
+ HEAD: DIM,
25
+ OPTIONS: DIM,
26
+ };
27
+
28
+ function statusColor(s: number): string {
29
+ if (s >= 500) return RED;
30
+ if (s >= 400) return YELLOW;
31
+ if (s >= 300) return CYAN;
32
+ return GREEN;
33
+ }
34
+
35
+ function timeColor(ms: number): string {
36
+ if (ms > 1000) return RED;
37
+ if (ms > 300) return YELLOW;
38
+ return DIM;
39
+ }
40
+
41
+ const LINE_WIDTH = 60;
42
+
43
+ export interface LoggerOptions {
44
+ /** Output format. Defaults to LOG_FORMAT env var, then 'text'. */
45
+ format?: "text" | "json";
46
+ }
47
+
48
+ /**
49
+ * HTTP request logger — logs every request with timing and status.
50
+ * Lives in @zerotal/core/logger so it routes output through the configured LogManager
51
+ * channels (file, daily, etc.) rather than always writing to process.stdout.
52
+ *
53
+ * LogProvider automatically registers this middleware via useOnce() and wires
54
+ * the LogManager via setManager() — no manual setup needed.
55
+ *
56
+ * @category Logging
57
+ */
58
+ export class LoggerMiddleware extends BaseMiddleware<LoggerOptions> {
59
+ protected options: LoggerOptions = {};
60
+
61
+ private static _manager: LogManager | null = null;
62
+
63
+ /** Called by LogProvider.onBooting() to wire the configured LogManager. */
64
+ static setManager(mgr: LogManager): void {
65
+ LoggerMiddleware._manager = mgr;
66
+ }
67
+
68
+ async handle(http: HttpContext, next: NextFn): Promise<Response | void> {
69
+ const start = performance.now();
70
+
71
+ await next();
72
+
73
+ const ms = Math.round(performance.now() - start);
74
+ const status = http.response?.status ?? 0;
75
+ const method = http.request.method.toUpperCase();
76
+ const path = http.url.pathname + (http.url.search || "");
77
+
78
+ if (http.response) {
79
+ http.response.headers.set("X-Request-Id", http.requestId);
80
+ }
81
+
82
+ const format =
83
+ this.options.format ?? (Bun.env["LOG_FORMAT"] as "text" | "json" | undefined) ?? "text";
84
+
85
+ const level = status >= 500 ? "error" : status >= 400 ? "warn" : "info";
86
+ const context = {
87
+ method,
88
+ path,
89
+ status,
90
+ duration_ms: ms,
91
+ request_id: http.requestId,
92
+ };
93
+
94
+ if (LoggerMiddleware._manager) {
95
+ // Route through the configured logger channels (file, daily, etc.) regardless
96
+ // of format — the channels own their own presentation.
97
+ LoggerMiddleware._manager[level](`${method} ${path}`, context);
98
+ } else if (format === "json") {
99
+ // Fallback when no LogManager is wired (e.g. tests without LogProvider)
100
+ process.stdout.write(
101
+ JSON.stringify({
102
+ timestamp: new Date().toISOString(),
103
+ level,
104
+ ...context,
105
+ }) + "\n",
106
+ );
107
+ } else {
108
+ const mColor = METHOD_COLOR[method] ?? DIM;
109
+ const sColor = statusColor(status);
110
+ const tColor = timeColor(ms);
111
+
112
+ const mStr = `${BOLD}${mColor}${method.padEnd(7)}${R}`;
113
+ const dots =
114
+ path.length < LINE_WIDTH ? " " + ".".repeat(LINE_WIDTH - path.length - 1) + " " : " ";
115
+ const pStr = `${WHITE}${path}${R}${DIM}${dots}${R}`;
116
+ const sStr = `${BOLD}${sColor}${status}${R}`;
117
+ const tRaw = `${ms}ms`;
118
+ const tStr = `${tColor}${tRaw.padStart(6)}${R}`;
119
+
120
+ process.stdout.write(` ${mStr} ${pStr}${sStr} ${tStr}\n`);
121
+ }
122
+
123
+ return http.response;
124
+ }
125
+ }
@@ -0,0 +1,139 @@
1
+ import type { LogChannel, LogEntry } from "../types.ts";
2
+ import { renderTable } from "../renderTable.ts";
3
+ import { formatPairs, visibleWidth } from "../format.ts";
4
+
5
+ const LEVEL_COLOR: Record<string, string> = {
6
+ debug: "\x1b[2m",
7
+ info: "\x1b[32m",
8
+ warn: "\x1b[33m",
9
+ error: "\x1b[31m",
10
+ fatal: "\x1b[95m",
11
+ };
12
+
13
+ const RESET = "\x1b[0m";
14
+
15
+ /** Visible width reserved for the `[SCOPE]` tag column. */
16
+ const SCOPE_WIDTH = 10;
17
+ /** Characters the dim/reset escapes add to the tag without taking any width. */
18
+ const DIM_OVERHEAD = "\x1b[2m".length + RESET.length;
19
+ /** Assumed page width when stdout is a pipe or a file rather than a terminal. */
20
+ const DEFAULT_WIDTH = 120;
21
+ /** Floor for a wrapped context line, so a narrow terminal still gets whole pairs. */
22
+ const MIN_WRAP_WIDTH = 20;
23
+
24
+ /**
25
+ * Writes log entries to `process.stdout`.
26
+ *
27
+ * In `"pretty"` format (the default) it emits a colorized, human-readable line
28
+ * — dimmed time, level-colored level, message, an 8-char request-id snippet,
29
+ * inline context JSON, and any error/stack. In `"json"` format it writes the
30
+ * full {@link LogEntry} as one JSON line, suitable for log collectors.
31
+ *
32
+ * @category Channels
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * // config/logging.ts
37
+ * channels: {
38
+ * console: { driver: "console", format: "json" }, // or "pretty"
39
+ * }
40
+ * ```
41
+ */
42
+ export class ConsoleChannel implements LogChannel {
43
+ /** @param _format - `"pretty"` for colorized output (default) or `"json"` for one JSON line per entry. */
44
+ constructor(private readonly _format: "json" | "pretty" = "pretty") {}
45
+
46
+ async write(entry: LogEntry): Promise<void> {
47
+ if (this._format === "json") {
48
+ process.stdout.write(JSON.stringify(entry) + "\n");
49
+ return;
50
+ }
51
+
52
+ const color = LEVEL_COLOR[entry.level] ?? "";
53
+ const time = entry.timestamp.slice(11, 23);
54
+ const level = entry.level.toUpperCase().padEnd(5);
55
+ // Padded so messages line up down the column whether or not an entry is
56
+ // scoped — the tag is what makes a boot log readable at a glance.
57
+ const scope = entry.scope
58
+ ? `\x1b[2m[${entry.scope.toUpperCase()}]${RESET}`.padEnd(SCOPE_WIDTH + DIM_OVERHEAD)
59
+ : " ".repeat(SCOPE_WIDTH);
60
+
61
+ // The gutter every continuation line is indented to, so wrapped context sits
62
+ // under the message rather than under the timestamp. Measured rather than
63
+ // hard-coded, so it follows the columns above if they ever change.
64
+ const prefix = `\x1b[2m${time}${RESET} ${color}${level}${RESET} ${scope} `;
65
+ let line = `${prefix}${entry.message}`;
66
+
67
+ if (entry.requestId !== undefined) {
68
+ line += ` \x1b[2m[${entry.requestId.slice(0, 8)}]${RESET}`;
69
+ }
70
+ if (entry.context !== undefined && Object.keys(entry.context).length > 0) {
71
+ if (entry.display === "table") {
72
+ const { rows } = entry.context as { rows?: unknown };
73
+ const data = Array.isArray(rows) ? (rows as Record<string, unknown>[]) : entry.context;
74
+ const table = renderTable(data);
75
+ if (table.length > 0) line += `\n${table.join("\n")}`;
76
+ } else {
77
+ line += this._context(entry.context, line, visibleWidth(prefix));
78
+ }
79
+ }
80
+ if (entry.error !== undefined) {
81
+ line += `\n \x1b[31m${entry.error}${RESET}`;
82
+ }
83
+ if (entry.stack !== undefined) {
84
+ line += `\n\x1b[2m${entry.stack}${RESET}`;
85
+ }
86
+
87
+ process.stdout.write(line + "\n");
88
+ }
89
+
90
+ /**
91
+ * Context appended to the message, or folded onto continuation lines when it
92
+ * would run past the terminal's edge.
93
+ *
94
+ * A short bag stays inline, because one event reading as one line is worth
95
+ * keeping. A long one wrapped by the terminal itself breaks mid-pair at a
96
+ * random column and buries the message it belongs to, so it is laid out here
97
+ * instead: indented to the message gutter, marked with a `↳`, and filled
98
+ * greedily so each line carries as many whole pairs as fit.
99
+ *
100
+ * @param head The line so far, whose width decides whether the rest fits.
101
+ * @param gutter Visible width of the timestamp/level/scope columns.
102
+ */
103
+ private _context(context: Record<string, unknown>, head: string, gutter: number): string {
104
+ const pairs = formatPairs(context);
105
+ const inline = pairs.map((pair) => ` ${pair}`).join("");
106
+ const columns = _terminalWidth();
107
+
108
+ if (visibleWidth(head) + visibleWidth(inline) <= columns) return inline;
109
+
110
+ const indent = " ".repeat(gutter);
111
+ // Never let a narrow terminal squeeze the text to nothing; below this the
112
+ // pairs simply go one per line.
113
+ const room = Math.max(columns - gutter - 2, MIN_WRAP_WIDTH);
114
+
115
+ const lines: string[] = [];
116
+ let current = "";
117
+ for (const pair of pairs) {
118
+ const candidate = current === "" ? pair : `${current} ${pair}`;
119
+ if (current !== "" && visibleWidth(candidate) > room) {
120
+ lines.push(current);
121
+ current = pair;
122
+ } else {
123
+ current = candidate;
124
+ }
125
+ }
126
+ if (current !== "") lines.push(current);
127
+
128
+ // Only the first continuation carries the marker; the rest align under it.
129
+ return lines
130
+ .map((text, index) => `\n${indent}${index === 0 ? `\x1b[2m↳${RESET}` : " "} ${text}`)
131
+ .join("");
132
+ }
133
+ }
134
+
135
+ /** Terminal width, or a sensible page width when output is not a terminal. */
136
+ function _terminalWidth(): number {
137
+ const columns = process.stdout.columns;
138
+ return typeof columns === "number" && columns > 0 ? columns : DEFAULT_WIDTH;
139
+ }