Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Cross-platform environment variables loader and validator with [Standard Schema]
- 🚀 **Cross-platform** - Works on Node.js, Bun, and Deno
- ✅ **Standard Schema** - Compatible with Zod, Valibot, ArkType, and more
- 📁 **.env file loading** - With proper file priority support
- 🔌 **Pluggable sources** - Load env from JSON files, CLI commands, secret managers, or any custom source
- 🔒 **Type-safe** - Full TypeScript support with type inference
- 🪶 **Lightweight** - Minimal dependencies

Expand Down Expand Up @@ -118,6 +119,9 @@ interface EnvCreateOptions {

// Custom environment variables source
envSource?: Record<string, string | undefined>

// Custom source adapters (cannot be used with path argument)
sources?: EnvSourceAdapter[]
}
```

Expand Down Expand Up @@ -169,6 +173,82 @@ catch (error) {
}
```

## Sources

By default, enwow loads environment variables from `.env` files and `process.env`. With source adapters, you can load from any source — JSON files, CLI commands, secret managers, or custom providers.

```sh
import { Env } from 'enwow'
import { fromFiles, fromJSON, fromObject, fromProcessEnv } from 'enwow/adapters'
```

Sources are merged left-to-right: later sources override earlier ones.

### Built-in Adapters

| Adapter | Description |
|---------|-------------|
| `fromFiles({ directory, ...options })` | Load from `.env` files (same as the default behavior) |
| `fromProcessEnv()` | Read from `process.env` (cross-platform) |
| `fromJSON({ path })` | Read from a JSON file with flat key-value structure |
| `fromObject({ env })` | Use a static object (useful for testing and defaults) |

### Example: Multiple Sources

```typescript
import { Env } from 'enwow'
import { fromFiles, fromJSON, fromProcessEnv } from 'enwow/adapters'
import { z } from 'zod'

const env = await Env.create({
PORT: z.string().transform(Number).default('3000'),
DATABASE_URL: z.string().url(),
API_SECRET: z.string(),
}, {
sources: [
fromFiles({ directory: new URL('./', import.meta.url) }), // lowest priority
fromJSON({ path: './config/env.json' }),
fromProcessEnv(), // highest priority
],
})
```

### Custom Adapter

Use `defineSourceAdapter` to create a type-safe adapter factory:

```typescript
import { defineSourceAdapter, fromFiles, fromProcessEnv } from 'enwow/adapters'

interface VaultOptions {
url: string
token: string
}

const vault = defineSourceAdapter<VaultOptions>((opts) => {
return {
name: 'vault',
async load() {
const response = await fetch(opts.url, {
headers: { 'X-Vault-Token': opts.token },
})
const { data } = await response.json()
return data.data
},
}
})

const env = await Env.create(schema, {
sources: [
fromFiles({ directory: new URL('./', import.meta.url) }),
vault({ url: 'https://vault.example.com/v1/secret/data/myapp', token: process.env.VAULT_TOKEN! }),
fromProcessEnv(),
],
})
```

> **Note:** The `sources` option cannot be used together with the `path` argument in `Env.create(path, schema)`. Use `fromFiles()` adapter instead.

## Advanced Usage

### With Valibot
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"sideEffects": false,
"exports": {
".": "./dist/index.mjs",
"./adapters": "./dist/adapters/index.mjs",
"./package.json": "./package.json"
},
"main": "./dist/index.mjs",
Expand Down
58 changes: 58 additions & 0 deletions src/adapters/define.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { EnvSourceAdapter } from '../types'

/**
* Factory function type for creating source adapters with options
*/
export type SourceAdapterFactory<OptionsT> = (opts: OptionsT) => EnvSourceAdapter

/**
* Define a custom source adapter factory with type safety
*
* This is an identity function that provides type inference for adapter factories.
* It follows the same pattern as `defineConfig` / `defineDriver` from unstorage.
*
* @param factory - Function that takes options and returns an adapter definition
*
* @example
* ```ts
* import { defineSourceAdapter } from 'enwow/adapters'
*
* const vault = defineSourceAdapter<{ token: string; url: string }>((opts) => {
* return {
* name: 'vault',
* async load() {
* const response = await fetch(opts.url, {
* headers: { 'X-Vault-Token': opts.token },
* })
* const { data } = await response.json()
* return data.data
* },
* }
* })
*
* // Usage:
* const env = await Env.create(schema, {
* sources: [vault({ token: 'my-token', url: 'https://vault.example.com' })],
* })
* ```
*/
export function defineSourceAdapter<OptionsT = any>(
factory: SourceAdapterFactory<OptionsT>,
): SourceAdapterFactory<OptionsT> {
return factory
}

/**
* Resolve an array of adapters into a merged environment object.
* Sources are merged left-to-right: later sources override earlier ones.
*/
export async function resolveAdapters(adapters: EnvSourceAdapter[]): Promise<Record<string, string | undefined>> {
let result: Record<string, string | undefined> = {}

for (const adapter of adapters) {
const values = await adapter.load()
result = { ...result, ...values }
}

return result
}
55 changes: 55 additions & 0 deletions src/adapters/files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { LoaderOptions } from '../loader'
import type { ParseOptions } from '../parser'
import { EnvLoader } from '../loader'
import { EnvParser } from '../parser'
import { defineSourceAdapter } from './define'

