Skip to content
Merged
5 changes: 5 additions & 0 deletions .bumpy/process-env-augmentation-conflict.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
varlock: patch
---

Skip the process.env type augmentation when another .d.ts (e.g. wrangler's worker-configuration.d.ts) already declares NodeJS.ProcessEnv, which previously caused a TS2320 conflict. Set processEnv=strict on @generateTsTypes to override.
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,18 @@ Some framework templates split their TypeScript setup into multiple referenced c

This types the `ENV` object. For the native `env` parameter in your fetch handler, use [`varlock-wrangler types`](#varlock-wrangler-types) to generate an `Env` interface that includes your varlock-managed vars alongside your other Cloudflare bindings.

### `process.env` is typed by wrangler, not varlock

`wrangler types` writes its own `NodeJS.ProcessEnv` augmentation into `worker-configuration.d.ts`, covering every var and secret it knows about. TypeScript merges all declarations of that interface and requires every shared key to be identical across them, so varlock's augmentation and wrangler's cannot both apply: a single optional item (`FOO?: string` against wrangler's `FOO: string`) fails with `TS2320`, as do enums and booleans, which varlock types as literal unions.

When varlock sees an existing `NodeJS.ProcessEnv` declaration next to your schema, it defers and skips its own, noting why at the top of the generated file. Nothing is lost if you generate types with [`varlock-wrangler types`](#varlock-wrangler-types): it feeds your schema's keys to wrangler, so all of them are typed on `process.env` as strings. Use `ENV` where you want the coerced types (enums, booleans, numbers) and the docs from your schema.

Setting `processEnv=strict` on [`@generateTsTypes`](/reference/root-decorators/#generatetstypes) opts back in, but only do that if nothing else is declaring `NodeJS.ProcessEnv`: with wrangler's block present, the two conflict and TypeScript reports `TS2320`.

:::caution[With `skipLibCheck: true` the conflict is silent]
Most Worker templates enable `skipLibCheck`, which suppresses `TS2320` in `.d.ts` files entirely. The two declarations still clash, and whichever file TypeScript reaches first wins, so a key can silently take wrangler's literal type from `wrangler.jsonc` instead of your schema's. Deferring avoids that as well.
:::

----

## Log redaction and leak prevention
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,8 @@ TypeScript type declarations. Makes `import { ENV } from 'varlock/env'` typed an
- `local`: export a package-local typed `ENV` from the generated file (with **no** global augmentation, since `processEnv`/`importMetaEnv` default to `none` too, so nothing merges across packages). Use this in monorepos where multiple packages have different schemas. Requires a `.ts` output path (not `.d.ts`, since it contains a runtime re-export), and you import from the generated file (e.g. `import { ENV } from './env'`).
- `none`: emit only the type definitions, no `ENV` binding.
- `processEnv`: how `process.env` is typed: `strict`, `loose`, or `none` (defaults to `strict`; to `none` when `exposeEnv=local`, or when [`@disableProcessEnvInjection`](#disableprocessenvinjection) is set, since values aren't put on `process.env` then). Note that `@types/node` declares a base string index signature that can't be removed, so extra keys remain allowed on `process.env` regardless.

It also defaults to `none` when another `.d.ts` sitting next to your schema (or next to the generated file) already declares `NodeJS.ProcessEnv`. The common case is `worker-configuration.d.ts`, written by [`wrangler types`](/integrations/cloudflare/#type-safety-and-intellisense). TypeScript merges every declaration of that one interface and requires each shared key to be identical, so two of them can't coexist: an optional item alone (`FOO?: string` vs `FOO: string`) is enough to fail with `TS2320`. The generated file names the file that triggered the skip. Set `processEnv=strict` to emit ours anyway.
- `importMetaEnv`: how `import.meta.env` is typed: `strict`, `loose`, or `none` (defaults to `strict`; to `none` when `exposeEnv=local`).

```env-spec
Expand Down
1 change: 1 addition & 0 deletions packages/varlock/src/env-graph/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export {
export {
builtInCodeGenerators,
collectTypeGenItems,
findConflictingProcessEnvAugmentation,
generateCsharpEnvSrc,
generateGoEnvSrc,
generateJavaEnvSrc,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
export type TypeGenCommentStyle = '//' | '#';

/** stable substring of the banner, used to recognize our own generated files */
export const AUTOGENERATED_FILE_MARKER = 'THIS IS AN AUTOGENERATED FILE';

const AUTOGENERATED_BANNER_LINES = [
'🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑',
'🛑 THIS IS AN AUTOGENERATED FILE - DO NOT EDIT DIRECTLY 🛑',
`🛑 ${AUTOGENERATED_FILE_MARKER} - DO NOT EDIT DIRECTLY 🛑`,
'🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑 🛑',
];

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import path from 'node:path';
import type { EnvGraph } from '../env-graph';
import type { TypeGenItemInfo } from '../config-item';
import { isVarlockReservedKey } from '../reserved-vars';
Expand All @@ -8,6 +9,7 @@ import { generatePhpEnvSrc } from './emitters/php';
import { generatePythonEnvSrc } from './emitters/python';
import { generateRustEnvSrc } from './emitters/rust';
import { generateTsTypesSrc } from './emitters/ts';
import { findConflictingProcessEnvAugmentation } from './detect-process-env-augmentation';
import { type ResolvedFieldType } from './shared';

/** Everything a code generator needs to produce a single output file. */
Expand Down Expand Up @@ -67,7 +69,7 @@ const LANG_TO_DECORATOR: Record<string, string> = {
csharp: 'generateCsharpEnv',
};

function generateTsFile(ctx: CodeGenContext): Promise<string> {
async function generateTsFile(ctx: CodeGenContext): Promise<string> {
// local mode emits a runtime re-export (TypeScript syntax) — a `.d.ts` has no runtime binding,
// and anything else (`.js`, ...) would not parse, so require a real `.ts` module
if (ctx.options.exposeEnv === 'local'
Expand All @@ -83,6 +85,25 @@ function generateTsFile(ctx: CodeGenContext): Promise<string> {
if (options.processEnv === undefined && ctx.graph.isProcessEnvInjectionDisabled) {
options.processEnv = 'none';
}
// `NodeJS.ProcessEnv` is a single global interface, and merged declarations of it must have
// identical members for every shared key, so ours cannot coexist with another tool's (see
// detect-process-env-augmentation). When something else already declares it, defer rather than
// emit a guaranteed TS2320 - an explicit `processEnv=` still wins. `exposeEnv=local` already
// defaults to `none`, so there is nothing to defer there.
if (options.processEnv === undefined && options.exposeEnv !== 'local') {
const conflictPath = await findConflictingProcessEnvAugmentation({
dirs: [ctx.sourceDir, path.dirname(ctx.outputPath)],
outputPath: ctx.outputPath,
});
if (conflictPath) {
options.processEnv = 'none';
options.processEnvSkipNote = [
`NOTE: the \`process.env\` augmentation was skipped because ${path.basename(conflictPath)}`,
'already declares NodeJS.ProcessEnv, and two declarations of it cannot both apply.',
'Use `ENV` for the full types, or set `processEnv=strict` on @generateTsTypes to override.',
].join('\n');
}
}
// `@injectUndefinedAsEmpty` means unset items land on process.env as empty strings, so the
// process.env augmentation drops its optionality (graph-level flag, not a decorator arg)
options.injectUndefinedAsEmpty = ctx.graph.injectUndefinedAsEmpty;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import fs from 'node:fs';
import path from 'node:path';
import { AUTOGENERATED_FILE_MARKER } from './banners';

/**
* `NodeJS.ProcessEnv` is a single global interface, so two declarations of it must have
* *identical* members for every shared key or TypeScript errors (TS2320 - "Interface
* 'ProcessEnv' cannot simultaneously extend types ... are not identical"). Our augmentation can
* never match another tool's: an optional item alone (`FOO?: string` vs `FOO: string`) is enough
* to conflict, as are enums and booleans, which we type as literal unions.
*
* The best known example is `wrangler types`, which writes a `NodeJS.ProcessEnv` augmentation
* into `worker-configuration.d.ts` covering every var and secret. Rather than detect Cloudflare
* (or any other platform - infra-as-code setups may not have a wrangler config at all), we look
* for the thing that actually collides: an existing ProcessEnv declaration.
*
* Note the failure bias. Missing a declaration is safe: we keep emitting what we emit today, and
* if nothing else declares ProcessEnv there was no conflict to avoid. Matching too eagerly would
* silently drop typing, so the generated file names the file we found.
*/

/**
* Only the head of each file is scanned. These declarations sit at the top of generated files
* (wrangler writes env types first, then ~1MB of runtime types after a `// Begin runtime types`
* marker), and codegen runs on every load, so slurping whole files would be wasteful.
*/
const MAX_SCAN_BYTES = 64 * 1024;

async function readFileHead(filePath: string, maxBytes = MAX_SCAN_BYTES): Promise<string | undefined> {
let fileHandle;
try {
fileHandle = await fs.promises.open(filePath, 'r');
const buffer = Buffer.alloc(maxBytes);
const { bytesRead } = await fileHandle.read(buffer, 0, maxBytes, 0);
return buffer.subarray(0, bytesRead).toString('utf-8');
} catch {
// unreadable (deleted mid-scan, permissions, ...) - treat as "no declaration found"
return undefined;
} finally {
await fileHandle?.close().catch(() => undefined);
}
}

// Comments and string/template literals are not declaration syntax, and either can spoof the
// match in both directions (prose that reads like a declaration, or a stray `}` that hides one).
// Both classes are alternatives of ONE regex, so a single left-to-right pass takes whichever
// opens first: stripping either class ahead of the other lets it be spoofed (`// don't` eating
// real code, or `type M = '//'` hiding the rest of a line). Replaced with a space, not removed,
// so neighbouring tokens can't fuse.
const COMMENTS_AND_LITERALS = new RegExp([
/\/\*[\s\S]*?\*\//, // block comment
/\/\/[^\n]*/, // line comment
// the escape alternative takes any character, newline included, so a backslash-newline line
// continuation stays inside the literal instead of ending it and exposing its contents. Line
// endings are normalized before this runs, so `\\` + newline is always exactly two characters:
// spelling CRLF out here instead would give `\\` + CRLF two ways to match, which backtracks
// exponentially on an unterminated literal (js/redos).
/'(?:[^'\\\n]|\\[\s\S])*'/, // single-quoted
/"(?:[^"\\\n]|\\[\s\S])*"/, // double-quoted
/`(?:[^`\\]|\\[\s\S])*`/, // template literal
].map((r) => r.source).join('|'), 'g');

/**
* `interface ProcessEnv` opening a `namespace NodeJS` block. The two tokens must be adjacent
* (nothing but whitespace, modifiers, or `}`-free text between them), which is what every
* generator that writes one of these actually emits, ours included:
*
* declare namespace NodeJS { interface ProcessEnv extends ... {} } // wrangler
* declare global { namespace NodeJS { interface ProcessEnv ... } } // varlock
*
* Requiring adjacency is what keeps this honest without a parser. `[^}]` can't run past the end
* of the first member, so a `ProcessEnv` declared *after* the namespace closes can't match, and
* `\b` keeps `interface ProcessEnvExtra` out.
*/
const NODEJS_PROCESS_ENV = /\bnamespace\s+NodeJS\s*\{[^}]{0,400}?\binterface\s+ProcessEnv\b/;

/**
* True when the source looks like it declares `NodeJS.ProcessEnv`.
*
* A heuristic on purpose. It is deliberately biased to miss rather than over-match, because the
* two failure directions are not symmetric: a miss just leaves today's behaviour in place, and
* the conflict it would have avoided shows up as a loud `TS2320` with a documented one-arg fix
* (`processEnv=none`), whereas a false positive silently drops typing that was fine. So anything
* it can't read confidently (a declaration that isn't the first member of its namespace, or one
* buried in an exotic literal the strip above doesn't recognize) simply doesn't match, and lands
* the user on the error and the documented fix. Matching by structure rather than by any one tool's output shape also keeps
* it from rotting when that output changes.
*/
function declaresProcessEnv(rawSrc: string): boolean {
// normalize line endings first so the patterns above only ever deal with `\n`
const src = rawSrc.replace(/\r\n?/g, '\n');
return NODEJS_PROCESS_ENV.test(src.replace(COMMENTS_AND_LITERALS, ' '));
}

/**
* Look for a `.d.ts` alongside the schema (and alongside the generated file, when that lives
* elsewhere) that already augments `NodeJS.ProcessEnv`.
*
* Returns the absolute path of the first match, or undefined. Our own generated files are
* skipped - they carry the autogenerated banner, so a second `@generateTsTypes` writing into the
* same directory doesn't look like a foreign declaration.
*/
export async function findConflictingProcessEnvAugmentation(opts: {
/** directories to scan, non-recursively */
dirs: Array<string>;
/** absolute path of the file being generated, never treated as a conflict */
outputPath: string;
}): Promise<string | undefined> {
const scanned = new Set<string>();
for (const dir of opts.dirs) {
const resolvedDir = path.resolve(dir);
if (scanned.has(resolvedDir)) continue;
scanned.add(resolvedDir);

let entries: Array<string>;
try {
entries = await fs.promises.readdir(resolvedDir);
} catch {
continue;
}

for (const entry of entries.sort()) {
if (!entry.endsWith('.d.ts')) continue;
const filePath = path.join(resolvedDir, entry);
if (filePath === path.resolve(opts.outputPath)) continue;
const src = await readFileHead(filePath);
if (!src) continue;
if (src.includes(AUTOGENERATED_FILE_MARKER)) continue;
if (declaresProcessEnv(src)) return filePath;
}
}
return undefined;
}
12 changes: 12 additions & 0 deletions packages/varlock/src/env-graph/lib/type-generation/emitters/ts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,12 @@ export type TsGenOptions = {
* through it, so a schema key may be absent there regardless of injection mode.
*/
injectUndefinedAsEmpty?: boolean;
/**
* Set when `processEnv` was defaulted to `none` because another `.d.ts` already declares
* `NodeJS.ProcessEnv` (not a decorator arg). Emitted as a comment in the generated file, so the
* skip is visible where someone would notice it, naming the file found and how to override.
*/
processEnvSkipNote?: string;
};

// defaults preserve the historical output: globally augment `varlock/env`, and augment both globals
Expand All @@ -181,6 +187,7 @@ const DEFAULT_TS_GEN_OPTIONS = {
processEnv: 'strict',
importMetaEnv: 'strict',
injectUndefinedAsEmpty: false,
processEnvSkipNote: '',
} satisfies Required<TsGenOptions>;

const TS_ENV_EXPOSURE_VALUES: ReadonlyArray<TsEnvExposure> = ['global', 'local', 'none'];
Expand All @@ -207,6 +214,7 @@ function resolveTsGenOptions(options: Record<string, any> = {}): Required<TsGenO
processEnv: coerceOption(options.processEnv, TS_GLOBAL_AUGMENT_VALUES, defaultAugment ?? DEFAULT_TS_GEN_OPTIONS.processEnv, 'processEnv'),
importMetaEnv: coerceOption(options.importMetaEnv, TS_GLOBAL_AUGMENT_VALUES, defaultAugment ?? DEFAULT_TS_GEN_OPTIONS.importMetaEnv, 'importMetaEnv'),
injectUndefinedAsEmpty: !!options.injectUndefinedAsEmpty,
processEnvSkipNote: options.processEnvSkipNote || '',
};
}

Expand All @@ -225,6 +233,10 @@ export async function generateTsTypesSrc(fields: Array<ResolvedFieldType>, optio
'/* eslint-disable */',
];

if (opts.processEnvSkipNote) {
tsSrc.push('', ..._.map(opts.processEnvSkipNote.split('\n'), (line) => `// ${line}`));
}

// `local` exposure re-exports the runtime ENV proxy, so we need to import it
if (opts.exposeEnv === 'local') {
tsSrc.push("import { ENV as _ENV } from 'varlock/env';", '');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export {
type CoercedType,
type ResolvedFieldType,
} from './shared';
export { findConflictingProcessEnvAugmentation } from './detect-process-env-augmentation';
export {
builtInCodeGenerators,
collectTypeGenItems,
Expand Down
Loading
Loading