@pyreon/zero 0.12.1 → 0.12.3

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.
Files changed (140) hide show
  1. package/lib/actions.js +97 -0
  2. package/lib/actions.js.map +1 -0
  3. package/lib/ai.js +503 -0
  4. package/lib/ai.js.map +1 -0
  5. package/lib/api-routes.js +137 -0
  6. package/lib/api-routes.js.map +1 -0
  7. package/lib/compression.js +80 -0
  8. package/lib/compression.js.map +1 -0
  9. package/lib/cors.js +57 -0
  10. package/lib/cors.js.map +1 -0
  11. package/lib/csp.js +119 -0
  12. package/lib/csp.js.map +1 -0
  13. package/lib/env.js +217 -0
  14. package/lib/env.js.map +1 -0
  15. package/lib/favicon.js +424 -0
  16. package/lib/favicon.js.map +1 -0
  17. package/lib/i18n-routing.js +167 -0
  18. package/lib/i18n-routing.js.map +1 -0
  19. package/lib/index.js +1631 -179
  20. package/lib/index.js.map +1 -1
  21. package/lib/link.js +5 -0
  22. package/lib/link.js.map +1 -1
  23. package/lib/logger.js +78 -0
  24. package/lib/logger.js.map +1 -0
  25. package/lib/meta.js +336 -0
  26. package/lib/meta.js.map +1 -0
  27. package/lib/middleware.js +53 -0
  28. package/lib/middleware.js.map +1 -0
  29. package/lib/og-image.js +233 -0
  30. package/lib/og-image.js.map +1 -0
  31. package/lib/rate-limit.js +76 -0
  32. package/lib/rate-limit.js.map +1 -0
  33. package/lib/testing.js +179 -0
  34. package/lib/testing.js.map +1 -0
  35. package/lib/theme.js +11 -2
  36. package/lib/theme.js.map +1 -1
  37. package/lib/types/actions.d.ts +27 -24
  38. package/lib/types/actions.d.ts.map +1 -1
  39. package/lib/types/ai.d.ts +163 -0
  40. package/lib/types/ai.d.ts.map +1 -0
  41. package/lib/types/api-routes.d.ts +37 -33
  42. package/lib/types/api-routes.d.ts.map +1 -1
  43. package/lib/types/cache.d.ts +26 -22
  44. package/lib/types/cache.d.ts.map +1 -1
  45. package/lib/types/client.d.ts +13 -9
  46. package/lib/types/client.d.ts.map +1 -1
  47. package/lib/types/compression.d.ts +14 -10
  48. package/lib/types/compression.d.ts.map +1 -1
  49. package/lib/types/config.d.ts +39 -4
  50. package/lib/types/config.d.ts.map +1 -1
  51. package/lib/types/cors.d.ts +20 -16
  52. package/lib/types/cors.d.ts.map +1 -1
  53. package/lib/types/csp.d.ts +88 -0
  54. package/lib/types/csp.d.ts.map +1 -0
  55. package/lib/types/env.d.ts +118 -0
  56. package/lib/types/env.d.ts.map +1 -0
  57. package/lib/types/favicon.d.ts +70 -24
  58. package/lib/types/favicon.d.ts.map +1 -1
  59. package/lib/types/font.d.ts +68 -65
  60. package/lib/types/font.d.ts.map +1 -1
  61. package/lib/types/i18n-routing.d.ts +43 -37
  62. package/lib/types/i18n-routing.d.ts.map +1 -1
  63. package/lib/types/image-plugin.d.ts +49 -45
  64. package/lib/types/image-plugin.d.ts.map +1 -1
  65. package/lib/types/image.d.ts +47 -36
  66. package/lib/types/image.d.ts.map +1 -1
  67. package/lib/types/index.d.ts +1961 -46
  68. package/lib/types/index.d.ts.map +1 -1
  69. package/lib/types/link.d.ts +61 -56
  70. package/lib/types/link.d.ts.map +1 -1
  71. package/lib/types/logger.d.ts +57 -0
  72. package/lib/types/logger.d.ts.map +1 -0
  73. package/lib/types/meta.d.ts +180 -69
  74. package/lib/types/meta.d.ts.map +1 -1
  75. package/lib/types/middleware.d.ts +8 -4
  76. package/lib/types/middleware.d.ts.map +1 -1
  77. package/lib/types/og-image.d.ts +111 -0
  78. package/lib/types/og-image.d.ts.map +1 -0
  79. package/lib/types/rate-limit.d.ts +20 -16
  80. package/lib/types/rate-limit.d.ts.map +1 -1
  81. package/lib/types/script.d.ts +23 -19
  82. package/lib/types/script.d.ts.map +1 -1
  83. package/lib/types/seo.d.ts +47 -43
  84. package/lib/types/seo.d.ts.map +1 -1
  85. package/lib/types/testing.d.ts +64 -27
  86. package/lib/types/testing.d.ts.map +1 -1
  87. package/lib/types/theme.d.ts +22 -12
  88. package/lib/types/theme.d.ts.map +1 -1
  89. package/package.json +37 -12
  90. package/src/actions.ts +1 -3
  91. package/src/adapters/bun.ts +2 -0
  92. package/src/adapters/cloudflare.ts +84 -0
  93. package/src/adapters/index.ts +13 -1
  94. package/src/adapters/netlify.ts +86 -0
  95. package/src/adapters/node.ts +2 -0
  96. package/src/adapters/validate.ts +16 -0
  97. package/src/adapters/vercel.ts +86 -0
  98. package/src/ai.ts +623 -0
  99. package/src/compression.ts +19 -3
  100. package/src/csp.ts +207 -0
  101. package/src/entry-server.ts +28 -5
  102. package/src/env.ts +344 -0
  103. package/src/favicon.ts +221 -80
  104. package/src/index.ts +42 -2
  105. package/src/link.tsx +6 -0
  106. package/src/logger.ts +144 -0
  107. package/src/meta.tsx +124 -14
  108. package/src/og-image.ts +378 -0
  109. package/src/rate-limit.ts +11 -9
  110. package/src/theme.tsx +12 -1
  111. package/src/types.ts +1 -1
  112. package/src/vite-plugin.ts +5 -1
  113. package/lib/types/adapters/bun.d.ts +0 -6
  114. package/lib/types/adapters/bun.d.ts.map +0 -1
  115. package/lib/types/adapters/index.d.ts +0 -10
  116. package/lib/types/adapters/index.d.ts.map +0 -1
  117. package/lib/types/adapters/node.d.ts +0 -6
  118. package/lib/types/adapters/node.d.ts.map +0 -1
  119. package/lib/types/adapters/static.d.ts +0 -7
  120. package/lib/types/adapters/static.d.ts.map +0 -1
  121. package/lib/types/app.d.ts +0 -24
  122. package/lib/types/app.d.ts.map +0 -1
  123. package/lib/types/entry-server.d.ts +0 -37
  124. package/lib/types/entry-server.d.ts.map +0 -1
  125. package/lib/types/error-overlay.d.ts +0 -6
  126. package/lib/types/error-overlay.d.ts.map +0 -1
  127. package/lib/types/fs-router.d.ts +0 -47
  128. package/lib/types/fs-router.d.ts.map +0 -1
  129. package/lib/types/isr.d.ts +0 -9
  130. package/lib/types/isr.d.ts.map +0 -1
  131. package/lib/types/not-found.d.ts +0 -7
  132. package/lib/types/not-found.d.ts.map +0 -1
  133. package/lib/types/types.d.ts +0 -111
  134. package/lib/types/types.d.ts.map +0 -1
  135. package/lib/types/utils/use-intersection-observer.d.ts +0 -10
  136. package/lib/types/utils/use-intersection-observer.d.ts.map +0 -1
  137. package/lib/types/utils/with-headers.d.ts +0 -6
  138. package/lib/types/utils/with-headers.d.ts.map +0 -1
  139. package/lib/types/vite-plugin.d.ts +0 -17
  140. package/lib/types/vite-plugin.d.ts.map +0 -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