/**
* Options for the files source adapter
*/
export type FilesAdapterOptions = {
directory: URL | string
} & LoaderOptions & ParseOptions

/**
* Create an adapter that loads environment variables from .env files
*
* This wraps the built-in EnvLoader and EnvParser into the adapter interface.
*
* @example
* ```ts
* import { Env } from 'enwow'
* import { fromFiles, fromProcessEnv } from 'enwow/adapters'
*
* const env = await Env.create(schema, {
* sources: [
* fromFiles({ directory: new URL('./', import.meta.url) }),
* fromProcessEnv(),
* ],
* })
* ```
*/
export const fromFiles = defineSourceAdapter<FilesAdapterOptions>((opts) => {
return {
name: 'files',
async load() {
const loader = new EnvLoader(opts.directory, {
nodeEnv: opts.nodeEnv,
files: opts.files,
})
const files = await loader.load()

let envValues: Record<string, string> = {}

for (const file of files.reverse()) {
const parser = new EnvParser(file.contents, {
ignoreProcessEnv: opts.ignoreProcessEnv,
envSource: opts.envSource,
})
envValues = { ...envValues, ...parser.parse() }
}

return envValues
},
}
})
6 changes: 6 additions & 0 deletions src/adapters/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export type { EnvSourceAdapter } from '../types'
export { defineSourceAdapter, resolveAdapters, type SourceAdapterFactory } from './define'
export { type FilesAdapterOptions, fromFiles } from './files'
export { fromJSON, type JsonAdapterOptions } from './json'
export { fromObject, type ObjectAdapterOptions } from './object'
export { fromProcessEnv } from './process-env'
83 changes: 83 additions & 0 deletions src/adapters/json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { defineSourceAdapter } from './define'

/**
* Options for the JSON source adapter
*/
export interface JsonAdapterOptions {
path: string | URL
}

/**
* Create an adapter that reads from a JSON file
*
* Expects the JSON file to contain a flat object with string values:
* ```json
* { "PORT": "3000", "HOST": "localhost" }
* ```
*
* @example
* ```ts
* import { fromJSON } from 'enwow/adapters'
*
* const env = await Env.create(schema, {
* sources: [fromJSON({ path: './config/env.json' })],
* })
* ```
*/
export const fromJSON = defineSourceAdapter<JsonAdapterOptions>((opts) => {
return {
name: 'json',
async load() {
const filePath = opts.path instanceof URL ? opts.path.pathname : opts.path
const contents = await readFileContent(filePath)

if (contents === null) {
return {}
}

const data = JSON.parse(contents)

if (typeof data !== 'object' || data === null || Array.isArray(data)) {
throw new TypeError(`[enwow] fromJSON: expected a flat object in "${filePath}", got ${Array.isArray(data) ? 'array' : typeof data}`)
}

const result: Record<string, string | undefined> = {}
for (const [key, value] of Object.entries(data)) {
result[key] = value == null ? undefined : String(value)
}
return result
},
}
})

/**
* Cross-platform file reading helper
*/
async function readFileContent(path: string): Promise<string | null> {
const { runtime } = await import('std-env')

if (runtime === 'bun') {
const file = Bun.file(path)
const exists = await file.exists()
if (!exists)
return null
return file.text()
}

if (runtime === 'deno') {
try {
return await Deno.readTextFile(path)
}
catch {
return null
}
}

const { readFile } = await import('node:fs/promises')
try {
return await readFile(path, 'utf-8')
}
catch {
return null
}
}
32 changes: 32 additions & 0 deletions src/adapters/object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { defineSourceAdapter } from './define'

/**
* Options for the object source adapter
*/
export interface ObjectAdapterOptions {
env: Record<string, string | undefined>
}

/**
* Create an adapter that reads from a static object
*
* Useful for testing and providing fallback/default values.
*
* @example
* ```ts
* import { fromObject } from 'enwow/adapters'
*
* // In tests
* const env = await Env.create(schema, {
* sources: [fromObject({ env: { PORT: '4000', HOST: 'localhost' } })],
* })
* ```
*/
export const fromObject = defineSourceAdapter<ObjectAdapterOptions>((opts) => {
return {
name: 'object',
load() {
return { ...opts.env }
},
}
})
23 changes: 23 additions & 0 deletions src/adapters/process-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { defineSourceAdapter } from './define'

/**
* Create an adapter that reads from process.env (cross-platform)
*
* @example
* ```ts
* import { fromProcessEnv } from 'enwow/adapters'
*
* const env = await Env.create(schema, {
* sources: [fromProcessEnv()],
* })
* ```
*/
export const fromProcessEnv = defineSourceAdapter<void>(() => {
return {
name: 'process-env',
async load() {
const { env } = await import('std-env')
return env
},
}
})
Loading