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