Astilba Env: Public-alpha documentation for the local-first @astilba/env configuration contract compiler. # Env > Compile one portable configuration declaration into separated, typed browser and server interfaces. Astilba Env is a local-first configuration contract compiler for TypeScript. You declare which values each application artifact needs, when those values may be resolved, and where they may be exposed. Env generates typed browser and server modules from that contract. `@astilba/env` 0.3.0 is a public alpha. It is intended for evaluation and controlled adoption while the contract is still free to improve. ```sh pnpm add @astilba/env@0.3.0 --save-exact ``` Env does not replace your secret manager or `.env` files, and it has no hosted control plane. Generation and checking read only the source values their current operation needs, inside your project. Public build values selected by a browser consumer are deliberately emitted into generated `.build.ts` modules; public build values selected only by a server consumer resolve through a generated server target. Other generated evidence is value-free. Env does not send your values to Astilba. ## Choose your next step [Section titled “Choose your next step”](#choose-your-next-step) | Goal | Start here | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | Decide whether Env fits | [Overview](/docs/env/overview/) | | Add Env to a Node application | [Configure a Node application](/docs/env/quickstart/) | | Compare declared names with a provider or platform list | [Check name inventory drift](/docs/env/inventory-and-drift/) | | Check a runtime boundary | [Node.js](/docs/env/nodejs/) or [Browser](/docs/env/browser-runtime/) | | Configure a Cloudflare Worker | [Cloudflare Workers](/docs/env/cloudflare-workers/) | | Integrate a framework | [Vite](/docs/env/vite/) or [Next.js](/docs/env/nextjs/) | | Choose a runtime path | [Node server, Worker deployment target, Next static shell, or Vite browser shell](/docs/env/overview/#choose-the-runtime-path) | | Understand build, deployment, and request values | [Lifecycles and projections](/docs/env/lifecycles-and-projections/) | | Choose built-in or custom validation | [Validation and Standard Schema](/docs/env/validation-and-standard-schema/) | | Serve validated browser configuration | [Deliver browser configuration](/docs/env/browser-delivery/) | | Replace `next-dynamic-env` | [Migrate from next-dynamic-env](/docs/env/migrate-from-next-dynamic-env/) | | Look up builders and codecs | [Declaration reference](/docs/env/declaration-reference/) | | Automate generation, checking, and planning | [CLI reference](/docs/env/cli-reference/) | | Check runtimes, exports, and alpha boundaries | [Release and support](/docs/env/release-and-support/) | Start with the Node quickstart for a new integration. Choose a runtime or framework page when the declaration already exists. Use the migration guide if your application currently exposes runtime configuration through `DynamicEnvScript`, `clientEnv`, or `serverEnv`. # Overview > Understand Astilba Env's contract model, generated boundaries, supported runtimes, and responsibility boundary. Astilba Env turns one portable TypeScript declaration into generated configuration interfaces for the artifacts that consume them. It keeps browser and server projections physically separate, distinguishes build, deployment, and request values, and validates values at the lifecycle where they become available. The 0.3 release is a public alpha. Use it when explicit configuration boundaries are worth adopting before the API reaches stability. ## Decide whether Env fits [Section titled “Decide whether Env fits”](#decide-whether-env-fits) | You want to… | Fit | | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Promote one built artifact through several deployments | Declare deployment values and resolve them from each deployment’s source object. | | Keep private names and bindings out of browser bundles | Browser consumers receive generated public projections only. | | Validate build, deployment, and request values separately | Each entry declares its lifecycle; each generated target resolves one lifecycle. | | Run without an Astilba service | Generation, checking, and planning are local; the application owns value storage and delivery. | | Compare declared names with an application-owned provider list | Export a value-free target inventory, convert the provider’s names to Env’s strict observed format, and check open or closed ownership without giving Env provider credentials. | | Validate Worker bindings without a Node.js compatibility layer | Env admits a narrow generated deployment-target path for Cloudflare Workers. | | Inject arbitrary configuration through inline JavaScript | Not supported. Browser deployment and request values use inert, same-origin JSON. | | Import one mutable environment object everywhere | Not supported. Generated modules create explicit artifact boundaries. | | Provision secrets or platform bindings | Not supported. Env validates application-owned sources; it does not operate providers. | ## Model four decisions [Section titled “Model four decisions”](#model-four-decisions) An Env declaration records four independent decisions: 1. an entry’s visibility is `public` or `private`; 2. its lifecycle is `build`, `deployment`, or `request`; 3. its codec defines the accepted input and typed output; and 4. a consumer selects which entries one artifact may know. A target then maps one consumer and one complete lifecycle to names in an application-owned source object. ```ts import { defineEnvironment, env } from "@astilba/env"; export default defineEnvironment({ id: "com.example.application", entries: { apiOrigin: env.public.deployment.origin(), databaseUrl: env.private.deployment.secret(), }, consumers: { browser: env.browser(["apiOrigin"]), server: env.server(["databaseUrl"]), }, targets: { browserDeployment: env.process("browser", { apiOrigin: "API_ORIGIN", }), serverDeployment: env.process("server", { databaseUrl: "DATABASE_URL", }), }, }); ``` The declaration does not read either value. ## Generate artifact-specific modules [Section titled “Generate artifact-specific modules”](#generate-artifact-specific-modules) Run generation on a supported Node.js release and check drift in CI: ```sh pnpm exec astilba-env generate pnpm exec astilba-env generate --check ``` The declaration above produces separate interfaces: ```text .astilba/env/browser/browser.deployment.ts .astilba/env/browserDeployment.server.ts .astilba/env/serverDeployment.server.ts ``` Generated target modules export typed `check(source)` and `load(source)` functions. Generated browser build modules may contain frozen public build values; deployment and request projection modules contain the public decoder and compatibility identity for one consumer and lifecycle, but no values, private entry names, private bindings, or complete contract metadata. Read [Lifecycles and projections](/docs/env/lifecycles-and-projections/) for the generated file model. ## Choose the runtime path [Section titled “Choose the runtime path”](#choose-the-runtime-path) | Path | Lifecycle and generated import | Example | | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | Node server | Generate on Node.js, then load a generated server target for the lifecycle your server owns. | [Node server](https://github.com/astilbahq/env/tree/main/examples/node-service) | | Cloudflare Worker deployment target | Run a generated deployment-lifecycle target through `@astilba/env/runtime` with built-in codecs. Authoring and generation stay on Node.js. | [Cloudflare Worker](https://github.com/astilbahq/env/tree/main/examples/cloudflare-worker) | | Next static shell | Import public build values from the generated browser `.build.ts` module. Bootstrap deployment values through an application-owned route without making the page dynamic. | [Next static shell](https://github.com/astilbahq/env/tree/main/examples/next-static-shell) | | Vite browser shell | Import public build values from the generated browser `.build.ts` module, or bootstrap deployment values from an application-owned endpoint and generated browser projection. | [Vite browser shell](https://github.com/astilbahq/env/tree/main/examples/vite) | The Worker path is limited to generated deployment-lifecycle targets with built-in codecs. It does not establish support for declaration authoring, generation, request targets, opaque schemas, or other package exports in Workers. Framework pages explain the application-owned wiring: [Vite](/docs/env/vite/) adds a private-module browser-graph boundary, [Next.js](/docs/env/nextjs/) keeps static shells static, and [Deliver browser configuration](/docs/env/browser-delivery/) defines both same-origin endpoint delivery and framework-transported inert JSON. Check [Release and support](/docs/env/release-and-support/) before choosing a runtime. Evidence for one package export does not make every Env export portable to that runtime. ## Compose Env with your stack [Section titled “Compose Env with your stack”](#compose-env-with-your-stack) Env can replace application-specific configuration parsing, required-value helpers, public and private naming conventions, and projection glue. It complements the systems that store, deliver, inventory, or describe the source values. | Concern | Existing authority | Env’s role | | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Secret storage and injection | A secret manager, CI system, or deployment platform owns the values and delivers them to the application. | A generated target reads only the selected values inside your application process after delivery. Astilba receives no values; Env provides no secret storage, rotation, or provisioning. | | Worker binding types | `wrangler types` describes the complete Worker binding interface. | A generated deployment target decodes mapped string settings and secrets. D1, KV, R2, and service bindings are capabilities, so they stay outside the configuration result and remain available through the Wrangler-generated interface. | | Live deployment inventory and preflight | Deployment tooling or application-owned assertions query the platform and decide whether a deployment may proceed. | Env validates the explicit source object at the declared lifecycle. It does not query a provider or prove what is present in a remote deployment. | | Declared-name drift | Application-owned tooling converts a provider or platform name list into Env’s strict observed format. | `inventory export` publishes the declared name contract; `inventory check` compares names and required presence. Env never receives values or provider credentials. | | Server configuration parsing | Hand-written `requiredEnv()` helpers or schema wrappers turn ambient strings into application values. | Generated `check(source)` and `load(source)` operations can replace that parsing while sharing one contract across selected artifacts. | | Browser configuration delivery | An application-owned route serves inert, same-origin JSON, or a framework safely transports the serialized envelope as inert data. The application owns the HTTP and rendering policy. | Env generates the public projection, compatibility identity, and envelope validation; it does not host a route or choose the transport. | Use Env when one contract replaces repeated parsing or crosses artifact and lifecycle boundaries. Keep your existing parser when one server already has a reliable parser, you have no browser configuration surface, and you do not need to promote the same artifact through several deployments. Continue with [Configure a Node application](/docs/env/quickstart/) for the smallest working setup, or [Check name inventory drift](/docs/env/inventory-and-drift/) when duplicated name lists are the problem. # Configure a Node application > Install Astilba Env, declare deployment configuration, generate typed modules, and fail startup safely. In this guide, you will replace direct `process.env` reads with one typed deployment target. Env will validate the values without logging them and generate a module owned by your application. ## Check the requirements [Section titled “Check the requirements”](#check-the-requirements) Env 0.3.0 requires a supported Node.js release: * Node.js 22.14.0 or later within Node 22; * Node.js 24.x; or * Node.js 26.x. The default `astilba.env.ts` filename requires an ESM package with `"type": "module"`. A lowercase `.mts` configuration file works without changing the package type. Install the exact public-alpha release: ```sh pnpm add @astilba/env@0.3.0 --save-exact ``` ## Declare the configuration [Section titled “Declare the configuration”](#declare-the-configuration) Create `astilba.env.ts` at the package root: ```ts import { defineEnvironment, env } from "@astilba/env"; export default defineEnvironment({ id: "com.example.api", entries: { apiOrigin: env.public.deployment.origin(), databaseUrl: env.private.deployment.secret(), port: env.private.deployment.integer({ minimum: 1, maximum: 65_535, }), }, consumers: { server: env.server(["apiOrigin", "databaseUrl", "port"]), }, targets: { serverDeployment: env.process("server", { apiOrigin: "API_ORIGIN", databaseUrl: "DATABASE_URL", port: "PORT", }), }, }); ``` The declaration does not read the three environment variables. It records: * which logical entries exist; * whether each entry is public or private; * when each value becomes available; * which artifact may consume it; and * how the target maps logical entries to source names. `public` means the value may be exposed to a browser consumer. It does not mean that Env publishes the value. `private` entries cannot enter a browser projection. ## Generate the target module [Section titled “Generate the target module”](#generate-the-target-module) Generate the application-owned output: ```sh pnpm exec astilba-env generate ``` The target in this guide produces `.astilba/env/serverDeployment.server.ts`. Import its generated `load` function during startup: ```ts import { load } from "./.astilba/env/serverDeployment.server"; const configuration = load(process.env); startServer({ databaseUrl: configuration.databaseUrl, origin: configuration.apiOrigin, port: configuration.port, }); ``` `load` returns a frozen, typed configuration when every value is valid. It throws `EnvironmentConfigurationError` otherwise. The error carries redacted diagnostics; it does not include the rejected values. Use `check` when your application needs to choose its own failure response: ```ts import { check } from "./.astilba/env/serverDeployment.server"; const result = check(process.env); if (!result.ok) { console.error("Configuration is invalid.", result.diagnostics); process.exitCode = 1; } else { startServer(result.value); } ``` The diagnostic entries identify stable error codes and logical entry names. Do not add the original source record to logs. ## Check values without starting the application [Section titled “Check values without starting the application”](#check-values-without-starting-the-application) The CLI can validate the target against the current process environment: ```sh API_ORIGIN=https://api.example.com \ DATABASE_URL=postgres://example \ PORT=3000 \ pnpm exec astilba-env check --target serverDeployment ``` A valid target exits with status `0`. Missing or invalid values exit with status `1`. The command reports whether the target is valid, never the values it observed. ## Keep generated output current [Section titled “Keep generated output current”](#keep-generated-output-current) Add repeatable scripts: ```json { "scripts": { "env:generate": "astilba-env generate", "env:check": "astilba-env generate --check" } } ``` Run `pnpm env:generate` after changing the declaration. Commit `astilba.env.ts` and `.astilba/env/`, then require `pnpm env:check` in CI. `generate --check` performs no writes. It exits nonzero when a generated file is missing, changed, or unexpected. Keeping `snapshot.json` in Git also lets [`astilba-env plan --base`](/docs/env/cli-reference/#plan) compare a proposed contract with a committed revision without executing the historical configuration file. Next, read [Lifecycles and projections](/docs/env/lifecycles-and-projections/) before adding build, browser, or request configuration. # Node.js > Author Env declarations, generate project-owned modules, and resolve typed configuration in supported Node.js releases. Node.js is Env’s authoring and tooling runtime. Use it to execute `astilba.env.ts`, generate project-owned modules, check generated drift, validate process targets through the CLI, and plan declaration changes. Generated server targets also run in Node.js. They read only the source object you pass to `check` or `load`; Env does not add a global environment loader. ## Check the supported releases [Section titled “Check the supported releases”](#check-the-supported-releases) Env 0.3.0 supports: * Node.js 22.14.0 or later within Node 22; * Node.js 24 within Node 24; and * Node.js 26 within Node 26. Use an ESM package with `"type": "module"` for the default `astilba.env.ts` configuration filename. A lowercase `.mts` configuration file is also supported. ## Keep authoring separate from resolution [Section titled “Keep authoring separate from resolution”](#keep-authoring-separate-from-resolution) The declaration describes configuration without reading current values: ```ts import { defineEnvironment, env } from "@astilba/env"; export default defineEnvironment({ id: "com.example.api", entries: { databaseUrl: env.private.deployment.secret(), port: env.private.deployment.safeInteger({ maximum: 65_535, minimum: 1, }), }, consumers: { server: env.server(["databaseUrl", "port"]), }, targets: { serverDeployment: env.process("server", { databaseUrl: "DATABASE_URL", port: "PORT", }), }, }); ``` Run generation on a supported Node.js release: ```sh pnpm exec astilba-env generate ``` The generated `.astilba/env/serverDeployment.server.ts` module imports the narrow `@astilba/env/runtime` surface. Your application chooses when to pass `process.env`. ## Choose `check` or `load` [Section titled “Choose check or load”](#choose-check-or-load) Use `check` when application code owns the failure path: ```ts import { check } from "./.astilba/env/serverDeployment.server"; const result = check(process.env); if (!result.ok) { console.error("Configuration is invalid.", result.diagnostics); process.exitCode = 1; } else { startServer(result.value); } ``` Use `load` when invalid configuration should throw: ```ts import { load } from "./.astilba/env/serverDeployment.server"; const configuration = load(process.env); startServer(configuration); ``` Both operations return owned, frozen values. Diagnostics contain stable codes and logical identities where appropriate; they do not contain rejected values, fragments, lengths, or hashes. ## Audit the runtime export [Section titled “Audit the runtime export”](#audit-the-runtime-export) Generated server targets import `@astilba/env/runtime` for you. Application code should normally import the generated target’s typed `check` or `load` function rather than construct a target definition itself. The runtime export is public so generated modules can use one stable boundary and advanced consumers can audit every dependency: | Export | Purpose | | ------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `checkProcessTarget` | Validate and resolve a generated target with built-in codecs without throwing for configuration failures. | | `loadProcessTarget` | Resolve a generated target with built-in codecs or throw `EnvironmentConfigurationError`. | | `checkProcessTargetWithSchemas` | Resolve a Node.js target with application-owned Standard Schema validators and return a Promise of the result. | | `loadProcessTargetWithSchemas` | Resolve a Node.js target with Standard Schema validators or reject with `EnvironmentConfigurationError`. | | `EnvironmentConfigurationError` | Identify a failed `load*` operation and expose its value-free `diagnostics`. Direct construction is rejected. | | `ProcessSource` | Describe the application-owned object from which a generated target reads raw values. | | `ProcessTargetDefinition` | Describe the generated target definition consumed by the runtime. | | `ProcessTargetSchemas` | Map an opaque entry name to its application-owned Standard Schema validator. | | `StandardSchemaV1` | Describe the minimal Standard Schema v1 contract accepted for private opaque entries. | | `StandardSchemaResult` | Describe the immediate success or failure result returned by that validator contract. | The `*WithSchemas` operations return Promises, but validators must settle synchronously. A Promise or thenable from a validator produces `ENV_VALIDATOR_ASYNC_UNSUPPORTED`. The workerd conditional export does not execute opaque validators and admits deployment targets only. ## Resolve request values explicitly [Section titled “Resolve request values explicitly”](#resolve-request-values-explicitly) Node.js targets may also resolve request-lifecycle values from an application-owned object: ```ts import { load } from "./.astilba/env/serverRequest.server"; export const handleRequest = (request: Request): Response => { const configuration = load({ TENANT_ID: readTrustedTenant(request), }); return respondForTenant(configuration.tenantId); }; ``` Do not retain request configuration in process-global state or an unpartitioned cache. Env validates the supplied object; your application still authenticates the request and owns the source lifetime. ## Keep the CLI in Node.js [Section titled “Keep the CLI in Node.js”](#keep-the-cli-in-nodejs) The declaration builders, generator, and CLI are Node.js tools even when a generated target runs somewhere else. For example, author and generate a [Cloudflare Workers target](/docs/env/cloudflare-workers/) in Node.js, then import only its generated module in the Worker. Read [Configure a Node application](/docs/env/quickstart/) for the complete first setup and [CLI reference](/docs/env/cli-reference/) for command behavior. # Browser > Load and validate public browser configuration through Env's isolated browser runtime. The `@astilba/env/browser` export validates public configuration before browser application code uses it. It accepts only generated public projections; declaration builders, private targets, provider bindings, and complete contract metadata stay outside the browser graph. Browser delivery is application-owned. Env validates either a same-origin JSON response or inert JSON that your framework has already transported. The browser export contains the runtime values below: | Export | Purpose | | ------------------------- | -------------------------------------------------------------------------------------------------- | | `loadBrowserBootstrap` | Fetch and validate a same-origin JSON envelope. | | `parseBrowserBootstrap` | Validate an already transported serialized JSON envelope without fetching. | | `startBrowserApplication` | Validate with `loadBrowserBootstrap`, then import and start the application module. | | `BootstrapFailure` | Identify an expected bootstrap failure with `instanceof` and read its stable `code`. | | `BOOTSTRAP_PROTOCOL` | Build an application-owned response with the current `astilba.env.bootstrap/v1` protocol identity. | | `MAXIMUM_BOOTSTRAP_BYTES` | Read the current 65,536-byte response limit without duplicating it in application code. | It also exports the supporting `BootstrapFailureCode`, `BrowserApplicationModule`, `BrowserAudience`, `BrowserProjection`, `BrowserValues`, `LoadBootstrapOptions`, `ParseBootstrapOptions`, `StartBrowserApplicationOptions`, and `ValidatedBootstrap` types. Generated projections provide `BrowserProjection`; application code should import a generated projection rather than construct one. ## Import only the browser surface [Section titled “Import only the browser surface”](#import-only-the-browser-surface) Generate a public browser consumer and import its projection: ```ts import { loadBrowserBootstrap } from "@astilba/env/browser"; import { projection } from "./.astilba/env/browser/browser.deployment"; const bootstrap = await loadBrowserBootstrap({ endpoint: "/api/env", expectedAudience: { origin: window.location.origin }, fetch: globalThis.fetch, projection, requestBaseUrl: window.location.href, }); renderApplication(bootstrap.values); ``` `loadBrowserBootstrap` requests with `cache: "no-store"` and `redirect: "error"`. It checks the same-origin request and final response, JSON content type and size, envelope identity, expected audience, generated projection digest, exact fields, and typed values. The returned values are copied into frozen, owned data. ## Parse framework-transported JSON [Section titled “Parse framework-transported JSON”](#parse-framework-transported-json) Use `parseBrowserBootstrap` when a framework has already delivered the exact envelope as inert data: ```ts import { BootstrapFailure, parseBrowserBootstrap, } from "@astilba/env/browser"; import { projection } from "./.astilba/env/browser/browser.deployment"; const source = document.querySelector("#env")?.textContent ?? ""; try { const bootstrap = parseBrowserBootstrap({ expectedAudience: { origin: window.location.origin }, projection, source, }); renderApplication(bootstrap.values); } catch (error) { const code = error instanceof BootstrapFailure ? error.code : "BOOTSTRAP_UNEXPECTED"; renderConfigurationFailure(code); } ``` Pass the serialized JSON text, not the result of `JSON.parse`. Let the framework serialize and escape the envelope for an inert data container; do not interpolate unescaped JSON into HTML yourself. Do not replace the data with an executable script assignment or a mutable global. Missing or malformed transported JSON follows the same configuration-failure boundary as an invalid bootstrap. ## Delay the application import [Section titled “Delay the application import”](#delay-the-application-import) Use `startBrowserApplication` when the main application module must not enter the active graph before configuration validates: ```ts import { startBrowserApplication } from "@astilba/env/browser"; import { projection } from "./.astilba/env/browser/browser.deployment"; await startBrowserApplication({ endpoint: "/api/env", expectedAudience: { origin: window.location.origin }, fetch: globalThis.fetch, importApplication: () => import("./application"), projection, requestBaseUrl: window.location.href, }); ``` The imported module exports `start(values, audience)`. Env validates first, imports second, and calls `start` last. ## Handle failures without fallback [Section titled “Handle failures without fallback”](#handle-failures-without-fallback) Browser operations throw `BootstrapFailure` with a stable code: ```ts import { BootstrapFailure, loadBrowserBootstrap, } from "@astilba/env/browser"; try { const bootstrap = await loadBrowserBootstrap(options); renderApplication(bootstrap.values); } catch (error) { const code = error instanceof BootstrapFailure ? error.code : "BOOTSTRAP_UNEXPECTED"; renderConfigurationFailure(code); } ``` Do not continue with ambient, baked, or previously cached values after validation fails. A retry should perform a new validation without weakening the expected audience or projection identity. The public `BootstrapFailureCode` union is grouped by the boundary that refused the bootstrap: * request and response: `BOOTSTRAP_REQUEST_ORIGIN_MISMATCH`, `BOOTSTRAP_FETCH_FAILED`, `BOOTSTRAP_REDIRECTED`, `BOOTSTRAP_FINAL_ORIGIN_MISMATCH`, `BOOTSTRAP_HTTP_STATUS_INVALID`, `BOOTSTRAP_INVALID_MIME`, `BOOTSTRAP_BODY_READ_FAILED`, `BOOTSTRAP_BODY_TOO_LARGE`, and `BOOTSTRAP_INVALID_UTF8`; * JSON input: `BOOTSTRAP_INVALID_JSON`, `BOOTSTRAP_DUPLICATE_KEY`, `BOOTSTRAP_JSON_TOO_DEEP`, `BOOTSTRAP_JSON_TOO_MANY_KEYS`, and `BOOTSTRAP_NON_PORTABLE_JSON`; * envelope identity: `BOOTSTRAP_UNKNOWN_FIELD`, `BOOTSTRAP_FIELD_MISSING`, `BOOTSTRAP_FIELD_INVALID`, `BOOTSTRAP_PROTOCOL_UNSUPPORTED`, `BOOTSTRAP_CONTRACT_MISMATCH`, `BOOTSTRAP_LIFECYCLE_MISMATCH`, `BOOTSTRAP_PROJECTION_MISMATCH`, and `BOOTSTRAP_AUDIENCE_MISMATCH`; and * generated projection and values: `BOOTSTRAP_PROJECTION_INVALID`, `BOOTSTRAP_GENERATED_FORMAT_UNSUPPORTED`, `BOOTSTRAP_VALUE_MISSING`, and `BOOTSTRAP_VALUE_INVALID`. Treat the codes as diagnostics and telemetry identities, not user-facing copy. An unexpected non-Env exception has no Env failure code; map it to an application-owned fallback state. ## Keep private modules out of the graph [Section titled “Keep private modules out of the graph”](#keep-private-modules-out-of-the-graph) Browser code may import: * `@astilba/env/browser`; * generated `browser/*.build.ts` modules; and * generated `browser/*.deployment.ts` or `browser/*.request.ts` projections; and * generated `consumers/*.public.json` evidence when an application explicitly needs the value-free public manifest. It must not import the root package, `@astilba/env/runtime`, the Env configuration file, generated `*.server.ts` targets, or complete generated metadata. Use the [Vite integration](/docs/env/vite/) where Vite builds the browser graph. Other build tools need an equivalent application-owned boundary. Continue with [Deliver browser configuration](/docs/env/browser-delivery/) to build the endpoint and exact envelope. # Cloudflare Workers > Generate a deployment target on Node.js and validate Wrangler bindings directly inside a Cloudflare Worker. Env 0.3.0 admits a narrow Cloudflare Workers runtime path: a generated server target for the `deployment` lifecycle may call `check(env)` or `load(env)` inside a Worker handler when every selected entry uses a first-party Env codec. Authoring, generation, CLI checks, and planning still run on a [supported Node.js release](/docs/env/nodejs/). The Worker imports the generated target and the `@astilba/env/runtime` dependency behind it; it does not import the root package, browser runtime, CLI, or Vite integration. ## Declare one deployment target [Section titled “Declare one deployment target”](#declare-one-deployment-target) Declare Worker configuration on Node.js: ```ts import { defineEnvironment, env } from "@astilba/env"; export default defineEnvironment({ id: "com.example.worker", entries: { apiOrigin: env.public.deployment.origin(), signingKey: env.private.deployment.secret(), }, consumers: { worker: env.server(["apiOrigin", "signingKey"]), }, targets: { workerDeployment: env.process("worker", { apiOrigin: "API_ORIGIN", signingKey: "SIGNING_KEY", }), }, }); ``` Generate and commit the application-owned output: ```sh pnpm exec astilba-env generate pnpm exec astilba-env generate --check ``` This creates `.astilba/env/workerDeployment.server.ts`. Generation does not read Worker bindings for a deployment target and does not contact Cloudflare. ## Declare Wrangler bindings [Section titled “Declare Wrangler bindings”](#declare-wrangler-bindings) Keep non-secret values in `vars`, and declare required secret names with `secrets.required`: ```jsonc { "$schema": "node_modules/wrangler/config-schema.json", "name": "example-worker-staging", "main": "src/index.ts", "compatibility_date": "2026-07-29", "vars": { "API_ORIGIN": "https://staging-api.example.com" }, "secrets": { "required": ["SIGNING_KEY"] }, "kv_namespaces": [ { "binding": "CACHE", "id": "" } ] } ``` Configure the secret value through Cloudflare, outside source control. `secrets.required` records the required name; it does not contain the secret. The same built Worker artifact can use another binding set: ```jsonc { "$schema": "node_modules/wrangler/config-schema.json", "name": "example-worker-production", "main": "src/index.ts", "compatibility_date": "2026-07-29", "vars": { "API_ORIGIN": "https://api.example.com" }, "secrets": { "required": ["SIGNING_KEY"] }, "kv_namespaces": [ { "binding": "CACHE", "id": "" } ] } ``` Env validates values at runtime. Its provider-neutral inventory command can compare the declared string names with an application-supplied name list; it does not call Cloudflare, inspect binding kinds, or decide when a changed set should redeploy. Choose the latest compatibility date supported by your installed Wrangler release. Env does not impose its own date floor. The [0.3.0 release matrix](https://github.com/astilbahq/env/actions/runs/31482997555) exercises the exact packed archive with stock Wrangler 4.115.0 and its bundled workerd at compatibility date `2026-07-29`; it does not replace Wrangler’s transitive runtime. ## Generate Cloudflare’s binding types [Section titled “Generate Cloudflare’s binding types”](#generate-cloudflares-binding-types) Generate the Worker `Env` interface from Wrangler configuration: ```sh pnpm exec wrangler types pnpm exec wrangler types --check ``` [`wrangler types`](https://developers.cloudflare.com/workers/languages/typescript/#generate-types) derives binding types from your configuration. Use `--check` in CI, so the generated interface cannot drift. The generated target accepts that interface without requiring a string index signature: ```ts import { check } from "../.astilba/env/workerDeployment.server"; export default { async fetch(request: Request, env: Env): Promise { const result = check(env); if (!result.ok) { return Response.json( { diagnostics: result.diagnostics, ok: false }, { status: 500 } ); } const cached = await env.CACHE.get(request.url); return Response.json({ apiOrigin: result.value.apiOrigin, cached: cached !== null, }); }, } satisfies ExportedHandler; ``` `check(env)` reads only `API_ORIGIN` and `SIGNING_KEY`, then returns an exact owned result. The unrelated `CACHE` capability binding remains available to application code and does not enter the configuration result. Use `load(env)` instead when invalid deployment configuration should throw. Do not log the original `env` object on failure. ## Pass string bindings without coercion [Section titled “Pass string bindings without coercion”](#pass-string-bindings-without-coercion) Every source binding selected by the generated target must be a string or `undefined`. A missing optional binding may be `undefined`; a present binding is decoded from its exact string value. Cloudflare also allows JSON values in `vars` and objects for capability bindings. Env does not stringify or coerce those values. If a selected source name resolves to a JSON value, KV namespace, D1 database, service binding, or another capability object, `check` returns a redacted invalid-value diagnostic and `load` throws. Unselected bindings are different: Env does not read or reject them. Keep `CACHE` and other capabilities outside the target mapping, then use them directly through the Wrangler-generated `Env` interface. ## Keep the compatibility surface narrow [Section titled “Keep the compatibility surface narrow”](#keep-the-compatibility-surface-narrow) Env’s generated runtime does not need the [`nodejs_compat` compatibility flag](https://developers.cloudflare.com/workers/runtime-apis/nodejs/) for this path. Add that flag only when other application dependencies require Node.js APIs. The Env 0.3.0 Workers claim includes: * generated server targets for the `deployment` lifecycle; * first-party Env codecs; and * direct `check(env)` or `load(env)` calls inside the handler. It does not include: * request-lifecycle generated targets; * `opaque` entries or caller-provided Standard Schema validators; * the root `@astilba/env` authoring export in workerd; * `@astilba/env/browser` or `@astilba/env/vite` in workerd; or * the Env CLI in workerd. ## Know what Env does not operate [Section titled “Know what Env does not operate”](#know-what-env-does-not-operate) Env does not provide: * Worker, route, or binding provisioning; * secret storage or rotation; * a live Cloudflare binding query or Wrangler-specific parser; * a Cloudflare provider API client; or * automatic deployment or redeployment planning. Wrangler and Cloudflare own those operations. If duplicated string-binding names are the problem, convert a Wrangler-produced name list into Env’s provider-neutral observed format and follow [Check name inventory drift](/docs/env/inventory-and-drift/). See the official [Wrangler configuration reference](https://developers.cloudflare.com/workers/wrangler/configuration/) for binding configuration and required-secret behavior. # Vite > Keep private Env declarations, targets, and metadata out of Vite browser graphs. The `@astilba/env/vite` integration rejects private Env surfaces when Vite builds browser code. It is a build-time import boundary; it does not generate configuration, create an endpoint, or load browser values. Env 0.3.0 supports Vite 8.1.5 or later within Vite 8. ## Add the boundary plugin [Section titled “Add the boundary plugin”](#add-the-boundary-plugin) Add the plugin to every Vite configuration that can produce a browser graph: ```ts import { astilbaEnvBrowserBoundary } from "@astilba/env/vite"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [astilbaEnvBrowserBoundary()], }); ``` Keep the plugin active in development and production builds. A development-only boundary can allow an import that fails or leaks later in CI. ## Understand the allowed graph [Section titled “Understand the allowed graph”](#understand-the-allowed-graph) Browser modules may import: * `@astilba/env/browser`; * generated `browser/*.build.ts` values; and * generated `browser/*.deployment.ts` or `browser/*.request.ts` projections; and * generated `consumers/*.public.json` evidence. The plugin rejects imports of: * the root `@astilba/env` declaration package; * `@astilba/env/runtime` and `@astilba/env/vite`; * `astilba.env.ts` or `.mts`; * generated `*.server.ts` modules; * `contract.json`, `snapshot.json`, and generated `consumers/*.server.json` metadata; and * package-owned files outside the public browser runtime. The plugin emits the stable `ENV_BROWSER_PRIVATE_IMPORT` failure code. Use Vite’s import trace to locate the refused edge, then move it behind a server boundary instead of adding an exception. ## Choose build-fixed or deployment-bootstrap values [Section titled “Choose build-fixed or deployment-bootstrap values”](#choose-build-fixed-or-deployment-bootstrap-values) Use a public `build` entry and its generated browser `.build.ts` module when Vite may fix the value at build time. When one built browser shell must receive deployment values, expose the generated public projection through an application-owned endpoint and bootstrap it in the browser. The [Vite browser shell example](https://github.com/astilbahq/env/tree/main/examples/vite) is an executable adoption fixture that demonstrates both choices. Its `Host`-derived audience fallback is restricted to exact local `localhost` and loopback forms, and its combined server target deliberately exercises a private secret. For production wiring, use a separate public bootstrap target and a configured canonical HTTPS audience as described in [Deliver browser configuration](/docs/env/browser-delivery/). ## Keep endpoint behavior elsewhere [Section titled “Keep endpoint behavior elsewhere”](#keep-endpoint-behavior-elsewhere) The Vite plugin does not decide: * which route returns browser configuration; * where the canonical audience origin comes from; * whether a request needs authentication; * which cache headers the response uses; or * how the application presents a bootstrap failure. Use [Deliver browser configuration](/docs/env/browser-delivery/) for the JSON protocol and [Browser](/docs/env/browser-runtime/) for runtime loading. ## Verify the production artifact [Section titled “Verify the production artifact”](#verify-the-production-artifact) The plugin is one layer, not proof that every application import path is safe. In CI: 1. run `astilba-env generate --check`; 2. build every browser entry with the plugin active; 3. scan production assets for private logical names and binding names; and 4. include a non-production canary value and prove it does not enter browser output. Frameworks or build paths that do not use Vite need an equivalent application-owned rule. # Next.js > Wire generated Env targets and browser projections into Next.js App Router or Pages Router applications. Next.js integration is application-owned wiring around generated Env modules. There is no `@astilba/env/next` export and no framework-specific configuration semantics. Use a generated server target wherever Next.js runs server code. When the browser needs deployment configuration, choose one of two application-owned delivery modes: * return the public envelope from a same-origin JSON route and load it asynchronously with `loadBrowserBootstrap`; or * transport the exact envelope as inert, safely escaped serialized JSON through Next.js and parse it synchronously with `parseBrowserBootstrap`. Both modes use the same generated public projection and validate the same envelope identity. Neither turns the Env envelope into executable JavaScript, writes a `window` global, or interpolates unescaped JSON. `@astilba/env` 0.3.0 has package-consumer evidence for Next.js 15.5.22 and 16.2.12 across App Router static, App Router request, Pages Router static, and Pages Router request modes. The server side of that evidence uses the Node.js runtime. The maintained independent-pnpm example invokes Next’s webpack builder because default Turbopack cannot resolve the exact-registry dependency from that repository fixture layout. The package-consumer matrix also validates a default `next build`; this is not a general Env or Turbopack incompatibility. The Next.js 16 evidence and the maintained examples use the previous caching model with Cache Components disabled. This page does not claim support for `cacheComponents: true`; `dynamic = "force-dynamic"` belongs to that previous model. This page does not claim support for Next.js Edge Runtime or a Next.js deployment on Cloudflare Workers. The separate [Cloudflare Workers](/docs/env/cloudflare-workers/) boundary admits only the documented direct Worker-handler path. ## Generate framework-neutral modules [Section titled “Generate framework-neutral modules”](#generate-framework-neutral-modules) Declare separate browser and server consumers: ```ts import { defineEnvironment, env } from "@astilba/env"; export default defineEnvironment({ id: "com.example.web", entries: { apiOrigin: env.public.deployment.origin(), applicationOrigin: env.public.deployment.origin(), databaseUrl: env.private.deployment.secret(), }, consumers: { browser: env.browser(["apiOrigin", "applicationOrigin"]), server: env.server(["databaseUrl"]), }, targets: { browserDeployment: env.process("browser", { apiOrigin: "API_ORIGIN", applicationOrigin: "APPLICATION_ORIGIN", }), serverDeployment: env.process("server", { databaseUrl: "DATABASE_URL", }), }, }); ``` Generate and check the application-owned modules: ```sh pnpm exec astilba-env generate pnpm exec astilba-env generate --check ``` The browser target creates: * `.astilba/env/browserDeployment.server.ts`, which checks the application source; and * `.astilba/env/browser/browser.deployment.ts`, which contains the public projection and decoder without values. ## Keep server and browser modules separate [Section titled “Keep server and browser modules separate”](#keep-server-and-browser-modules-separate) Treat the generated server target and browser projection as two different import roots. A small application-owned layout makes the boundary visible: ```text environment/ ├── public-env.server.ts # server target, source checks, response assembly ├── public-env.client.ts # browser projection and shared readiness promise └── public-env-provider.tsx # React distribution and application UI policy ``` Add `import "server-only"` to the server module and `import "client-only"` to the browser module. Do not re-export both through a `public-env` barrel. A Client Component or `instrumentation-client.ts` that reaches a mixed barrel can pull the generated `*.server.ts` target and `@astilba/env/runtime` into Turbopack’s browser graph. The generated `.server.ts` suffix communicates intent, but the application owns this framework boundary. Import server targets directly from server-owned modules; import only `@astilba/env/browser` and generated `browser/*` projections from client-owned modules. ## Load private configuration on the server [Section titled “Load private configuration on the server”](#load-private-configuration-on-the-server) Import a generated server target only from server-owned code: ```ts import "server-only"; import { load } from "../.astilba/env/serverDeployment.server"; export const configuration = load(process.env); ``` Use `check(process.env)` instead when a route or startup boundary needs to choose its own failure response. Never pass the resulting private configuration through props to a Client Component. ## Choose a browser delivery mode [Section titled “Choose a browser delivery mode”](#choose-a-browser-delivery-mode) | Mode | Use it when | Cost | | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | Same-origin JSON route with `loadBrowserBootstrap` | You want to keep the document shell static and can delay configuration-dependent UI. | One no-store configuration request; dependent UI begins asynchronously. | | Framework-transported JSON with `parseBrowserBootstrap` | Configuration must be available synchronously to the first Client Component render, or the application already renders deployment data on the server. | The server rendering path that produces deployment values must run per request; the envelope participates in the rendered response. | The route mode is a useful default for a static shell. The transported mode removes the extra request; it does not preserve an ambient `env(key)` or `window.__ENV__` API. In either case, pass the validated typed values through an application-owned provider or props. ## Option 1: load through a same-origin JSON route [Section titled “Option 1: load through a same-origin JSON route”](#option-1-load-through-a-same-origin-json-route) Choose this mode when deployment values should vary independently of a static document shell. The route runs on the Node.js runtime; the browser fetches and validates the public envelope after the shell is served. ### Add an App Router endpoint [Section titled “Add an App Router endpoint”](#add-an-app-router-endpoint) Return the exact public envelope from a dynamic route: app/api/env/route.ts ```ts import { BOOTSTRAP_PROTOCOL } from "@astilba/env/browser"; import { NextResponse } from "next/server"; import { projection } from "../../../.astilba/env/browser/browser.deployment"; import { check } from "../../../.astilba/env/browserDeployment.server"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; export const GET = (): NextResponse => { const result = check(process.env); if (!result.ok) { return NextResponse.json( { diagnostics: result.diagnostics, ok: false }, { headers: { "Cache-Control": "private, no-store" }, status: 500, } ); } return NextResponse.json( { audience: { origin: result.value.applicationOrigin }, consumer: projection.consumer, contract: projection.contract, lifecycle: projection.lifecycle, projection: projection.digest, protocol: BOOTSTRAP_PROTOCOL, values: result.value, }, { headers: { "Cache-Control": "private, no-store" } } ); }; ``` `APPLICATION_ORIGIN` must be the trusted canonical origin that serves both the page and endpoint. Do not derive it directly from an untrusted `Host` or forwarded header. ### Add a Pages Router endpoint [Section titled “Add a Pages Router endpoint”](#add-a-pages-router-endpoint) The Pages Router uses the same generated modules and envelope. Keep its API handler on the Node.js runtime, not the Edge Runtime: pages/api/env.ts ```ts import { BOOTSTRAP_PROTOCOL } from "@astilba/env/browser"; import type { NextApiRequest, NextApiResponse } from "next"; import { projection } from "../../.astilba/env/browser/browser.deployment"; import { check } from "../../.astilba/env/browserDeployment.server"; export default function handler( _request: NextApiRequest, response: NextApiResponse ): void { const result = check(process.env); response.setHeader("Cache-Control", "private, no-store"); if (!result.ok) { response.status(500).json({ diagnostics: result.diagnostics, ok: false, }); return; } response.status(200).json({ audience: { origin: result.value.applicationOrigin }, consumer: projection.consumer, contract: projection.contract, lifecycle: projection.lifecycle, projection: projection.digest, protocol: BOOTSTRAP_PROTOCOL, values: result.value, }); } ``` Both response helpers produce the required JSON content type. The Env browser loader also requests with `cache: "no-store"` and refuses redirects. ### Share one browser readiness promise [Section titled “Share one browser readiness promise”](#share-one-browser-readiness-promise) Create one client-only module that owns the bootstrap request. Instrumentation, the provider, and other browser consumers can await the same promise without starting duplicate requests: environment/public-env.client.ts ```ts import "client-only"; import { loadBrowserBootstrap, type ValidatedBootstrap, } from "@astilba/env/browser"; import { type Configuration, projection, } from "../.astilba/env/browser/browser.deployment"; let readiness: Promise> | undefined; export const ensureBrowserEnvironment = (): Promise< ValidatedBootstrap > => { readiness ??= loadBrowserBootstrap({ endpoint: "/api/env", expectedAudience: { origin: window.location.origin }, fetch: globalThis.fetch, projection, requestBaseUrl: window.location.href, }); return readiness; }; ``` The module caches the in-flight, fulfilled, or rejected validation for the current page. This keeps React development remounts and a separate `instrumentation-client.ts` on one request. With the sample above, a rejected promise remains rejected, and the provider stays in its error state until the page reloads. If your application offers retry without a reload, it must deliberately replace the cached promise with a new validated load rather than weakening the audience or projection checks. Start configuration-dependent instrumentation from the same promise without blocking module evaluation, and handle rejection explicitly: instrumentation-client.ts ```ts import { ensureBrowserEnvironment } from "./environment/public-env.client"; import { startInstrumentation } from "./instrumentation"; void ensureBrowserEnvironment() .then( ({ values }) => startInstrumentation(values), () => { // The provider owns the user-visible configuration failure state. } ) .catch(() => { // The application owns instrumentation startup failure reporting. }); ``` Do not use an unhandled top-level `await` or start a second bootstrap request from instrumentation. ### Load before rendering dependent UI [Section titled “Load before rendering dependent UI”](#load-before-rendering-dependent-ui) Create a Client Component that owns loading, success, and failure while the client-only module owns readiness: environment/public-env-provider.tsx ```tsx "use client"; import { createContext, useEffect, useState } from "react"; import type { Configuration } from "../.astilba/env/browser/browser.deployment"; import { ensureBrowserEnvironment } from "./public-env.client"; export const EnvironmentContext = createContext< Readonly | undefined >(undefined); type EnvironmentState = | { status: "loading" } | { status: "ready"; values: Readonly } | { status: "error" }; export function EnvironmentProvider({ children, }: { children: React.ReactNode; }) { const [state, setState] = useState({ status: "loading", }); useEffect(() => { let active = true; void ensureBrowserEnvironment().then( ({ values }) => { if (active) { setState({ status: "ready", values }); } }, () => { if (active) { setState({ status: "error" }); } } ); return () => { active = false; }; }, []); if (state.status === "loading") { return (