+ }
@@ -50,19 +50,42 @@ function createRouteMiddlewareDispatcher(
50
50
  };
51
51
  }
52
52
 
53
- /** Simple URL pattern matcher supporting :param and :param* segments. */
53
+ /**
54
+ * URL pattern matcher supporting :param and :param* segments.
55
+ *
56
+ * Rules:
57
+ * - Static segments must match exactly
58
+ * - `:param` matches a single path segment
59
+ * - `:param*` matches all remaining segments (must be last, and path must
60
+ * have matched all preceding segments)
61
+ * - Path length must match pattern length (unless catch-all)
62
+ */
54
63
  export function matchPattern(pattern: string, path: string): boolean {
55
64
  const patternParts = pattern.split("/").filter(Boolean);
56
65
  const pathParts = path.split("/").filter(Boolean);
57
66
 
58
67
  for (let i = 0; i < patternParts.length; i++) {
59
- const pp = patternParts[i];
60
- if (!pp) continue;
61
- if (pp.endsWith("*")) return true; // catch-all matches everything after
62
- if (pp.startsWith(":")) continue; // dynamic segment matches anything
68
+ const pp = patternParts[i]!;
69
+
70
+ // Catch-all: matches remaining segments, but only if we've matched
71
+ // all preceding segments up to this point
72
+ if (pp.endsWith("*")) {
73
+ // All segments before the catch-all must have matched (we got here)
74
+ // and there must be at least one remaining path segment
75
+ return i <= pathParts.length;
76
+ }
77
+
78
+ // No more path segments to match against
79
+ if (i >= pathParts.length) return false;
80
+
81
+ // Dynamic segment matches any single segment
82
+ if (pp.startsWith(":")) continue;
83
+
84
+ // Static segment must match exactly
63
85
  if (pp !== pathParts[i]) return false;
64
86
  }
65
87
 
88
+ // All pattern parts consumed — path must also be fully consumed
66
89
  return patternParts.length === pathParts.length;
67
90
  }
68
91
 
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
+ }