@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,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synchronous loader for a `config/` directory: discovers each config file,
|
|
3
|
+
* exposes its values as a namespaced map, and collects optional per-file
|
|
4
|
+
* validators so configuration can be checked before the app boots.
|
|
5
|
+
*/
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
import { frameworkLog } from "../logger/frameworkLog.ts";
|
|
9
|
+
|
|
10
|
+
const requireModule = createRequire(import.meta.url);
|
|
11
|
+
|
|
12
|
+
/** Namespace → config object map (filename without extension is the namespace). */
|
|
13
|
+
export type ConfigMap = Record<string, Record<string, unknown>>;
|
|
14
|
+
type Validator = (value: Record<string, unknown>) => void;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A loaded set of config files, keyed by filename (`config/app.ts` → `app`). Pass `all()` (or the
|
|
18
|
+
* loader itself) to `Application.create({ config })` / `app.useConfig(...)`.
|
|
19
|
+
*/
|
|
20
|
+
export class ConfigLoader {
|
|
21
|
+
constructor(
|
|
22
|
+
private readonly _map: ConfigMap,
|
|
23
|
+
private readonly _validators: ReadonlyArray<{ ns: string; fn: Validator }> = [],
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
/** The full namespace → config map. */
|
|
27
|
+
all(): ConfigMap {
|
|
28
|
+
return this._map;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Dot-path read, e.g. `get("database.url")`.
|
|
33
|
+
*
|
|
34
|
+
* @param key Dot-notation path across the loaded namespaces.
|
|
35
|
+
* @param fallback Value returned when the path is missing.
|
|
36
|
+
* @returns The stored value, or `fallback` when unset.
|
|
37
|
+
*/
|
|
38
|
+
get<T = unknown>(key: string, fallback?: T): T {
|
|
39
|
+
let current: unknown = this._map;
|
|
40
|
+
for (const part of key.split(".")) {
|
|
41
|
+
if (current && typeof current === "object" && part in (current as Record<string, unknown>)) {
|
|
42
|
+
current = (current as Record<string, unknown>)[part];
|
|
43
|
+
} else {
|
|
44
|
+
return fallback as T;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return (current ?? fallback) as T;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Whether a value exists at the given dot-path. */
|
|
51
|
+
has(key: string): boolean {
|
|
52
|
+
return this.get(key, undefined as unknown) !== undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Run each config file's optional `validate(config)` named export against its value, throwing on
|
|
57
|
+
* the first failure. Returns `this` for chaining. (Config files that read env at module load are
|
|
58
|
+
* already validated by the act of loading.)
|
|
59
|
+
*
|
|
60
|
+
* @returns `this`, so the call chains into `Application.create({ config })`.
|
|
61
|
+
* @throws Whatever a config file's `validate(config)` export throws on the first failure.
|
|
62
|
+
*/
|
|
63
|
+
validate(): this {
|
|
64
|
+
for (const { ns: namespace, fn: validate } of this._validators) {
|
|
65
|
+
validate(this._map[namespace] ?? {});
|
|
66
|
+
}
|
|
67
|
+
return this;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Synchronously load every `*.ts` / `*.js` file in `dir` (default `./config`) into a namespace map
|
|
73
|
+
* keyed by filename. Safe to call at the top level of an entry script (`zerotal.ts`) — no `await`.
|
|
74
|
+
*
|
|
75
|
+
* Each file's **default export** is the config object; an optional named **`validate(config)`**
|
|
76
|
+
* export is collected and run by `ConfigLoader.validate()`.
|
|
77
|
+
*
|
|
78
|
+
* @param dir Directory to scan, relative to `process.cwd()`. Defaults to `./config`.
|
|
79
|
+
* @returns A {@link ConfigLoader} over the discovered namespaces. A missing
|
|
80
|
+
* directory yields an empty loader rather than throwing.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```ts
|
|
84
|
+
* const config = configLoader("./config");
|
|
85
|
+
* config.validate();
|
|
86
|
+
* const app = Application.create({ config }); // or app.useConfig(config)
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export function configLoader(dir = "./config"): ConfigLoader {
|
|
90
|
+
const absoluteDirectory = path.resolve(process.cwd(), dir);
|
|
91
|
+
const map: ConfigMap = {};
|
|
92
|
+
const validators: Array<{ ns: string; fn: Validator }> = [];
|
|
93
|
+
try {
|
|
94
|
+
const glob = new Bun.Glob("*.{ts,js}");
|
|
95
|
+
for (const file of glob.scanSync({ cwd: absoluteDirectory, onlyFiles: true })) {
|
|
96
|
+
const namespace = file.replace(/\.(ts|js)$/, "");
|
|
97
|
+
try {
|
|
98
|
+
const loadedModule = requireModule(path.join(absoluteDirectory, file)) as Record<
|
|
99
|
+
string,
|
|
100
|
+
unknown
|
|
101
|
+
>;
|
|
102
|
+
const value = loadedModule["default"] ?? loadedModule;
|
|
103
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
104
|
+
map[namespace] = value as Record<string, unknown>;
|
|
105
|
+
}
|
|
106
|
+
if (typeof loadedModule["validate"] === "function") {
|
|
107
|
+
validators.push({ ns: namespace, fn: loadedModule["validate"] as Validator });
|
|
108
|
+
}
|
|
109
|
+
} catch (error) {
|
|
110
|
+
frameworkLog("config").error(`Config "${file}" failed to load`, { file }, error);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// A missing config/ directory is fine — minimal apps may not have one.
|
|
115
|
+
}
|
|
116
|
+
return new ConfigLoader(map, validators);
|
|
117
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime configuration store: holds the merged config namespaces and
|
|
3
|
+
* serves dot-path reads/writes behind the `Config` facade and `config()` helper.
|
|
4
|
+
*/
|
|
5
|
+
import { ConfigError } from "../errors/ConfigError.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The runtime configuration store — holds every loaded config namespace and
|
|
9
|
+
* serves dot-path reads and writes.
|
|
10
|
+
*
|
|
11
|
+
* Populated from the `config/*.ts` files during `onBooting()` by a
|
|
12
|
+
* ServiceProvider, then bound in the container under `config`. Application code
|
|
13
|
+
* rarely touches this class directly; it reads through the `Config` facade or
|
|
14
|
+
* the `config()` helper, both of which delegate here.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { config } from "@zerotal/core";
|
|
19
|
+
* import { Config } from "@zerotal/core/facades";
|
|
20
|
+
*
|
|
21
|
+
* // Typed dot-path access (via the config() helper):
|
|
22
|
+
* const name = config("app.name"); // string
|
|
23
|
+
* const port = config("app.port"); // number
|
|
24
|
+
*
|
|
25
|
+
* // Or through the facade:
|
|
26
|
+
* const url = Config.get("app.url", "http://localhost:3000");
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export class ConfigManager {
|
|
30
|
+
private _data: Record<string, unknown>;
|
|
31
|
+
constructor() {
|
|
32
|
+
this._data = {};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Loading ───────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Merge a config namespace into the store, replacing any existing value under
|
|
39
|
+
* the same key. Called by ConfigProvider during `onBooting()`.
|
|
40
|
+
*
|
|
41
|
+
* @param namespace Top-level namespace key (the config filename, e.g. `app`).
|
|
42
|
+
* @param values The namespace's config object.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* config.load('app', { name: 'Zerotal', env: 'production' });
|
|
47
|
+
* config.load('database', { url: 'postgres://localhost/mydb' });
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
load(namespace: string, values: Record<string, unknown>): void {
|
|
51
|
+
this._data[namespace] = values;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Access ────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get a config value by dot-notation path, returning `fallback` when the path
|
|
58
|
+
* resolves to `null`/`undefined` or does not exist.
|
|
59
|
+
*
|
|
60
|
+
* @param path Dot-notation path, e.g. `app.name`.
|
|
61
|
+
* @param fallback Value returned when the path is missing.
|
|
62
|
+
* @returns The stored value, or `fallback` when unset.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```ts
|
|
66
|
+
* config.get('app.name') // 'Zerotal'
|
|
67
|
+
* config.get('database.connections.postgres') // { url: '...' }
|
|
68
|
+
* config.get('app.missing', 'default') // 'default'
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
get<T = unknown>(path: string, fallback?: T): T {
|
|
72
|
+
const keys = path.split(".");
|
|
73
|
+
let current: unknown = this._data;
|
|
74
|
+
|
|
75
|
+
for (const key of keys) {
|
|
76
|
+
if (current == null || typeof current !== "object") {
|
|
77
|
+
return (fallback ?? undefined) as T;
|
|
78
|
+
}
|
|
79
|
+
current = (current as Record<string, unknown>)[key];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (current ?? fallback ?? undefined) as T;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get a config value, throwing when it is `null` or `undefined`. Use for
|
|
87
|
+
* required config that the app cannot start without.
|
|
88
|
+
*
|
|
89
|
+
* @param path Dot-notation path, e.g. `app.key`.
|
|
90
|
+
* @returns The stored value (guaranteed present).
|
|
91
|
+
* @throws {ConfigError} When the path resolves to `undefined` or `null`.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```ts
|
|
95
|
+
* config.require('app.key') // throws if APP_KEY is not set
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
require<T = unknown>(path: string): T {
|
|
99
|
+
const value = this.get<T>(path);
|
|
100
|
+
if (value === undefined || value === null) {
|
|
101
|
+
throw new ConfigError(
|
|
102
|
+
`Required config value "${path}" is not set. ` +
|
|
103
|
+
`Check your .env file and config/${path.split(".")[0]}.ts.`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Set a config value at a dot-path at runtime, creating intermediate objects
|
|
111
|
+
* as needed. Intended for tests or dynamic overrides; in production prefer
|
|
112
|
+
* loading config files via {@link ConfigManager.load}.
|
|
113
|
+
*
|
|
114
|
+
* @param path Dot-notation path to write, e.g. `app.debug`.
|
|
115
|
+
* @param value The value to store.
|
|
116
|
+
*/
|
|
117
|
+
set(path: string, value: unknown): void {
|
|
118
|
+
const keys = path.split(".");
|
|
119
|
+
// `config.set("__proto__.isAdmin", true)` used to walk onto Object.prototype and pollute
|
|
120
|
+
// every object in the process. deepMerge already guards exactly these keys
|
|
121
|
+
// (support/deepMerge.ts _UNSAFE_KEYS); ConfigManager did not.
|
|
122
|
+
if (keys.some((key) => _UNSAFE_CONFIG_KEYS.has(key))) {
|
|
123
|
+
throw new ConfigError(
|
|
124
|
+
`Refusing to set config path "${path}": it walks through a prototype key ` +
|
|
125
|
+
`(__proto__, constructor, prototype), which would mutate Object.prototype.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
let current = this._data;
|
|
129
|
+
|
|
130
|
+
for (let index = 0; index < keys.length - 1; index++) {
|
|
131
|
+
const key = keys[index]!;
|
|
132
|
+
if (typeof current[key] !== "object" || current[key] == null) {
|
|
133
|
+
current[key] = {};
|
|
134
|
+
}
|
|
135
|
+
current = current[key] as Record<string, unknown>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
current[keys[keys.length - 1]!] = value;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Check whether a config path has a value set (i.e. `get(path)` is not
|
|
143
|
+
* `undefined`).
|
|
144
|
+
*
|
|
145
|
+
* @param path Dot-notation path to test.
|
|
146
|
+
* @returns `true` when a value exists at the path.
|
|
147
|
+
*/
|
|
148
|
+
has(path: string): boolean {
|
|
149
|
+
return this.get(path) !== undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Return a shallow copy of all loaded config namespaces. Useful for
|
|
154
|
+
* debugging.
|
|
155
|
+
*
|
|
156
|
+
* @returns A new object mapping each namespace to its config value. The copy
|
|
157
|
+
* is shallow — nested namespace objects are shared, not cloned.
|
|
158
|
+
*/
|
|
159
|
+
all(): Record<string, unknown> {
|
|
160
|
+
return { ...this._data };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Keys that must never appear in a `set()` path. Writing through any of them reaches
|
|
166
|
+
* `Object.prototype` and pollutes every object in the process. Mirrors the guard in
|
|
167
|
+
* `support/deepMerge.ts`.
|
|
168
|
+
*/
|
|
169
|
+
const _UNSAFE_CONFIG_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `@zerotal/core/config` subpath — the runtime configuration system.
|
|
3
|
+
*
|
|
4
|
+
* Config lives in a set of `config/*.ts` files, each default-exporting one
|
|
5
|
+
* namespace object (`config/app.ts` → the `app` namespace). {@link configLoader}
|
|
6
|
+
* discovers and loads them synchronously into a {@link ConfigLoader}, which the
|
|
7
|
+
* application hands to a {@link ConfigManager} — the store that serves
|
|
8
|
+
* dot-path reads/writes behind the `Config` facade and the `config()` helper.
|
|
9
|
+
* {@link AppConfig} builds the framework's own `app` namespace with defaults,
|
|
10
|
+
* and the {@link ConfigRegistry} interface makes dot-path access type-checked.
|
|
11
|
+
*
|
|
12
|
+
* The everyday `config()` helper lives on the kernel barrel (`@zerotal/core`);
|
|
13
|
+
* the symbols here are the lower-level classes and types used when defining or
|
|
14
|
+
* wiring configuration.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { configLoader } from "@zerotal/core/config";
|
|
19
|
+
* import { Application } from "@zerotal/core";
|
|
20
|
+
*
|
|
21
|
+
* // In the entry script — load and validate config, then boot the app.
|
|
22
|
+
* const config = configLoader("./config").validate();
|
|
23
|
+
* const app = Application.create({ config });
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* @packageDocumentation
|
|
27
|
+
*/
|
|
28
|
+
export { ConfigManager } from "./ConfigManager.ts";
|
|
29
|
+
export { configLoader, ConfigLoader } from "./ConfigLoader.ts";
|
|
30
|
+
export type { ConfigMap } from "./ConfigLoader.ts";
|
|
31
|
+
export { AppConfig } from "./AppConfig.ts";
|
|
32
|
+
export type {
|
|
33
|
+
AppConfigShape,
|
|
34
|
+
AppTlsConfig,
|
|
35
|
+
ConventionsConfig,
|
|
36
|
+
AppAssetsConfig,
|
|
37
|
+
} from "./AppConfig.ts";
|
|
38
|
+
export type { ConfigRegistry, ConfigPath, ConfigValue } from "./registry.ts";
|
|
39
|
+
export { ConfigValidationError } from "./validation.ts";
|
|
40
|
+
export type {
|
|
41
|
+
ConfigIssue,
|
|
42
|
+
ConfigIssueLevel,
|
|
43
|
+
ConfigValidator,
|
|
44
|
+
ConfigValidationContext,
|
|
45
|
+
RegisteredConfigValidator,
|
|
46
|
+
} from "./validation.ts";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The typed config registry and the dot-path types built from it. `ConfigRegistry`
|
|
3
|
+
* is the config analogue of `ContainerBindings`: every config-owning package
|
|
4
|
+
* augments it via declaration merging so `config("app.name")` is type-checked and
|
|
5
|
+
* auto-completed by namespace. Core owns the `app` and `health` namespaces.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Augmenting packages extend the registry like so:
|
|
9
|
+
//
|
|
10
|
+
// declare module "@zerotal/core" {
|
|
11
|
+
// interface ConfigRegistry { cache: CacheConfigShape }
|
|
12
|
+
// }
|
|
13
|
+
|
|
14
|
+
import type { AppConfigShape } from "./AppConfig.ts";
|
|
15
|
+
import type { HealthConfigShape } from "../health/Health.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Maps each config namespace to its shape. Augment it from any package that owns a
|
|
19
|
+
* `config/<namespace>.ts` file. Unaugmented namespaces fall back to the untyped
|
|
20
|
+
* `config(path: string)` overload, so dynamic access still compiles.
|
|
21
|
+
*/
|
|
22
|
+
export interface ConfigRegistry {
|
|
23
|
+
app: AppConfigShape;
|
|
24
|
+
health: HealthConfigShape;
|
|
25
|
+
lock: import("../lock/config.ts").LockConfigShape;
|
|
26
|
+
logging: import("../logger/config.ts").LoggingConfigShape;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Dot-path machinery ────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/** True only for plain nested objects — not arrays or functions (which shouldn't expand). */
|
|
32
|
+
type IsNested<T> = T extends readonly unknown[]
|
|
33
|
+
? false
|
|
34
|
+
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
35
|
+
T extends (...args: any[]) => unknown
|
|
36
|
+
? false
|
|
37
|
+
: T extends object
|
|
38
|
+
? true
|
|
39
|
+
: false;
|
|
40
|
+
|
|
41
|
+
/** Union of every dot-path through `T` (e.g. `'app' | 'app.name' | 'cache.ttl'`). */
|
|
42
|
+
type Leaves<T> = {
|
|
43
|
+
[K in keyof T & string]: IsNested<T[K]> extends true ? `${K}` | `${K}.${Leaves<T[K]>}` : `${K}`;
|
|
44
|
+
}[keyof T & string];
|
|
45
|
+
|
|
46
|
+
/** The value type at dot-path `P` within `T`. Unknown paths resolve to `unknown`. */
|
|
47
|
+
type ValueAt<T, P extends string> = P extends `${infer Head}.${infer Rest}`
|
|
48
|
+
? Head extends keyof T
|
|
49
|
+
? ValueAt<T[Head], Rest>
|
|
50
|
+
: unknown
|
|
51
|
+
: P extends keyof T
|
|
52
|
+
? T[P]
|
|
53
|
+
: unknown;
|
|
54
|
+
|
|
55
|
+
/** Every valid dot-path string across the registered config namespaces. */
|
|
56
|
+
export type ConfigPath = Leaves<ConfigRegistry>;
|
|
57
|
+
|
|
58
|
+
/** The value type stored at a given config dot-path. */
|
|
59
|
+
export type ConfigValue<P extends string> = ValueAt<ConfigRegistry, P>;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot-time config validation with production-refusal semantics.
|
|
3
|
+
*
|
|
4
|
+
* Each config namespace already has a typed shape in {@link ConfigRegistry}; a
|
|
5
|
+
* package can attach a validator to its namespace (via
|
|
6
|
+
* `app.registerConfigValidator(namespace, fn)`) that inspects the loaded value
|
|
7
|
+
* and reports problems. The boot sequence runs every validator once, after
|
|
8
|
+
* providers register and before they boot:
|
|
9
|
+
*
|
|
10
|
+
* - in a **production-like** deployment, any `error`-level issue **refuses boot**
|
|
11
|
+
* — aggregated into a {@link ConfigValidationError} that names each culprit;
|
|
12
|
+
* - everywhere else, issues are logged as warnings and boot continues.
|
|
13
|
+
*
|
|
14
|
+
* `warning`-level issues are always advisory (logged, never fatal). This is the
|
|
15
|
+
* "delight in development, refusal in production" posture: a placeholder secret
|
|
16
|
+
* or an insecure cookie flag is a nuisance locally and a hard stop in prod.
|
|
17
|
+
*/
|
|
18
|
+
import type { ConfigManager } from "./ConfigManager.ts";
|
|
19
|
+
import { ZerotalError } from "../errors/ZerotalError.ts";
|
|
20
|
+
|
|
21
|
+
/** Severity of a config problem. `error` refuses boot in production; `warning` only ever warns. */
|
|
22
|
+
export type ConfigIssueLevel = "error" | "warning";
|
|
23
|
+
|
|
24
|
+
/** A single problem a config validator reports. */
|
|
25
|
+
export interface ConfigIssue {
|
|
26
|
+
/** `error` — fatal in production. `warning` — always advisory. */
|
|
27
|
+
level: ConfigIssueLevel;
|
|
28
|
+
/** Human-readable description plus the remedy. */
|
|
29
|
+
message: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Context handed to a config validator. */
|
|
33
|
+
export interface ConfigValidationContext {
|
|
34
|
+
/** The namespace being validated (e.g. `"session"`). */
|
|
35
|
+
namespace: string;
|
|
36
|
+
/** True in a production-like deployment — the only case where `error` issues are fatal. */
|
|
37
|
+
isProduction: boolean;
|
|
38
|
+
/** The full config store, for cross-namespace reads (e.g. checking `app.key`). */
|
|
39
|
+
config: ConfigManager;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Validates one config namespace's loaded value and returns any problems found.
|
|
44
|
+
*
|
|
45
|
+
* The value arrives as `unknown` — config is a dynamic dot-path store, so a
|
|
46
|
+
* validator narrows to its namespace's shape itself (usually with a single cast
|
|
47
|
+
* to the package's `*ConfigShape`). Return an empty array (or nothing) when the
|
|
48
|
+
* namespace is well-formed.
|
|
49
|
+
*/
|
|
50
|
+
export type ConfigValidator = (
|
|
51
|
+
value: unknown,
|
|
52
|
+
ctx: ConfigValidationContext,
|
|
53
|
+
) => ConfigIssue[] | void;
|
|
54
|
+
|
|
55
|
+
/** A validator paired with the namespace it guards, as held on the application. */
|
|
56
|
+
export interface RegisteredConfigValidator {
|
|
57
|
+
namespace: string;
|
|
58
|
+
validate: ConfigValidator;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Thrown when config validation refuses a production boot. Lists every fatal
|
|
63
|
+
* issue by namespace, so a startup failure names exactly what to fix.
|
|
64
|
+
*
|
|
65
|
+
* @category Errors
|
|
66
|
+
*/
|
|
67
|
+
export class ConfigValidationError extends ZerotalError {
|
|
68
|
+
constructor(public readonly issues: Array<{ namespace: string; message: string }>) {
|
|
69
|
+
super(ConfigValidationError._format(issues), "E_CONFIG_VALIDATION_FAILED", 500, { issues });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private static _format(issues: Array<{ namespace: string; message: string }>): string {
|
|
73
|
+
const lines = issues.map((i) => ` • config(${i.namespace}): ${i.message}`);
|
|
74
|
+
const plural = issues.length === 1 ? "" : "s";
|
|
75
|
+
return (
|
|
76
|
+
`[Zerotal] Refusing to boot: ${issues.length} insecure or invalid config value${plural} ` +
|
|
77
|
+
`in this production deployment:\n${lines.join("\n")}`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Run every registered validator against the loaded config.
|
|
84
|
+
*
|
|
85
|
+
* In a production-like deployment, each `error`-level issue is collected and, if
|
|
86
|
+
* any exist, an aggregated {@link ConfigValidationError} is thrown (boot
|
|
87
|
+
* refused). Otherwise — and for every `warning`-level issue in any environment —
|
|
88
|
+
* the issue is logged via `warn` and boot continues.
|
|
89
|
+
*
|
|
90
|
+
* @param validators - The namespace/validator pairs to run.
|
|
91
|
+
* @param config - The loaded configuration store.
|
|
92
|
+
* @param isProduction - Whether this is a production-like deployment.
|
|
93
|
+
* @param warn - Sink for advisory messages (defaults to `console.warn`).
|
|
94
|
+
* @throws {ConfigValidationError} in production when one or more `error` issues are found.
|
|
95
|
+
*/
|
|
96
|
+
export function runConfigValidators(
|
|
97
|
+
validators: readonly RegisteredConfigValidator[],
|
|
98
|
+
config: ConfigManager,
|
|
99
|
+
isProduction: boolean,
|
|
100
|
+
warn: (message: string) => void = console.warn,
|
|
101
|
+
): void {
|
|
102
|
+
const fatal: Array<{ namespace: string; message: string }> = [];
|
|
103
|
+
|
|
104
|
+
for (const { namespace, validate } of validators) {
|
|
105
|
+
const value = config.get(namespace);
|
|
106
|
+
const issues = validate(value, { namespace, isProduction, config }) ?? [];
|
|
107
|
+
for (const issue of issues) {
|
|
108
|
+
if (issue.level === "error" && isProduction) {
|
|
109
|
+
fatal.push({ namespace, message: issue.message });
|
|
110
|
+
} else {
|
|
111
|
+
warn(`[Zerotal] config(${namespace}): ${issue.message}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (fatal.length > 0) throw new ConfigValidationError(fatal);
|
|
117
|
+
}
|