Loading configuration.

); } if (state.status === "error") { return

Configuration is unavailable.

; } return ( {children} ); } ``` Mount the provider in `app/layout.tsx` or `pages/_app.tsx`. Keep dependent children out of the tree until validation succeeds, and replace the sample status text with application-specific UI. This mode adds one no-store configuration request before dependent UI can render. ### Diagnose a browser import leak [Section titled “Diagnose a browser import leak”](#diagnose-a-browser-import-leak) If Turbopack reports that it cannot resolve `@astilba/env/runtime` from a Client Component or `instrumentation-client.ts`, inspect the import path before changing package resolution. The usual cause is a client-safe helper importing a barrel that also exports a server component or generated `*.server.ts` target. Split the modules, add Next’s `server-only` and `client-only` sentinels, and confirm that the browser graph reaches only the generated browser projection and `@astilba/env/browser`. Do not alias the runtime to an empty browser module; that would conceal the boundary violation. `serverExternalPackages: ["@astilba/env"]` can be an application-specific server-bundling choice after the import graph is clean. It does not repair a server module that is reachable from browser code. ### Keep the static shell static [Section titled “Keep the static shell static”](#keep-the-static-shell-static) For public build values, import the generated browser `.build.ts` module directly. When deployment values vary, keep the page static and make only the application-owned `/api/env` route `force-dynamic`; the browser loads and validates its public projection after the shell is served. ## Option 2: transport a serialized envelope through Next.js [Section titled “Option 2: transport a serialized envelope through Next.js”](#option-2-transport-a-serialized-envelope-through-nextjs) Choose this mode when the browser needs validated deployment values synchronously. The server constructs the exact envelope, serializes it with `JSON.stringify`, and passes that inert string through Next.js to a Client Component. `parseBrowserBootstrap` validates the serialized JSON without a fetch. For deployment values to vary for one built artifact, the server path that creates the envelope must run at request time. In App Router, mark that path dynamic and keep it on the Node.js runtime. This is a trade-off with the static-shell route mode; it is not Next.js Edge Runtime support. app/layout.tsx ```tsx import "server-only"; import { BOOTSTRAP_PROTOCOL } from "@astilba/env/browser"; import { EnvironmentProvider } from "./environment-provider"; import { projection } from "../.astilba/env/browser/browser.deployment"; import { check } from "../.astilba/env/browserDeployment.server"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { const result = check(process.env); if (!result.ok) { throw new Error("Environment configuration is unavailable."); } const serverExpectedOrigin = result.value.applicationOrigin; const source = JSON.stringify({ audience: { origin: serverExpectedOrigin }, consumer: projection.consumer, contract: projection.contract, lifecycle: projection.lifecycle, projection: projection.digest, protocol: BOOTSTRAP_PROTOCOL, values: result.value, }); return ( {children} ); } ``` The Client Component receives the serialized source and validates it before distributing the typed values: app/environment-provider.tsx ```tsx "use client"; import { parseBrowserBootstrap } from "@astilba/env/browser"; import { createContext, useMemo } from "react"; import { type Configuration, projection, } from "../.astilba/env/browser/browser.deployment"; export const EnvironmentContext = createContext< Readonly | undefined >(undefined); export function EnvironmentProvider({ children, serverExpectedOrigin, source, }: { children: React.ReactNode; serverExpectedOrigin: string; source: string; }) { const values = useMemo( () => { const expectedAudience = { origin: typeof window === "undefined" ? serverExpectedOrigin : window.location.origin, }; return parseBrowserBootstrap({ expectedAudience, projection, source, }).values; }, [serverExpectedOrigin, source] ); return ( {children} ); } ``` Pass the serialized string as a component prop or use a framework-supported inert data container. Let Next.js serialize and escape the prop or container contents. Do not use `dangerouslySetInnerHTML`, interpolate JSON into HTML, emit a `beforeInteractive` assignment, or write a mutable `window.__ENV__` global. `source.audience` remains the trusted canonical server configuration. During server rendering, the Client Component uses `serverExpectedOrigin` because `window` is unavailable. During browser rendering and hydration, it independently expects `window.location.origin`. A page or proxy that serves an envelope for the wrong origin therefore fails parsing during the browser render or hydration; it does not silently trust the envelope’s own audience field. For local HTTP development, select `serverExpectedOrigin` from the same exact development allowlist used to create `source.audience`. The browser still compares it with its current origin. Follow the [local HTTP audience guidance](/docs/env/browser-delivery/#develop-with-a-local-http-origin); do not derive either value from `Host`, `Forwarded`, or `X-Forwarded-Host`. `parseBrowserBootstrap` throws `BootstrapFailure` when the envelope is missing or invalid. Route that failure to application-specific, perceivable error UI; do not fall back to ambient, baked, or previously cached values. A provider in the root layout needs `app/global-error.tsx` to handle root-layout check or parse errors. A provider mounted by a page can use a nearer `error.tsx` boundary. In Pages Router, export `getServerSideProps` from every page that needs deployment values; it cannot run in `pages/_app.tsx`. Each page passes the serialized source and `serverExpectedOrigin` through `pageProps` to the provider in `_app.tsx`. Do not use `getStaticProps` for deployment values that must change without rebuilding. There is no `@astilba/env/next` export. The [Next static shell example](https://github.com/astilbahq/env/tree/main/examples/next-static-shell) is an executable adoption fixture that keeps framework wiring in the application. Its combined target and request-URL audience are local fixture wiring, not the production route pattern. For production, use the separate public target and trusted configured origin shown on this page. Read [Deliver browser configuration](/docs/env/browser-delivery/) for the complete envelope protocol, canonical-origin, cache, and failure requirements. If you are replacing `DynamicEnvScript`, `clientEnv`, or `serverEnv`, continue with [Migrate from next-dynamic-env](/docs/env/migrate-from-next-dynamic-env/). # Deliver browser configuration > Generate a public projection, deliver an inert same-origin JSON envelope, and validate it before browser application startup. Env accepts browser deployment and request configuration as inert JSON. Your application either owns a same-origin endpoint or transports safely escaped serialized JSON through its framework. The generated projection and browser runtime validate that the envelope belongs to the expected contract, consumer, lifecycle, and origin before application code uses it. Endpoint delivery adds one configuration request. Framework-transported delivery adds no separate request, but the envelope participates in the rendered response. Use a public build entry instead when a value may be fixed in the browser artifact. ## Declare a browser target [Section titled “Declare a browser target”](#declare-a-browser-target) Select only the public entries the browser needs: ```ts import { defineEnvironment, env } from "@astilba/env"; export default defineEnvironment({ id: "com.example.web", entries: { apiOrigin: env.public.deployment.origin(), applicationOrigin: env.public.deployment.origin(), databaseUrl: env.private.deployment.secret(), }, consumers: { browser: env.browser(["apiOrigin", "applicationOrigin"]), server: env.server(["databaseUrl"]), }, targets: { browserDeployment: env.process("browser", { apiOrigin: "API_ORIGIN", applicationOrigin: "APPLICATION_ORIGIN", }), serverDeployment: env.process("server", { databaseUrl: "DATABASE_URL", }), }, }); ``` Generate the modules: ```sh pnpm exec astilba-env generate ``` The browser target produces two different interfaces: * `.astilba/env/browserDeployment.server.ts` checks the application-owned source; and * `.astilba/env/browser/browser.deployment.ts` contains the public projection and typed decoder, but no values. The generated public projection does not contain `databaseUrl`, its source name, or its codec metadata. ## Return the exact envelope [Section titled “Return the exact envelope”](#return-the-exact-envelope) Create a same-origin route that checks the public target and returns the selected values: ```ts import { BOOTSTRAP_PROTOCOL } from "@astilba/env/browser"; import { projection } from "./.astilba/env/browser/browser.deployment"; import { check } from "./.astilba/env/browserDeployment.server"; export const environmentResponse = (): Response => { const result = check(process.env); if (!result.ok) { return Response.json( { diagnostics: result.diagnostics, ok: false }, { headers: { "Cache-Control": "private, no-store" }, status: 500, } ); } return Response.json( { audience: { origin: result.value.applicationOrigin }, consumer: projection.consumer, contract: projection.contract, lifecycle: projection.lifecycle, projection: projection.digest, protocol: BOOTSTRAP_PROTOCOL, values: result.value, }, { headers: { "Cache-Control": "private, no-store" }, } ); }; ``` The successful envelope has exactly seven top-level fields: | Field | Source | | ------------ | ---------------------------------------------------------------------- | | `protocol` | `BOOTSTRAP_PROTOCOL`, currently `astilba.env.bootstrap/v1`. | | `contract` | Generated `projection.contract`. | | `consumer` | Generated `projection.consumer`. | | `lifecycle` | Generated `projection.lifecycle`. | | `projection` | Generated `projection.digest`. | | `audience` | `{ origin }` derived from trusted canonical application configuration. | | `values` | The successful generated target value. | Do not derive the audience from an untrusted `Host` or forwarded header. If your platform constructs canonical origins at a trusted proxy boundary, test that boundary as application code. Return `application/json` with a 2xx status. Redirects are refused. A response that can vary by request must include `Cache-Control: private, no-store`; the loader also requests every bootstrap with `cache: "no-store"`. The endpoint contains public values, but authentication and authorisation can still matter for request-specific configuration. Env does not choose the route or access policy. ## Validate before use [Section titled “Validate before use”](#validate-before-use) Load the envelope with the generated projection: ```ts import { loadBrowserBootstrap } from "@astilba/env/browser"; import { projection } from "./.astilba/env/browser/browser.deployment"; const bootstrap = await loadBrowserBootstrap({ endpoint: "/api/env", expectedAudience: { origin: window.location.origin }, fetch: globalThis.fetch, projection, requestBaseUrl: window.location.href, }); renderApplication(bootstrap.values); ``` The result is typed from the generated projection. Its values are copied into frozen, owned data before return. `loadBrowserBootstrap` refuses: * a request URL outside the expected origin; * fetch failure, redirects, a non-2xx status, or a final cross-origin URL; * a non-JSON content type, invalid UTF-8, invalid JSON, or a body over 65,536 bytes; * missing or unknown envelope fields; * a different audience, protocol, contract, consumer, lifecycle, or projection digest; and * missing, unknown, or invalid typed values. It throws `BootstrapFailure` with a stable `code` such as `BOOTSTRAP_PROJECTION_MISMATCH`. Do not continue with ambient, baked, or previously cached values after a failure. ```ts import { BootstrapFailure, loadBrowserBootstrap, } from "@astilba/env/browser"; try { const bootstrap = await loadBrowserBootstrap(options); renderApplication(bootstrap.values); } catch (error) { const code = error instanceof BootstrapFailure ? error.code : "BOOTSTRAP_UNEXPECTED"; renderConfigurationFailure(code); } ``` Keep failure UI application-specific and perceivable. A retry should perform a new validated load; it should not weaken any expected identity. ## Delay application import [Section titled “Delay application import”](#delay-application-import) Use `startBrowserApplication` when configuration must validate before the main application module enters the browser graph: ```ts import { startBrowserApplication } from "@astilba/env/browser"; import { projection } from "./.astilba/env/browser/browser.deployment"; await startBrowserApplication({ endpoint: "/api/env", expectedAudience: { origin: window.location.origin }, fetch: globalThis.fetch, importApplication: () => import("./application"), projection, requestBaseUrl: window.location.href, }); ``` The imported module must export `start(values, audience)`. Env loads and validates the bootstrap first, imports the application second, then calls `start`. If your framework has already transported the exact envelope as inert data, use `parseBrowserBootstrap({ source, expectedAudience, projection })`. It applies the same envelope and value checks without fetching. Pass the serialized JSON text as `source`, not the result of `JSON.parse`, and let the framework escape it for its inert data container. Do not interpolate unescaped JSON into HTML or turn the envelope into executable JavaScript. ## Develop with a local HTTP origin [Section titled “Develop with a local HTTP origin”](#develop-with-a-local-http-origin) The Env `origin()` codec accepts canonical HTTPS origins and deliberately rejects `localhost` and IP literals. The browser runtime can still validate an exact HTTP `expectedAudience` during local development. Use local HTTPS, or derive a development-only audience in application code from an allowlist of exact local origins such as `http://localhost:` and `http://127.0.0.1:`. The endpoint must emit that same allowlisted origin in `audience.origin` when the browser uses it as `expectedAudience`; do not override only the client-side expectation. Keep this branch out of production, reject forwarded host headers, and never weaken the production canonical-origin check. Env does not add a development fallback for you. For example, select one fixed origin for the current development command, then use the selected value in the endpoint envelope: ```ts const developmentOrigins = Object.freeze({ localhost: "http://localhost:3000", loopback: "http://127.0.0.1:3000", }); const audienceOrigin = process.env.NODE_ENV === "development" ? developmentOrigins.localhost : result.value.applicationOrigin; return Response.json( { audience: { origin: audienceOrigin }, consumer: projection.consumer, contract: projection.contract, lifecycle: projection.lifecycle, projection: projection.digest, protocol: BOOTSTRAP_PROTOCOL, values: result.value, }, { headers: { "Cache-Control": "private, no-store" }, } ); ``` Choose `localhost` or `loopback` explicitly in application-owned development configuration. Do not select between them from `Host`, `Forwarded`, or `X-Forwarded-Host`. ## Use build values without a request [Section titled “Use build values without a request”](#use-build-values-without-a-request) A public build entry is validated during generation and emitted into a browser-only module: ```ts entries: { releaseSha: env.public.build.string({ minimumCodePoints: 7, maximumCodePoints: 64, }), }, consumers: { browser: env.browser(["releaseSha"]), }, targets: { browserBuild: env.process("browser", { releaseSha: "RELEASE_SHA", }), }, ``` Generate with the value available: ```sh RELEASE_SHA=abcdef0 pnpm exec astilba-env generate ``` Then import the frozen value directly: ```ts import { configuration } from "./.astilba/env/browser/browser.build"; configuration.releaseSha; ``` Changing `RELEASE_SHA` requires generation and a new application build. The generated module contains the public value, so do not use build entries for secrets. Keep the browser runtime and generated public modules physically separate from private targets. If Vite builds the browser graph, add the dedicated [Vite boundary](/docs/env/vite/). # Check name inventory drift > Export a value-free target inventory and compare it with an application-owned provider or platform name list. Env 0.3.0 can make one declaration the authority for the configuration names a process target expects. Export the declared names, convert a provider or platform list into Env’s small observed format, then check required presence and explicit namespace ownership in CI. This workflow compares names only. Env does not receive values, contact a provider, inspect secret kinds, sync configuration, or decide which namespace your application owns. ## Export the declared inventory [Section titled “Export the declared inventory”](#export-the-declared-inventory) Choose one generated process target from `astilba.env.ts`: ```sh pnpm exec astilba-env inventory export --target workerDeployment ``` Without `--json`, the command writes one canonical `astilba.env.contract-inventory/v1` document to standard output: ```json { "entries": [ { "entry": "apiKey", "lifecycle": "deployment", "name": "API_KEY", "required": true, "visibility": "private" }, { "entry": "previousApiKey", "lifecycle": "deployment", "name": "PREVIOUS_API_KEY", "required": false, "visibility": "private" } ], "format": "astilba.env.contract-inventory/v1", "target": "workerDeployment" } ``` The inventory contains declared metadata only: logical entry ID, lifecycle, bound source name, required presence, and visibility. It contains no value, value hash, provider kind, routing rule, or provider identity. `inventory export` supports process targets. It refuses unknown, unsupported, empty, malformed, duplicate, or case-folded name mappings rather than emitting ambiguous evidence. ## Convert the observed names [Section titled “Convert the observed names”](#convert-the-observed-names) Keep provider access in application-owned tooling. Query the provider with its supported CLI or API, discard every field except the names, then write this exact document: ```json { "entries": [ { "name": "API_KEY" }, { "name": "UNRELATED_PLATFORM_KEY" } ], "format": "astilba.env.observed-name-inventory/v1" } ``` Env does not accept Wrangler, Infisical, GitHub, or another provider’s native response directly. A thin converter keeps authentication, pagination, destination selection, and provider-specific interpretation outside Env. The observed format is deliberately strict: * the top-level object has only `entries` and `format`; * each entry has only `name`; * names match `[A-Z_][A-Z0-9_]{0,127}` and are unique under ASCII case folding; * at most 2,048 names and 1 MiB of JSON are accepted; and * the CLI rejects symbolic links, invalid UTF-8, malformed JSON, unknown fields, and unsupported formats. Treat the names as sensitive operational metadata. Do not upload expected or observed inventories as CI artefacts by default. ## Choose ownership explicitly [Section titled “Choose ownership explicitly”](#choose-ownership-explicitly) Check an open namespace when the destination legitimately contains names owned by other applications or tools: ```sh pnpm exec astilba-env inventory check \ --target workerDeployment \ --observed ./observed-names.json ``` Open ownership is the default. Missing required names fail; missing optional names and unexpected names are notices. Use closed ownership only when this target owns the complete observed namespace: ```sh pnpm exec astilba-env inventory check \ --target workerDeployment \ --observed ./observed-names.json \ --ownership closed ``` Closed ownership makes an unexpected name fail. Env never infers closed ownership from the target, provider, file name, or CI environment. ## Interpret the result [Section titled “Interpret the result”](#interpret-the-result) The check reports three issue codes: | Code | Meaning | Fails open ownership | Fails closed ownership | | ------------------ | ------------------------------------------------- | -------------------- | ---------------------- | | `REQUIRED_MISSING` | A declared `required: true` name is absent. | Yes | Yes | | `OPTIONAL_MISSING` | A declared `required: false` name is absent. | No | No | | `UNEXPECTED_ENTRY` | An observed name is outside the target inventory. | No | Yes | `required: false` models optional presence, such as an empty rotation slot. It is not warning severity. Keep application-specific warnings and escalation policy outside Env. For CI, add `--json` and verify the response discriminator before consuming the report: ```sh pnpm exec astilba-env inventory check \ --target workerDeployment \ --observed ./observed-names.json \ --ownership closed \ --json ``` ```json { "command": "inventory", "format": "astilba.env.cli.inventory/v1", "ok": false, "operation": "check", "report": { "format": "astilba.env.inventory-check/v1", "issues": [ { "code": "OPTIONAL_MISSING", "entry": "previousApiKey", "name": "PREVIOUS_API_KEY" }, { "code": "UNEXPECTED_ENTRY", "entry": null, "name": "UNRELATED_PLATFORM_KEY" } ], "ownership": "closed", "pass": false, "target": "workerDeployment" } } ``` Exit `0` means the inventory is acceptable under the selected ownership mode. Exit `1` means drift or invalid operational evidence. Exit `2` is reserved for command-line misuse. ## Keep delivery separate [Section titled “Keep delivery separate”](#keep-delivery-separate) An inventory pass proves only that the supplied list contains the expected names. It does not prove that: * a value is non-empty or valid; * a provider stored the value as a secret; * the application received the current value; * the observed list came from the intended account, project, or environment; or * a sync or prune operation is safe. Use the generated target’s `check(source)` or `load(source)` operation for runtime value validation. Keep provider sync, prune, routing, authentication, and destination selection in the system that already owns delivery. # Migrate from next-dynamic-env > Replace next-dynamic-env proxies and script injection with an explicit Env contract and generated application boundaries. [`next-dynamic-env`](https://github.com/ReesMorris/next-dynamic-env) is retired and deprecated on npm. Astilba Env covers its build-once deployment use case, but does not preserve that package’s API or runtime mechanism. The migration replaces ambient proxies and script injection with an explicit contract, generated browser and server modules, and application-owned inert JSON delivery. Treat this as an architectural migration, not a package rename. Install the exact public-alpha release before declaring the replacement contract: ```sh pnpm add @astilba/env@0.3.0 --save-exact ``` ## Map the concepts [Section titled “Map the concepts”](#map-the-concepts) | `next-dynamic-env` | Astilba Env 0.3 | | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | `createDynamicEnv({ client, server })` | [`defineEnvironment({ entries, consumers, targets })`](/docs/env/declaration-reference/#defineenvironment) | | `clientEnv` proxy | Generated browser build configuration or validated bootstrap values | | `serverEnv` proxy | Generated server target `check` or `load` | | `DynamicEnvScript` | Application-owned JSON endpoint plus `loadBrowserBootstrap` | | `waitForEnv` | Await `loadBrowserBootstrap` or use `startBrowserApplication` | | Inline `window.__NEXT_DYNAMIC_ENV__` value | Inert, same-origin JSON response | | Validator tuple beside each current value | Lifecycle-aware codec in the declaration | | Automatic validation skip during `next build` | Explicit build, deployment, and request lifecycles | | `emptyStringAsUndefined` global option | Per-codec blank, required, and normalisation policy | | Framework package with one root export | Framework-neutral root, browser, runtime, and Vite boundaries | There is no `@astilba/env/next` export. App Router and Pages Router integrations use application code around the same generated modules. ## Replace the runtime declaration [Section titled “Replace the runtime declaration”](#replace-the-runtime-declaration) A `next-dynamic-env` declaration reads current values while it constructs `clientEnv` and `serverEnv`. An Env declaration describes values without reading them: astilba.env.ts ```ts import { defineEnvironment, env } from "@astilba/env"; export default defineEnvironment({ id: "com.example.web", entries: { apiOrigin: env.public.deployment.origin(), applicationOrigin: env.public.deployment.origin(), databaseUrl: env.private.deployment.secret(), port: env.private.deployment.safeInteger({ maximum: 65_535, minimum: 1, }), }, consumers: { browser: env.browser(["apiOrigin", "applicationOrigin"]), server: env.server(["databaseUrl", "port"]), }, targets: { browserDeployment: env.process("browser", { apiOrigin: "API_ORIGIN", applicationOrigin: "APPLICATION_ORIGIN", }), serverDeployment: env.process("server", { databaseUrl: "DATABASE_URL", port: "PORT", }), }, }); ``` Public exposure is determined by both the entry visibility and the browser consumer selection. A variable name does not become public merely because it has a `NEXT_PUBLIC_` prefix. Generate and check the application-owned interfaces: ```sh pnpm exec astilba-env generate pnpm exec astilba-env generate --check ``` ## Replace `serverEnv` [Section titled “Replace serverEnv”](#replace-serverenv) Replace ambient proxy access with an explicit generated target: ```ts import "server-only"; import { load } from "./.astilba/env/serverDeployment.server"; const configuration = load(process.env); configuration.databaseUrl; configuration.port; ``` Use `check` where application code needs to choose the failure response. Diagnostics contain stable codes and logical identities where appropriate; they do not echo configuration values. ## Replace browser injection [Section titled “Replace browser injection”](#replace-browser-injection) Remove `DynamicEnvScript`, the mutable `clientEnv` proxy, and `waitForEnv`. Add the application-owned Next.js JSON route and Client Component described in [Next.js](/docs/env/nextjs/). The replacement has three explicit pieces: 1. a generated server target checks the public source values; 2. the route returns the exact public envelope with `Cache-Control: private, no-store`; and 3. `@astilba/env/browser` validates the same-origin response before dependent UI renders. Use [Deliver browser configuration](/docs/env/browser-delivery/) when you need the complete framework-neutral protocol and failure behavior. Do not keep both delivery mechanisms active. Once the JSON bootstrap path passes application tests, remove the inline script and every read from `window.__NEXT_DYNAMIC_ENV__`. ## Understand the validation differences [Section titled “Understand the validation differences”](#understand-the-validation-differences) | Previous behavior | Migration decision | | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | Validation ran while `createDynamicEnv` built its proxies | Env checks a generated target when your code calls `check` or `load`. | | Validation was automatically skipped during `next build` | Mark values as `build`, `deployment`, or `request`; there is no automatic Next build bypass. | | Raw values could be accepted without a schema | Choose an explicit [Env codec](/docs/env/declaration-reference/#built-in-codecs). | | One global option converted empty strings to missing values | Configure blank and required behavior on each codec. | | Validator transforms supplied defaults or arbitrary output types | Use a built-in codec, a private opaque schema, or application validation after loading. | | Validation errors could throw, warn, or call a handler | Use `check` for an explicit result or `load` for an exception. | | Client and server values lived behind runtime proxies | Generated browser and server modules create a static import boundary. | The browser projection accepts only Env’s portable public codecs. Arbitrary schemas and opaque transforms cannot enter a browser consumer. ## Migrate Yup validation deliberately [Section titled “Migrate Yup validation deliberately”](#migrate-yup-validation-deliberately) Prefer built-in codecs for common configuration: ```ts entries: { enabled: env.public.deployment.boolean(), mode: env.public.deployment.enum(["standard", "compact"]), origin: env.public.deployment.origin(), port: env.private.deployment.safeInteger({ maximum: 65_535, minimum: 1, }), } ``` For a genuinely custom private transform, use an `opaque` entry and pass an exactly typed, synchronous Standard Schema v1 implementation to the generated target. A Yup adapter can wrap `validateSync`; it must translate success or failure into the Standard Schema result without exposing the rejected value. Read [Validation and Standard Schema](/docs/env/validation-and-standard-schema/) for the exact type, runtime, CLI, and portability limits. If the Yup schema represents application business rules instead of configuration syntax, load a built-in private value and validate it after the Env boundary. ## Account for intentional non-compatibilities [Section titled “Account for intentional non-compatibilities”](#account-for-intentional-non-compatibilities) Env does not provide compatibility exports or shims for: * `createDynamicEnv`; * `clientEnv` or `serverEnv`; * `DynamicEnvScript`; * `waitForEnv`; * `window.__NEXT_DYNAMIC_ENV__`; * `__raw`; * `skipValidation` or automatic build-phase detection; * `onValidationError`; * a global `emptyStringAsUndefined` switch; * implicit `NEXT_PUBLIC_*` exposure; or * `@astilba/env/next`. These omissions preserve explicit lifecycles, static artifact boundaries, and inert browser delivery. ## Verify and remove the old package [Section titled “Verify and remove the old package”](#verify-and-remove-the-old-package) Before removing `next-dynamic-env`: 1. run `pnpm exec astilba-env generate --check` in CI; 2. fail application startup or the endpoint when a required deployment value is missing; 3. verify browser bundles contain no private entry name, binding name, or value; 4. verify the JSON response has the expected audience and `Cache-Control: private, no-store`; 5. change deployment values without rebuilding and confirm one built artifact observes the new values; 6. exercise successful and rejected bootstrap responses; and 7. remove every import, script component, and global reference from `next-dynamic-env`. Remove the old dependency only after those checks pass: ```sh pnpm remove next-dynamic-env ``` The Env package tests exercise App Router and Pages Router builds in static and request modes. Your route, proxy trust, startup policy, and failure UI still require application tests. # Lifecycles and projections > Model when configuration becomes available and keep each application artifact on a physically separate projection. Env separates two decisions that ambient environment access usually combines: 1. the **lifecycle** says when a value may be resolved; and 2. the **consumer projection** says which artifact may know that the entry exists. The result is one declaration with several generated interfaces, not one mutable configuration object shared across the application. ## Choose the lifecycle [Section titled “Choose the lifecycle”](#choose-the-lifecycle) | Lifecycle | Resolve it when | Artifact effect | | ------------ | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `build` | Producing an application artifact | A browser-selected value is emitted into a generated browser `.build.ts` module; a server-only selection resolves through a generated Node.js build target. Changing either requires another build. | | `deployment` | Starting or configuring one deployment | Application artifact bytes stay unchanged. Server code loads it from an explicit source; browser code receives it through a validated bootstrap. | | `request` | Handling one request or tenant context | Pass a request-owned source to the generated target. Do not retain the result in process-global state or an unpartitioned cache. | Only public entries have a build builder. Env does not offer `env.private.build`: embedding a private value in an artifact would make the artifact configuration-specific and copy the value into durable bytes. A browser consumer that selects build entries requires exactly one complete build target so generation has one unambiguous source mapping. Use deployment values for configuration that should change between environments without rebuilding. Use request values only when the value genuinely differs per request or tenant; the explicit source and lifetime are part of the safety boundary. ## Select a consumer projection [Section titled “Select a consumer projection”](#select-a-consumer-projection) A consumer chooses the exact logical entries one artifact needs: ```ts consumers: { browser: env.browser(["apiOrigin", "featureMode"]), worker: env.server(["apiOrigin", "databaseUrl", "tenantId"]), } ``` `env.browser(...)` accepts public, browser-portable entries only. Its generated projection contains public entry identities, portable decoders, and a compatibility digest. It excludes: * private entry names and codecs; * private source bindings; * server-only codecs; * co-presence rules and their entries; * complete contract metadata; and * configuration values. `env.server(...)` may select public and private entries. Calling `env.server()` with no list selects every declared entry. Prefer an explicit list when a process has more than one independently deployed artifact. Calling `env.browser()` with no list also selects every entry, then rejects the declaration if any selection is private, uses a server-only codec, or belongs to a co-presence rule. Co-presence rules are server-projection only in 0.3. An explicit browser list makes the exposure decision easier to review. ## Bind one complete lifecycle per target [Section titled “Bind one complete lifecycle per target”](#bind-one-complete-lifecycle-per-target) A process target maps one consumer and one lifecycle to names in an application-owned source record: ```ts targets: { serverDeployment: env.process("worker", { apiOrigin: "API_ORIGIN", databaseUrl: "DATABASE_URL", }), serverRequest: env.process("worker", { tenantId: "TENANT_ID", }), } ``` Each target must bind every selected entry for exactly one lifecycle. A target cannot mix deployment and request bindings or omit one selected deployment entry. You can define alternate complete targets for the same consumer and lifecycle when your application needs different source mappings. The generated module accepts any plain source record, not only `process.env`: ```ts import { load } from "./.astilba/env/serverRequest.server"; export const handleRequest = (request: Request): Response => { const configuration = load({ TENANT_ID: readTrustedTenant(request), }); return respondForTenant(configuration.tenantId); }; ``` Env validates the supplied record and returns an owned configuration value. Your application remains responsible for authenticating the request, deriving the source, and limiting the value’s lifetime. ## Understand the generated separation [Section titled “Understand the generated separation”](#understand-the-generated-separation) Generation writes these kinds of files under `.astilba/env/`: | Output | Purpose | | ---------------------------------- | ---------------------------------------------------------------------------- | | `.server.ts` | Typed `check` and `load` functions for one process target. | | `browser/.build.ts` | Frozen public build values for direct browser import. | | `browser/.deployment.ts` | Public deployment projection and decoder; contains no values. | | `browser/.request.ts` | Public request projection and decoder; contains no values. | | `consumers/.*.json` | Value-free public or server projection evidence. | | `contract.json` | Complete value-free contract evidence. Keep this file out of browser graphs. | | `snapshot.json` | Value-free planning input used by `plan --base`. | | `manifest.json` | The exact generated-directory file list and format. | The generated server and metadata files can contain private logical names and source bindings. Physical separation works only when your build graph imports browser modules deliberately. Add the [Vite boundary plugin](/docs/env/vite/) where Vite builds browser code. ## Treat compatibility as exact or unknown [Section titled “Treat compatibility as exact or unknown”](#treat-compatibility-as-exact-or-unknown) Env derives projection digests from the declared contract, not the current values. A matching digest proves that the consumer sees the same declared projection. Opaque validators are different: their `semantics` and `revision` fields describe compatibility, but Env cannot prove that two arbitrary implementations behave the same. Planning therefore reports unknown confidence where a safe conclusion is unavailable. This is compatibility evidence, not secret management. Env never provisions a value source, rotates a secret, or verifies that a live provider contains the declared value. # Validation and Standard Schema > Choose portable built-in codecs, understand redacted failures, and reserve opaque Standard Schema validation for private Node.js targets. Env validates configuration at an explicit lifecycle boundary. The declaration records the accepted source form and typed output; a generated target applies that contract when application code calls `check` or `load`. Prefer a first-party codec whenever it can express the configuration. Use an `opaque` Standard Schema validator only for private server semantics that Env cannot represent portably. ## Start with built-in codecs [Section titled “Start with built-in codecs”](#start-with-built-in-codecs) Built-in codecs make validation deterministic and portable: ```ts entries: { enabled: env.public.deployment.boolean(), mode: env.public.deployment.enum(["standard", "compact"]), origin: env.public.deployment.origin(), port: env.private.deployment.safeInteger({ maximum: 65_535, minimum: 1, }), } ``` They define exact details such as case-sensitive Boolean tokens, canonical HTTPS origins, numeric ranges, blank handling, required presence, and bounded strings or JSON shapes. Portable codecs can participate in public browser projections. A subset also works in the admitted [Cloudflare Workers deployment-target path](/docs/env/cloudflare-workers/). Check [Declaration reference](/docs/env/declaration-reference/#built-in-codecs) for each builder’s input and portability. ## Choose the failure contract [Section titled “Choose the failure contract”](#choose-the-failure-contract) Generated targets expose two operations: ```ts const result = await check(source); const configuration = await load(source); ``` `check` returns an explicit result: ```ts if (!result.ok) { console.error("Configuration is invalid.", result.diagnostics); } else { startApplication(result.value); } ``` `load` returns the same typed configuration or throws `EnvironmentConfigurationError`. This `await` form also works for targets that use built-in codecs only. Both paths redact rejected values. Diagnostics may contain a stable error code, consumer, entry, lifecycle, or rule identity; they do not contain the value, a fragment, length, or hash. Do not log the source object around that boundary. ## Use `opaque` for private custom semantics [Section titled “Use opaque for private custom semantics”](#use-opaque-for-private-custom-semantics) An opaque entry declares value-free input and output shapes plus an application-owned semantic identity: ```ts serviceOptions: env.private.deployment.opaque({ input: { kind: "string" }, output: { kind: "object", properties: [ { name: "region", required: true, shape: { kind: "string" }, }, ], }, revision: "1", semantics: "com.example.service-options/v1", }) ``` Generation makes the schema requirement explicit in the target type: ```ts import type { StandardSchemaV1 } from "@astilba/env/runtime"; import { load } from "./.astilba/env/serverDeployment.server"; type ServiceOptions = Readonly<{ region: string }>; const serviceOptions: StandardSchemaV1 = { "~standard": { validate(input) { if (typeof input === "string") { try { const value: unknown = JSON.parse(input); if ( typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 1 && "region" in value && typeof value.region === "string" ) { return { value: Object.freeze({ region: value.region }), }; } } catch { // Return the same redacted issue as any other invalid input. } } return { issues: [{ message: "Invalid service options." }], }; }, vendor: "example", version: 1, }, }; const configuration = await load(process.env, { serviceOptions }); ``` The schema’s declared input and output types must exactly match the declaration shapes. Extra or missing schema keys fail the generated type gate. Env has no first-party Zod adapter or named schema-library guarantee. You may pass any implementation that structurally satisfies Standard Schema v1 for the declared private opaque Node.js target; verify that library and schema version in your application, and keep its validation synchronous. ## Keep validation synchronous [Section titled “Keep validation synchronous”](#keep-validation-synchronous) Env 0.3.0 requires opaque Standard Schema validation to settle synchronously. A returned promise produces `ENV_VALIDATOR_ASYNC_UNSUPPORTED`. Because the generated operation accepts an arbitrary validator implementation, `check` and `load` return promises for a target that contains an opaque entry even when the validator settles synchronously. The CLI cannot validate an opaque target because it does not have your application schema map. Import the generated operation and pass the schemas in application code or a focused test. ## Respect the runtime boundaries [Section titled “Respect the runtime boundaries”](#respect-the-runtime-boundaries) Opaque entries are: * private; * server-only; * unavailable to browser consumers; and * not admitted in the Cloudflare Workers support claim. The exact input/output shapes, `semantics`, and `revision` provide value-free compatibility evidence. Env cannot prove that two arbitrary validator implementations behave identically, so planning reports `UNKNOWN` when compatibility depends on opaque behavior. If custom validation belongs to business rules rather than the configuration boundary, load a built-in `text`, `secret`, or `json` value first and validate it in application code. # Declaration reference > Reference the Env 0.3 declaration fields, entry builders, codecs, consumers, targets, and co-presence rules. The root `@astilba/env` export contains two runtime values: ```ts import { defineEnvironment, env } from "@astilba/env"; ``` `defineEnvironment` validates one complete declaration and returns an opaque `EnvironmentDefinition`. The `env` object creates branded entries, consumers, targets, and rules that only `defineEnvironment` can compile. `EnvironmentDefinition` is also available as a type-only export. Use it to annotate a boundary that accepts any compiled Env declaration; do not construct or inspect one yourself. ## `defineEnvironment` [Section titled “defineEnvironment”](#defineenvironment) ```ts defineEnvironment({ id, entries, consumers, targets, rules, }); ``` | Field | Requirement | | ----------- | ----------------------------------------------------------------------------------------------------------------- | | `id` | A lowercase reverse-DNS identifier such as `com.example.application`. | | `entries` | One or more logical entries created by an `env.public.*` or `env.private.*` builder. | | `consumers` | One or more named `env.browser(...)` or `env.server(...)` selections. | | `targets` | One or more named `env.process(...)` mappings. Each target binds one complete lifecycle selected by its consumer. | | `rules` | Optional array of `env.together(...)` co-presence rules. | Entry, consumer, target, and rule identifiers start with a lowercase ASCII letter, contain only ASCII letters or digits, and have at most 64 characters. Identifiers are also unique under ASCII case folding. Process source names use the environment-variable form `[A-Z_][A-Z0-9_]{0,127}`. ## Visibility and lifecycle builders [Section titled “Visibility and lifecycle builders”](#visibility-and-lifecycle-builders) Choose visibility and lifecycle before the codec: ```ts env.public.build env.public.deployment env.public.request env.private.deployment env.private.request ``` Every builder below supports `required: false`. Entries are required by default. There is no private build builder. `secret` and `opaque` are available only on private deployment and request builders. ## Built-in codecs [Section titled “Built-in codecs”](#built-in-codecs) The source column describes an `env.process` source such as `process.env`. The browser bootstrap receives already-typed JSON values and validates them against the same portable contract. | Builder | Source and output | Important options and defaults | Browser portable | | ------------------------ | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------- | | `boolean(options?)` | Exact source token to `boolean`. | `trueInput: "true"`; `falseInput: "false"`; `blank: "missing"`. | Yes | | `enum(values, options?)` | Exact source string to the declared string union. | 1–1,024 unique portable strings; see the declaration limits below. | Yes | | `integer(options)` | Trimmed signed decimal to a safe integer in range. | Required `minimum` and `maximum`; `blank: "missing"`. | No | | `json(shape, options?)` | Bounded JSON text to the exact typed shape. | `blank: "missing"`. | Yes | | `origin(options?)` | Canonical HTTPS origin string. | No path, query, fragment, credentials, IP literal, or `localhost`; default port and trailing slash are normalised away. | Yes | | `safeInteger(options)` | Canonical decimal to a safe integer in range. | Required `minimum` and `maximum`; no leading `+`, whitespace, or non-canonical leading zero; `blank: "missing"`. | Yes | | `string(options?)` | Preserved portable string. | `minimumCodePoints: 0`; `maximumCodePoints: 65_535`. Empty string is valid unless you raise the minimum. | Yes | | `stringList(options?)` | Comma-separated source to a readonly string array. | Empty items `drop`; defaults to 0–64 items and 1–1,024 code points per item. | Yes | | `text(options?)` | Optional trim-aware server string. | `normalise: "preserve"`; `blank: "missing"`; 1–65,535 code points. | No | | `secret(options?)` | Preserved private string with no trimming. | `blank: "missing"`; 1–65,535 code points. | No; private only | | `opaque(options)` | Private source string through a caller-supplied synchronous Standard Schema v1 validator. | Exact `input` and `output` shapes plus value-free `semantics` and `revision`. | No; private only | `integer` accepts conventional signed, whitespace-trimmed server input. Use `safeInteger` when the same canonical decimal contract must work in server and browser projections. `text` treats a whitespace-only value as blank even when `normalise` is `"preserve"`. `string` preserves and can accept an empty string. Choose deliberately instead of relying on a global empty-string policy. ### Boolean options [Section titled “Boolean options”](#boolean-options) ```ts enabled: env.public.deployment.boolean({ blank: "invalid", falseInput: "disabled", trueInput: "enabled", }) ``` `trueInput` and `falseInput` are case-sensitive, non-empty printable ASCII tokens and must differ. ### String-list options [Section titled “String-list options”](#string-list-options) ```ts regions: env.public.deployment.stringList({ emptyItems: "invalid", minimumItems: 1, maximumItems: 8, minimumItemCodePoints: 2, maximumItemCodePoints: 32, }) ``` The separator is always a comma. Items are not trimmed automatically. ### Portable JSON shapes [Section titled “Portable JSON shapes”](#portable-json-shapes) `json` uses data-only shape descriptors: ```ts clientConfiguration: env.public.deployment.json({ kind: "object", properties: [ { name: "region", required: true, shape: { kind: "string" }, }, { name: "retryCount", required: false, shape: { kind: "safe-integer", minimum: 0, maximum: 5, }, }, ], }) ``` Shape kinds are: * `string`, `boolean`, and `null`; * `safe-integer` with `minimum` and `maximum`; * `array` with `items`, `minimumItems`, and `maximumItems`; and * `object` with named, required or optional `properties`. Objects are exact: unknown properties are rejected. Values are copied into frozen, owned data before the application receives them. ### Declaration limits [Section titled “Declaration limits”](#declaration-limits) Env bounds declaration size and portable value work before runtime resolution: | Surface | Limit | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Enum choices | 1–1,024 unique choices; each choice is at most 65,535 UTF-8 bytes and all choices together are at most 65,536 bytes. | | String lists | Defaults to at most 64 items and 1,024 code points per item. Options may raise those values to at most 1,024 items or 65,536 code points per item, while `maximumItems × maximumItemCodePoints` must not exceed 65,536. | | Portable shapes | At most 8 levels and 256 shape nodes. Arrays declare at most 1,024 items; objects declare at most 256 unique keys. | | Portable object keys | At most 255 UTF-8 bytes; `__proto__`, `constructor`, and `prototype` are rejected. | | Portable string values | At most 65,536 UTF-8 bytes when copied through a declared portable shape. | ### Opaque schemas [Section titled “Opaque schemas”](#opaque-schemas) Use `opaque` only for private semantics that a built-in codec cannot express: ```ts serviceOptions: env.private.deployment.opaque({ input: { kind: "string" }, output: { kind: "object", properties: [ { name: "region", required: true, shape: { kind: "string" }, }, ], }, semantics: "com.example.service-options/v1", revision: "1", }) ``` The generated target requires a schema map and returns promises: ```ts import { load } from "./.astilba/env/serverDeployment.server"; const configuration = await load(process.env, { serviceOptions: serviceOptionsSchema, }); ``` The schema’s declared input and output types must exactly match the shapes in the declaration. Validation must settle synchronously; a returned promise produces `ENV_VALIDATOR_ASYNC_UNSUPPORTED`. An opaque input shape is either `{ kind: "string" }` or an optional string wrapper: ```ts input: { kind: "optional", value: { kind: "string" }, } ``` An opaque output may use any portable shape above or wrap one with the same `optional` form. Entry presence and schema input are separate decisions: * with `required: false` and a non-optional input, a missing source omits the entry without calling the validator; * with an optional input, a missing source calls the validator with `undefined`; and * if the validator returns `undefined` for an optional output, a required entry fails with `ENV_MISSING_VALUE` while an optional entry is omitted. Returning `undefined` for a non-optional output fails with `ENV_INVALID_VALUE`. The entry builder’s `required` option controls whether the whole entry may be absent. A `required` flag inside an object shape controls only that named output property. Omitting a required property, adding an unknown property, or returning another value that does not match the declared shape fails with `ENV_INVALID_VALUE`; omitting a property marked `required: false` is valid. The CLI `check` command cannot accept application schema implementations. Validate an opaque target through its generated `check` or `load` function. ## Consumers [Section titled “Consumers”](#consumers) ```ts env.browser(["apiOrigin", "featureMode"]) env.server(["databaseUrl", "port"]) ``` An explicit list must contain at least one unique entry. Omitting the list selects every declared entry: ```ts env.server() ``` Browser consumers may select only public entries using `boolean`, `enum`, `json`, `origin`, `safeInteger`, `string`, or `stringList`. They cannot select an entry that belongs to an `env.together` rule; co-presence rules are server-projection only in 0.3. A browser consumer that selects one or more build entries requires exactly one complete build target. This gives generation one unambiguous source mapping for the emitted browser values. ## Process targets [Section titled “Process targets”](#process-targets) ```ts env.process("server", { databaseUrl: "DATABASE_URL", port: "PORT", }) ``` The first argument names an existing consumer. The record maps logical entry names to raw source names. A target must bind all entries selected by that consumer for one lifecycle. Split build, deployment, and request bindings into separate targets. One target cannot map two logical entries to the same raw source name. You can define alternate complete targets for the same consumer and lifecycle when your application needs different source mappings. ## Co-presence rules [Section titled “Co-presence rules”](#co-presence-rules) Use `env.together` when optional entries form one configuration unit: ```ts rules: [ env.together("smtpCredentials", [ "smtpHost", "smtpUser", "smtpPassword", ]), ] ``` Resolution succeeds when all rule entries are present or all are absent. A partial set returns `ENV_RULE_VIOLATION` with the logical rule and entry names, not their values. Each rule requires at least two unique entries from the same lifecycle. Co-presence rules are server-projection only in 0.3; browser consumers cannot select rule entries. A server consumer that selects any entry in a rule must select every entry in that rule. Keep a co-presence rule within one operational configuration unit. # CLI reference > Generate Env modules, validate values, compare name inventories, plan contract changes, and consume stable machine output. The `astilba-env` command compiles `astilba.env.ts` in the current package. It generates project-owned interfaces, checks current values without exposing them, compares value-free name inventories, and compares value-free planning snapshots. Run it through your package manager: ```sh pnpm exec astilba-env generate ``` Inside `package.json` scripts, call `astilba-env` directly. ## Command summary [Section titled “Command summary”](#command-summary) ```text astilba-env generate [--config PATH] [--check] [--json] astilba-env check --target ID [--config PATH] [--json] astilba-env inventory export --target ID [--config PATH] [--json] astilba-env inventory check --target ID --observed PATH [--ownership open|closed] [--config PATH] [--json] astilba-env plan --base GIT_REF [--config PATH] [--json] ``` Options use a separate value token. `--config=custom.mts` is not accepted. The default configuration is `astilba.env.ts`. `--config` accepts lowercase `.ts` and `.mts` files. A `.ts` file must be inside an ESM package with `"type": "module"`. ## `generate` [Section titled “generate”](#generate) Compile the declaration and replace the owned `.astilba/env/` directory: ```sh pnpm exec astilba-env generate ``` Generation writes: * typed server target modules; * typed browser build or projection modules; * full and consumer-specific value-free contract JSON; * `snapshot.json` for planning; and * `manifest.json` binding the exact generated tree. When a public browser consumer has build entries, generation reads the corresponding build target names from the current process environment and emits the validated public values into `browser/.build.ts`. Env refuses to replace a generated directory that is malformed, contains symbolic links, has an unsupported format, or contains unowned files. Generated files start with an ownership marker where appropriate; do not edit them manually. ### Check drift [Section titled “Check drift”](#check-drift) ```sh pnpm exec astilba-env generate --check ``` This compiles the declaration and compares every expected byte without writing. It fails if a file is missing, changed, or unexpected. Use it as a CI gate: ```json { "scripts": { "env:check": "astilba-env generate --check" } } ``` The CI environment must supply any public build values needed to reproduce `.build.ts` modules. ## `check` [Section titled “check”](#check) Validate one named process target against the current environment: ```sh pnpm exec astilba-env check --target serverDeployment ``` `check` prints one validity statement. It does not print resolved values. With `--json`, a failure includes redacted diagnostic codes and logical identities: ```json { "command": "check", "diagnostics": [ { "code": "ENV_MISSING_VALUE", "consumer": "server", "entry": "databaseUrl", "lifecycle": "deployment" } ], "format": "astilba.env.cli.check/v1", "ok": false, "target": "serverDeployment" } ``` The exact diagnostic fields depend on the failure. Values, value fragments, lengths, and hashes are not included. The CLI cannot validate an `opaque` entry because it has no application schema implementation. Import the target’s generated `check(source, schemas)` function instead. ## `inventory export` [Section titled “inventory export”](#inventory-export) Compile the declared names for one process target: ```sh pnpm exec astilba-env inventory export --target serverDeployment ``` Without `--json`, the command writes a canonical `astilba.env.contract-inventory/v1` document. It contains logical entry IDs, source names, lifecycle, visibility, and required presence; it contains no values or provider-kind claims. With `--json`, the document is the `inventory` field inside `astilba.env.cli.inventory/v1`. ## `inventory check` [Section titled “inventory check”](#inventory-check) Compare the target with a strict application-supplied name list: ```sh pnpm exec astilba-env inventory check \ --target serverDeployment \ --observed ./observed-names.json \ --ownership closed ``` The observed document uses `astilba.env.observed-name-inventory/v1` and contains only `{ "name": "..." }` entries. Env does not query a provider or accept provider-native output; convert the provider’s response in application-owned tooling. Ownership defaults to `open`. Required absence fails in both modes. Optional absence is a notice. Unexpected names are notices in open mode and failures in explicitly selected closed mode. Read [Check name inventory drift](/docs/env/inventory-and-drift/) for the schemas, issue codes, trust boundary, and CI workflow. ## `plan` [Section titled “plan”](#plan) Compare the current declaration with a generated snapshot committed at a Git revision: ```sh pnpm exec astilba-env plan --base origin/main ``` Env reads `.astilba/env/snapshot.json` from the resolved base commit and compiles the current declaration. It does not execute the historical `astilba.env.ts`. Plain output tells you whether actions are required. Use `--json` for the value-free impact plan: ```sh pnpm exec astilba-env plan --base origin/main --json ``` The plan can call for actions such as: * rebuilding or activating an application artifact; * adding, reconfiguring, or removing configuration; * rebuilding an adapter; * revalidating a target; or * performing manual or security review. Confidence is `PROVEN` only when the declared change supports an exact conclusion. Opaque or otherwise unprovable compatibility remains `UNKNOWN`. `plan` compares declarations and bindings. It does not inspect live provider state, current values, secret-manager contents, or configuration drift outside the generated snapshot. ## `--json` [Section titled “--json”](#--json) Add `--json` to any command for one canonical JSON object. Successful results go to standard output. Command and usage errors go to standard error. Machine formats are versioned independently: | Command | Success format | | ---------------------------------------- | ------------------------------ | | `generate` | `astilba.env.cli.generate/v1` | | `check` | `astilba.env.cli.check/v1` | | `inventory export` and `inventory check` | `astilba.env.cli.inventory/v1` | | `plan` | `astilba.env.cli.plan/v1` | | Any command error | `astilba.env.cli.error/v1` | Check the `format` field before consuming other fields. Treat a newer or unknown discriminator as unsupported instead of guessing its meaning. ## Exit statuses [Section titled “Exit statuses”](#exit-statuses) | Status | Meaning | | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | Command completed successfully. An inventory is acceptable under its ownership mode; a plan has no consumer with `UNKNOWN` confidence. | | `1` | Invalid configuration, stale or invalid generated output, inventory drift or invalid evidence, command failure, or a plan with unknown confidence. | | `2` | Invalid command syntax, target name, configuration extension, or Git reference. | Do not parse human-readable output for automation. Use `--json`, the versioned format field, and the exit status together. # Release and support > Check the package, CLI, runtime, framework, and public-alpha boundaries of Astilba Env 0.3.0. `@astilba/env` 0.3.0 is a public alpha. The package and source are public, so applications can evaluate the complete contract, but the API may change deliberately before a stable release. Install the exact alpha when reproducibility matters: ```sh pnpm add @astilba/env@0.3.0 --save-exact ``` Version 0.3.0 adds provider-neutral contract inventory export and name-drift checking for process targets. It adds no provider client, value sync, provider-kind evidence, JavaScript package export, browser API, runtime API, or generated-module protocol. Version 0.2.3 added complete TSDoc coverage and the executable adoption-example suite. ## Supported public surface [Section titled “Supported public surface”](#supported-public-surface) | Surface | 0.3.0 status | | ---------------------------------------------------- | --------------------------------------------------------------------- | | `defineEnvironment` and `env` builders | Public on supported Node.js releases | | `astilba-env generate` and `generate --check` | Public on supported Node.js releases | | `astilba-env check --target ID` | Public for targets the CLI can validate | | `astilba-env inventory export` and `inventory check` | Public for provider-neutral process-target name evidence | | `astilba-env plan --base GIT_REF` | Public on supported Node.js releases | | Generated Node.js `check` and `load` functions | Public | | Generated Cloudflare Workers deployment targets | Public with first-party codecs; see the narrow runtime boundary below | | Generated public browser projections | Public | | Inert same-origin JSON browser protocol | Public | | Vite private-module boundary | Public for Vite 8.1.5 or later within Vite 8 | | Next.js App and Pages Router wiring | Application-owned integration; no Astilba adapter | | Hosted configuration service | Not provided | | Secret storage or provider provisioning | Not provided | | Stable API compatibility | Not promised during the public alpha | ## Runtime matrix [Section titled “Runtime matrix”](#runtime-matrix) | Operation | Node.js | Browser | Cloudflare Workers | | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | --------- | --------------------------------------- | | Import `@astilba/env` and author declarations | Supported | Blocked | Blocked | | Run the generator, CLI, or planner | Supported | Blocked | Blocked | | Import a generated server target with first-party codecs | Supported | Blocked | Supported for deployment lifecycle only | | Import a generated target with `opaque` Standard Schema validators | Supported | Blocked | Not admitted | | Import `@astilba/env/browser` | Supported for server-side envelope assembly, but not as a Node.js configuration target | Supported | Blocked | | Import generated public browser modules | Supported for server-side envelope assembly and build tooling | Supported | Blocked | | Import `@astilba/env/vite` | Supported in Vite configuration | Blocked | Blocked | The package supports these Node.js ranges: * Node.js 22.14.0 or later within Node 22; * Node.js 24 within Node 24; and * Node.js 26 within Node 26. The Cloudflare Workers path does not require `nodejs_compat` for Env. It accepts a Wrangler-generated `Env` binding interface without an index signature, reads only the declared binding names, and leaves unrelated capability bindings to application code. Runtime support is export-specific. Evidence for the generated runtime does not make the declaration builders, CLI, browser runtime, or Vite plugin portable to workerd. ## Executable evidence [Section titled “Executable evidence”](#executable-evidence) Support claims are backed by package-consumer and maintained-example runs rather than inferred from type declarations alone: | Evidence lane | Exercised versions and boundary | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Node.js and TypeScript | Node.js 22.14.0, 22.23.2, 24.18.1, and 26.5.1; TypeScript 6.0.3 and 7.0.2. | | Package managers | Clean exact-registry consumers with npm and pnpm. | | Operating systems | Linux release lanes and a Windows Node.js 22.14.0 package-consumer lane. | | Vite | Vite 8.1.5 package-consumer evidence for the browser boundary. | | Next.js | Next.js 15.5.22 and 16.2.12 across App Router static, App Router request, Pages Router static, and Pages Router request modes. | | Cloudflare Workers | Wrangler 4.115.0 with compatibility date `2026-07-29` and bundled workerd `1.20260722.1`; only the narrow generated deployment-target path documented below is admitted. | | Portable runtime comparison | Bun 1.3.14 exercises portable generated-runtime equivalence; this is not a declaration-authoring, generator, or CLI support claim. | | Maintained examples | Exact-registry applications for Node.js, Cloudflare Workers, a Next.js static shell, and Vite. Check each example’s lockfile for its admitted package version. | The maintained Next.js example is an isolated pnpm application and deliberately invokes Next’s webpack builder because default Turbopack cannot resolve the exact-registry dependency from that repository fixture layout. The package-consumer matrix also passes a default `next build`; do not infer a general Env or Turbopack incompatibility from the example command. ## Package boundaries [Section titled “Package boundaries”](#package-boundaries) | Import or command | Responsibility | | ---------------------- | ------------------------------------------------------------------------------------------------------ | | `@astilba/env` | Declaration builders on Node.js | | `@astilba/env/runtime` | Runtime operations used by generated server targets; exposed to admitted Node.js and workerd consumers | | `@astilba/env/browser` | Public browser bootstrap loading and validation | | `@astilba/env/vite` | Node.js Vite boundary that rejects private Env modules from browser graphs | | `astilba-env` | Node.js command-line interface | There is no `@astilba/env/next` export. Next.js support uses the same generated modules and browser protocol as another framework. ## Cloudflare Workers boundary [Section titled “Cloudflare Workers boundary”](#cloudflare-workers-boundary) Env’s admitted Workers surface is deliberately limited to: * a generated server target; * the `deployment` lifecycle; * first-party Env codecs; * a direct `check(env)` or `load(env)` call inside the handler; and * application-owned Wrangler `vars`, `secrets.required`, secret values, and capability bindings. Request-lifecycle targets and arbitrary Standard Schema validator graphs are not part of the Workers support claim. Env does not inspect live bindings, store secrets, call provider APIs, provision resources, or plan automatic redeployments. Read [Cloudflare Workers](/docs/env/cloudflare-workers/) before adopting this path. ## Browser boundary [Section titled “Browser boundary”](#browser-boundary) Browser deployment and request values must arrive as same-origin JSON. The runtime validates: * response status, redirect state, content type, and size; * the expected audience origin; * the bootstrap protocol; * contract, consumer, lifecycle, and projection identity; and * the exact generated value projection. The runtime fetches with `cache: "no-store"`. An application endpoint whose response can vary by request must also send `Cache-Control: private, no-store`. Env does not inject inline JavaScript, write to `window`, mutate HTML, or choose an application route. ## Alpha boundaries [Section titled “Alpha boundaries”](#alpha-boundaries) Plan for these constraints in 0.3: * generated files are application-owned build artifacts and must be regenerated when the declaration changes; * public build values selected by a browser consumer require an explicit build source during generation; * custom Standard Schema validation is available only for private server `opaque` entries; * browser projections use Env’s portable built-in codecs; * compatibility plans contain descriptors and change classifications, never configuration values; * diagnostics are deliberately redacted; and * framework-specific startup, routing, authentication, and failure UI remain application responsibilities. The inventory CLI is also deliberately narrow. It compares declared process-target names with a strict application-supplied name list. It does not query providers, inspect values or provider kinds, infer closed ownership, sync or prune configuration, or replace runtime `check` and `load` validation. The public [Env repository](https://github.com/astilbahq/env) is the source and issue tracker for the alpha. Report a contract, generation, runtime, or browser-boundary defect there.