@pyreon/zero 0.12.1 → 0.12.2
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/lib/index.js +1476 -82
- package/lib/index.js.map +1 -1
- package/lib/types/adapters/cloudflare.d.ts +26 -0
- package/lib/types/adapters/cloudflare.d.ts.map +1 -0
- package/lib/types/adapters/index.d.ts +3 -0
- package/lib/types/adapters/index.d.ts.map +1 -1
- package/lib/types/adapters/netlify.d.ts +21 -0
- package/lib/types/adapters/netlify.d.ts.map +1 -0
- package/lib/types/adapters/vercel.d.ts +21 -0
- package/lib/types/adapters/vercel.d.ts.map +1 -0
- package/lib/types/ai.d.ts +182 -0
- package/lib/types/ai.d.ts.map +1 -0
- package/lib/types/csp.d.ts +107 -0
- package/lib/types/csp.d.ts.map +1 -0
- package/lib/types/env.d.ts +118 -0
- package/lib/types/env.d.ts.map +1 -0
- package/lib/types/favicon.d.ts +42 -0
- package/lib/types/favicon.d.ts.map +1 -1
- package/lib/types/index.d.ts +13 -3
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/logger.d.ts +68 -0
- package/lib/types/logger.d.ts.map +1 -0
- package/lib/types/meta.d.ts +36 -0
- package/lib/types/meta.d.ts.map +1 -1
- package/lib/types/og-image.d.ts +107 -0
- package/lib/types/og-image.d.ts.map +1 -0
- package/lib/types/types.d.ts +1 -1
- package/lib/types/types.d.ts.map +1 -1
- package/package.json +35 -10
- package/src/adapters/cloudflare.ts +82 -0
- package/src/adapters/index.ts +13 -1
- package/src/adapters/netlify.ts +84 -0
- package/src/adapters/vercel.ts +84 -0
- package/src/ai.ts +623 -0
- package/src/csp.ts +207 -0
- package/src/env.ts +344 -0
- package/src/favicon.ts +221 -80
- package/src/index.ts +41 -2
- package/src/logger.ts +144 -0
- package/src/meta.tsx +84 -2
- package/src/og-image.ts +378 -0
- package/src/types.ts +1 -1
package/src/csp.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content Security Policy middleware.
|
|
3
|
+
*
|
|
4
|
+
* Generates a CSP header from a typed configuration object.
|
|
5
|
+
* Supports all CSP directives, nonces for inline scripts,
|
|
6
|
+
* and report-only mode for testing.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { cspMiddleware } from "@pyreon/zero"
|
|
11
|
+
*
|
|
12
|
+
* const csp = cspMiddleware({
|
|
13
|
+
* directives: {
|
|
14
|
+
* defaultSrc: ["'self'"],
|
|
15
|
+
* scriptSrc: ["'self'", "'nonce'"],
|
|
16
|
+
* styleSrc: ["'self'", "'unsafe-inline'"],
|
|
17
|
+
* imgSrc: ["'self'", "data:", "https:"],
|
|
18
|
+
* connectSrc: ["'self'", "https://api.example.com"],
|
|
19
|
+
* },
|
|
20
|
+
* reportOnly: false,
|
|
21
|
+
* })
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
import type { Middleware, MiddlewareContext } from '@pyreon/server'
|
|
25
|
+
import { useRequestLocals } from '@pyreon/server'
|
|
26
|
+
|
|
27
|
+
/** Client-side fallback nonce (dev server, SPA). */
|
|
28
|
+
let _clientNonce = ''
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read the current CSP nonce in a component.
|
|
32
|
+
*
|
|
33
|
+
* SSR: reads from per-request `ctx.locals.cspNonce` via Pyreon's context
|
|
34
|
+
* system — fully isolated between concurrent requests via AsyncLocalStorage.
|
|
35
|
+
* Client/dev: falls back to module-level variable set by middleware.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```tsx
|
|
39
|
+
* import { useNonce } from "@pyreon/zero/csp"
|
|
40
|
+
*
|
|
41
|
+
* function InlineScript() {
|
|
42
|
+
* const nonce = useNonce()
|
|
43
|
+
* return <script nonce={nonce}>console.log("safe")</script>
|
|
44
|
+
* }
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export function useNonce(): string {
|
|
48
|
+
const locals = useRequestLocals()
|
|
49
|
+
if (locals.cspNonce) return locals.cspNonce as string
|
|
50
|
+
return _clientNonce
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CspDirectives {
|
|
54
|
+
defaultSrc?: string[]
|
|
55
|
+
scriptSrc?: string[]
|
|
56
|
+
styleSrc?: string[]
|
|
57
|
+
imgSrc?: string[]
|
|
58
|
+
fontSrc?: string[]
|
|
59
|
+
connectSrc?: string[]
|
|
60
|
+
mediaSrc?: string[]
|
|
61
|
+
objectSrc?: string[]
|
|
62
|
+
frameSrc?: string[]
|
|
63
|
+
childSrc?: string[]
|
|
64
|
+
workerSrc?: string[]
|
|
65
|
+
frameAncestors?: string[]
|
|
66
|
+
formAction?: string[]
|
|
67
|
+
baseUri?: string[]
|
|
68
|
+
manifestSrc?: string[]
|
|
69
|
+
/** Reporting endpoint URL. */
|
|
70
|
+
reportUri?: string
|
|
71
|
+
/** Reporting endpoint name (CSP Level 3). */
|
|
72
|
+
reportTo?: string
|
|
73
|
+
/** Upgrade insecure requests. */
|
|
74
|
+
upgradeInsecureRequests?: boolean
|
|
75
|
+
/** Block all mixed content. */
|
|
76
|
+
blockAllMixedContent?: boolean
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface CspConfig {
|
|
80
|
+
/** CSP directives. */
|
|
81
|
+
directives: CspDirectives
|
|
82
|
+
/**
|
|
83
|
+
* Report-only mode — logs violations without blocking.
|
|
84
|
+
* Uses Content-Security-Policy-Report-Only header instead.
|
|
85
|
+
* Default: false
|
|
86
|
+
*/
|
|
87
|
+
reportOnly?: boolean
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const DIRECTIVE_MAP: Record<string, string> = {
|
|
91
|
+
defaultSrc: 'default-src',
|
|
92
|
+
scriptSrc: 'script-src',
|
|
93
|
+
styleSrc: 'style-src',
|
|
94
|
+
imgSrc: 'img-src',
|
|
95
|
+
fontSrc: 'font-src',
|
|
96
|
+
connectSrc: 'connect-src',
|
|
97
|
+
mediaSrc: 'media-src',
|
|
98
|
+
objectSrc: 'object-src',
|
|
99
|
+
frameSrc: 'frame-src',
|
|
100
|
+
childSrc: 'child-src',
|
|
101
|
+
workerSrc: 'worker-src',
|
|
102
|
+
frameAncestors: 'frame-ancestors',
|
|
103
|
+
formAction: 'form-action',
|
|
104
|
+
baseUri: 'base-uri',
|
|
105
|
+
manifestSrc: 'manifest-src',
|
|
106
|
+
reportUri: 'report-uri',
|
|
107
|
+
reportTo: 'report-to',
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Build a CSP header string from directives.
|
|
112
|
+
* Exported for testing.
|
|
113
|
+
*/
|
|
114
|
+
export function buildCspHeader(directives: CspDirectives, nonce?: string): string {
|
|
115
|
+
const parts: string[] = []
|
|
116
|
+
|
|
117
|
+
for (const [key, cssProp] of Object.entries(DIRECTIVE_MAP)) {
|
|
118
|
+
const value = (directives as Record<string, unknown>)[key]
|
|
119
|
+
if (!value) continue
|
|
120
|
+
|
|
121
|
+
if (Array.isArray(value)) {
|
|
122
|
+
// Replace "'nonce'" placeholder with actual nonce
|
|
123
|
+
const resolved = nonce
|
|
124
|
+
? value.map((v: string) => (v === "'nonce'" ? `'nonce-${nonce}'` : v))
|
|
125
|
+
: value.filter((v: string) => v !== "'nonce'")
|
|
126
|
+
parts.push(`${cssProp} ${resolved.join(' ')}`)
|
|
127
|
+
} else if (typeof value === 'string') {
|
|
128
|
+
parts.push(`${cssProp} ${value}`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (directives.upgradeInsecureRequests) {
|
|
133
|
+
parts.push('upgrade-insecure-requests')
|
|
134
|
+
}
|
|
135
|
+
if (directives.blockAllMixedContent) {
|
|
136
|
+
parts.push('block-all-mixed-content')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return parts.join('; ')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Generate a random nonce string (base64, 16 bytes).
|
|
144
|
+
*/
|
|
145
|
+
function generateNonce(): string {
|
|
146
|
+
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
|
147
|
+
const bytes = new Uint8Array(16)
|
|
148
|
+
crypto.getRandomValues(bytes)
|
|
149
|
+
// Convert to base64 using btoa
|
|
150
|
+
let binary = ''
|
|
151
|
+
for (const byte of bytes) binary += String.fromCharCode(byte)
|
|
152
|
+
return typeof btoa === 'function'
|
|
153
|
+
? btoa(binary)
|
|
154
|
+
: Buffer.from(bytes).toString('base64')
|
|
155
|
+
}
|
|
156
|
+
// Fallback for environments without crypto
|
|
157
|
+
return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* CSP middleware — sets Content-Security-Policy header.
|
|
162
|
+
*
|
|
163
|
+
* When directives contain `"'nonce'"`, a fresh nonce is generated per-request
|
|
164
|
+
* and attached to `ctx.locals.cspNonce` for use in inline script tags.
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* ```ts
|
|
168
|
+
* // Apply to all routes
|
|
169
|
+
* export default defineConfig({
|
|
170
|
+
* middleware: [
|
|
171
|
+
* cspMiddleware({
|
|
172
|
+
* directives: {
|
|
173
|
+
* defaultSrc: ["'self'"],
|
|
174
|
+
* scriptSrc: ["'self'", "'nonce'"],
|
|
175
|
+
* styleSrc: ["'self'", "'unsafe-inline'"],
|
|
176
|
+
* imgSrc: ["'self'", "data:", "https:"],
|
|
177
|
+
* },
|
|
178
|
+
* }),
|
|
179
|
+
* ],
|
|
180
|
+
* })
|
|
181
|
+
* ```
|
|
182
|
+
*/
|
|
183
|
+
export function cspMiddleware(config: CspConfig): Middleware {
|
|
184
|
+
const headerName = config.reportOnly
|
|
185
|
+
? 'Content-Security-Policy-Report-Only'
|
|
186
|
+
: 'Content-Security-Policy'
|
|
187
|
+
|
|
188
|
+
// Check if nonce is needed
|
|
189
|
+
const needsNonce = Object.values(config.directives).some(
|
|
190
|
+
(v) => Array.isArray(v) && v.includes("'nonce'"),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
// Pre-build header for static case (no nonce)
|
|
194
|
+
const staticHeader = needsNonce ? null : buildCspHeader(config.directives)
|
|
195
|
+
|
|
196
|
+
return (ctx: MiddlewareContext) => {
|
|
197
|
+
if (staticHeader) {
|
|
198
|
+
_clientNonce = ''
|
|
199
|
+
ctx.headers.set(headerName, staticHeader)
|
|
200
|
+
} else {
|
|
201
|
+
const nonce = generateNonce()
|
|
202
|
+
_clientNonce = nonce
|
|
203
|
+
;(ctx.locals as Record<string, unknown>).cspNonce = nonce
|
|
204
|
+
ctx.headers.set(headerName, buildCspHeader(config.directives, nonce))
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment variable validation.
|
|
3
|
+
*
|
|
4
|
+
* Infers types from default values — no verbose validator imports needed.
|
|
5
|
+
* Explicit validators (`url()`, `oneOf()`) available for special cases.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { validateEnv, url, oneOf } from "@pyreon/zero/env"
|
|
10
|
+
*
|
|
11
|
+
* const env = validateEnv({
|
|
12
|
+
* PORT: 3000, // number, default 3000
|
|
13
|
+
* DEBUG: false, // boolean, default false
|
|
14
|
+
* HOST: "localhost", // string, default "localhost"
|
|
15
|
+
* DATABASE_URL: url(), // validated URL, required
|
|
16
|
+
* NODE_ENV: oneOf(["development", "production", "test"]),
|
|
17
|
+
* API_KEY: String, // required string, no default
|
|
18
|
+
* MAX_RETRIES: Number, // required number, no default
|
|
19
|
+
* })
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface EnvValidatorOptions<T = string> {
|
|
24
|
+
/** Whether this variable is required. Default: true */
|
|
25
|
+
required?: boolean
|
|
26
|
+
/** Default value when not set. Makes the variable optional. */
|
|
27
|
+
default?: T
|
|
28
|
+
/** Human-readable description for error messages. */
|
|
29
|
+
description?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface EnvValidator<T> {
|
|
33
|
+
__type: 'env-validator'
|
|
34
|
+
parse: (raw: string | undefined, key: string) => T
|
|
35
|
+
required: boolean
|
|
36
|
+
defaultValue?: T | undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── Explicit validators (for special cases) ────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* String validator — accepts any non-empty string.
|
|
43
|
+
*/
|
|
44
|
+
export function str(options?: EnvValidatorOptions<string>): EnvValidator<string> {
|
|
45
|
+
const required = options?.default === undefined && options?.required !== false
|
|
46
|
+
return {
|
|
47
|
+
__type: 'env-validator',
|
|
48
|
+
required,
|
|
49
|
+
defaultValue: options?.default,
|
|
50
|
+
parse(raw, key) {
|
|
51
|
+
if (raw === undefined || raw === '') {
|
|
52
|
+
if (options?.default !== undefined) return options.default
|
|
53
|
+
throw new EnvError(key, 'is required but not set', options?.description)
|
|
54
|
+
}
|
|
55
|
+
return raw
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Number validator — parses to a number, rejects NaN.
|
|
62
|
+
*/
|
|
63
|
+
export function num(options?: EnvValidatorOptions<number>): EnvValidator<number> {
|
|
64
|
+
const required = options?.default === undefined && options?.required !== false
|
|
65
|
+
return {
|
|
66
|
+
__type: 'env-validator',
|
|
67
|
+
required,
|
|
68
|
+
defaultValue: options?.default,
|
|
69
|
+
parse(raw, key) {
|
|
70
|
+
if (raw === undefined || raw === '') {
|
|
71
|
+
if (options?.default !== undefined) return options.default
|
|
72
|
+
throw new EnvError(key, 'is required but not set', options?.description)
|
|
73
|
+
}
|
|
74
|
+
const n = Number(raw)
|
|
75
|
+
if (Number.isNaN(n)) {
|
|
76
|
+
throw new EnvError(key, `must be a number, got "${raw}"`, options?.description)
|
|
77
|
+
}
|
|
78
|
+
return n
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Boolean validator — accepts "true"/"1" as true, "false"/"0" as false.
|
|
85
|
+
*/
|
|
86
|
+
export function bool(options?: EnvValidatorOptions<boolean>): EnvValidator<boolean> {
|
|
87
|
+
const required = options?.default === undefined && options?.required !== false
|
|
88
|
+
return {
|
|
89
|
+
__type: 'env-validator',
|
|
90
|
+
required,
|
|
91
|
+
defaultValue: options?.default,
|
|
92
|
+
parse(raw, key) {
|
|
93
|
+
if (raw === undefined || raw === '') {
|
|
94
|
+
if (options?.default !== undefined) return options.default
|
|
95
|
+
throw new EnvError(key, 'is required but not set', options?.description)
|
|
96
|
+
}
|
|
97
|
+
const lower = raw.toLowerCase()
|
|
98
|
+
if (lower === 'true' || lower === '1') return true
|
|
99
|
+
if (lower === 'false' || lower === '0') return false
|
|
100
|
+
throw new EnvError(key, `must be "true" or "false", got "${raw}"`, options?.description)
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* URL validator — validates that the value is a valid URL.
|
|
107
|
+
*/
|
|
108
|
+
export function url(options?: EnvValidatorOptions<string>): EnvValidator<string> {
|
|
109
|
+
const required = options?.default === undefined && options?.required !== false
|
|
110
|
+
return {
|
|
111
|
+
__type: 'env-validator',
|
|
112
|
+
required,
|
|
113
|
+
defaultValue: options?.default,
|
|
114
|
+
parse(raw, key) {
|
|
115
|
+
if (raw === undefined || raw === '') {
|
|
116
|
+
if (options?.default !== undefined) return options.default
|
|
117
|
+
throw new EnvError(key, 'is required but not set', options?.description)
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
new URL(raw)
|
|
121
|
+
return raw
|
|
122
|
+
} catch {
|
|
123
|
+
throw new EnvError(key, `must be a valid URL, got "${raw}"`, options?.description)
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Enum validator — value must be one of the allowed values.
|
|
131
|
+
*/
|
|
132
|
+
export function oneOf<T extends string>(
|
|
133
|
+
values: readonly T[],
|
|
134
|
+
options?: EnvValidatorOptions<T>,
|
|
135
|
+
): EnvValidator<T> {
|
|
136
|
+
const required = options?.default === undefined && options?.required !== false
|
|
137
|
+
return {
|
|
138
|
+
__type: 'env-validator',
|
|
139
|
+
required,
|
|
140
|
+
defaultValue: options?.default,
|
|
141
|
+
parse(raw, key) {
|
|
142
|
+
if (raw === undefined || raw === '') {
|
|
143
|
+
if (options?.default !== undefined) return options.default
|
|
144
|
+
throw new EnvError(key, 'is required but not set', options?.description)
|
|
145
|
+
}
|
|
146
|
+
if (!values.includes(raw as T)) {
|
|
147
|
+
throw new EnvError(
|
|
148
|
+
key,
|
|
149
|
+
`must be one of [${values.join(', ')}], got "${raw}"`,
|
|
150
|
+
options?.description,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
return raw as T
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── Internal helpers ───────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
class EnvError extends Error {
|
|
161
|
+
constructor(key: string, message: string, description?: string) {
|
|
162
|
+
const desc = description ? ` (${description})` : ''
|
|
163
|
+
super(`[zero:env] ${key}${desc}: ${message}`)
|
|
164
|
+
this.name = 'EnvError'
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isEnvValidator(v: unknown): v is EnvValidator<unknown> {
|
|
169
|
+
return typeof v === 'object' && v !== null && (v as any).__type === 'env-validator'
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Convert a plain schema value to an EnvValidator.
|
|
174
|
+
*
|
|
175
|
+
* - `3000` → num({ default: 3000 })
|
|
176
|
+
* - `false` → bool({ default: false })
|
|
177
|
+
* - `"localhost"` → str({ default: "localhost" })
|
|
178
|
+
* - `String` → str() (required)
|
|
179
|
+
* - `Number` → num() (required)
|
|
180
|
+
* - `Boolean` → bool() (required)
|
|
181
|
+
* - EnvValidator → pass through
|
|
182
|
+
*/
|
|
183
|
+
function toValidator(value: unknown): EnvValidator<unknown> {
|
|
184
|
+
if (isEnvValidator(value)) return value
|
|
185
|
+
|
|
186
|
+
// Constructor markers → required, no default
|
|
187
|
+
if (value === String) return str()
|
|
188
|
+
if (value === Number) return num()
|
|
189
|
+
if (value === Boolean) return bool()
|
|
190
|
+
|
|
191
|
+
// Plain values → infer type + use as default
|
|
192
|
+
if (typeof value === 'number') return num({ default: value })
|
|
193
|
+
if (typeof value === 'boolean') return bool({ default: value })
|
|
194
|
+
if (typeof value === 'string') return str({ default: value })
|
|
195
|
+
|
|
196
|
+
throw new Error(`[zero:env] Invalid schema value: ${String(value)}. Use a default value, String/Number/Boolean, or a validator like url().`)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Type inference ─────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/** Schema entry: plain value, constructor, or explicit validator. */
|
|
202
|
+
type SchemaEntry =
|
|
203
|
+
| string | number | boolean
|
|
204
|
+
| StringConstructor | NumberConstructor | BooleanConstructor
|
|
205
|
+
| EnvValidator<any>
|
|
206
|
+
|
|
207
|
+
/** Infer the output type from a schema entry. */
|
|
208
|
+
type InferEntry<T> =
|
|
209
|
+
T extends EnvValidator<infer V> ? V :
|
|
210
|
+
T extends StringConstructor ? string :
|
|
211
|
+
T extends NumberConstructor ? number :
|
|
212
|
+
T extends BooleanConstructor ? boolean :
|
|
213
|
+
T extends string ? string :
|
|
214
|
+
T extends number ? number :
|
|
215
|
+
T extends boolean ? boolean :
|
|
216
|
+
never
|
|
217
|
+
|
|
218
|
+
type InferEnvSchema<T> = {
|
|
219
|
+
[K in keyof T]: InferEntry<T[K]>
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ─── Main API ───────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Validate environment variables.
|
|
226
|
+
*
|
|
227
|
+
* Schema values can be:
|
|
228
|
+
* - **Default values**: `3000`, `false`, `"localhost"` → type inferred, used as default
|
|
229
|
+
* - **Constructors**: `String`, `Number`, `Boolean` → required, no default
|
|
230
|
+
* - **Validators**: `url()`, `oneOf([...])`, `str()`, `num()`, `bool()` → explicit validation
|
|
231
|
+
* - **Custom**: `schema(raw => z.coerce.number().parse(raw))` — bridge to any schema library
|
|
232
|
+
*
|
|
233
|
+
* @example
|
|
234
|
+
* ```ts
|
|
235
|
+
* import { validateEnv, url, oneOf } from "@pyreon/zero/env"
|
|
236
|
+
*
|
|
237
|
+
* const env = validateEnv({
|
|
238
|
+
* PORT: 3000, // optional, default 3000
|
|
239
|
+
* DATABASE_URL: url(), // required, validated URL
|
|
240
|
+
* NODE_ENV: oneOf(["dev", "prod", "test"]), // required, must be one of
|
|
241
|
+
* API_KEY: String, // required string
|
|
242
|
+
* DEBUG: false, // optional, default false
|
|
243
|
+
* })
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
export function validateEnv<T extends Record<string, SchemaEntry>>(
|
|
247
|
+
schema: T,
|
|
248
|
+
source?: Record<string, string | undefined>,
|
|
249
|
+
): InferEnvSchema<T> {
|
|
250
|
+
const env = source ?? (typeof process !== 'undefined' ? process.env : {})
|
|
251
|
+
const result: Record<string, unknown> = {}
|
|
252
|
+
const errors: string[] = []
|
|
253
|
+
|
|
254
|
+
for (const [key, entry] of Object.entries(schema)) {
|
|
255
|
+
const validator = toValidator(entry)
|
|
256
|
+
try {
|
|
257
|
+
result[key] = validator.parse(env[key], key)
|
|
258
|
+
} catch (e) {
|
|
259
|
+
errors.push((e as Error).message)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (errors.length > 0) {
|
|
264
|
+
const header = `\n[zero:env] Environment validation failed (${errors.length} error${errors.length > 1 ? 's' : ''}):\n`
|
|
265
|
+
const body = errors.map((e) => ` ✗ ${e.replace('[zero:env] ', '')}`).join('\n')
|
|
266
|
+
throw new Error(header + body + '\n')
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return result as InferEnvSchema<T>
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ─── Public env (client-safe) ────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Extract public environment variables (prefixed with `ZERO_PUBLIC_`).
|
|
276
|
+
*
|
|
277
|
+
* @example
|
|
278
|
+
* ```ts
|
|
279
|
+
* const pub = publicEnv()
|
|
280
|
+
* // → { API_URL: "https://...", APP_NAME: "MyApp" }
|
|
281
|
+
*
|
|
282
|
+
* const pub = publicEnv({ API_URL: url(), APP_NAME: "Default" })
|
|
283
|
+
* // → validated against ZERO_PUBLIC_API_URL, ZERO_PUBLIC_APP_NAME
|
|
284
|
+
* ```
|
|
285
|
+
*/
|
|
286
|
+
export function publicEnv(): Record<string, string>
|
|
287
|
+
export function publicEnv<T extends Record<string, SchemaEntry>>(schema: T): InferEnvSchema<T>
|
|
288
|
+
export function publicEnv(schema?: Record<string, SchemaEntry>): Record<string, unknown> {
|
|
289
|
+
const prefix = 'ZERO_PUBLIC_'
|
|
290
|
+
const env = typeof process !== 'undefined' ? process.env : {}
|
|
291
|
+
|
|
292
|
+
if (!schema) {
|
|
293
|
+
const result: Record<string, string> = {}
|
|
294
|
+
for (const [key, value] of Object.entries(env)) {
|
|
295
|
+
if (key.startsWith(prefix) && value !== undefined) {
|
|
296
|
+
result[key.slice(prefix.length)] = value
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return result
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const prefixedSource: Record<string, string | undefined> = {}
|
|
303
|
+
for (const key of Object.keys(schema)) {
|
|
304
|
+
prefixedSource[key] = env[`${prefix}${key}`]
|
|
305
|
+
}
|
|
306
|
+
return validateEnv(schema, prefixedSource)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ─── Custom validator escape hatch ──────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Create an env validator from a custom parse function.
|
|
313
|
+
* Use this to integrate any schema library (Zod, Valibot, ArkType, etc.).
|
|
314
|
+
*
|
|
315
|
+
* @example
|
|
316
|
+
* ```ts
|
|
317
|
+
* import { z } from "zod"
|
|
318
|
+
* import { validateEnv, schema } from "@pyreon/zero/env"
|
|
319
|
+
*
|
|
320
|
+
* const env = validateEnv({
|
|
321
|
+
* PORT: schema(raw => z.coerce.number().parse(raw)),
|
|
322
|
+
* DATABASE_URL: schema(raw => z.string().url().parse(raw)),
|
|
323
|
+
* HOST: "localhost", // plain defaults still work alongside
|
|
324
|
+
* })
|
|
325
|
+
* ```
|
|
326
|
+
*/
|
|
327
|
+
export function schema<T>(parse: (raw: string) => T): EnvValidator<T> {
|
|
328
|
+
return {
|
|
329
|
+
__type: 'env-validator',
|
|
330
|
+
required: true,
|
|
331
|
+
defaultValue: undefined,
|
|
332
|
+
parse(raw: string | undefined, key: string) {
|
|
333
|
+
if (raw === undefined || raw === '') {
|
|
334
|
+
throw new Error(`[zero:env] ${key}: is required but not set`)
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
return parse(raw)
|
|
338
|
+
} catch (e) {
|
|
339
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
340
|
+
throw new Error(`[zero:env] ${key}: ${msg}`)
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
}
|
|
344
|
+
}
|