From c510e21fd6d7b836641ee41be971e868b3ccdab9 Mon Sep 17 00:00:00 2001 From: "Ricardo Q. Bazan" Date: Mon, 15 Jun 2026 19:33:10 -0500 Subject: [PATCH 1/2] feat(vite): select env from VITE_ENV var instead of requiring --mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The envConfig() plugin now reads VITE_ENV — from process.env and .env* files via Vite's loadEnv, inline values winning over file values — to pick the #config file and the __ENV_NAME__ build constant, falling back to Vite's mode when unset or empty. The var name is configurable via the new envVar option (default "VITE_ENV"). Additive and backward-compatible: --mode keeps working unchanged. Includes tests, a minor changeset, docs, and the landing version bump to v0.5.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/vite-env-var.md | 19 ++++++ docsite/content/docs/api-reference/vite.mdx | 15 ++++- docsite/content/docs/concepts/env-name.mdx | 4 +- docsite/content/docs/guides/custom-modes.mdx | 23 +++++++ .../content/docs/guides/spa-vite-plugin.mdx | 11 ++-- docsite/src/components/landing/data.ts | 2 +- package/src/__tests__/vite.test.ts | 42 ++++++++++++- package/src/vite.ts | 61 +++++++++++++------ 8 files changed, 148 insertions(+), 29 deletions(-) create mode 100644 .changeset/vite-env-var.md diff --git a/.changeset/vite-env-var.md b/.changeset/vite-env-var.md new file mode 100644 index 0000000..bef49d5 --- /dev/null +++ b/.changeset/vite-env-var.md @@ -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" }); +``` diff --git a/docsite/content/docs/api-reference/vite.mdx b/docsite/content/docs/api-reference/vite.mdx index 3b1ab53..3d22a8c 100644 --- a/docsite/content/docs/api-reference/vite.mdx +++ b/docsite/content/docs/api-reference/vite.mdx @@ -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. | + - 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. diff --git a/docsite/content/docs/concepts/env-name.mdx b/docsite/content/docs/concepts/env-name.mdx index 8625dd8..16515bf 100644 --- a/docsite/content/docs/concepts/env-name.mdx +++ b/docsite/content/docs/concepts/env-name.mdx @@ -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. @@ -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. diff --git a/docsite/content/docs/guides/custom-modes.mdx b/docsite/content/docs/guides/custom-modes.mdx index 5d4cb1b..b8c1c4d 100644 --- a/docsite/content/docs/guides/custom-modes.mdx +++ b/docsite/content/docs/guides/custom-modes.mdx @@ -46,6 +46,29 @@ src/ The plugin's discovery scans `[src/]config/.{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: diff --git a/docsite/content/docs/guides/spa-vite-plugin.mdx b/docsite/content/docs/guides/spa-vite-plugin.mdx index 3686966..6c83b0c 100644 --- a/docsite/content/docs/guides/spa-vite-plugin.mdx +++ b/docsite/content/docs/guides/spa-vite-plugin.mdx @@ -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 ` per environment. +- You're OK with one build per environment, selected via `--mode ` 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. @@ -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/.{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/.{ts,mts,cts,js,mjs,cjs,json}` (same discovery algorithm as `loadConfig`). The `` 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. @@ -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 ` 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 `, or `VITE_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). diff --git a/docsite/src/components/landing/data.ts b/docsite/src/components/landing/data.ts index d9a3d84..0c0278f 100644 --- a/docsite/src/components/landing/data.ts +++ b/docsite/src/components/landing/data.ts @@ -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", diff --git a/package/src/__tests__/vite.test.ts b/package/src/__tests__/vite.test.ts index 7849ec4..07a67e7 100644 --- a/package/src/__tests__/vite.test.ts +++ b/package/src/__tests__/vite.test.ts @@ -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; @@ -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", () => { @@ -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"'); + }); + }); }); diff --git a/package/src/vite.ts b/package/src/vite.ts index 34ceb7f..a777d4b 100644 --- a/package/src/vite.ts +++ b/package/src/vite.ts @@ -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 { @@ -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/.{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/.{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** — @@ -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"; @@ -72,13 +98,14 @@ 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: { @@ -86,7 +113,7 @@ export function envConfig(options: EnvConfigOptions = {}): Plugin { }, }, define: { - [BUILD_TIME_ENV_NAME_ID]: JSON.stringify(mode), + [BUILD_TIME_ENV_NAME_ID]: JSON.stringify(resolvedEnv), }, }; }, @@ -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; From 1da7d1c5e283d53439946e2fda393ac9786d76f9 Mon Sep 17 00:00:00 2001 From: "Ricardo Q. Bazan" Date: Mon, 15 Jun 2026 19:39:23 -0500 Subject: [PATCH 2/2] test(example): update spa-vite-plugin e2e for VITE_ENV + new error wording The envConfig error message changed from `...for mode "x"` to `...for env "x"`; update the assertion to match. Also add an e2e test proving VITE_ENV overrides --mode through the real packed plugin. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../spa-vite-plugin/test/e2e/build.spec.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/examples/spa-vite-plugin/test/e2e/build.spec.ts b/examples/spa-vite-plugin/test/e2e/build.spec.ts index c36f5d5..87e0cdc 100644 --- a/examples/spa-vite-plugin/test/e2e/build.spec.ts +++ b/examples/spa-vite-plugin/test/e2e/build.spec.ts @@ -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) { return spawnSync("pnpm", ["exec", "vite", "build", "--mode", mode], { cwd: ROOT, encoding: "utf8", timeout: 60_000, + env: { ...process.env, ...env }, }); } @@ -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"/); }); });