@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,74 @@
1
+ import type { LogChannel, LogEntry } from "../types.ts";
2
+ import type { StorageDriver } from "../../storage/types.ts";
3
+ import { LocalDriver } from "../../storage/drivers/LocalDriver.ts";
4
+ import { readdir, stat, unlink } from "node:fs/promises";
5
+
6
+ /**
7
+ * Appends entries as JSON lines to a date-rotated file, one file per day named
8
+ * `YYYY-MM-DD.log` (derived from the entry's timestamp) inside a directory.
9
+ *
10
+ * Writes go through a {@link StorageDriver}, so the log trail uses the same file
11
+ * API as uploads and the media library rather than a second, private way of
12
+ * putting bytes on disk — and inherits its path-traversal guard for free.
13
+ *
14
+ * When a retention window (`days`) is given, files whose modification time is
15
+ * older than the cutoff are pruned — checked at most once every 24 hours, in
16
+ * the background, and only on write. The directory is created on demand.
17
+ *
18
+ * @category Channels
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * // config/logging.ts — keep 14 days of daily files under ./storage/logs
23
+ * file: { path: "./storage/logs", days: 14 },
24
+ * ```
25
+ */
26
+ export class DailyChannel implements LogChannel {
27
+ private _lastPruned = 0;
28
+ private readonly _disk: StorageDriver;
29
+
30
+ /**
31
+ * @param _dir - Directory that holds the per-day log files.
32
+ * @param _days - Retention window in days; older files are pruned. Omit to keep files forever.
33
+ * @param disk - Driver to write through. Defaults to a {@link LocalDriver} rooted at `_dir`.
34
+ * Pass one to send the trail somewhere else — it must support `append`, which
35
+ * rules out object stores.
36
+ */
37
+ constructor(
38
+ private readonly _dir: string,
39
+ private readonly _days?: number,
40
+ disk?: StorageDriver,
41
+ ) {
42
+ this._disk = disk ?? new LocalDriver(_dir);
43
+ }
44
+
45
+ async write(entry: LogEntry): Promise<void> {
46
+ const date = entry.timestamp.slice(0, 10);
47
+ await this._disk.append(`${date}.log`, JSON.stringify(entry) + "\n");
48
+
49
+ if (this._days !== undefined) {
50
+ const now = Date.now();
51
+ if (now - this._lastPruned > 86_400_000) {
52
+ this._lastPruned = now;
53
+ void this._prune(now).catch(() => {});
54
+ }
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Delete day-files older than the retention window.
60
+ *
61
+ * Listing and stat'ing stay on `node:fs`: the driver contract has no
62
+ * directory listing, and inventing one for a log pruner would widen it for
63
+ * every backend to serve a single caller.
64
+ */
65
+ private async _prune(now: number): Promise<void> {
66
+ const cutoff = now - this._days! * 86_400_000;
67
+ const files = await readdir(this._dir);
68
+ for (const file of files) {
69
+ if (!/^\d{4}-\d{2}-\d{2}\.log$/.test(file)) continue;
70
+ const s = await stat(`${this._dir}/${file}`);
71
+ if (s.mtimeMs < cutoff) await unlink(`${this._dir}/${file}`);
72
+ }
73
+ }
74
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * A no-op {@link LogChannel} that discards every entry.
3
+ *
4
+ * Useful for silencing logging in tests, or as a `default` channel when you
5
+ * want logging calls to be harmless no-ops.
6
+ *
7
+ * @category Channels
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * // config/logging.ts
12
+ * channels: { null: { driver: "null" } }
13
+ * ```
14
+ */
15
+ export class NullChannel {
16
+ async write(): Promise<void> {}
17
+ }
@@ -0,0 +1,34 @@
1
+ import type { LogChannel, LogEntry } from "../types.ts";
2
+ import { appendFile, mkdir } from "node:fs/promises";
3
+
4
+ /**
5
+ * Appends every entry as a JSON line to a single, fixed log file.
6
+ *
7
+ * Parent directories are created on demand. Write failures are swallowed so a
8
+ * logging error never propagates into application code.
9
+ *
10
+ * @category Channels
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * // config/logging.ts
15
+ * channels: {
16
+ * single: { driver: "single", path: "./storage/logs/app.log", level: "info" },
17
+ * }
18
+ * ```
19
+ */
20
+ export class SingleChannel implements LogChannel {
21
+ /** @param _file - Path to the log file to append to (config key `path`). */
22
+ constructor(private readonly _file: string) {}
23
+
24
+ async write(entry: LogEntry): Promise<void> {
25
+ try {
26
+ const lastSlash = Math.max(this._file.lastIndexOf("/"), this._file.lastIndexOf("\\"));
27
+ const dir = lastSlash > 0 ? this._file.slice(0, lastSlash) : ".";
28
+ await mkdir(dir, { recursive: true });
29
+ await appendFile(this._file, JSON.stringify(entry) + "\n");
30
+ } catch {
31
+ return;
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,29 @@
1
+ import type { LogChannel, LogEntry } from "../types.ts";
2
+
3
+ /**
4
+ * Fans one entry out to several child channels at once — e.g. print to the
5
+ * console *and* persist to a daily file from a single log call.
6
+ *
7
+ * Writes are dispatched with `Promise.allSettled`, so one failing child channel
8
+ * never stops the others from receiving the entry.
9
+ *
10
+ * @category Channels
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * // config/logging.ts — "stack" references other channels by name
15
+ * channels: {
16
+ * stack: { driver: "stack", channels: ["console", "daily"] },
17
+ * console: { driver: "console", format: "pretty" },
18
+ * daily: { driver: "daily", path: "./storage/logs", days: 7 },
19
+ * }
20
+ * ```
21
+ */
22
+ export class StackChannel implements LogChannel {
23
+ /** @param _channels - The resolved child channels to broadcast each entry to. */
24
+ constructor(private readonly _channels: LogChannel[]) {}
25
+
26
+ async write(entry: LogEntry): Promise<void> {
27
+ await Promise.allSettled(this._channels.map((ch) => ch.write(entry)));
28
+ }
29
+ }
@@ -0,0 +1,90 @@
1
+ import { deepMerge } from "../support/deepMerge.ts";
2
+ import type {
3
+ LoggingConfigShape,
4
+ ChannelConfig,
5
+ ConsoleSinkConfig,
6
+ FileSinkConfig,
7
+ } from "./types.ts";
8
+
9
+ export type { LoggingConfigShape, ChannelConfig, ConsoleSinkConfig, FileSinkConfig };
10
+
11
+ /** Where the always-on file trail writes, and how long a day's file survives. */
12
+ export const DEFAULT_LOG_PATH = "./storage/logs";
13
+ export const DEFAULT_LOG_RETENTION_DAYS = 14;
14
+
15
+ /**
16
+ * Whether the file trail is on by default.
17
+ *
18
+ * Off under test: the path is relative to the working directory, so a suite
19
+ * that boots an app would otherwise grow a `storage/logs` directory wherever it
20
+ * happened to run — inside a package, inside a fixture, inside CI's checkout.
21
+ * A test that wants the trail asks for it explicitly.
22
+ */
23
+ function _fileDefault(): FileSinkConfig {
24
+ const env = (Bun.env["APP_ENV"] ?? "").trim().toLowerCase();
25
+ if (env === "test" || env === "testing") return false;
26
+ return { path: DEFAULT_LOG_PATH, days: DEFAULT_LOG_RETENTION_DAYS };
27
+ }
28
+
29
+ const defaults: LoggingConfigShape = {
30
+ console: { format: "pretty" },
31
+ file: { path: DEFAULT_LOG_PATH, days: DEFAULT_LOG_RETENTION_DAYS },
32
+ // `app` deliberately names no channel: the two sinks above are the
33
+ // destination, and `channels` starts empty. Keeping the old
34
+ // `default: "console"` here would make the baseline console print twice.
35
+ default: "app",
36
+ channels: {},
37
+ };
38
+
39
+ /**
40
+ * Build a {@link LoggingConfigShape} with framework defaults applied.
41
+ *
42
+ * Out of the box every entry goes two places: the terminal, and a date-rotated
43
+ * file under `./storage/logs` kept for 14 days. Both are on by default and
44
+ * independent of each other — you can silence the terminal without losing the
45
+ * trail, which is the entire point of having one.
46
+ *
47
+ * Named `channels` are *extra* destinations layered on top, for routing a
48
+ * subsystem somewhere specific. Because `channels` is a name-keyed map,
49
+ * anything you add is merged in rather than replacing the map.
50
+ *
51
+ * @param options - Partial overrides deep-merged over the defaults.
52
+ * @returns The resolved, fully-populated logging config.
53
+ * @category Configuration
54
+ *
55
+ * @example
56
+ * ```ts
57
+ * // config/logging.ts — the defaults, spelled out
58
+ * import { LoggingConfig } from "@zerotal/core/logger";
59
+ *
60
+ * export default LoggingConfig();
61
+ * ```
62
+ *
63
+ * @example
64
+ * ```ts
65
+ * // Quiet terminal, full trail on disk, a month of history
66
+ * export default LoggingConfig({
67
+ * console: { level: "warn" },
68
+ * file: { days: 30 },
69
+ * });
70
+ * ```
71
+ *
72
+ * @example
73
+ * ```ts
74
+ * // No files at all — containers that ship stdout to a collector
75
+ * export default LoggingConfig({ file: false });
76
+ * ```
77
+ */
78
+ export function LoggingConfig(options: Partial<LoggingConfigShape> = {}): LoggingConfigShape {
79
+ const merged = deepMerge({ ...defaults, file: _fileDefault() }, options);
80
+ // `deepMerge` merges an object over `false`, so an explicit switch-off — and
81
+ // an explicit switch-*on* under test — is taken verbatim rather than being
82
+ // overridden by the default.
83
+ if (options.console === false) merged.console = false;
84
+ if (options.file === false) merged.file = false;
85
+ if (options.file !== undefined && options.file !== false) merged.file = options.file;
86
+ return merged;
87
+ }
88
+
89
+ // The `logging` config namespace is registered directly on core's ConfigRegistry
90
+ // (see src/config/registry.ts) since the logger ships as part of core.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * How a log entry's context is written for a human reader.
3
+ *
4
+ * `JSON.stringify` is the right answer for a collector and the wrong one for a
5
+ * terminal: it escapes every backslash, so a Windows path arrives as
6
+ * `"C:\\Projects\\app"`, and it spends braces and quotes on structure the reader
7
+ * can already see. These helpers render the same data as `key=value` pairs with
8
+ * strings left literal.
9
+ */
10
+
11
+ /**
12
+ * A value as the string a person should see.
13
+ *
14
+ * Strings pass through untouched — the whole point, since a path or a URL is
15
+ * already in its readable form. Everything else gets the shortest faithful
16
+ * rendering, falling back to JSON for objects and arrays.
17
+ *
18
+ * @example
19
+ * displayValue("C:\\app"); // "C:\app" (not "C:\\\\app")
20
+ * displayValue({ a: 1 }); // '{"a":1}'
21
+ */
22
+ export function displayValue(value: unknown): string {
23
+ if (value === null) return "null";
24
+ if (value === undefined) return "";
25
+ if (typeof value === "string") return value;
26
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
27
+ return String(value);
28
+ }
29
+ if (value instanceof Date) return value.toISOString();
30
+ if (value instanceof Error) return value.message;
31
+ try {
32
+ return JSON.stringify(value) ?? String(value);
33
+ } catch {
34
+ // Circular, or a toJSON that throws — a readable placeholder beats a crash
35
+ // inside logging.
36
+ return String(value);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Render a context bag as ` key=value` pairs for a terminal line.
42
+ *
43
+ * A value containing whitespace is quoted so the pairs stay separable by eye;
44
+ * nothing else is escaped, which keeps `C:\Program Files\app` legible where a
45
+ * JSON rendering would double every separator. Keys are dimmed when `dim` is
46
+ * supplied, so they recede behind the values that carry the information.
47
+ *
48
+ * @param context Structured context from a {@link LogEntry}.
49
+ * @param dim ANSI dim escape, or "" to render without colour.
50
+ * @param reset ANSI reset escape, paired with `dim`.
51
+ * @returns The rendered pairs, each preceded by a space; "" when there is nothing.
52
+ *
53
+ * @example
54
+ * formatContext({ port: 3000, dir: "C:\\app" }, "", "");
55
+ * // " port=3000 dir=C:\app"
56
+ */
57
+ export function formatContext(
58
+ context: Record<string, unknown>,
59
+ dim = "\x1b[2m",
60
+ reset = "\x1b[0m",
61
+ ): string {
62
+ return formatPairs(context, dim, reset)
63
+ .map((pair) => ` ${pair}`)
64
+ .join("");
65
+ }
66
+
67
+ /**
68
+ * The same pairs as {@link formatContext}, one string each and unprefixed, so a
69
+ * caller can lay them out itself — wrapping them onto continuation lines when
70
+ * they would otherwise run off the terminal.
71
+ *
72
+ * @example
73
+ * formatPairs({ port: 3000, env: "web" }, "", ""); // ["port=3000", "env=web"]
74
+ */
75
+ export function formatPairs(
76
+ context: Record<string, unknown>,
77
+ dim = "\x1b[2m",
78
+ reset = "\x1b[0m",
79
+ ): string[] {
80
+ return Object.entries(context).map(
81
+ ([key, value]) => `${dim}${key}=${reset}${_quoted(displayValue(value))}`,
82
+ );
83
+ }
84
+
85
+ /** Printable width of `text`, ignoring ANSI colour escapes (which take no space). */
86
+ export function visibleWidth(text: string): number {
87
+ // eslint-disable-next-line no-control-regex
88
+ return text.replace(/\x1b\[[0-9;]*m/g, "").length;
89
+ }
90
+
91
+ /** Quote only when whitespace would otherwise run two pairs together. */
92
+ function _quoted(text: string): string {
93
+ if (text === "") return '""';
94
+ if (!/\s/.test(text)) return text;
95
+ return `"${text.replace(/"/g, '\\"')}"`;
96
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The logger framework internals use, so their output goes wherever the app's
3
+ * logging config sends it instead of straight to the terminal.
4
+ *
5
+ * Framework code runs at moments application code never does — during boot,
6
+ * inside a CLI command with no application, in a package used standalone — so
7
+ * it cannot reach for the `Log` facade, which throws when the container has no
8
+ * logger. This resolves the real logger when there is one and formats to the
9
+ * console identically when there is not, so a line looks the same either side
10
+ * of boot.
11
+ */
12
+ import os from "node:os";
13
+ import { tryCurrentApp } from "../application/currentApp.ts";
14
+ import { ConsoleChannel } from "./channels/ConsoleChannel.ts";
15
+ import type { BoundLogger, LogEntry, LogLevel } from "./types.ts";
16
+ import type { LogManager } from "./LogManager.ts";
17
+
18
+ /** Pre-boot output goes through the same renderer, so the format never changes. */
19
+ const _fallbackChannel = new ConsoleChannel("pretty");
20
+
21
+ let _hostname: string | undefined;
22
+
23
+ /**
24
+ * A logger tagged with the subsystem it belongs to.
25
+ *
26
+ * Resolve it per call rather than caching: a module-level logger would capture
27
+ * whichever application existed at import time, which in tests is the previous
28
+ * one and in a CLI is none at all.
29
+ *
30
+ * @param scope - Subsystem name, rendered as `[FLOW]` by the console channel.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * frameworkLog("flow").info("Compiled 4 page(s)", { ms: 76 });
35
+ * ```
36
+ *
37
+ * @internal
38
+ */
39
+ export function frameworkLog(scope: string): BoundLogger {
40
+ const manager = _tryManager();
41
+ if (manager) return manager.scope(scope);
42
+
43
+ const emit =
44
+ (level: LogLevel) =>
45
+ (message: string, context?: Record<string, unknown>, err?: unknown): void => {
46
+ _fallbackChannel.write(_entry(level, scope, message, context, err)).catch(() => {
47
+ // Logging must never throw into the code that called it.
48
+ });
49
+ };
50
+
51
+ return {
52
+ debug: emit("debug"),
53
+ info: emit("info"),
54
+ warn: emit("warn"),
55
+ error: emit("error"),
56
+ fatal: emit("fatal"),
57
+ table: (message, rows, level = "info"): void => {
58
+ const context = Array.isArray(rows) ? { rows } : (rows as Record<string, unknown>);
59
+ const entry = { ..._entry(level, scope, message, context), display: "table" as const };
60
+ _fallbackChannel.write(entry).catch(() => {});
61
+ },
62
+ };
63
+ }
64
+
65
+ /** The container's logger, or undefined when there is no application or no binding. */
66
+ function _tryManager(): LogManager | undefined {
67
+ return tryCurrentApp()?.container.tryMake("log") as LogManager | undefined;
68
+ }
69
+
70
+ function _entry(
71
+ level: LogLevel,
72
+ scope: string,
73
+ message: string,
74
+ context?: Record<string, unknown>,
75
+ err?: unknown,
76
+ ): LogEntry {
77
+ _hostname ??= os.hostname();
78
+ const entry: LogEntry = {
79
+ level,
80
+ channel: "console",
81
+ scope,
82
+ message,
83
+ timestamp: new Date().toISOString(),
84
+ hostname: _hostname,
85
+ pid: process.pid,
86
+ ...(context && Object.keys(context).length > 0 ? { context } : {}),
87
+ };
88
+ if (err !== undefined) {
89
+ entry.error = err instanceof Error ? err.message : String(err);
90
+ if (err instanceof Error && err.stack) entry.stack = err.stack;
91
+ }
92
+ return entry;
93
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Structured, channel-based logging for Zerotal applications.
3
+ *
4
+ * A single {@link LogManager} owns a set of named {@link LogChannel | channels}
5
+ * (console, single file, date-rotated `daily` files, `null`, or a `stack` that
6
+ * fans out to several at once) and routes every {@link LogEntry} to the channel
7
+ * you select. Entries are enriched automatically with hostname, pid, app/env,
8
+ * and the active request id, and each channel has its own minimum
9
+ * {@link LogLevel}. Reach the container-bound manager through the {@link Log}
10
+ * facade; configure it with {@link LoggingConfig}.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { Log } from "@zerotal/core/logger";
15
+ *
16
+ * // Log at any of the five levels on the default channel.
17
+ * Log.info("User signed in", { userId: 42 });
18
+ * Log.error("Payment failed", { orderId: 99 }, err);
19
+ *
20
+ * // Bind shared context, or target a specific channel.
21
+ * Log.withContext({ requestId: "abc" }).warn("Retrying");
22
+ * Log.channel("daily").info("Written to today's file");
23
+ * ```
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * // config/logging.ts — a "stack" that writes to both console and daily files.
28
+ * import { LoggingConfig } from "@zerotal/core/logger";
29
+ *
30
+ * export default LoggingConfig({
31
+ * default: "stack",
32
+ * channels: {
33
+ * stack: { driver: "stack", channels: ["console", "daily"] },
34
+ * console: { driver: "console", format: "pretty" },
35
+ * daily: { driver: "daily", path: "./storage/logs", days: 14, level: "info" },
36
+ * },
37
+ * });
38
+ * ```
39
+ *
40
+ * @packageDocumentation
41
+ */
42
+
43
+ export { Log } from "./Log.ts";
44
+ export { LogManager } from "./LogManager.ts";
45
+ export { frameworkLog } from "./frameworkLog.ts";
46
+ export { LogProvider } from "../provider/LogProvider.ts";
47
+ export { LoggingConfig } from "./config.ts";
48
+
49
+ export { NullChannel } from "./channels/NullChannel.ts";
50
+ export { StackChannel } from "./channels/StackChannel.ts";
51
+ export { ConsoleChannel } from "./channels/ConsoleChannel.ts";
52
+ export { SingleChannel } from "./channels/SingleChannel.ts";
53
+ export { DailyChannel } from "./channels/DailyChannel.ts";
54
+
55
+ export { LoggerMiddleware } from "./LoggerMiddleware.ts";
56
+ export type { LoggerOptions } from "./LoggerMiddleware.ts";
57
+
58
+ export { renderTable } from "./renderTable.ts";
59
+ export type { TableData } from "./renderTable.ts";
60
+
61
+ export type {
62
+ LogLevel,
63
+ LogEntry,
64
+ LogChannel,
65
+ BoundLogger,
66
+ ChannelConfig,
67
+ LoggingConfigShape,
68
+ } from "./types.ts";
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Box-drawn tables for terminal log output.
3
+ *
4
+ * A structured entry is easy to write and hard to read: past three or four keys
5
+ * an inline JSON blob is a wall the eye slides off. The same data in columns is
6
+ * scannable, and lining values up makes an odd one out visible without reading
7
+ * every key.
8
+ */
9
+
10
+ import { displayValue } from "./format.ts";
11
+
12
+ /** Either one object rendered as key/value rows, or a list rendered as columns. */
13
+ export type TableData = Record<string, unknown> | ReadonlyArray<Record<string, unknown>>;
14
+
15
+ const DIM = "\x1b[2m";
16
+ const RESET = "\x1b[0m";
17
+
18
+ /** Widest a single cell may render before it is truncated. */
19
+ const MAX_CELL = 60;
20
+
21
+ /**
22
+ * Render `data` as the lines of a box-drawn table, without a trailing newline.
23
+ *
24
+ * An array of objects becomes a column per key, in the order the first row
25
+ * introduces them, with a header. A single object becomes two columns of
26
+ * key and value. Numeric columns are right-aligned so digits line up.
27
+ *
28
+ * @param data Rows to render.
29
+ * @param indent Spaces before each line, to sit the table under its message.
30
+ * @returns One string per line; empty when there is nothing to show.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * renderTable([{ page: "home", ms: 12 }, { page: "about", ms: 4 }]).join("\n");
35
+ * ```
36
+ */
37
+ export function renderTable(data: TableData, indent = 2): string[] {
38
+ const { headers, rows } = _shape(data);
39
+ if (rows.length === 0) return [];
40
+
41
+ const widths = headers.map((header, column) =>
42
+ Math.max(_width(header), ...rows.map((row) => _width(row[column] ?? ""))),
43
+ );
44
+
45
+ // A column is numeric only if every cell in it is, so a "—" placeholder or a
46
+ // stray label does not silently right-align a column of text.
47
+ const numeric = headers.map((_, column) => rows.every((row) => _isNumeric(row[column] ?? "")));
48
+
49
+ const pad = " ".repeat(indent);
50
+ const rule = (left: string, mid: string, right: string): string =>
51
+ `${pad}${DIM}${left}${widths.map((w) => "─".repeat(w + 2)).join(mid)}${right}${RESET}`;
52
+
53
+ const line = (cells: readonly string[]): string => {
54
+ const body = cells
55
+ .map((cell, column) => ` ${_fit(cell, widths[column]!, numeric[column]!)} `)
56
+ .join(`${DIM}│${RESET}`);
57
+ return `${pad}${DIM}│${RESET}${body}${DIM}│${RESET}`;
58
+ };
59
+
60
+ const out = [rule("┌", "┬", "┐")];
61
+ if (headers.some((header) => header !== "")) {
62
+ out.push(line(headers), rule("├", "┼", "┤"));
63
+ }
64
+ for (const row of rows) out.push(line(headers.map((_, column) => row[column] ?? "")));
65
+ out.push(rule("└", "┴", "┘"));
66
+ return out;
67
+ }
68
+
69
+ // ── Private ──────────────────────────────────────────────────────────────────
70
+
71
+ /** Normalise either input shape into a header row plus string cells. */
72
+ function _shape(data: TableData): { headers: string[]; rows: string[][] } {
73
+ if (Array.isArray(data)) {
74
+ // Union of every row's keys, first-seen order — a row missing a key gets a
75
+ // blank cell rather than shifting the table.
76
+ const headers: string[] = [];
77
+ for (const row of data) {
78
+ for (const key of Object.keys(row)) if (!headers.includes(key)) headers.push(key);
79
+ }
80
+ return {
81
+ headers,
82
+ rows: data.map((row) => headers.map((key) => displayValue(row[key]))),
83
+ };
84
+ }
85
+
86
+ const entries = Object.entries(data);
87
+ // No header on a key/value table: "key | value" is a caption for something the
88
+ // reader can already see.
89
+ return { headers: ["", ""], rows: entries.map(([key, value]) => [key, displayValue(value)]) };
90
+ }
91
+
92
+ function _isNumeric(cell: string): boolean {
93
+ return cell !== "" && !Number.isNaN(Number(cell));
94
+ }
95
+
96
+ /** Visible width, ignoring any colour escapes a caller pre-formatted in. */
97
+ function _width(cell: string): number {
98
+ return Math.min(_strip(cell).length, MAX_CELL);
99
+ }
100
+
101
+ function _strip(cell: string): string {
102
+ // eslint-disable-next-line no-control-regex
103
+ return cell.replace(/\x1b\[[0-9;]*m/g, "");
104
+ }
105
+
106
+ /** Truncate to `width`, then pad to it — right-aligned for numbers. */
107
+ function _fit(cell: string, width: number, right: boolean): string {
108
+ const visible = _strip(cell);
109
+ const text = visible.length > width ? `${visible.slice(0, Math.max(0, width - 1))}…` : visible;
110
+ return right ? text.padStart(width) : text.padEnd(width);
111
+ }