@arki/env 0.0.2 → 0.0.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arki/env",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Type-safe environment variable parsing for ARKI — defineEnv() built on @t3-oss/env-core with Zod contracts for common variable shapes.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -47,6 +47,12 @@
47
47
  },
48
48
  "files": [
49
49
  "dist/**",
50
+ "src/**",
51
+ "!src/**/*.test.*",
52
+ "!src/**/*.spec.*",
53
+ "!src/**/tests/**",
54
+ "!src/**/test/**",
55
+ "!src/**/__tests__/**",
50
56
  "README.md",
51
57
  "LICENSE",
52
58
  "package.json"
@@ -56,7 +62,7 @@
56
62
  "zod": "4.3.5"
57
63
  },
58
64
  "peerDependencies": {
59
- "@arki/dot": "^0.1.1"
65
+ "@arki/dot": "^0.1.3"
60
66
  },
61
67
  "peerDependenciesMeta": {
62
68
  "@arki/dot": {
@@ -0,0 +1,31 @@
1
+ import { z } from 'zod';
2
+
3
+ export const commonContracts = {
4
+ string: (defaultValue?: string) => {
5
+ const schema = z.string();
6
+ return defaultValue ? schema.default(defaultValue) : schema;
7
+ },
8
+
9
+ number: (defaultValue?: number) => {
10
+ const schema = z.coerce.number();
11
+ return defaultValue ? schema.default(defaultValue) : schema;
12
+ },
13
+
14
+ port: (defaultValue = 3000) =>
15
+ z
16
+ .coerce.number()
17
+ .min(1, 'Port must be at least 1')
18
+ .max(65_535, 'Port must be at most 65535')
19
+ .default(defaultValue),
20
+
21
+ nodeEnv: () => z.enum(['development', 'production', 'test']).default('development'),
22
+
23
+ boolean: (defaultValue = false) =>
24
+ z
25
+ .enum(['true', 'false', '1', '0'])
26
+ .default(defaultValue ? 'true' : 'false')
27
+ .transform(val => val === 'true' || val === '1'),
28
+
29
+ logLevel: (defaultValue: 'debug' | 'info' | 'warn' | 'error' = 'info') =>
30
+ z.enum(['debug', 'info', 'warn', 'error']).default(defaultValue),
31
+ };
@@ -0,0 +1,17 @@
1
+ import { commonContracts } from './common.js';
2
+ import { parseContracts } from './parsing.js';
3
+ import { urlContracts } from './urls.js';
4
+
5
+ // Unified contracts export
6
+ export const contracts = {
7
+ ...commonContracts,
8
+ ...urlContracts,
9
+ ...parseContracts,
10
+ };
11
+
12
+ // Individual category exports
13
+
14
+
15
+ export {commonContracts} from './common.js';
16
+ export {urlContracts} from './urls.js';
17
+ export {parseContracts} from './parsing.js';
@@ -0,0 +1,27 @@
1
+ import { z } from 'zod';
2
+
3
+ export const parseContracts = {
4
+ json: <T extends z.ZodType>(schema: T) =>
5
+ z.string().transform((str) => {
6
+ const parsed = JSON.parse(str);
7
+ return schema.parse(parsed) as z.infer<T>;
8
+ }),
9
+
10
+ csvArray: <T extends z.ZodType>(itemSchema: T) =>
11
+ z.preprocess(
12
+ (val) => {
13
+ if (typeof val !== 'string') return val;
14
+ return val.split(',').map((s) => s.trim());
15
+ },
16
+ z.array(itemSchema)
17
+ ),
18
+
19
+ csvSet: <T extends z.ZodType>(itemSchema: T) =>
20
+ z.preprocess(
21
+ (val) => {
22
+ if (typeof val !== 'string') return val;
23
+ return [...new Set(val.split(',').map((s) => s.trim()))];
24
+ },
25
+ z.array(itemSchema)
26
+ ),
27
+ };
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod';
2
+
3
+ export const urlContracts = {
4
+ url: (defaultValue?: string) => {
5
+ const schema = z.url();
6
+ return defaultValue ? schema.default(defaultValue) : schema;
7
+ },
8
+
9
+ databaseUrl: () =>
10
+ z
11
+ .url()
12
+ .refine(
13
+ url => url.startsWith('postgres://') || url.startsWith('postgresql://'),
14
+ 'Database URL must start with postgres:// or postgresql://',
15
+ ),
16
+
17
+ redisUrl: () =>
18
+ z
19
+ .url()
20
+ .refine(
21
+ url => url.startsWith('redis://') || url.startsWith('rediss://'),
22
+ 'Redis URL must start with redis:// or rediss://',
23
+ ),
24
+ };
@@ -0,0 +1,134 @@
1
+ import { createEnv } from '@t3-oss/env-core';
2
+ import type { CreateEnv, DefaultCombinedSchema } from '@t3-oss/env-core';
3
+ import type { ZodType } from 'zod';
4
+
5
+ /** The issue shape t3-oss hands to `onValidationError`, derived so it stays in sync with the lib. */
6
+ type ValidationIssues = Parameters<
7
+ NonNullable<Parameters<typeof createEnv>[0]['onValidationError']>
8
+ >[0];
9
+
10
+ /**
11
+ * Fold validation issues into a human-legible, log-pipeline-safe string. The t3-oss default handler
12
+ * logs `issues` as a raw object, which platforms like Coolify collapse to `[Object]` — so the var
13
+ * that actually failed never reaches the logs. A flat ` path: message` list survives transport.
14
+ */
15
+ function formatIssues(name: string | undefined, issues: ValidationIssues): string {
16
+ const label = name ? `[${name}] ` : '';
17
+ const lines = issues.map(issue => {
18
+ const path = (issue.path ?? [])
19
+ .map(seg => (typeof seg === 'object' && seg !== null && 'key' in seg ? String(seg.key) : String(seg)))
20
+ .join('.');
21
+ return ` ${path || '(root)'}: ${issue.message}`;
22
+ });
23
+ return `❌ ${label}Invalid environment variables:\n${lines.join('\n')}`;
24
+ }
25
+
26
+ /**
27
+ * Thin, type-preserving wrapper over `@t3-oss/env-core`'s `createEnv`. Generic over the server,
28
+ * client, shared and composed schemas so the parsed env keeps its exact inferred types — the wrapper
29
+ * adds the ARKI defaults (auto `runtimeEnv`, prod-safe `skipValidation`, a legible labelled error
30
+ * handler) without widening any value to `unknown`.
31
+ *
32
+ * `TPrefix` is kept as a string-literal generic on purpose: `createEnv` uses it to enforce that
33
+ * client keys (and only client keys) carry the prefix. Widening it to `string` would make every
34
+ * server key collapse to a type-level `ErrorMessage`, so the prefix must stay a literal.
35
+ */
36
+ export function defineEnv<
37
+ TServer extends Record<string, ZodType>,
38
+ TClient extends Record<string, ZodType> = NonNullable<unknown>,
39
+ TShared extends Record<string, ZodType> = NonNullable<unknown>,
40
+ const TPrefix extends string = '',
41
+ const TExtends extends Array<Record<string, unknown>> = [],
42
+ >(config: {
43
+ /**
44
+ * Label shown in validation-error logs, e.g. `'@acme/backend'`. Without it a misconfigured
45
+ * deploy logs a bare "Invalid environment variables" with no hint which env failed — exactly the
46
+ * blind failure this wrapper exists to prevent.
47
+ */
48
+ name?: string;
49
+ server: TServer;
50
+ /** Client-side schema. Every key must start with `clientPrefix` (enforced by `createEnv`). */
51
+ client?: TClient;
52
+ /** Prefix client keys must carry, e.g. `'VITE_'`. Defaults to `''` (no client vars). */
53
+ clientPrefix?: TPrefix;
54
+ /** Shared vars available to both client and server, unprefixed (e.g. `NODE_ENV`). */
55
+ shared?: TShared;
56
+ /**
57
+ * Already-built envs to compose in (e.g. `@arki/auth`, `@arki/db`). Their parsed variables merge
58
+ * into the result with full types; each was validated by its own `createEnv` at import time.
59
+ */
60
+ extends?: TExtends;
61
+ /**
62
+ * Explicit runtime source. Omit it and `defineEnv` auto-builds one by reading `process.env` for
63
+ * every declared key — correct for any server-side env. Provide it only when the auto-build would
64
+ * be wrong: Vite/`start` apps whose client vars come from `import.meta.env` in the browser, or
65
+ * keys with a fallback/alias (e.g. `DB_URL: process.env.DB_URL ?? process.env.DATABASE_URL`).
66
+ */
67
+ runtimeEnv?: Record<string, string | boolean | number | undefined>;
68
+ options?: {
69
+ skipValidation?: boolean;
70
+ /**
71
+ * Treat empty-string env values as `undefined` so defaults apply (e.g. `PORT=` in `.env`, or a
72
+ * Docker arg baked as `X=""`). Defaults to `true` — set `false` only if an app intentionally
73
+ * accepts `""` as a valid required string.
74
+ */
75
+ emptyStringAsUndefined?: boolean;
76
+ onValidationError?: (issues: ValidationIssues) => never;
77
+ };
78
+ }): CreateEnv<DefaultCombinedSchema<TServer, TClient, TShared>, TExtends> {
79
+ const skipValidation =
80
+ config.options?.skipValidation ?? (!!process.env.CI || process.env.NODE_ENV !== 'development');
81
+
82
+ // Use the caller's explicit runtimeEnv when supplied (Vite client vars via import.meta.env, or
83
+ // fallback aliases). Otherwise auto-build from every declared key (server + client + shared);
84
+ // createEnv tolerates keys absent from process.env (they parse as undefined → optional/default).
85
+ let runtimeEnv: Record<string, string | boolean | number | undefined>;
86
+ if (config.runtimeEnv) {
87
+ runtimeEnv = config.runtimeEnv;
88
+ } else {
89
+ runtimeEnv = {};
90
+ for (const key of Object.keys(config.server)) {
91
+ runtimeEnv[key] = process.env[key];
92
+ }
93
+ for (const key of Object.keys(config.client ?? {})) {
94
+ runtimeEnv[key] = process.env[key];
95
+ }
96
+ for (const key of Object.keys(config.shared ?? {})) {
97
+ runtimeEnv[key] = process.env[key];
98
+ }
99
+ }
100
+
101
+ const onValidationError =
102
+ config.options?.onValidationError ??
103
+ ((issues: ValidationIssues): never => {
104
+ const message = formatIssues(config.name, issues);
105
+ console.error(message);
106
+ throw new Error(message);
107
+ });
108
+
109
+ // `createEnv`'s `server`/`client` params are conditional on `clientPrefix` being a *concrete*
110
+ // string literal; inside a generic wrapper `TPrefix` is abstract, so the body cannot be verified
111
+ // for all prefixes and the call argument must be asserted once. The PUBLIC contract stays exact:
112
+ // the explicit return type above reconstructs `createEnv`'s own result from the generics, and the
113
+ // caller's `server`/`client`/`extends` are fully type-checked against `TServer`/`TClient`/`TExtends`
114
+ // at the call site. This is the only seam where types are asserted — value types are never widened.
115
+ const options = {
116
+ shared: config.shared ?? {},
117
+ extends: config.extends,
118
+ server: config.server,
119
+ client: config.client ?? {},
120
+ clientPrefix: config.clientPrefix ?? '',
121
+ runtimeEnv,
122
+ skipValidation,
123
+ // Default ON: in containerized deploys an unset var is baked as "" (e.g. Docker `ENV X=${X}`
124
+ // with no build arg), not left absent. Treating "" as undefined lets `.default()`/`.optional()`
125
+ // apply as intended instead of failing format checks like `z.url()` on an empty string.
126
+ emptyStringAsUndefined: config.options?.emptyStringAsUndefined ?? true,
127
+ onValidationError,
128
+ } satisfies Record<string, unknown>;
129
+
130
+ return createEnv(options as Parameters<typeof createEnv>[0]) as CreateEnv<
131
+ DefaultCombinedSchema<TServer, TClient, TShared>,
132
+ TExtends
133
+ >;
134
+ }
@@ -0,0 +1,2 @@
1
+ export { defineEnv } from './define-env.js';
2
+ export { contracts } from '../contracts/index.js';
package/src/dot.ts ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * DOT adapter for `@arki/env`.
3
+ *
4
+ * Wraps `defineEnv()` as a DOT pip so a DOT app can validate its
5
+ * environment alongside any other services. The pip is sync (env
6
+ * validation is sync), so the `boot` hook returns the validated env
7
+ * object synchronously through the standard provides channel.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { defineApp } from '@arki/dot';
12
+ * import { env } from '@arki/env/dot';
13
+ * import { z } from 'zod';
14
+ *
15
+ * const app = await defineApp('my-app')
16
+ * .use(env({ schema: { PORT: z.coerce.number().default(3000) } }))
17
+ * .boot();
18
+ *
19
+ * console.log(app.services.env.PORT); // number, typed.
20
+ * ```
21
+ *
22
+ * To mount a second env scope in the same app, rename the published wire
23
+ * key at the mount site:
24
+ *
25
+ * ```ts
26
+ * import { rename } from '@arki/dot';
27
+ *
28
+ * .use(env({ schema: appSchema }))
29
+ * .use(rename(env({ schema: publicSchema }), { env: 'publicEnv' }, 'public-env'))
30
+ * ```
31
+ *
32
+ * The `@arki/dot` package is an OPTIONAL peer of `@arki/env`. Importing
33
+ * this adapter without `@arki/dot` installed will fail at module load —
34
+ * that is intentional: the adapter only makes sense in a DOT app.
35
+ */
36
+
37
+ import { pip, type EmptyShape, type Pip } from '@arki/dot/pip';
38
+ import type { ZodType } from 'zod';
39
+
40
+ import { defineEnv } from './core/define-env.js';
41
+
42
+ /**
43
+ * Options for the env DOT adapter.
44
+ *
45
+ * @typeParam TSchema - The shape of the `server` schema record. The
46
+ * resulting `app.services.env` is typed from this.
47
+ */
48
+ export type EnvDotOptions<TSchema extends Record<string, ZodType>> = {
49
+ /** Zod schemas for each env var the pip validates. */
50
+ readonly schema: TSchema;
51
+ /**
52
+ * Forwarded to `defineEnv`. If `true`, the validator is skipped
53
+ * entirely. By default, validation is skipped when `CI` is set or
54
+ * `NODE_ENV !== 'development'`.
55
+ */
56
+ readonly skipValidation?: boolean;
57
+ };
58
+
59
+ /** The shape of services published by the env adapter. */
60
+ export type EnvServices<TSchema extends Record<string, ZodType>> = {
61
+ readonly env: { readonly [K in keyof TSchema]: TSchema[K] extends ZodType<infer U> ? U : never };
62
+ };
63
+
64
+ /**
65
+ * Build a DOT pip that validates and publishes a typed `env` service.
66
+ *
67
+ * @param options - Schema + optional config.
68
+ * @returns A pip that publishes `services.env`.
69
+ */
70
+ export function env<TSchema extends Record<string, ZodType>>(
71
+ options: EnvDotOptions<TSchema>,
72
+ ): Pip<EmptyShape, EnvServices<TSchema>> {
73
+ return pip({
74
+ name: 'env',
75
+ version: '0.1.0',
76
+ configure(ctx) {
77
+ ctx.registerService('env', 'env');
78
+ },
79
+ boot() {
80
+ const validated = defineEnv({
81
+ server: options.schema,
82
+ options:
83
+ options.skipValidation !== undefined
84
+ ? { skipValidation: options.skipValidation }
85
+ : undefined,
86
+ });
87
+ // `defineEnv` returns a Proxy from `@t3-oss/env-core`; we narrow it
88
+ // to the schema-derived shape so downstream typing flows through.
89
+ // The runtime guarantee comes from Zod-side validation inside
90
+ // `defineEnv`. No `any` involved — the cast is type-only.
91
+ const envObject = validated as unknown as EnvServices<TSchema>['env'];
92
+ return { env: envObject };
93
+ },
94
+ });
95
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ // Re-export contracts for convenience
2
+ export { contracts } from './contracts/index.js';