({
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.