@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,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-reload WebSocket server.
|
|
3
|
+
*
|
|
4
|
+
* Manages the set of browser tabs connected to the /__dev/ws endpoint.
|
|
5
|
+
* When the DevOrchestrator detects a file change it writes "reload\n" to
|
|
6
|
+
* the dev-worker's stdin; ServeCommand reads that line and calls broadcast()
|
|
7
|
+
* here, pushing the message to every connected tab.
|
|
8
|
+
*
|
|
9
|
+
* The browser client (injected by InertiaProvider) reconnects automatically
|
|
10
|
+
* on close, so server restarts never create a reload loop.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** A connected dev-reload browser socket. */
|
|
14
|
+
type DevSocket = {
|
|
15
|
+
data: Record<string, unknown>;
|
|
16
|
+
send(message: string): void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const _clients = new Set<DevSocket>();
|
|
20
|
+
|
|
21
|
+
/** Register a newly opened dev-reload socket. */
|
|
22
|
+
export function open(socket: DevSocket): void {
|
|
23
|
+
_clients.add(socket);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Forget a closed dev-reload socket. */
|
|
27
|
+
export function close(socket: DevSocket): void {
|
|
28
|
+
_clients.delete(socket);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Push a message to every connected dev browser tab.
|
|
33
|
+
* Stale sockets (closed tabs) are pruned lazily on send failure.
|
|
34
|
+
*/
|
|
35
|
+
export function broadcast(message: string): void {
|
|
36
|
+
const staleSockets: DevSocket[] = [];
|
|
37
|
+
for (const socket of _clients) {
|
|
38
|
+
try {
|
|
39
|
+
socket.send(message);
|
|
40
|
+
} catch {
|
|
41
|
+
staleSockets.push(socket);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const socket of staleSockets) _clients.delete(socket);
|
|
45
|
+
}
|
package/src/dev/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-only build and live-reload tooling (the `@zerotal/core/dev` subpath):
|
|
3
|
+
* the build hook, the reload middleware that injects the HMR client snippet,
|
|
4
|
+
* and the CSS/JS bundling helpers. Owns `Bun.build`, so it is kept off the
|
|
5
|
+
* kernel barrel and is inactive outside the dev worker.
|
|
6
|
+
*
|
|
7
|
+
* @internal — framework dev-server wiring; not part of the app-facing API.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
export { registerDevBuildHook } from "./DevBuildHook.ts";
|
|
12
|
+
export type { BuildHookFn, BuildResult } from "./DevBuildHook.ts";
|
|
13
|
+
export { DevReloadMiddleware, registerDevHtmlSnippet } from "./DevReloadMiddleware.ts";
|
|
14
|
+
export type { DevHtmlSnippet } from "./DevReloadMiddleware.ts";
|
|
15
|
+
export { DEV_RELOAD_CLIENT } from "./reloadClient.ts";
|
|
16
|
+
export { detectCssPlugins, buildCssBundle, buildJsBundle } from "./CssPlugins.ts";
|
|
17
|
+
export type { AssetBuildConfig } from "./CssPlugins.ts";
|
|
18
|
+
export { pruneBuildOutput } from "./BuildOutput.ts";
|
|
19
|
+
// `AppAssetsConfig` (the app-level assets config shape) lives on @zerotal/core/config.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser half of dev live-reload: an inline script injected into dev HTML
|
|
3
|
+
* responses. It connects to `/__dev/ws` and reloads the tab on two signals.
|
|
4
|
+
*
|
|
5
|
+
* • `reload` — the orchestrator finished a rebuild triggered by a frontend
|
|
6
|
+
* change and pushed it straight through the worker.
|
|
7
|
+
*
|
|
8
|
+
* • `version:<token>` — sent by the worker the moment a socket opens. A
|
|
9
|
+
* backend change also rebuilds assets, but it restarts the worker, and the
|
|
10
|
+
* restart kills every socket: a `reload` pushed at that instant reaches
|
|
11
|
+
* nobody, because the tab only reconnects a second later. So the tab
|
|
12
|
+
* remembers the build token it first saw and reloads when a reconnect
|
|
13
|
+
* reports a different one. That is how a rebuild riding along with a
|
|
14
|
+
* server restart still reaches the browser.
|
|
15
|
+
*
|
|
16
|
+
* Reconnecting on close (rather than on error) is what keeps a server restart
|
|
17
|
+
* from wedging the page without spinning up a reload loop.
|
|
18
|
+
*
|
|
19
|
+
* Exported as a string because it is injected inline: `DevReloadMiddleware`
|
|
20
|
+
* rewrites HTML responses with it for every view layer, and Inertia bakes it
|
|
21
|
+
* into its cached template so streamed pages need no body rewrite.
|
|
22
|
+
*/
|
|
23
|
+
export const DEV_RELOAD_CLIENT: string =
|
|
24
|
+
`<script>(function(){` +
|
|
25
|
+
`var proto=location.protocol==='https:'?'wss:':'ws:';` +
|
|
26
|
+
`var build=null;` +
|
|
27
|
+
`function connect(){` +
|
|
28
|
+
`var ws=new WebSocket(proto+'//'+location.host+'/__dev/ws');` +
|
|
29
|
+
`ws.onmessage=function(e){` +
|
|
30
|
+
`var d=e.data;` +
|
|
31
|
+
`if(d==='reload'){location.reload();return;}` +
|
|
32
|
+
`if(d.indexOf('version:')===0){` +
|
|
33
|
+
`var v=d.slice(8);` +
|
|
34
|
+
`if(!v)return;` +
|
|
35
|
+
`if(build===null)build=v;` +
|
|
36
|
+
`else if(build!==v)location.reload();` +
|
|
37
|
+
`}};` +
|
|
38
|
+
`ws.onclose=function(){setTimeout(connect,1000);};` +
|
|
39
|
+
`}connect();})();</script>`;
|
package/src/env/Def.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Def<T>` — the typed field definition produced by the `t` env builder, plus
|
|
3
|
+
* the field-specification and error types that back environment validation.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ── Required sentinel ─────────────────────────────────────────────────────────
|
|
7
|
+
// Used as the third argument to .when() so the API reads naturally:
|
|
8
|
+
// STRIPE_KEY: t.string().when('NODE_ENV', 'production', t.required())
|
|
9
|
+
|
|
10
|
+
const _REQUIRED = Symbol.for("@zerotal/env:required");
|
|
11
|
+
|
|
12
|
+
/** The type of the {@link REQUIRED_MARKER} sentinel passed to `.when()`. */
|
|
13
|
+
export type RequiredMarker = typeof _REQUIRED;
|
|
14
|
+
|
|
15
|
+
/** Sentinel marking a field as required under a `.when()` condition. */
|
|
16
|
+
export const REQUIRED_MARKER: RequiredMarker = _REQUIRED;
|
|
17
|
+
|
|
18
|
+
// ── Internal field specification ──────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/** The set of value kinds a field can declare. */
|
|
21
|
+
export type FieldType = "string" | "number" | "boolean" | "enum" | "url" | "port";
|
|
22
|
+
|
|
23
|
+
/** @internal A single conditional-requirement rule attached by `.when()`. */
|
|
24
|
+
export interface ConditionSpec {
|
|
25
|
+
field: string;
|
|
26
|
+
value: string;
|
|
27
|
+
thenRequired: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @internal The fully resolved specification a `Def<T>` carries into validation. */
|
|
31
|
+
export interface FieldSpec {
|
|
32
|
+
type: FieldType;
|
|
33
|
+
required: boolean;
|
|
34
|
+
defaultValue: unknown; // Already typed — skip re-parsing in parse().
|
|
35
|
+
enumValues?: readonly string[];
|
|
36
|
+
conditions: ConditionSpec[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Validation error ──────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/** A single field-level validation failure. Collected before the final throw. */
|
|
42
|
+
export class EnvFieldError {
|
|
43
|
+
constructor(
|
|
44
|
+
readonly key: string,
|
|
45
|
+
readonly message: string,
|
|
46
|
+
) {}
|
|
47
|
+
|
|
48
|
+
format(): string {
|
|
49
|
+
return ` ✗ ${this.key.padEnd(20)} ${this.message}`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Def<T> ────────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A typed field definition produced by the `t` builder.
|
|
57
|
+
* Carry it directly in `EnvSchema.define()` — the generic `T` is inferred automatically.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* PORT: t.number().default(3000) // Def<number>
|
|
62
|
+
* DB_HOST: t.string().required() // Def<string>
|
|
63
|
+
* NODE_ENV: t.enum([...]).required() // Def<'development'|'production'|'testing'>
|
|
64
|
+
* API_KEY: t.string().when('NODE_ENV', 'production', t.required()) // Def<string|undefined>
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export class Def<T> {
|
|
68
|
+
/** @internal — phantom property for TypeScript inference only, never set at runtime */
|
|
69
|
+
declare readonly _output: T;
|
|
70
|
+
|
|
71
|
+
/** @internal */
|
|
72
|
+
readonly _spec: FieldSpec;
|
|
73
|
+
|
|
74
|
+
constructor(spec: FieldSpec) {
|
|
75
|
+
this._spec = spec;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Modifiers ────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Mark the field as required.
|
|
82
|
+
* The output type becomes `NonNullable<T>` — `undefined` is removed.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* ```ts
|
|
86
|
+
* DB_HOST: t.string().required() // string (throws at boot if missing)
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
required(): Def<NonNullable<T>> {
|
|
90
|
+
return new Def<NonNullable<T>>({ ...this._spec, required: true });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Provide a fallback value when the env variable is absent.
|
|
95
|
+
* The output type becomes `NonNullable<T>` — `undefined` is removed.
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* ```ts
|
|
99
|
+
* PORT: t.number().default(3000) // number — always present
|
|
100
|
+
* DEBUG: t.boolean().default(false) // boolean
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
default(value: NonNullable<T>): Def<NonNullable<T>> {
|
|
104
|
+
return new Def<NonNullable<T>>({
|
|
105
|
+
...this._spec,
|
|
106
|
+
required: true,
|
|
107
|
+
defaultValue: value,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Make the field conditionally required.
|
|
113
|
+
* When `conditionField === conditionValue` at runtime, the field is validated
|
|
114
|
+
* as required. Otherwise it remains optional.
|
|
115
|
+
*
|
|
116
|
+
* The TypeScript type stays `T` (potentially `undefined`) because the condition
|
|
117
|
+
* is evaluated at runtime — access the value with a null-check or after calling
|
|
118
|
+
* EnvSchema.define() in the appropriate environment.
|
|
119
|
+
*
|
|
120
|
+
* Pass `t.required()` as the third argument (or omit — required is the default action).
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* ```ts
|
|
124
|
+
* STRIPE_SECRET: t.string().when('NODE_ENV', 'production', t.required())
|
|
125
|
+
* ```
|
|
126
|
+
*/
|
|
127
|
+
when(
|
|
128
|
+
conditionField: string,
|
|
129
|
+
conditionValue: string,
|
|
130
|
+
rule?: RequiredMarker | Def<unknown>,
|
|
131
|
+
): Def<T> {
|
|
132
|
+
const thenRequired =
|
|
133
|
+
rule === undefined ||
|
|
134
|
+
rule === REQUIRED_MARKER ||
|
|
135
|
+
(rule instanceof Def && rule._spec.required);
|
|
136
|
+
|
|
137
|
+
return new Def<T>({
|
|
138
|
+
...this._spec,
|
|
139
|
+
conditions: [
|
|
140
|
+
...this._spec.conditions,
|
|
141
|
+
{ field: conditionField, value: conditionValue, thenRequired },
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Internal parse ────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @internal
|
|
150
|
+
* Parse and validate a raw env string, respecting conditions.
|
|
151
|
+
* Throws `EnvFieldError` on failure (collected by EnvSchema before the final throw).
|
|
152
|
+
*/
|
|
153
|
+
_parse(key: string, raw: string | undefined, allEnv: Record<string, string | undefined>): T {
|
|
154
|
+
// Evaluate conditional requirements
|
|
155
|
+
for (const condition of this._spec.conditions) {
|
|
156
|
+
if (allEnv[condition.field] === condition.value && condition.thenRequired) {
|
|
157
|
+
if (raw === undefined || raw === "") {
|
|
158
|
+
throw new EnvFieldError(
|
|
159
|
+
key,
|
|
160
|
+
`required when ${condition.field}="${condition.value}" but is missing`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Apply default
|
|
167
|
+
if (raw === undefined || raw === "") {
|
|
168
|
+
if (this._spec.defaultValue !== undefined) {
|
|
169
|
+
return this._spec.defaultValue as T;
|
|
170
|
+
}
|
|
171
|
+
if (this._spec.required) {
|
|
172
|
+
throw new EnvFieldError(key, "is required but missing or empty");
|
|
173
|
+
}
|
|
174
|
+
return undefined as T;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Type coercion & validation
|
|
178
|
+
switch (this._spec.type) {
|
|
179
|
+
case "string":
|
|
180
|
+
return raw as T;
|
|
181
|
+
|
|
182
|
+
case "url": {
|
|
183
|
+
try {
|
|
184
|
+
new URL(raw);
|
|
185
|
+
} catch {
|
|
186
|
+
throw new EnvFieldError(key, `must be a valid URL — got: "${raw}"`);
|
|
187
|
+
}
|
|
188
|
+
return raw as T;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
case "number":
|
|
192
|
+
case "port": {
|
|
193
|
+
const parsed = Number(raw);
|
|
194
|
+
if (Number.isNaN(parsed) || !Number.isFinite(parsed)) {
|
|
195
|
+
throw new EnvFieldError(key, `must be a number — got: "${raw}"`);
|
|
196
|
+
}
|
|
197
|
+
if (
|
|
198
|
+
this._spec.type === "port" &&
|
|
199
|
+
(parsed < 1 || parsed > 65535 || !Number.isInteger(parsed))
|
|
200
|
+
) {
|
|
201
|
+
throw new EnvFieldError(key, `must be a valid port number (1–65535) — got: ${parsed}`);
|
|
202
|
+
}
|
|
203
|
+
return parsed as T;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
case "boolean": {
|
|
207
|
+
const normalized = raw.toLowerCase();
|
|
208
|
+
if (normalized === "true" || normalized === "1" || normalized === "yes") return true as T;
|
|
209
|
+
if (normalized === "false" || normalized === "0" || normalized === "no") return false as T;
|
|
210
|
+
throw new EnvFieldError(key, `must be a boolean (true/false/1/0/yes/no) — got: "${raw}"`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
case "enum": {
|
|
214
|
+
const values = this._spec.enumValues!;
|
|
215
|
+
if (!values.includes(raw)) {
|
|
216
|
+
throw new EnvFieldError(key, `must be one of [${values.join(", ")}] — got: "${raw}"`);
|
|
217
|
+
}
|
|
218
|
+
return raw as T;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Infer the output type of a schema map ─────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
/** Infer the concrete type of a single `Def<T>`. */
|
|
227
|
+
export type InferDef<D> = D extends Def<infer T> ? T : never;
|
|
228
|
+
|
|
229
|
+
/** Infer the full output object type from an `EnvSchema.define()` schema map. */
|
|
230
|
+
export type EnvOutput<S extends Record<string, Def<unknown>>> = {
|
|
231
|
+
readonly [K in keyof S]: InferDef<S[K]>;
|
|
232
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `EnvSchema` — defines and validates a strict, fully-typed environment schema
|
|
3
|
+
* at import time, collecting every failure into a single readable error.
|
|
4
|
+
*/
|
|
5
|
+
import { Def, EnvFieldError } from "./Def.ts";
|
|
6
|
+
import type { EnvOutput } from "./Def.ts";
|
|
7
|
+
import { ZerotalError } from "../errors/ZerotalError.ts";
|
|
8
|
+
|
|
9
|
+
// ── Schema-level error ────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Thrown by `EnvSchema.define()` when one or more environment variables
|
|
13
|
+
* fail validation. Contains all failures, not just the first.
|
|
14
|
+
*/
|
|
15
|
+
export class EnvSchemaError extends ZerotalError {
|
|
16
|
+
readonly errors: EnvFieldError[];
|
|
17
|
+
|
|
18
|
+
constructor(errors: EnvFieldError[]) {
|
|
19
|
+
super(EnvSchemaError._format(errors), "E_ENV_SCHEMA", 500);
|
|
20
|
+
this.errors = errors;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private static _format(errors: EnvFieldError[]): string {
|
|
24
|
+
const lines = [
|
|
25
|
+
"",
|
|
26
|
+
"\x1b[31m[Zerotal Env]\x1b[0m Environment validation failed — the application cannot start.",
|
|
27
|
+
"",
|
|
28
|
+
...errors.map((error) => error.format()),
|
|
29
|
+
"",
|
|
30
|
+
"Check your \x1b[33m.env\x1b[0m file and try again.",
|
|
31
|
+
"",
|
|
32
|
+
];
|
|
33
|
+
return lines.join("\n");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── EnvSchema ─────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Define and validate a strict environment schema at import time.
|
|
41
|
+
*
|
|
42
|
+
* The schema is parsed once when the module is first imported. If any variable
|
|
43
|
+
* fails validation, the application refuses to start and prints every failure
|
|
44
|
+
* in a single readable error — not just the first one.
|
|
45
|
+
*
|
|
46
|
+
* All fields of the returned object are **read-only** and **fully typed**.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```ts
|
|
50
|
+
* // app/config/env.ts
|
|
51
|
+
* import { EnvSchema, t } from '@zerotal/core/env';
|
|
52
|
+
*
|
|
53
|
+
* export const env = EnvSchema.define({
|
|
54
|
+
* PORT: t.number().default(3000),
|
|
55
|
+
* NODE_ENV: t.enum(['development', 'production', 'testing']).required(),
|
|
56
|
+
* DB_HOST: t.string().required(),
|
|
57
|
+
* DB_PASS: t.string().when('NODE_ENV', 'production', t.required()),
|
|
58
|
+
* STRIPE_SECRET: t.string().when('NODE_ENV', 'production', t.required()),
|
|
59
|
+
* DEBUG: t.boolean().default(false),
|
|
60
|
+
* BASE_URL: t.url().default('http://localhost:3000'),
|
|
61
|
+
* });
|
|
62
|
+
*
|
|
63
|
+
* // Anywhere else:
|
|
64
|
+
* import { env } from './config/env.ts';
|
|
65
|
+
* console.log(env.PORT); // number — never undefined
|
|
66
|
+
* console.log(env.DB_HOST); // string — validated at boot
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export const EnvSchema = {
|
|
70
|
+
/**
|
|
71
|
+
* Parse and validate all env variables against the schema.
|
|
72
|
+
*
|
|
73
|
+
* @param schema A plain object whose values are `Def<T>` instances from `t.*`.
|
|
74
|
+
* @param source Optional env source (defaults to `Bun.env`). Pass a plain
|
|
75
|
+
* object in tests to avoid polluting the real environment.
|
|
76
|
+
*
|
|
77
|
+
* @throws EnvSchemaError when any variable fails validation.
|
|
78
|
+
*/
|
|
79
|
+
define<S extends Record<string, Def<unknown>>>(
|
|
80
|
+
schema: S,
|
|
81
|
+
source: Record<string, string | undefined> = Bun.env as Record<string, string | undefined>,
|
|
82
|
+
): EnvOutput<S> {
|
|
83
|
+
const errors: EnvFieldError[] = [];
|
|
84
|
+
const result: Record<string, unknown> = {};
|
|
85
|
+
|
|
86
|
+
for (const [key, def] of Object.entries(schema)) {
|
|
87
|
+
const raw = source[key];
|
|
88
|
+
try {
|
|
89
|
+
result[key] = def._parse(key, raw, source);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error instanceof EnvFieldError) {
|
|
92
|
+
errors.push(error);
|
|
93
|
+
} else {
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (errors.length > 0) {
|
|
100
|
+
throw new EnvSchemaError(errors);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return Object.freeze(result) as EnvOutput<S>;
|
|
104
|
+
},
|
|
105
|
+
} as const;
|
package/src/env/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `@zerotal/core/env` subpath — a strict, fully-typed environment schema.
|
|
3
|
+
*
|
|
4
|
+
* Declare every variable your app reads with the {@link t} field builder
|
|
5
|
+
* (`t.string()`, `t.number()`, `t.enum([...])`, …) and hand the map to
|
|
6
|
+
* {@link EnvSchema.define}. The schema is parsed and validated once at import
|
|
7
|
+
* time: missing required vars, bad numbers, invalid URLs, and out-of-range
|
|
8
|
+
* ports all fail the boot with a single {@link EnvSchemaError} that lists every
|
|
9
|
+
* failure at once. The returned object is frozen and precisely typed, so
|
|
10
|
+
* `env.PORT` is `number` (never `undefined`) and enum fields narrow to their
|
|
11
|
+
* literals.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { EnvSchema, t } from "@zerotal/core/env";
|
|
16
|
+
*
|
|
17
|
+
* export const env = EnvSchema.define({
|
|
18
|
+
* PORT: t.number().default(3000),
|
|
19
|
+
* NODE_ENV: t.enum(["development", "production", "testing"]).required(),
|
|
20
|
+
* DB_HOST: t.string().required(),
|
|
21
|
+
* DB_PASS: t.string().when("NODE_ENV", "production", t.required()),
|
|
22
|
+
* BASE_URL: t.url().default("http://localhost:3000"),
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* console.log(env.PORT); // number — never undefined
|
|
26
|
+
* console.log(env.DB_HOST); // string — validated at boot
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* @packageDocumentation
|
|
30
|
+
*/
|
|
31
|
+
export { EnvSchema, EnvSchemaError } from "./EnvSchema.ts";
|
|
32
|
+
export { t } from "./t.ts";
|
|
33
|
+
export { Def, EnvFieldError } from "./Def.ts";
|
|
34
|
+
export type { EnvOutput, InferDef, FieldType } from "./Def.ts";
|
package/src/env/t.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `t` env-field builder — the entry point that produces typed `Def<T>`
|
|
3
|
+
* field definitions (`t.string()`, `t.number()`, `t.enum([...])`, …) for use
|
|
4
|
+
* with `EnvSchema.define()`.
|
|
5
|
+
*/
|
|
6
|
+
import { Def, REQUIRED_MARKER } from "./Def.ts";
|
|
7
|
+
import type { RequiredMarker, FieldType, FieldSpec } from "./Def.ts";
|
|
8
|
+
|
|
9
|
+
function _def<T>(type: FieldType, extra?: Partial<FieldSpec>): Def<T> {
|
|
10
|
+
return new Def<T>({ type, required: false, defaultValue: undefined, conditions: [], ...extra });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The `t` builder — the entry point for all field type definitions.
|
|
15
|
+
*
|
|
16
|
+
* Every method returns a `Def<T>` which can be further configured with
|
|
17
|
+
* `.required()`, `.default(val)`, or `.when(field, value, t.required())`.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* import { EnvSchema, t } from '@zerotal/core/env';
|
|
22
|
+
*
|
|
23
|
+
* export const env = EnvSchema.define({
|
|
24
|
+
* PORT: t.number().default(3000),
|
|
25
|
+
* NODE_ENV: t.enum(['development', 'production', 'testing']).required(),
|
|
26
|
+
* DB_HOST: t.string().required(),
|
|
27
|
+
* DB_PASS: t.string().when('NODE_ENV', 'production', t.required()),
|
|
28
|
+
* DEBUG: t.boolean().default(false),
|
|
29
|
+
* BASE_URL: t.url().required(),
|
|
30
|
+
* });
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export const t = {
|
|
34
|
+
/**
|
|
35
|
+
* A plain string environment variable.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* APP_NAME: t.string().default('Zerotal App') // string
|
|
40
|
+
* DB_HOST: t.string().required() // string
|
|
41
|
+
* REDIS_URL: t.string() // string | undefined
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
string(): Def<string | undefined> {
|
|
45
|
+
return _def<string | undefined>("string");
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A numeric environment variable (parsed with `Number()`).
|
|
50
|
+
* Non-numeric values cause a boot-time error.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```ts
|
|
54
|
+
* PORT: t.number().default(3000) // number
|
|
55
|
+
* TIMEOUT: t.number().required() // number
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
number(): Def<number | undefined> {
|
|
59
|
+
return _def<number | undefined>("number");
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A boolean environment variable.
|
|
64
|
+
* Accepts: `true`, `false`, `1`, `0`, `yes`, `no` (case-insensitive).
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```ts
|
|
68
|
+
* DEBUG: t.boolean().default(false) // boolean
|
|
69
|
+
* CACHE_HOT: t.boolean().required() // boolean
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
boolean(): Def<boolean | undefined> {
|
|
73
|
+
return _def<boolean | undefined>("boolean");
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* An enum-constrained string variable.
|
|
78
|
+
* The value must be one of the provided literals — anything else causes a boot-time error.
|
|
79
|
+
* Pass the array `as const` (or inline) for accurate literal type inference.
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```ts
|
|
83
|
+
* NODE_ENV: t.enum(['development', 'production', 'testing']).required()
|
|
84
|
+
* // output: 'development' | 'production' | 'testing'
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
enum<const V extends readonly string[]>(values: V): Def<V[number] | undefined> {
|
|
88
|
+
return _def<V[number] | undefined>("enum", { enumValues: values });
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A URL-validated string. Parses with `new URL()` — invalid values cause a boot-time error.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```ts
|
|
96
|
+
* API_BASE: t.url().required() // string (guaranteed valid URL)
|
|
97
|
+
* CDN_URL: t.url().default('...') // string
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
url(): Def<string | undefined> {
|
|
101
|
+
return _def<string | undefined>("url");
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A validated TCP port number (integer 1–65535).
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* ```ts
|
|
109
|
+
* PORT: t.port().default(3000) // number
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
port(): Def<number | undefined> {
|
|
113
|
+
return _def<number | undefined>("port");
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Sentinel value for use as the third argument to `.when()`.
|
|
118
|
+
* Signals that the field is required under the given condition.
|
|
119
|
+
*
|
|
120
|
+
* @example
|
|
121
|
+
* ```ts
|
|
122
|
+
* STRIPE_KEY: t.string().when('NODE_ENV', 'production', t.required())
|
|
123
|
+
* ```
|
|
124
|
+
*/
|
|
125
|
+
required(): RequiredMarker {
|
|
126
|
+
return REQUIRED_MARKER;
|
|
127
|
+
},
|
|
128
|
+
} as const;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The error raised for invalid or missing configuration — a bad config file,
|
|
3
|
+
* an unknown key, or a value that fails its schema.
|
|
4
|
+
*/
|
|
5
|
+
import { ZerotalError } from "./ZerotalError.ts";
|
|
6
|
+
|
|
7
|
+
/** Raised when configuration is invalid or missing. */
|
|
8
|
+
export class ConfigError extends ZerotalError {
|
|
9
|
+
constructor(message: string) {
|
|
10
|
+
super(message, "E_CONFIG_ERROR", 500);
|
|
11
|
+
}
|
|
12
|
+
}
|