From 77371f6bae43176924d59ffb4742c488e3fc8400 Mon Sep 17 00:00:00 2001 From: Viktor Zdorovtsev Date: Sun, 22 Feb 2026 15:56:03 +0700 Subject: [PATCH] feat: support source adapters --- README.md | 80 +++++++++ package.json | 1 + src/adapters/define.ts | 58 +++++++ src/adapters/files.ts | 55 ++++++ src/adapters/index.ts | 6 + src/adapters/json.ts | 83 +++++++++ src/adapters/object.ts | 32 ++++ src/adapters/process-env.ts | 23 +++ src/env.ts | 55 +++--- src/types.ts | 48 ++++++ test/adapters.test.ts | 331 ++++++++++++++++++++++++++++++++++++ tsdown.config.ts | 1 + 12 files changed, 753 insertions(+), 20 deletions(-) create mode 100644 src/adapters/define.ts create mode 100644 src/adapters/files.ts create mode 100644 src/adapters/index.ts create mode 100644 src/adapters/json.ts create mode 100644 src/adapters/object.ts create mode 100644 src/adapters/process-env.ts create mode 100644 test/adapters.test.ts diff --git a/README.md b/README.md index 8413fce..5a52808 100644 --- a/README.md +++ b/README.md @@ -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 @@ -118,6 +119,9 @@ interface EnvCreateOptions { // Custom environment variables source envSource?: Record + + // Custom source adapters (cannot be used with path argument) + sources?: EnvSourceAdapter[] } ``` @@ -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((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 diff --git a/package.json b/package.json index 535d619..77f7a43 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "sideEffects": false, "exports": { ".": "./dist/index.mjs", + "./adapters": "./dist/adapters/index.mjs", "./package.json": "./package.json" }, "main": "./dist/index.mjs", diff --git a/src/adapters/define.ts b/src/adapters/define.ts new file mode 100644 index 0000000..b33078b --- /dev/null +++ b/src/adapters/define.ts @@ -0,0 +1,58 @@ +import type { EnvSourceAdapter } from '../types' + +/** + * Factory function type for creating source adapters with options + */ +export type SourceAdapterFactory = (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( + factory: SourceAdapterFactory, +): SourceAdapterFactory { + 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> { + let result: Record = {} + + for (const adapter of adapters) { + const values = await adapter.load() + result = { ...result, ...values } + } + + return result +} diff --git a/src/adapters/files.ts b/src/adapters/files.ts new file mode 100644 index 0000000..d430286 --- /dev/null +++ b/src/adapters/files.ts @@ -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((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 = {} + + for (const file of files.reverse()) { + const parser = new EnvParser(file.contents, { + ignoreProcessEnv: opts.ignoreProcessEnv, + envSource: opts.envSource, + }) + envValues = { ...envValues, ...parser.parse() } + } + + return envValues + }, + } +}) diff --git a/src/adapters/index.ts b/src/adapters/index.ts new file mode 100644 index 0000000..d192588 --- /dev/null +++ b/src/adapters/index.ts @@ -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' diff --git a/src/adapters/json.ts b/src/adapters/json.ts new file mode 100644 index 0000000..768b5b1 --- /dev/null +++ b/src/adapters/json.ts @@ -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((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 = {} + 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 { + 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 + } +} diff --git a/src/adapters/object.ts b/src/adapters/object.ts new file mode 100644 index 0000000..a3416a6 --- /dev/null +++ b/src/adapters/object.ts @@ -0,0 +1,32 @@ +import { defineSourceAdapter } from './define' + +/** + * Options for the object source adapter + */ +export interface ObjectAdapterOptions { + env: Record +} + +/** + * 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((opts) => { + return { + name: 'object', + load() { + return { ...opts.env } + }, + } +}) diff --git a/src/adapters/process-env.ts b/src/adapters/process-env.ts new file mode 100644 index 0000000..2f9041c --- /dev/null +++ b/src/adapters/process-env.ts @@ -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(() => { + return { + name: 'process-env', + async load() { + const { env } = await import('std-env') + return env + }, + } +}) diff --git a/src/env.ts b/src/env.ts index 001df7f..d894dae 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,5 +1,6 @@ import type { EnvCreateOptions, EnvSchema, InferEnv } from './types' import { env } from 'std-env' +import { resolveAdapters } from './adapters/define' import { EnvLoader } from './loader' import { EnvParser } from './parser' import { EnvValidator } from './validator' @@ -86,28 +87,42 @@ export class Env { opts = (schemaOrOptions as EnvCreateOptions) ?? {} } - let envValues: Record = {} - - if (path) { - const loader = new EnvLoader(path, { nodeEnv: opts.nodeEnv }) - const files = await loader.load() - - // Parse files in reverse order (lowest priority first) - // This ensures higher priority files override lower priority ones - for (const file of files.reverse()) { - const parser = new EnvParser(file.contents, { - ignoreProcessEnv: opts.ignoreProcessEnv, - envSource: opts.envSource, - }) - const parsed = parser.parse() - envValues = { ...envValues, ...parsed } - } + if (path && opts.sources) { + throw new TypeError( + 'Cannot use \'sources\' option together with path argument. ' + + 'Use fromFiles() adapter instead.', + ) } - // Merge with process.env (highest priority) - if (!opts.ignoreProcessEnv) { - const processEnv = opts.envSource ?? getProcessEnv() - envValues = { ...envValues, ...processEnv } + let envValues: Record + + if (opts.sources) { + envValues = await resolveAdapters(opts.sources) + } + else { + envValues = {} + + if (path) { + const loader = new EnvLoader(path, { nodeEnv: opts.nodeEnv }) + const files = await loader.load() + + // Parse files in reverse order (lowest priority first) + // This ensures higher priority files override lower priority ones + for (const file of files.reverse()) { + const parser = new EnvParser(file.contents, { + ignoreProcessEnv: opts.ignoreProcessEnv, + envSource: opts.envSource, + }) + const parsed = parser.parse() + envValues = { ...envValues, ...parsed } + } + } + + // Merge with process.env (highest priority) + if (!opts.ignoreProcessEnv) { + const processEnv = opts.envSource ?? getProcessEnv() + envValues = { ...envValues, ...processEnv } + } } const validator = new EnvValidator(schema) diff --git a/src/types.ts b/src/types.ts index 2c2bd9c..17c58e1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -48,6 +48,27 @@ export interface EnvCreateOptions { * Useful for testing or custom sources */ envSource?: Record + + /** + * Custom source adapters for loading environment variables. + * Sources are merged left-to-right: later sources override earlier ones. + * + * Cannot be used together with the `path` argument in `Env.create(path, schema)`. + * + * @example + * ```ts + * import { fromFiles, fromProcessEnv, fromJSON } from 'enwow/adapters' + * + * const env = await Env.create(schema, { + * sources: [ + * fromFiles({ directory: new URL('./', import.meta.url) }), + * fromJSON({ path: './config/env.json' }), + * fromProcessEnv(), + * ], + * }) + * ``` + */ + sources?: EnvSourceAdapter[] } /** @@ -84,3 +105,30 @@ export interface LoadedEnvFile { * Parsed environment variables */ export type ParsedEnv = Record + +/** + * Adapter interface for loading environment variables from any source + * + * Use `defineSourceAdapter` from `enwow/adapters` to create custom adapter factories. + * + * @example + * ```ts + * import { defineSourceAdapter } from 'enwow/adapters' + * + * const myAdapter = defineSourceAdapter<{ url: string }>((opts) => { + * return { + * name: 'my-source', + * async load() { + * const response = await fetch(opts.url) + * return response.json() + * }, + * } + * }) + * ``` + */ +export interface EnvSourceAdapter { + /** Adapter name (used in error messages and debugging) */ + name: string + /** Load environment variables from this source */ + load: () => Record | Promise> +} diff --git a/test/adapters.test.ts b/test/adapters.test.ts new file mode 100644 index 0000000..bf633e6 --- /dev/null +++ b/test/adapters.test.ts @@ -0,0 +1,331 @@ +import { mkdir, rm, unlink, writeFile } from 'node:fs/promises' +import { join } from 'pathe' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { z } from 'zod' +import { Env, EnvValidationError } from '../src' +import { defineSourceAdapter, fromFiles, fromJSON, fromObject, fromProcessEnv, resolveAdapters } from '../src/adapters' + +describe('adapters', () => { + const tempDir = join(process.cwd(), 'test-temp-adapters') + + beforeAll(async () => { + await mkdir(tempDir, { recursive: true }) + }) + + afterAll(async () => { + await rm(tempDir, { recursive: true, force: true }) + }) + + describe('defineSourceAdapter', () => { + it('should return a factory function', () => { + const factory = defineSourceAdapter<{ prefix: string }>((opts) => { + return { + name: 'test', + load: () => ({ [`${opts.prefix}_KEY`]: 'value' }), + } + }) + + expect(typeof factory).toBe('function') + + const adapter = factory({ prefix: 'MY' }) + expect(adapter.name).toBe('test') + expect(adapter.load()).toEqual({ MY_KEY: 'value' }) + }) + + it('should support async load function', async () => { + const factory = defineSourceAdapter<{ value: string }>((opts) => { + return { + name: 'async-test', + async load() { + return { KEY: opts.value } + }, + } + }) + + const adapter = factory({ value: 'hello' }) + expect(adapter.name).toBe('async-test') + expect(await adapter.load()).toEqual({ KEY: 'hello' }) + }) + }) + + describe('fromObject', () => { + it('should return provided values', async () => { + const adapter = fromObject({ env: { PORT: '3000', HOST: 'localhost' } }) + + expect(adapter.name).toBe('object') + expect(await adapter.load()).toEqual({ PORT: '3000', HOST: 'localhost' }) + }) + + it('should return a copy of the object', async () => { + const source = { PORT: '3000' } + const adapter = fromObject({ env: source }) + + const result = await adapter.load() + result.PORT = '4000' + + expect(await adapter.load()).toEqual({ PORT: '3000' }) + }) + }) + + describe('fromProcessEnv', () => { + it('should return process.env values', async () => { + process.env.ADAPTER_TEST_VAR = 'test_value' + + const adapter = fromProcessEnv() + const result = await adapter.load() + + expect(adapter.name).toBe('process-env') + expect(result.ADAPTER_TEST_VAR).toBe('test_value') + + delete process.env.ADAPTER_TEST_VAR + }) + }) + + describe('fromFiles', () => { + afterEach(async () => { + const { readdir } = await import('node:fs/promises') + const files = await readdir(tempDir) + await Promise.all(files.map(f => unlink(join(tempDir, f)))) + }) + + it('should load .env files from directory', async () => { + await writeFile(join(tempDir, '.env'), 'FILE_VAR=from_file') + + const adapter = fromFiles({ directory: tempDir }) + const result = await adapter.load() + + expect(adapter.name).toBe('files') + expect(result.FILE_VAR).toBe('from_file') + }) + + it('should respect file priority', async () => { + await writeFile(join(tempDir, '.env'), 'VALUE=base') + await writeFile(join(tempDir, '.env.development'), 'VALUE=development') + + const adapter = fromFiles({ directory: tempDir, nodeEnv: 'development' }) + const result = await adapter.load() + + expect(result.VALUE).toBe('development') + }) + + it('should return empty object when no files exist', async () => { + const adapter = fromFiles({ directory: join(tempDir, 'nonexistent') }) + const result = await adapter.load() + + expect(result).toEqual({}) + }) + }) + + describe('fromJSON', () => { + afterEach(async () => { + const { readdir } = await import('node:fs/promises') + const files = await readdir(tempDir) + await Promise.all(files.map(f => unlink(join(tempDir, f)))) + }) + + it('should load values from JSON file', async () => { + const jsonPath = join(tempDir, 'env.json') + await writeFile(jsonPath, JSON.stringify({ PORT: '3000', HOST: 'localhost' })) + + const adapter = fromJSON({ path: jsonPath }) + const result = await adapter.load() + + expect(adapter.name).toBe('json') + expect(result).toEqual({ PORT: '3000', HOST: 'localhost' }) + }) + + it('should convert non-string values to strings', async () => { + const jsonPath = join(tempDir, 'env.json') + await writeFile(jsonPath, JSON.stringify({ PORT: 3000, DEBUG: true })) + + const adapter = fromJSON({ path: jsonPath }) + const result = await adapter.load() + + expect(result).toEqual({ PORT: '3000', DEBUG: 'true' }) + }) + + it('should handle null values as undefined', async () => { + const jsonPath = join(tempDir, 'env.json') + await writeFile(jsonPath, JSON.stringify({ KEY: null })) + + const adapter = fromJSON({ path: jsonPath }) + const result = await adapter.load() + + expect(result.KEY).toBeUndefined() + }) + + it('should return empty object when file does not exist', async () => { + const adapter = fromJSON({ path: join(tempDir, 'nonexistent.json') }) + const result = await adapter.load() + + expect(result).toEqual({}) + }) + + it('should throw on invalid JSON structure (array)', async () => { + const jsonPath = join(tempDir, 'env.json') + await writeFile(jsonPath, JSON.stringify([1, 2, 3])) + + const adapter = fromJSON({ path: jsonPath }) + await expect(adapter.load()).rejects.toThrow(TypeError) + }) + }) + + describe('resolveAdapters', () => { + it('should merge sources left-to-right', async () => { + const result = await resolveAdapters([ + fromObject({ env: { PORT: '3000', HOST: 'localhost' } }), + fromObject({ env: { PORT: '4000' } }), + ]) + + expect(result).toEqual({ PORT: '4000', HOST: 'localhost' }) + }) + + it('should handle empty adapter list', async () => { + const result = await resolveAdapters([]) + expect(result).toEqual({}) + }) + + it('should handle single adapter', async () => { + const result = await resolveAdapters([ + fromObject({ env: { PORT: '3000' } }), + ]) + + expect(result).toEqual({ PORT: '3000' }) + }) + + it('should merge multiple adapters in order', async () => { + const result = await resolveAdapters([ + fromObject({ env: { A: '1', B: '2', C: '3' } }), + fromObject({ env: { B: '20' } }), + fromObject({ env: { C: '30' } }), + ]) + + expect(result).toEqual({ A: '1', B: '20', C: '30' }) + }) + }) + + describe('env.create with sources', () => { + it('should create env from adapter sources', async () => { + const env = await Env.create({ + PORT: z.string().transform(Number), + HOST: z.string(), + }, { + sources: [ + fromObject({ env: { PORT: '3000', HOST: 'localhost' } }), + ], + }) + + expect(env.get('PORT')).toBe(3000) + expect(env.get('HOST')).toBe('localhost') + }) + + it('should respect source priority (last wins)', async () => { + const env = await Env.create({ + PORT: z.string().transform(Number), + }, { + sources: [ + fromObject({ env: { PORT: '3000' } }), + fromObject({ env: { PORT: '4000' } }), + ], + }) + + expect(env.get('PORT')).toBe(4000) + }) + + it('should throw on validation error with sources', async () => { + await expect( + Env.create({ + REQUIRED_VAR: z.string(), + }, { + sources: [fromObject({ env: {} })], + }), + ).rejects.toThrow(EnvValidationError) + }) + + it('should throw when combining path and sources', async () => { + await expect( + Env.create(new URL(`${tempDir}/`, import.meta.url), { + PORT: z.string(), + }, { + sources: [fromObject({ env: { PORT: '3000' } })], + }), + ).rejects.toThrow(TypeError) + }) + + it('should work with fromFiles adapter', async () => { + await writeFile(join(tempDir, '.env'), 'FROM_ADAPTER=adapter_value') + + const env = await Env.create({ + FROM_ADAPTER: z.string(), + }, { + sources: [ + fromFiles({ directory: tempDir }), + ], + }) + + expect(env.get('FROM_ADAPTER')).toBe('adapter_value') + + await unlink(join(tempDir, '.env')) + }) + + it('should work with mixed adapters', async () => { + const jsonPath = join(tempDir, 'env.json') + await writeFile(join(tempDir, '.env'), 'FILE_VAR=from_file\nSHARED=file') + await writeFile(jsonPath, JSON.stringify({ JSON_VAR: 'from_json', SHARED: 'json' })) + + const env = await Env.create({ + FILE_VAR: z.string(), + JSON_VAR: z.string(), + SHARED: z.string(), + }, { + sources: [ + fromFiles({ directory: tempDir }), + fromJSON({ path: jsonPath }), + ], + }) + + expect(env.get('FILE_VAR')).toBe('from_file') + expect(env.get('JSON_VAR')).toBe('from_json') + expect(env.get('SHARED')).toBe('json') + + await unlink(join(tempDir, '.env')) + await unlink(jsonPath) + }) + + it('should work with defineSourceAdapter', async () => { + const custom = defineSourceAdapter<{ value: string }>((opts) => { + return { + name: 'custom', + load: () => ({ CUSTOM: opts.value }), + } + }) + + const env = await Env.create({ + CUSTOM: z.string(), + }, { + sources: [custom({ value: 'custom_value' })], + }) + + expect(env.get('CUSTOM')).toBe('custom_value') + }) + + it('should work with async defineSourceAdapter', async () => { + const asyncFactory = defineSourceAdapter<{ value: string }>((opts) => { + return { + name: 'async-custom', + async load() { + return { ASYNC_VAR: opts.value } + }, + } + }) + + const env = await Env.create({ + ASYNC_VAR: z.string(), + }, { + sources: [asyncFactory({ value: 'async_value' })], + }) + + expect(env.get('ASYNC_VAR')).toBe('async_value') + }) + }) +}) diff --git a/tsdown.config.ts b/tsdown.config.ts index bd2a55a..39ffb81 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from 'tsdown' export default defineConfig({ entry: [ 'src/index.ts', + 'src/adapters/index.ts', ], dts: true, exports: true,