Skip to content

Commit bc3eda8

Browse files
committed
✨ Add enum parser for type-safe environment variable validation
Introduce envParsers.enum() to validate environment variables against a set of allowed values with full type safety. The parser supports: - Generic type parameter for compile-time type inference - Optional default values that fall back when undefined - Clear error messages listing allowed values - Isomorphic operation (server and client contexts) Update README and MIGRATION docs to document the new parser alongside other v4.x features including default values, requireEnv(), and the existing parsers (boolean, number, array, json, url).
1 parent dddd5ac commit bc3eda8

4 files changed

Lines changed: 250 additions & 15 deletions

File tree

README.md

Lines changed: 92 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
<div align="center">
44

5-
[![Next.js](https://img.shields.io/badge/Next.js%2015-e135ff.svg?style=for-the-badge&logo=next.js&logoColor=white)](https://nextjs.org/)
5+
[![Next.js](https://img.shields.io/badge/Next.js%2015%20%7C%2016-e135ff.svg?style=for-the-badge&logo=next.js&logoColor=white)](https://nextjs.org/)
66
[![React](https://img.shields.io/badge/React%2019-80ffea.svg?style=for-the-badge&logo=react&logoColor=white)](https://react.dev/)
77
[![TypeScript](https://img.shields.io/badge/TypeScript-ff79c6?style=for-the-badge&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
88
[![License](https://img.shields.io/badge/License-MIT-f1fa8c?style=for-the-badge&logo=opensourceinitiative&logoColor=white)](https://opensource.org/licenses/MIT)
@@ -25,9 +25,11 @@
2525
## ✨ Highlights
2626

2727
- **Isomorphic Design:** Works seamlessly on both server and browser, and even in middleware
28-
- **Next.js 15 & React 19 Ready:** Fully compatible with the latest Next.js features including async server components
28+
- **Next.js 15/16 & React 19 Ready:** Fully compatible with the latest Next.js features including async server
29+
components
2930
- **`.env` Friendly:** Use `.env` files during development, just like standard Next.js
30-
- **Type-Safe:** Full TypeScript support for environment variables
31+
- **Type-Safe Parsers:** Convert environment strings to booleans, numbers, arrays, JSON, URLs, and enums
32+
- **Secure by Default:** XSS protection via JSON escaping, immutable runtime values with `Object.freeze`
3133
- **Zero Config:** Works out of the box with sensible defaults
3234

3335
## 🤔 Why `next-runtime-env`?
@@ -170,6 +172,76 @@ export const config = {
170172
> **Note:** The `env()` function works in all Next.js contexts - server components, client components, API routes, and
171173
> middleware. It's safe to use everywhere and provides a consistent API across your application.
172174
175+
### Default Values
176+
177+
The `env()` function accepts an optional default value:
178+
179+
```tsx
180+
import { env } from '@hyperb1iss/next-runtime-env'
181+
182+
// Returns 'https://api.default.com' if NEXT_PUBLIC_API_URL is undefined
183+
const apiUrl = env('NEXT_PUBLIC_API_URL', 'https://api.default.com')
184+
185+
// Default values work in all contexts (client, server, middleware)
186+
const timeout = env('NEXT_PUBLIC_TIMEOUT', '5000')
187+
```
188+
189+
### Required Environment Variables
190+
191+
Use `requireEnv()` when a variable must be defined:
192+
193+
```tsx
194+
import { requireEnv } from '@hyperb1iss/next-runtime-env'
195+
196+
// Throws descriptive error if NEXT_PUBLIC_API_URL is undefined
197+
const apiUrl = requireEnv('NEXT_PUBLIC_API_URL')
198+
// Error: "Required environment variable 'NEXT_PUBLIC_API_URL' is not defined."
199+
```
200+
201+
### Type-Safe Parsers
202+
203+
Use `envParsers` to convert environment strings to typed values:
204+
205+
```tsx
206+
import { envParsers } from '@hyperb1iss/next-runtime-env'
207+
208+
// Boolean - recognizes 'true', '1', 'yes', 'on' (case-insensitive)
209+
const debug = envParsers.boolean('NEXT_PUBLIC_DEBUG') // false by default
210+
const enabled = envParsers.boolean('NEXT_PUBLIC_FEATURE', true) // custom default
211+
212+
// Number - integers and floats
213+
const port = envParsers.number('NEXT_PUBLIC_PORT', 3000)
214+
const ratio = envParsers.number('NEXT_PUBLIC_RATIO', 1.0)
215+
216+
// Array - comma-separated values (trims whitespace, filters empty)
217+
const features = envParsers.array('NEXT_PUBLIC_FEATURES')
218+
// 'auth, payments, analytics' → ['auth', 'payments', 'analytics']
219+
220+
// JSON - parse complex objects
221+
interface Config {
222+
api: string
223+
timeout: number
224+
}
225+
const config = envParsers.json<Config>('NEXT_PUBLIC_CONFIG')
226+
227+
// URL - validates and returns URL string
228+
const apiUrl = envParsers.url('NEXT_PUBLIC_API_URL')
229+
const cdn = envParsers.url('NEXT_PUBLIC_CDN', 'https://cdn.default.com')
230+
231+
// Enum - restrict to allowed values with type safety
232+
type Environment = 'development' | 'staging' | 'production'
233+
const appEnv = envParsers.enum<Environment>(
234+
'NEXT_PUBLIC_ENV',
235+
['development', 'staging', 'production'],
236+
'development', // default value
237+
)
238+
239+
type LogLevel = 'debug' | 'info' | 'warn' | 'error'
240+
const logLevel = envParsers.enum<LogLevel>('NEXT_PUBLIC_LOG_LEVEL', ['debug', 'info', 'warn', 'error'])
241+
```
242+
243+
All parsers work isomorphically (server and client) and provide clear error messages for invalid values.
244+
173245
## 🛠 Advanced Usage
174246

175247
### Exposing Non-Prefixed Variables
@@ -268,6 +340,16 @@ For static exports with runtime environment support:
268340

269341
## 🔒 Security Considerations
270342

343+
### Built-in Security Features
344+
345+
This library includes multiple layers of security by default:
346+
347+
- **XSS Protection:** All environment values are JSON-escaped before injection, preventing script injection attacks
348+
- **Immutable Runtime Values:** Environment values are wrapped with `Object.freeze()`, preventing modification after
349+
initialization
350+
- **Strict Prefix Enforcement:** Only `NEXT_PUBLIC_*` variables are exposed to the browser; accessing private variables
351+
throws an error
352+
271353
### Never Expose Secrets to the Browser
272354

273355
**Critical:** Only variables prefixed with `NEXT_PUBLIC_` are exposed to the browser. Never expose sensitive data:
@@ -286,10 +368,15 @@ export async function getData() {
286368

287369
### Environment Variable Validation
288370

289-
Validate required environment variables at build time:
371+
Use `requireEnv()` for required variables, or validate multiple at once:
290372

291373
```tsx
292-
// lib/env.ts
374+
// Using requireEnv() - throws if undefined
375+
import { requireEnv } from '@hyperb1iss/next-runtime-env'
376+
377+
const apiUrl = requireEnv('NEXT_PUBLIC_API_URL')
378+
379+
// Validating multiple variables
293380
import { env } from '@hyperb1iss/next-runtime-env'
294381

295382
export function validateEnv() {
@@ -301,9 +388,6 @@ export function validateEnv() {
301388
}
302389
}
303390
}
304-
305-
// Call in your app initialization
306-
validateEnv()
307391
```
308392

309393
### Content Security Policy

docs/MIGRATION.md

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,51 @@ The migration from the original `next-runtime-env@3.x` to `@hyperb1iss/next-runt
8787

8888
All existing code using the library will continue to work without modification.
8989

90+
## New Features in v4.x
91+
92+
v4.x adds several new features while maintaining backwards compatibility:
93+
94+
### Default Values
95+
96+
The `env()` function now accepts an optional default value:
97+
98+
```tsx
99+
import { env } from '@hyperb1iss/next-runtime-env'
100+
101+
const apiUrl = env('NEXT_PUBLIC_API_URL', 'https://api.default.com')
102+
```
103+
104+
### Required Environment Variables
105+
106+
Use `requireEnv()` for variables that must be defined:
107+
108+
```tsx
109+
import { requireEnv } from '@hyperb1iss/next-runtime-env'
110+
111+
// Throws if undefined
112+
const apiUrl = requireEnv('NEXT_PUBLIC_API_URL')
113+
```
114+
115+
### Type-Safe Parsers
116+
117+
Convert environment strings to typed values:
118+
119+
```tsx
120+
import { envParsers } from '@hyperb1iss/next-runtime-env'
121+
122+
const debug = envParsers.boolean('NEXT_PUBLIC_DEBUG')
123+
const port = envParsers.number('NEXT_PUBLIC_PORT', 3000)
124+
const features = envParsers.array('NEXT_PUBLIC_FEATURES')
125+
const config = envParsers.json<Config>('NEXT_PUBLIC_CONFIG')
126+
const apiUrl = envParsers.url('NEXT_PUBLIC_API_URL')
127+
const appEnv = envParsers.enum('NEXT_PUBLIC_ENV', ['development', 'staging', 'production'])
128+
```
129+
130+
### Enhanced Security
131+
132+
- **XSS Protection:** All injected values are JSON-escaped
133+
- **Immutability:** Runtime values are wrapped with `Object.freeze()`
134+
90135
## Upgrading to Next.js 15 (Additional Steps)
91136

92137
If you're upgrading from Next.js 14 to Next.js 15 as part of this migration, you may need to update custom code that
@@ -145,11 +190,11 @@ export function getCustomEnv() {
145190

146191
### What Changed Under the Hood
147192

148-
The library now uses Next.js 15's stable dynamic rendering APIs:
193+
The library now uses Next.js 15/16's stable dynamic rendering APIs:
149194

150-
- `PublicEnvScript` and `PublicEnvProvider` use `await connection()`
151-
- The `env()` utility uses `headers()` to force dynamic rendering
152-
- All components are now async server components (React handles this automatically)
195+
- `PublicEnvScript` and `EnvScript` use `headers()` for nonce retrieval (when configured)
196+
- The `env()` utility reads from `process.env` (server) or `window.__ENV` (client) directly
197+
- Script injection includes XSS protection and `Object.freeze()` for immutability
153198

154199
### Troubleshooting
155200

@@ -204,9 +249,9 @@ If you're still using the original `next-runtime-env@1.x` with the Pages Router:
204249

205250
### This Fork (Maintained)
206251

207-
| Package | Version | Next.js | React | Notes |
208-
| ---------------------------- | ------- | ------- | ----- | ------------------------------------------- |
209-
| @hyperb1iss/next-runtime-env | 4.x | 15.x | 19.x | Next.js 15 & React 19 with async components |
252+
| Package | Version | Next.js | React | Notes |
253+
| ---------------------------- | ------- | ---------- | ----- | ---------------------------------------------- |
254+
| @hyperb1iss/next-runtime-env | 4.x | 15.x, 16.x | 19.x | Next.js 15/16 & React 19 with async components |
210255

211256
### Original Project (Unmaintained)
212257

src/script/parsers.spec.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,4 +207,56 @@ describe('envParsers', () => {
207207
expect(envParsers.url('NEXT_PUBLIC_API_URL')).toBe('https://api.example.com')
208208
})
209209
})
210+
211+
describe('enum()', () => {
212+
const environments = ['development', 'staging', 'production'] as const
213+
214+
it('should return valid enum value', () => {
215+
process.env.NEXT_PUBLIC_ENV = 'production'
216+
expect(envParsers.enum('NEXT_PUBLIC_ENV', environments)).toBe('production')
217+
})
218+
219+
it('should return default when undefined', () => {
220+
expect(envParsers.enum('NEXT_PUBLIC_MISSING', environments, 'development')).toBe('development')
221+
})
222+
223+
it('should throw when undefined with no default', () => {
224+
expect(() => envParsers.enum('NEXT_PUBLIC_MISSING', environments)).toThrow(
225+
"Required environment variable 'NEXT_PUBLIC_MISSING' is not defined",
226+
)
227+
})
228+
229+
it('should throw for invalid enum value', () => {
230+
process.env.NEXT_PUBLIC_ENV = 'invalid'
231+
expect(() => envParsers.enum('NEXT_PUBLIC_ENV', environments)).toThrow(
232+
"Environment variable 'NEXT_PUBLIC_ENV' has invalid value: 'invalid'",
233+
)
234+
})
235+
236+
it('should list allowed values in error message', () => {
237+
process.env.NEXT_PUBLIC_ENV = 'invalid'
238+
expect(() => envParsers.enum('NEXT_PUBLIC_ENV', environments)).toThrow(
239+
"Expected one of: 'development', 'staging', 'production'",
240+
)
241+
})
242+
243+
it('should work with two-value enums', () => {
244+
const booleanLike = ['enabled', 'disabled'] as const
245+
process.env.NEXT_PUBLIC_FEATURE = 'enabled'
246+
expect(envParsers.enum('NEXT_PUBLIC_FEATURE', booleanLike)).toBe('enabled')
247+
})
248+
249+
it('should work in browser context', () => {
250+
mockWindow({ NEXT_PUBLIC_ENV: 'staging' })
251+
expect(envParsers.enum('NEXT_PUBLIC_ENV', environments)).toBe('staging')
252+
})
253+
254+
it('should be type-safe with generic', () => {
255+
type LogLevel = 'debug' | 'info' | 'warn' | 'error'
256+
const levels: readonly LogLevel[] = ['debug', 'info', 'warn', 'error']
257+
process.env.NEXT_PUBLIC_LOG_LEVEL = 'warn'
258+
const result: LogLevel = envParsers.enum<LogLevel>('NEXT_PUBLIC_LOG_LEVEL', levels)
259+
expect(result).toBe('warn')
260+
})
261+
})
210262
})

src/script/parsers.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,4 +194,58 @@ export const envParsers = {
194194
)
195195
}
196196
},
197+
198+
/**
199+
* Parses an environment variable as one of a set of allowed values.
200+
*
201+
* Provides type-safe enum-like validation for environment variables
202+
* that should only contain specific string values.
203+
*
204+
* @param key - The environment variable name
205+
* @param allowedValues - Array of valid string values
206+
* @param defaultValue - Default value if undefined (must be in allowedValues)
207+
* @returns The validated enum value
208+
*
209+
* @throws {Error} If the value is not in the allowed values list
210+
* @throws {Error} If the value is undefined and no default is provided
211+
*
212+
* @example
213+
* ```ts
214+
* // NEXT_PUBLIC_ENV=production
215+
* type Environment = 'development' | 'staging' | 'production';
216+
* const appEnv = envParsers.enum<Environment>(
217+
* 'NEXT_PUBLIC_ENV',
218+
* ['development', 'staging', 'production'],
219+
* 'development'
220+
* );
221+
*
222+
* // NEXT_PUBLIC_LOG_LEVEL=debug
223+
* type LogLevel = 'debug' | 'info' | 'warn' | 'error';
224+
* const logLevel = envParsers.enum<LogLevel>(
225+
* 'NEXT_PUBLIC_LOG_LEVEL',
226+
* ['debug', 'info', 'warn', 'error']
227+
* );
228+
* ```
229+
*/
230+
enum<T extends string>(key: string, allowedValues: readonly T[], defaultValue?: T): T {
231+
const value = env(key)
232+
if (value === undefined) {
233+
if (defaultValue === undefined) {
234+
throw new Error(
235+
`Required environment variable '${key}' is not defined.\n` +
236+
`Expected one of: ${allowedValues.map((v) => `'${v}'`).join(', ')}.`,
237+
)
238+
}
239+
return defaultValue
240+
}
241+
242+
if (!allowedValues.includes(value as T)) {
243+
throw new Error(
244+
`Environment variable '${key}' has invalid value: '${value}'.\n` +
245+
`Expected one of: ${allowedValues.map((v) => `'${v}'`).join(', ')}.`,
246+
)
247+
}
248+
249+
return value as T
250+
},
197251
} as const

0 commit comments

Comments
 (0)