Skip to content
Closed
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
33 changes: 33 additions & 0 deletions packages/12factor-env/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,39 @@ parseEnvBoolean('YES'); // true
parseEnvNumber('42'); // 42
```

## Opt-in dotenv support

The 12-factor rule is **environment first, `.env` as a local-dev convenience**.
`dotenv()` builds an environment record where a local `.env` fills gaps but real
environment variables always win. It never mutates `process.env`, a missing file
is not an error, and nothing changes unless you call it — existing `env()` usage
is unaffected.

```ts
import { env, dotenv, str, port } from '12factor-env';

const config = env(
dotenv(), // process.env, backed by ./.env when present
{ DATABASE_URL: str() },
{ PORT: port({ default: 3000 }) }
);
```

Options:

```ts
dotenv(); // <cwd>/.env merged under process.env
dotenv({ cwd: projectDir }); // look for .env in another directory
dotenv({ file: '.env.local' }); // different file name under cwd
dotenv({ path: '/etc/app/config.env' }); // explicit file path
dotenv({ environment: {} }); // file only, ignore process.env
dotenv({ override: true }); // file values win over the environment
```

Parsing uses Node's built-in dotenv parser (`util.parseEnv`), which handles
comments, quoting, and `export` prefixes. The raw parser is exported as
`parseDotenv(source)` if you need it directly.

## Validators

All validators from [envalid](https://github.com/af/envalid) are re-exported:
Expand Down
71 changes: 71 additions & 0 deletions packages/12factor-env/__tests__/env.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';

import {
bool,
boolish,
devDefault,
dotenv,
env,
getNodeEnv,
getStrictEnvMode,
host,
isDevelopment,
isProduction,
isTest,
parseDotenv,
parseEnvBoolean,
parseEnvList,
parseEnvNumber,
Expand Down Expand Up @@ -297,4 +303,69 @@ describe('env', () => {
expect(getStrictEnvMode({ STRICT_ENV: 'THROW' })).toBe('throw');
});
});

describe('dotenv', () => {
let dir: string;

beforeEach(() => {
dir = mkdtempSync(path.join(os.tmpdir(), '12factor-env-'));
});

afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});

it('parses dotenv source with parseDotenv', () => {
const parsed = parseDotenv('A=1\n# comment\nB="two words"\n');
expect(parsed).toEqual({ A: '1', B: 'two words' });
});

it('merges .env values under the environment (environment wins)', () => {
writeFileSync(path.join(dir, '.env'), 'FROM_FILE=file\nSHARED=file\n');
const merged = dotenv({ cwd: dir, environment: { SHARED: 'env', FROM_ENV: 'env' } });
expect(merged).toEqual({ FROM_FILE: 'file', SHARED: 'env', FROM_ENV: 'env' });
});

it('lets file values win when override is true', () => {
writeFileSync(path.join(dir, '.env'), 'SHARED=file\n');
const merged = dotenv({ cwd: dir, environment: { SHARED: 'env' }, override: true });
expect(merged.SHARED).toBe('file');
});

it('returns the environment unchanged when the file is missing', () => {
const merged = dotenv({ cwd: dir, environment: { ONLY: 'env' } });
expect(merged).toEqual({ ONLY: 'env' });
});

it('resolves an explicit path over cwd/file', () => {
const custom = path.join(dir, 'custom.env');
writeFileSync(custom, 'CUSTOM=yes\n');
const merged = dotenv({ path: custom, cwd: '/nonexistent', environment: {} });
expect(merged).toEqual({ CUSTOM: 'yes' });
});

it('resolves a custom file name under cwd', () => {
writeFileSync(path.join(dir, '.env.local'), 'LOCAL=yes\n');
const merged = dotenv({ cwd: dir, file: '.env.local', environment: {} });
expect(merged).toEqual({ LOCAL: 'yes' });
});

it('never mutates process.env or the provided environment', () => {
writeFileSync(path.join(dir, '.env'), 'DOTENV_MUTATION_CHECK=file\n');
const provided = { KEEP: 'env' };
dotenv({ cwd: dir, environment: provided });
dotenv({ cwd: dir });
expect(provided).toEqual({ KEEP: 'env' });
expect(process.env.DOTENV_MUTATION_CHECK).toBeUndefined();
});

it('composes with env() for validation', () => {
writeFileSync(path.join(dir, '.env'), 'DATABASE_URL=postgres://localhost/dev\n');
const config = env(
dotenv({ cwd: dir, environment: {} }),
{ DATABASE_URL: str() }
);
expect(config.DATABASE_URL).toBe('postgres://localhost/dev');
});
});
});
59 changes: 59 additions & 0 deletions packages/12factor-env/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { parseEnv as parseEnvSource } from 'node:util';

import type { CleanedEnv, CleanOptions, Spec,ValidatorSpec } from 'envalid';
import {
bool,
Expand Down Expand Up @@ -191,6 +195,61 @@ const boolish = makeValidator<boolean>((value: string) => {
return parseEnvBoolean(String(raw)) ?? false;
});

// ── Opt-in dotenv support ────────────────────────────────────────────────────
//
// The 12-factor rule is "environment first, `.env` as a local-dev convenience":
// a container gets its configuration from the process environment, while a
// developer's project folder may carry a `.env`. `dotenv()` produces an
// environment record that honors that rule — file values fill gaps, real
// environment variables always win (unless `override` is set). It is a pure
// input builder for `env()`/`cleanEnv`; it never mutates `process.env`.

export type DotenvOptions = {
/** Explicit path to the env file. Takes precedence over `cwd`/`file`. */
path?: string;
/** Directory to resolve `file` in. Default: `process.cwd()`. */
cwd?: string;
/** File name resolved under `cwd`. Default: `.env`. */
file?: string;
/** Base environment the file is merged into. Default: `process.env`. */
environment?: Record<string, string | undefined>;
/** When true, file values win over the base environment. Default: false. */
override?: boolean;
};

/** Parse dotenv-format source text into a plain record (no interpolation). */
export const parseDotenv = (source: string): Record<string, string> =>
parseEnvSource(source) as Record<string, string>;

/**
* Read an env file and merge it with an environment record. A missing file is
* not an error — the base environment is returned unchanged, so the same code
* path works in a container (no file) and in local dev (file present).
*/
export const dotenv = (
options: DotenvOptions = {}
): Record<string, string | undefined> => {
const {
environment = process.env,
override = false,
cwd = process.cwd(),
file = '.env'
} = options;
const filePath = options.path ?? path.join(cwd, file);

let source: string;
try {
source = readFileSync(filePath, 'utf8');
} catch {
return { ...environment };
}

const fileVars = parseDotenv(source);
return override
? { ...environment, ...fileVars }
: { ...fileVars, ...environment };
};

// Type for specs object
type Specs = Record<string, ValidatorSpec<unknown>>;

Expand Down
Loading