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
19 changes: 19 additions & 0 deletions .changeset/vite-env-var.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@vlandoss/env": minor
---

The `envConfig()` Vite plugin can now select the env from a `VITE_ENV` env var instead of requiring `--mode`.

The plugin reads `VITE_ENV` from `process.env` **and** your `.env*` files (via Vite's `loadEnv`, so inline/shell values win over file values) and uses it to pick the per-env `#config` file and the `__ENV_NAME__` build constant. When `VITE_ENV` is unset or empty it falls back to Vite's `mode`, so `vite build --mode staging` keeps working unchanged — this is purely additive.

```bash
# These are now equivalent:
VITE_ENV=staging vite build
vite build --mode staging
```

The var name is configurable with the new `envVar` option (default `"VITE_ENV"`):

```ts
envConfig({ envVar: "APP_ENV" });
```
15 changes: 13 additions & 2 deletions docsite/content/docs/api-reference/vite.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,20 @@ The `@vlandoss/env/vite` entrypoint exposes a single Vite plugin. It does two th
| ------------ | -------- | ------------------------------------------------------------------------------------ |
| `envConfig` | Function | Returns a Vite plugin. Adds the `#config` alias and the `__ENV_NAME__` define. |

## `envConfig(options?)`

The env name the plugin keys off comes from `VITE_ENV` (read from `process.env` and your `.env*` files), falling back to Vite's `mode` when unset — so you can pick the env with `VITE_ENV=staging vite build` instead of `--mode staging`. See [Custom modes → Selecting the env without `--mode`](/docs/guides/custom-modes#selecting-the-env-without---mode).

`options` (all optional):

| Option | Type | Default | Summary |
| -------- | -------- | ---------------- | ------------------------------------------------------------------------------------------- |
| `alias` | `string` | `"#config"` | Import specifier the matched per-env config file is exposed as. |
| `cwd` | `string` | `process.cwd()` | Base directory for config discovery and `.env*` loading. |
| `envVar` | `string` | `"VITE_ENV"` | Env var that selects the env name. Falls back to Vite's `mode` when unset or empty. |

<Callout type="info">
Full signatures and option tables are coming soon. Until then, see
[Guides → SPA — Vite plugin](/docs/guides/spa-vite-plugin) and
See [Guides → SPA — Vite plugin](/docs/guides/spa-vite-plugin) and
[Guides → Custom modes](/docs/guides/custom-modes) for the wiring.
</Callout>

Expand Down
4 changes: 2 additions & 2 deletions docsite/content/docs/concepts/env-name.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ icon: Hash
First defined wins:

1. **`env.ENV`** — explicit runtime override (always wins). Use this in CI, tests, or scripts.
2. **`__ENV_NAME__`** — build-time literal injected by the `envConfig()` Vite plugin. Beats `NODE_ENV` because Vite forces `NODE_ENV="production"` regardless of `--mode`, so this is the only way `envName()` can return custom modes like `staging` / `qa` in the browser.
2. **`__ENV_NAME__`** — build-time literal injected by the `envConfig()` Vite plugin (the env it built for: `VITE_ENV` if set, otherwise Vite's `mode`). Beats `NODE_ENV` because Vite forces `NODE_ENV="production"` regardless of `--mode`, so this is the only way `envName()` can return custom envs like `staging` / `qa` in the browser.
3. **`env.NODE_ENV`**
4. **`env.VITE_ENV`**
5. **`"development"`** — fallback.
Expand All @@ -30,7 +30,7 @@ envName({ NODE_ENV: "test", ENV: "qa" }); // -> "qa"

If you run `vite build --mode staging`, Vite **still sets `NODE_ENV="production"`** at build time. Without the `envConfig()` plugin, `envName()` in browser code would return `"production"` — not `"staging"`.

The plugin solves this by injecting `__ENV_NAME__ = "staging"` as a build-time constant. That constant wins over `NODE_ENV` in the precedence chain, so the browser sees the mode you actually built.
The plugin solves this by injecting `__ENV_NAME__ = "staging"` as a build-time constant (from `--mode staging` or `VITE_ENV=staging`). That constant wins over `NODE_ENV` in the precedence chain, so the browser sees the env you actually built.

This is why **the plugin is required for custom Vite modes**, even if you load config via dynamic import (Pattern 1) and don't actually use the `#config` alias.

Expand Down
23 changes: 23 additions & 0 deletions docsite/content/docs/guides/custom-modes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,29 @@ src/

The plugin's discovery scans `[src/]config/<mode>.{ts,mts,cts,js,mjs,cjs,json}` and pulls in the right one — same algorithm as `loadConfig` in `@vlandoss/env/fs`.

## Selecting the env without `--mode`

If you'd rather not thread `--mode` through every command, set a `VITE_ENV` env var instead. The plugin reads it from `process.env` **and** your `.env*` files (via Vite's `loadEnv`, so an inline/shell value wins over a file value) and uses it to pick the config file and `__ENV_NAME__`. These are equivalent:

```bash title="terminal"
VITE_ENV=staging vite build
vite build --mode staging
```

`VITE_ENV` takes precedence; when it's unset or empty the plugin falls back to Vite's `mode`, so `--mode` keeps working unchanged. Put it in a `.env` file to make it the default for a project:

```dotenv title=".env.staging"
VITE_ENV=staging
```

Rename the var with the `envVar` option if `VITE_ENV` clashes with something:

```ts title="vite.config.ts"
export default defineConfig({
plugins: [envConfig({ envVar: "APP_ENV" })],
});
```

## Verifying it works

After a `vite build --mode staging`, anywhere in browser code:
Expand Down
11 changes: 6 additions & 5 deletions docsite/content/docs/guides/spa-vite-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ icon: Boxes

- Pure SPA built with Vite.
- You want **each build artifact to ship only its env's config** — other env files must not be present in the deployment at all.
- You're OK with one `vite build --mode <env>` per environment.
- You're OK with one build per environment, selected via `--mode <env>` or a [`VITE_ENV` env var](/docs/guides/custom-modes#selecting-the-env-without---mode).

If a single multi-env build is acceptable, the simpler [dynamic-import pattern](/docs/guides/spa-dynamic-import) is the default.

Expand Down Expand Up @@ -76,11 +76,11 @@ vite build --mode production
vite build --mode staging
```

Each command produces a separate `dist/` whose bundle contains **only** the matching `config/*.ts`.
Each command produces a separate `dist/` whose bundle contains **only** the matching `config/*.ts`. Prefer an env var over `--mode`? `VITE_ENV=staging vite build` is equivalent — see [Selecting the env without `--mode`](/docs/guides/custom-modes#selecting-the-env-without---mode).

## What each piece does

- **`envConfig()` plugin** registers `resolve.alias["#config"]` pointing at `[src/]config/<mode>.{ts,mts,cts,js,mjs,cjs,json}` (same discovery algorithm as `loadConfig`). It also injects `__ENV_NAME__ = JSON.stringify(mode)` so `envName()` returns the right mode in the browser — see [Custom modes](/docs/guides/custom-modes).
- **`envConfig()` plugin** registers `resolve.alias["#config"]` pointing at `[src/]config/<env>.{ts,mts,cts,js,mjs,cjs,json}` (same discovery algorithm as `loadConfig`). The `<env>` is `VITE_ENV` (from `process.env` or `.env*`) falling back to Vite's `mode`. It also injects `__ENV_NAME__ = JSON.stringify(env)` so `envName()` returns the right env in the browser — see [Custom modes](/docs/guides/custom-modes).
- **`#config.d.ts`** declares the type of the alias for TypeScript. Vite resolves the runtime import; this just stops `tsc` from complaining.
- **`defineEnv({ schema, config })`** runs synchronously here — `config` is a plain object, not a Promise.

Expand All @@ -90,12 +90,13 @@ Each command produces a separate `dist/` whose bundle contains **only** the matc
envConfig({
alias: "#config", // default
cwd: process.cwd(), // default — base directory for discovery
envVar: "VITE_ENV", // default — env var that selects the env (falls back to `mode`)
});
```

When no file matches the current mode, the alias resolves to a virtual module that throws **only if `#config` is actually imported** — so tools that introspect the Vite config (Vitest's IDE-driven discovery, third-party plugins) don't trip over a config-time error.
When no file matches the current env, the alias resolves to a virtual module that throws **only if `#config` is actually imported** — so tools that introspect the Vite config (Vitest's IDE-driven discovery, third-party plugins) don't trip over a config-time error.

## Tradeoffs

- **One build per env.** Your CI/CD needs to run `vite build --mode <env>` for every environment you ship, and produce a separate artifact for each.
- **One build per env.** Your CI/CD needs to run a build (`vite build --mode <env>`, or `VITE_ENV=<env> vite build`) for every environment you ship, and produce a separate artifact for each.
- **No runtime switching.** Once built, the artifact is locked to its env. If you need to swap envs without rebuilding, use the [dynamic-import pattern](/docs/guides/spa-dynamic-import).
2 changes: 1 addition & 1 deletion docsite/src/components/landing/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export const WHY_POINTS = [
] as const;

export const LANDING_META = {
version: "v0.4.0",
version: "v0.5.0",
vlandUrl: "https://variable.land",
githubUrl: "https://github.com/variableland/env",
npmUrl: "https://www.npmjs.com/package/@vlandoss/env",
Expand Down
18 changes: 15 additions & 3 deletions examples/spa-vite-plugin/test/e2e/build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import { expect, test } from "@playwright/test";
const ROOT = fileURLToPath(new URL("../..", import.meta.url));
const DIST = path.join(ROOT, "dist");

function runViteBuild(mode: string) {
function runViteBuild(mode: string, env?: Record<string, string>) {
return spawnSync("pnpm", ["exec", "vite", "build", "--mode", mode], {
cwd: ROOT,
encoding: "utf8",
timeout: 60_000,
env: { ...process.env, ...env },
});
}

Expand Down Expand Up @@ -46,9 +47,20 @@ test.describe("per-mode bundle isolation (envConfig plugin)", () => {
expect(bundle).not.toContain("prod-build-marker-b71c");
});

test("build fails when no config file matches the mode", () => {
test("VITE_ENV selects the env, overriding --mode", () => {
// --mode development would normally embed the development config, but
// VITE_ENV=production wins — proving the plugin keys off the env var.
const result = runViteBuild("development", { VITE_ENV: "production" });
expect(result.status, result.stderr).toBe(0);

const bundle = readAllJs();
expect(bundle).toContain("prod-build-marker-b71c");
expect(bundle).not.toContain("dev-build-marker-9f3a");
});

test("build fails when no config file matches the env", () => {
const result = runViteBuild("staging");
expect(result.status).not.toBe(0);
expect(result.stderr + result.stdout).toMatch(/no config file found for mode "staging"/);
expect(result.stderr + result.stdout).toMatch(/no config file found for env "staging"/);
});
});
42 changes: 40 additions & 2 deletions package/src/__tests__/vite.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { envConfig } from "../vite.ts";

const fixturesDir = new URL("./fixtures", import.meta.url).pathname;
Expand Down Expand Up @@ -67,7 +67,7 @@ describe("envConfig() Vite plugin", () => {
const plugin = envConfig({ cwd: fixturesDir });
const result = invoke(plugin, "missing");
const virtualId = result.resolve.alias["#config"];
expect(() => invokeLoad(plugin, virtualId)).toThrow(/no config file found for mode "missing"/);
expect(() => invokeLoad(plugin, virtualId)).toThrow(/no config file found for env "missing"/);
});

it("still injects __ENV_NAME__ when no config matches the mode", () => {
Expand Down Expand Up @@ -102,4 +102,42 @@ describe("envConfig() Vite plugin", () => {
const result = invoke(plugin, "staging");
expect(result.define.__ENV_NAME__).toBe('"staging"');
});

describe("VITE_ENV resolution", () => {
afterEach(() => {
delete process.env.VITE_ENV;
delete process.env.APP_ENV;
});

it("prefers VITE_ENV over Vite's mode for discovery and __ENV_NAME__", () => {
process.env.VITE_ENV = "staging";
const plugin = envConfig({ cwd: fixturesDir });
// `--mode development` would resolve config/development.json, but VITE_ENV wins.
const result = invoke(plugin, "development");
expect(result.resolve.alias["#config"]).toMatch(/fixtures\/src\/config\/staging\.json$/);
expect(result.define.__ENV_NAME__).toBe('"staging"');
});

it("falls back to mode when VITE_ENV is unset", () => {
const plugin = envConfig({ cwd: fixturesDir });
const result = invoke(plugin, "development");
expect(result.resolve.alias["#config"]).toMatch(/fixtures\/config\/development\.json$/);
expect(result.define.__ENV_NAME__).toBe('"development"');
});

it("treats an empty VITE_ENV as unset and falls back to mode", () => {
process.env.VITE_ENV = "";
const plugin = envConfig({ cwd: fixturesDir });
const result = invoke(plugin, "production");
expect(result.define.__ENV_NAME__).toBe('"production"');
});

it("honors a custom envVar name", () => {
process.env.APP_ENV = "staging";
const plugin = envConfig({ cwd: fixturesDir, envVar: "APP_ENV" });
const result = invoke(plugin, "development");
expect(result.resolve.alias["#config"]).toMatch(/fixtures\/src\/config\/staging\.json$/);
expect(result.define.__ENV_NAME__).toBe('"staging"');
});
});
});
61 changes: 44 additions & 17 deletions package/src/vite.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,26 @@
import { statSync } from "node:fs";
import path from "node:path";
import type { Plugin } from "vite";
import { loadEnv, type Plugin } from "vite";
import { BUILD_TIME_ENV_NAME_ID } from "./lib/const.ts";

const EXTENSIONS = [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json"];
const DIRS = ["config", "src/config"];

/** Env var the resolved env name is read from before falling back to Vite's `mode`. */
const DEFAULT_ENV_VAR = "VITE_ENV";

/**
* Rollup virtual-module id (the leading `\0` prevents other plugins from
* trying to read it from disk) used as a placeholder when no config file
* matches the current mode. The error is deferred until something actually
* matches the current env. The error is deferred until something actually
* imports `#config` — see `load()` below.
*/
const VIRTUAL_MISSING_ID = "\0variableland-env-config:missing";

function findConfigFile(mode: string, cwd: string): string | undefined {
function findConfigFile(env: string, cwd: string): string | undefined {
for (const ext of EXTENSIONS) {
for (const dir of DIRS) {
const candidate = path.join(cwd, dir, `${mode}${ext}`);
const candidate = path.join(cwd, dir, `${env}${ext}`);
try {
if (statSync(candidate).isFile()) return candidate;
} catch {
Expand All @@ -28,26 +31,46 @@ function findConfigFile(mode: string, cwd: string): string | undefined {
return undefined;
}

/**
* Resolve the env name the plugin keys off. Reads `envVar` (default `VITE_ENV`)
* from `process.env` **and** the `.env*` files under `cwd` — Vite's `loadEnv`
* merges both, with inline/shell values taking precedence — and falls back to
* Vite's `mode` when the var is unset or empty. Passing the full var name as
* the prefix scopes the file scan to just that one key.
*/
function resolveEnvName(envVar: string, mode: string, cwd: string): string {
return loadEnv(mode, cwd, envVar)[envVar] || mode;
}

export type EnvConfigOptions = {
/** Alias the per-mode config file is exposed as. Default: `"#config"`. */
/** Alias the per-env config file is exposed as. Default: `"#config"`. */
alias?: string;
/** Base directory for the discovery search. Default: `process.cwd()`. */
cwd?: string;
/**
* Env var that selects the env name, read from `process.env` and `.env*`
* files. When unset (or empty) the plugin falls back to Vite's `mode`, so
* `--mode` keeps working unchanged. Default: `"VITE_ENV"`.
*/
envVar?: string;
};

/**
* Vite plugin that:
*
* 1. Resolves an alias (`#config` by default) to the config file matching
* Vite's `mode`. Discovery is `[src/]config/<mode>.{ts,mts,cts,js,mjs,cjs,json}` —
* same algorithm as `loadConfig` in `@vlandoss/env/fs`. Only the
* matched file enters the bundle.
* 2. Injects `define: { __ENV_NAME__: JSON.stringify(mode) }`. The core's
* 1. Resolves an alias (`#config` by default) to the config file matching the
* current env name. The env name comes from `VITE_ENV` (configurable via
* `envVar`), falling back to Vite's `mode` — so `VITE_ENV=staging vite build`
* and `vite build --mode staging` are equivalent, and you no longer have to
* thread `--mode` through every command. Discovery is
* `[src/]config/<env>.{ts,mts,cts,js,mjs,cjs,json}` — same algorithm as
* `loadConfig` in `@vlandoss/env/fs`. Only the matched file enters the bundle.
* 2. Injects `define: { __ENV_NAME__: JSON.stringify(env) }`. The core's
* `envName()` reads this identifier so dynamic-import and alias patterns
* alike return the correct env in the browser — including custom modes
* alike return the correct env in the browser — including custom envs
* like `staging` or `qa`, which Vite forces `NODE_ENV="production"` for.
*
* When no config file matches the current mode, the plugin still registers
* When no config file matches the current env, the plugin still registers
* everything correctly (the `__ENV_NAME__` inject keeps working for the
* dynamic-import pattern that doesn't use `#config`). The alias resolves to
* a virtual module that throws a descriptive error **only when imported** —
Expand All @@ -62,6 +85,9 @@ export type EnvConfigOptions = {
*
* export default defineConfig({ plugins: [envConfig()] });
*
* // Pick the env without --mode:
* // VITE_ENV=staging vite build
*
* // src/env/index.ts
* import config from "#config";
* import { defineEnv } from "@vlandoss/env";
Expand All @@ -72,21 +98,22 @@ export type EnvConfigOptions = {
export function envConfig(options: EnvConfigOptions = {}): Plugin {
const alias = options.alias ?? "#config";
const cwd = options.cwd ?? process.cwd();
let resolvedMode = "";
const envVar = options.envVar ?? DEFAULT_ENV_VAR;
let resolvedEnv = "";

return {
name: "variableland-env-config",
config(_userConfig, { mode }) {
resolvedMode = mode;
const file = findConfigFile(mode, cwd);
resolvedEnv = resolveEnvName(envVar, mode, cwd);
const file = findConfigFile(resolvedEnv, cwd);
return {
resolve: {
alias: {
[alias]: file ?? VIRTUAL_MISSING_ID,
},
},
define: {
[BUILD_TIME_ENV_NAME_ID]: JSON.stringify(mode),
[BUILD_TIME_ENV_NAME_ID]: JSON.stringify(resolvedEnv),
},
};
},
Expand All @@ -97,7 +124,7 @@ export function envConfig(options: EnvConfigOptions = {}): Plugin {
load(id) {
if (id === VIRTUAL_MISSING_ID) {
throw new Error(
`@vlandoss/env/vite: no config file found for mode "${resolvedMode}" — searched [src/]config/${resolvedMode}.{ts,mts,cts,js,mjs,cjs,json} under ${cwd}`,
`@vlandoss/env/vite: no config file found for env "${resolvedEnv}" — searched [src/]config/${resolvedEnv}.{ts,mts,cts,js,mjs,cjs,json} under ${cwd}`,
);
}
return undefined;
Expand Down
Loading