Skip to content

Commit f67200b

Browse files
authored
docs: port env-object ADR to v1 (0021) + Nuxt #1424 glossary
Ports the missing canonical env-object ADR from dev onto v1 as ADR 0021. Drops the Nuxt-specific honesty ADR; keeps #1424 glossary in CONTEXT.
1 parent 4032cc5 commit f67200b

2 files changed

Lines changed: 145 additions & 2 deletions

File tree

docs/CONTEXT.md

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ The Vite plugin call shape `arkenv(schema)` that validates at build time and inl
2525

2626
## Flagged ambiguities
2727

28-
- **"SPA mode"** (#1105 / canonical env-object ADR): previously named the schema/define path as a permanent documented mode. Continuity-alone justification is rejected. Lasting stance (documented escape hatch vs time-boxed deprecate/remove) is **deferred** to **#1333** pending hands-on play with the transform DX. Current lean: teach **env object** only in docs/CLI defaults; keep schema/define working but unbranded until the call. The env-object ADR's soft-landing / "SPA mode" framing may need an amendment after that decision. (On `dev` that ADR is `0015-env-object-canonical-surface`; on `v1` ADR **0015** is a different document — Next.js conditional exports.)
28+
- **"SPA mode"** (#1105 / canonical env-object ADR): previously named the schema/define path as a permanent documented mode. Continuity-alone justification is rejected. Lasting stance (documented escape hatch vs time-boxed deprecate/remove) is **deferred** to **#1333** pending hands-on play with the transform DX. Current lean: teach **env object** only in docs/CLI defaults; keep schema/define working but unbranded until the call. The env-object ADR's soft-landing / "SPA mode" framing may need an amendment after that decision. (Ported to `v1` as **ADR 0021** / `0021-env-object-canonical-surface`; on `dev` it remains `0015-env-object-canonical-surface`. On `v1`, ADR **0015** is still Next.js conditional exports.)
2929

3030
## Tech stack
3131

@@ -209,7 +209,80 @@ pnpm run test:e2e # E2E tests
209209

210210
## Domain context
211211

212-
**Environment Variable Validation:**
212+
### Language (env surfaces & Nuxt transport)
213+
214+
**Canonical env object**:
215+
The imported `env` object (`import { env } from "./env"`) is the one supported way to read validated env across Next, Nuxt, Vite, and Bun.
216+
*Avoid*: treating `import.meta.env` / ambient `.d.ts` as a second canonical surface (that is **SPA mode** only)
217+
218+
**SPA mode**:
219+
Vite/Bun-only path that keeps plugin + native accessors + `.d.ts`; honest for static access, not the default fullstack surface.
220+
*Avoid*: “plugin-env as equal peer to the object surface”
221+
222+
**Vite transform mode**:
223+
Client-graph rewrite of `env.ts` that inlines build-validated coerced literals and strips the validator (Solid Start / `#1328` shape). Values are fixed at **build time**.
224+
*Avoid*: calling this “boot-time validation”
225+
226+
**Nitro boot override**:
227+
A `NUXT_PUBLIC_*` / `NUXT_*` value applied by Nitro as a string when the server starts, after the Vite build, which can differ from build-time env.
228+
*Avoid*: “runtimeConfig default”, “build-time public env” (those are earlier stages)
229+
230+
**Deploy-time override honesty** (resolved):
231+
For `@arkenv/nuxt`, coerced values after **Nitro boot overrides** are the source of truth on both server and client. A pure **Vite transform mode** cannot be Nuxt’s sole public-value transport, because it would freeze build-time literals and lie when overrides differ.
232+
233+
**Nuxt honesty transport** (resolved — direction A):
234+
Nitro boot-time coercion writes coerced schema values into `runtimeConfig` (including `public`) after **Nitro boot overrides**; the client **Canonical env object** is a thin reader of that payload with no validator. No Solid-Start-style client-graph literal inlining for Nuxt.
235+
*Avoid*: hybrid Vite-literal + Nitro dual sources of truth; “make Nuxt like #1328
236+
237+
**Thin client path** (resolved):
238+
Same userland import (`./env` / `@arkenv/nuxt` client entries). On the client, `arkenv` does not run `createEnv` / ship the validator; it reads the already-coerced public payload and keeps server-key guards. No separate virtual client module for users to import.
239+
*Avoid*: “client imports `arkenv/gen/...`”; second client specifier as the default DX
240+
241+
**Nuxt boot gate** (resolved):
242+
A Nitro plugin registered by `@arkenv/nuxt/module` is the single validation/coercion gate after **Nitro boot overrides**. It writes coerced values into `runtimeConfig` (including `public`). Server and client **Canonical env object** accessors then read that coerced config; they must not re-validate from raw `process.env` / string overrides in a way that can disagree with the gate.
243+
*Avoid*: dual `createEnv` (import-time + Nitro); “validate in `env.ts` then hope it runs after overrides”
244+
245+
**Symmetric thin accessors** (resolved):
246+
On Nuxt, both server and client `arkenv()` paths are thin readers of the coerced `runtimeConfig` / payload after the **Nuxt boot gate**. `createEnv` runs in the gate, not in userland `env` imports on either side.
247+
*Avoid*: “server still validates on import, client is thin”; asymmetric honesty
248+
249+
**Boot gate schema load** (resolved):
250+
The module loads the configured `schemaPath` / strict layout files and the **Nuxt boot gate** (and build-time validate) call **core** validation against that schema. Public thin `arkenv()` is never used as the validator entry.
251+
*Avoid*: requiring a user-exported `schema`; validating by side-effect of executing fat `arkenv()` in `env.ts`
252+
253+
**Boot gate scheduling** (resolved):
254+
Eager Nitro plugin at server startup for fail-fast, plus idempotent `ensureBootGate()` that thin *server* `arkenv()` may call if it runs first. Single `createEnv` (once). Client never runs the gate — it only reads the post-gate payload.
255+
*Avoid*: lazy-only gate; dual independent `createEnv` calls
256+
257+
**Client validator isolation** (resolved):
258+
`@arkenv/nuxt` client entries must not import `@arkenv/core` / `arktype`. Default ArkType string schemas are plain data. No Vite transform for stripping or inlining. Userland imports of `type` / Zod / other validators into a client-imported env module are the user’s bundle cost.
259+
*Avoid*: “rewrite env.ts to guarantee a validator-free graph”; blaming the integration for user-imported validators
260+
261+
**Build-time schema check** (resolved):
262+
Module setup / `nuxt build` may still run core validation against the build environment when `validate: true` (opt out with `validate: false`). This is an early CI/dev check only. Deploy-time honesty — including **Nitro boot overrides** — remains the **Nuxt boot gate**. Implementation must call core directly, not thin `arkenv()` side effects.
263+
*Avoid*: treating build-time validate as proof of production env; removing the check because the boot gate exists
264+
265+
**Flagged ambiguities**
266+
267+
- “Completely Vite-plugin-based like Solid Start” for Nuxt — **resolved: no** as the sole public-value transport. Nuxt keeps a Nuxt module + **Nuxt boot gate** + **symmetric thin accessors**; Vite is only for the compile-time import boundary (ADR 0016), not Solid-Start-style value inlining. See [#1424 design call](https://github.com/yamcodes/arkenv/issues/1424#issuecomment-5038256349).
268+
269+
**Relationships**:
270+
271+
- A **Nitro boot override** happens after Vite build and before the `__NUXT__` / `runtimeConfig` payload is served to the client
272+
- **Vite transform mode** is appropriate for hosts whose public env is build-time (e.g. Solid Start); it is not sufficient alone for Nuxt
273+
- **Canonical env object** is shared; the *transport* that materializes client values is host-specific
274+
- On Nuxt, the **Nuxt honesty transport** owns public client values; the existing Vite plugin remains for the compile-time import boundary only (not value transport)
275+
- The **Nuxt boot gate** runs after **Nitro boot overrides** and before honest **Canonical env object** reads on either side
276+
- **Boot gate scheduling** ensures the gate precedes thin server reads; the serialized public payload then precedes thin client reads
277+
- **Client validator isolation** is a package-entry concern, not a transform-mode concern, on Nuxt
278+
- The **Build-time schema check** is optional early feedback; it does not replace the **Nuxt boot gate**
279+
280+
**Example dialogue**:
281+
282+
> **Dev:** “Can we make Nuxt completely Vite-plugin-based like Solid Start?”
283+
> **Domain expert:** “Not as the only transport. Solid Start’s public keys are build-time; Nuxt’s can change via a **Nitro boot override**. Honesty requires the **Nuxt honesty transport** — the **Nuxt boot gate** (module-loaded schema, **boot gate scheduling**) coerces into `runtimeConfig` after that override, then **symmetric thin accessors**. The Vite plugin stays for import blocking; **client validator isolation** is a thin package entry, not a #1328-style rewrite. Keep the **Build-time schema check** for CI, but don’t confuse it with deploy honesty.”
284+
285+
### Environment Variable Validation
213286

214287
- ArkEnv uses ArkType's type system to validate environment variables
215288
- Schema is defined using TypeScript-like syntax (e.g., `"string.host"`, `"number.port"`)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# ADR 0021: Canonical env object surface across integrations (Vite/Bun transform design)
2+
3+
## Status
4+
5+
Accepted
6+
7+
## Context
8+
9+
Two integration families exist today:
10+
11+
- **Codegen frameworks (Next, Nuxt):** users import a validated `env` object from `env.ts`; a runtime proxy enforces the client/server boundary (ADR 0012), strict layouts add compile-time import blocking (ADR 0013), and build plumbing is shared via `@arkenv/build` (ADR 0009).
12+
- **Plugin-env frameworks (Vite, Bun):** the plugin validates at build time and rewrites native accessors - Vite `define` inlines coerced literals for `VITE_`-prefixed keys, the Bun plugin statically replaces `process.env.BUN_PUBLIC_*` - with types supplied by manual ambient augmentation (`ImportMetaEnvAugmented`, `ProcessEnvAugmented`).
13+
14+
Issue [#1105](https://github.com/yamcodes/arkenv/issues/1105) asked what the Vite/Bun plugins should learn from the Next.js work (#1084, #1092). Deciding that required settling whether the two families should converge on one surface at all. The forces:
15+
16+
1. **Coercion is a core product property (ADR 0002).** Validated outputs are real `number`/`boolean` values, not strings. Any surface must deliver those types honestly at runtime.
17+
2. **Vite and Bun are fullstack runtimes.** TanStack Start, React Router 7, SolidStart, and `Bun.serve` run server code that reads secrets at deploy time. Build-time-only validation checks the build machine's env, not the production server's. Boot-time fail-fast on `DATABASE_URL` - ArkEnv's core value proposition - is structurally outside the plugin+dts model. The `with-solid-start` example evidences the boundary: its schema contains only `VITE_`-prefixed keys because the model has nothing to offer server env.
18+
3. **Isolation is a module-graph concern.** Both existing boundaries (ADR 0012 conditional exports, ADR 0013 client-import blocking) operate on imports of env modules. Ambient globals cannot be boundary-enforced.
19+
4. **Static rewriting has an honesty ceiling.** `define`/rewrite replaces exact static property reads only. The bare `import.meta.env` object still holds raw strings, so the same key can be a `number` via static access and a `string` via dynamic access (`import.meta.env[key]`, spreads, aliased references) in one bundle - while the ambient `.d.ts` claims `number` unconditionally. This is architectural: no amount of transform cleverness rewrites every access path. By contrast, a materialized runtime object has no aliasing problem - types and values agree under every access pattern.
20+
5. **Ecosystem precedent.** The hosts with the most bundler power chose imported env modules with static-inlining transports: SvelteKit's `$env/static/private` / `$env/static/public` and Astro's `astro:env/client` / `astro:env/server`. Bundler power determines how cheaply the module surface can be implemented - not whether ambient access is preferable. Next's codegen apparatus (factory files, `runtimeEnv` destructuring, jiti) is the price of implementing the object surface *without* bundler power, not a property of the pattern.
21+
6. **Shared plumbing already points here.** ADR 0009 built `@arkenv/build` for exactly this reuse, and ADR 0014's consequences note that `clientPrefix` "should become first-class for all frameworks (including Vite/Bun)". Plumbing is surface-agnostic, so this is enablement rather than proof - but the machinery exists.
22+
23+
## Directions considered
24+
25+
1. **Keep the capability fork - plugin + `.d.ts` stays the Vite/Bun surface (rejected).**
26+
*For:* zero-refactor adoption for existing apps (one ambient `.d.ts` line, no call-site changes); leanest possible client output for SPAs (no imports, no runtime object, no validator bytes); coerced-literal inlining genuinely works for static reads and is a real capability Next lacks.
27+
*Against:* the honesty ceiling of force 4; no server-side story for fullstack apps (force 2) - the de-facto answer becomes "hand-wire core `arkenv` on the server", i.e. two unsupported idioms per app; no possible client/server boundary (force 3); the product permanently teaches two dialects across docs, CLI, and presets.
28+
29+
2. **Port Next's codegen apparatus (`env.gen.ts` + `runtimeEnv`) to Vite/Bun (rejected).** This was the original direction sketched on #1105. It ports the *workaround*, not the lesson: factory files on disk, schema watching, committed generated code, and user-visible `runtimeEnv` wiring exist in Next because ArkEnv cannot control Next's bundler. On hosts where ArkEnv owns the transform, that apparatus is pure overhead - and it would create a second DX path the CLI scaffold has to carry alongside the plugin path.
30+
31+
3. **Split the surface by module graph - object on the server, ambient accessors on the client (rejected).** A tempting middle: give Vite/Bun the layout model and boot validation server-side while keeping `import.meta.env`/`process.env` client-side. But this productizes the exact incoherence being resolved: two syntaxes per fullstack codebase as the *recommended* default. Its only independent justification was avoiding client-object costs, which the transform design (below) deletes.
32+
33+
4. **The opposite fold - move Next/Nuxt to plugin + `.d.ts` (rejected).** Tested for symmetry: it fails for host reasons. Next inlines strings only and strips non-static reads, so ArkEnv cannot make `process.env` coercion-correct there; Nuxt's public values live in `runtimeConfig`, not browser `process.env`. The fold would forfeit boot-time validation, coerced output types, and the proxy/conditional-export boundary - the three guarantees prior ADRs identify as the product core. The asymmetry is decisive: the object surface subsumes everything the plugin model does; the reverse does not hold.
34+
35+
5. **Canonical env object everywhere, via the transform design for Vite/Bun (chosen).**
36+
37+
## Decision
38+
39+
The imported `env` object is the canonical surface for **all** integrations. For Vite and Bun it is implemented with the **transform design** (SvelteKit-shaped, not Next-shaped):
40+
41+
1. **`env.ts` is the single typed source of truth.** The user writes `createEnv` in `env.ts`; types flow from normal inference. No generated declaration files, no committed codegen artifacts, no user-maintained `runtimeEnv` destructuring.
42+
2. **Server graph executes `env.ts` as-is.** Validation and coercion run at server boot against the real deployment environment - the fail-fast guarantee fullstack Vite/Bun apps currently lack.
43+
3. **Client graph gets a transformed module.** The plugin (which already owns validation in Vite's `config` hook / Bun's `setup`) rewrites the env module in client bundles: client-prefixed keys become inlined, build-validated, *coerced* literals; the validator import is stripped; server-only keys become a small throwing guard. No validator ships to the browser and nothing is re-validated client-side. The guard is the same "trust the proxy" stance as ADR 0010, and the flat-layout name/type-leak consequence of ADR 0012 carries over, with the strict layout as the escape hatch.
44+
4. **Strict layout reuses ADR 0013.** The client-import blocker for `env/server.ts` is already a Vite plugin; Bun gets the equivalent via its resolver hooks.
45+
5. **Plumbing consolidates in `@arkenv/build`** (layout resolution, key extraction, watching), and `clientPrefix` becomes first-class plugin config for all frameworks per ADR 0014's note.
46+
6. **Native accessors + `.d.ts` remain documented as SPA mode.** For client-only projects, "add the plugin, augment `ImportMetaEnv`" stays a supported, honest-for-static-access path with zero migration cost. It is positioned as a mode, not a second canonical surface.
47+
7. **The Nuxt proxy preference-order defect is fixed in the same milestone.** The security proxy currently prefers raw `useRuntimeConfig()` / `__NUXT__.config.public` / `process.env` strings over the coerced validation target on *both* the client and server branches - and on the server, `prop in process.env` is almost always true for schema keys, making coerced values near-unreachable in the common path. The aligned model's honesty claim is only as good as its weakest integration. Because Nitro applies `NUXT_PUBLIC_*` overrides as strings at boot, the fix requires a boot-time coercion pass (e.g. a Nitro plugin), not just build-time injection.
48+
49+
## Landing strategy
50+
51+
The implementation is **v1-native**; it does not land on `dev` first.
52+
53+
1. **The transform design ships on the `v1` branch only**, where it can land whole: defaults flipped, CLI scaffold collapsed to one family, and the held-back Next/Nuxt build-tool changes included - one migration story, no compatibility shims. Implementing on `dev` first would build the hardest plumbing (client-graph discrimination, HMR invalidation, alias handling) on a foundation `v1` replaces, forcing a re-implementation disguised as a forward-port and risking behavioral drift between generations.
54+
2. **No soft-landing preview on `dev`.** Because plugin + `.d.ts` survives permanently as SPA mode, v0 users are never forced to migrate - which removes most of a soft-landing's value while keeping its costs (docs describing a surface v0 only partially delivers; a two-step migration for v0 → v1 users). Early-adopter feedback is gathered through `v1` prereleases instead.
55+
3. **The Nuxt proxy preference-order fix is the exception**: it is a bug affecting current users, so it lands on `dev` first and is forward-ported to `v1` per the standard dual-tracking flow.
56+
4. **This ADR lands on `dev`**, since `docs/adr/` is the central decision log on the default branch (precedent: ADR 0014 recording v1 forward-port plans from `dev`).
57+
58+
## Consequences
59+
60+
- **One mental model.** `import { env } from "./env"` works identically in Next, Nuxt, Vite, and Bun apps, on both sides of the network boundary. Docs, the arkenv skill, hosting presets, and the CLI scaffold converge on one family (the #1316 codegen-vs-plugin-env scaffold seam dissolves rather than hardens).
61+
- **Fullstack Vite/Bun gains boot-time validation and a real security boundary** - capabilities the plugin+dts model could not express at any cost.
62+
- **The bill is Nuxt-module-shaped, not zero.** The plugin must identify the env module (convention/config, like ADR 0013's `schemaPath`) and discriminate client vs. server graphs (Vite's `ssr` transform flag; Bun's per-target `onLoad`), including HMR invalidation, aliased imports, and monorepo paths.
63+
- **Dead-code elimination is lost through the object.** `if (import.meta.env.VITE_FLAG)` constant-folds; `if (env.VITE_FLAG)` does not. Small and known - Next accepted the identical trade-off.
64+
- **Client chunks gain a small guard stub** (a few bytes of emitted getter code) in exchange for shipping no validator and doing no client-side re-validation.
65+
- **SPA mode persists as a documented second path**, which means maintaining the ambient helper types indefinitely (trivial) and one docs-framing decision (how prominently to position it) that is a product call, not architecture.
66+
- **Maintenance tripwire.** `@remarks` notes on the Vite/Bun transform entry points should reference this ADR so contributors do not reintroduce `env.gen.ts`-style codegen, client-side validation, or `runtimeEnv` wiring on hosts that own their transform.
67+
68+
## Numbering note
69+
70+
On `dev` this decision was recorded as `0015-env-object-canonical-surface`. On `v1` that number is already used by the Next.js conditional-exports ADR, so this port is **ADR 0021**.

0 commit comments

Comments
 (0)