alepha@docs:~/docs/framework/guides/core$
cat 2-configurations.md | pretty
5 min read
Last commit:

#Configurations

Alepha provides two primitives for configuration: $env for environment variables and $atom for runtime state.

#Environment Variables with $env

$env reads environment variables with schema validation, type coercion, and defaults. Import it from "alepha".

typescript
 1import { $env, z } from "alepha"; 2  3class App { 4  env = $env( 5    z.object({ 6      DATABASE_URL: z.text(), 7      PORT: z.integer().default(3000), 8      DEBUG: z.boolean().optional(), 9    }),10  );11 12  connect() {13    console.log(this.env.DATABASE_URL); // string, guaranteed to exist14    console.log(this.env.PORT); // number, defaults to 300015    console.log(this.env.DEBUG); // boolean | undefined16  }17}

The schema must be a z.object(...). Each property maps to an environment variable name. Alepha validates values at instantiation time and throws if required variables are missing.

#How env values are resolved

Alepha.create() merges process.env with the env key of the state object passed to create():

typescript
1const alepha = Alepha.create({2  env: {3    DATABASE_URL: "postgres://localhost/mydb",4    PORT: "8080",5  },6});

Values passed to Alepha.create({ env }) take precedence over process.env. Variables from .env files loaded before the process starts (e.g. via alepha dev) are available automatically through process.env.

#Variable interpolation

String values support $VAR interpolation using other variables from the same schema:

typescript
 1class Config { 2  env = $env( 3    z.object({ 4      HOST: z.text({ default: "localhost" }), 5      PORT: z.integer().default(5432), 6      DB_NAME: z.text({ default: "mydb" }), 7      DATABASE_URL: z.text({ default: "postgres://$HOST:$PORT/$DB_NAME" }), 8    }), 9  );10}

#Reading a variable under another name

aliases lets a variable be read from other names when it is not set itself. Hosts hand values over under names of their choosing - a port they allocated arrives as PORT, a database they provisioned as POSTGRES_URL - and this is how an app accepts them without renaming its own configuration:

typescript
 1import { $env, z } from "alepha"; 2  3class Config { 4  env = $env( 5    z.object({ 6      DATABASE_URL: z.text({ aliases: ["POSTGRES_URL"] }), 7      SERVER_PORT: z 8        .integer() 9        .meta({ aliases: ["PORT"] })10        .default(3000),11    }),12  );13}

The declared key always wins. Aliases are tried in order and only when the key itself is absent from the environment, and the value found is coerced and validated as the key it stands in for - so PORT=8080 yields the number 8080 on SERVER_PORT, and PORT=nonsense fails validation the same way SERVER_PORT=nonsense would. Defaults still apply when neither is set.

An alias is only ever read. It is not a key of its own: it stays out of the parsed result, out of alepha gen env's variable list (it is mentioned against the key it feeds) and out of the deploy manifest, so the declared name remains the single one the rest of the app - and the deploy target - refers to.

alepha/server uses this for SERVER_PORT, which is why an app deployed to a host that injects PORT binds the right port with no configuration.

#Environment caching

Alepha caches parsed env results per schema. Multiple services using the same z.object(...) reference will share the same parsed output.

#Declassifying a variable that is not secret

Every environment variable is treated as a secret. That is already what happens on deploy - every declared key is pushed to the target as an encrypted binding - so there is nothing to do for a DATABASE_URL or an API key.

The annotation is the opt-out, for the handful of variables that genuinely are not sensitive:

typescript
 1import { $env, z } from "alepha"; 2  3class Payments { 4  env = $env( 5    z.object({ 6      STRIPE_SECRET_KEY: z.text(), // secret, like everything else 7      PUBLIC_URL: z.text({ secret: false }), // declassified: safe in plaintext 8    }), 9  );10}

secret: true is accepted and is the default, so writing it documents intent without changing behaviour. .meta({ secret: false }) is equivalent to the option - z.text({ ... }) forwards unknown options to .meta().

The default runs this way round on purpose. The annotation is easy to forget, and forgetting it must never be what exposes a value: a missed secret: false costs you an unnecessarily encrypted log level, while a missed secret: true under the opposite default would leak a key.

Three things read it:

  • alepha gen env labels the declassified variables in the generated template, so whoever fills it in can see at a glance which ones are safe to commit - everything unlabelled belongs in a secret store:

    txt
    # (public)
    #PUBLIC_URL=
    
    # Stripe API key
    #STRIPE_SECRET_KEY=
  • alepha build records them as publicVars in dist/manifest.json, alongside the full env key list. Everything on env and not on publicVars is a secret.

  • alepha platform up pushes a declassified key to Cloudflare as a plain_text binding instead of an encrypted secret_text one. That makes it readable in the dashboard and - the actual point - editable there, which a write-only secret is not. A key the app never declassified is still encrypted, so this only ever loosens what an author asked to loosen.

    Only keys the artifact itself vouched for are eligible: a key injected by an orchestrator through .env.<env>.local, or listed in platform.secrets.keys, is not on publicVars and stays a secret.

#State Management with $atom

$atom defines a named, typed, validated piece of global state. Use it for application-level configuration and shared data.

#Defining an atom

typescript
 1import { $atom, z } from "alepha"; 2  3const appConfig = $atom({ 4  name: "app.config", 5  schema: z.object({ 6    theme: z.enum(["light", "dark"]), 7    language: z.text({ default: "en" }), 8  }), 9  default: { theme: "light", language: "en" },10});

The name uniquely identifies the atom in the state store. The schema defines the shape and validation. The default provides the initial value.

Recommended naming convention for name is dot-separated, e.g. "app.config", "user.settings", etc.

If the schema itself is optional (wrapped with .optional(), e.g. z.object({...}).optional()), the default is optional too. Otherwise - even when every field inside the object is optional - default is required.

Beyond name / schema / default, an atom also takes description, serverOnly, and persist: "cookie" | "localStorage" | "sessionStorage". Two things to know before reaching for them:

  • serverOnly and persist cannot be combined - $atom() throws at call time.
  • Cookie persistence is unsigned and unencrypted, and anything client-persisted is attacker-writable. Never persist roles, permissions, or entitlements in an atom; treat a persisted value as user input.

#Reading and writing atoms

Use alepha.store.get() and alepha.store.set() (or alepha.set() as shorthand):

typescript
 1const alepha = Alepha.create(); 2  3// Read 4const config = alepha.store.get(appConfig); 5console.log(config.theme); // "light" 6  7// Write 8alepha.store.set(appConfig, { theme: "dark", language: "fr" }); 9 10// Shorthand write on the container11alepha.set(appConfig, { theme: "dark", language: "fr" });

#Reading atoms with $store

$store creates a reactive getter that always returns the current atom value:

typescript
 1import { $atom, $store, z } from "alepha"; 2  3const count = $atom({ 4  name: "count", 5  schema: z.object({ value: z.number() }), 6  default: { value: 0 }, 7}); 8  9class Counter {10  count = $store(count);11 12  current() {13    return this.count.value; // always reads current state14  }15}

Under the hood, $store registers the atom and replaces the property with a getter that reads from the state store. When the state changes, the next property access returns the updated value:

typescript
1const alepha = Alepha.create();2const counter = alepha.inject(Counter);3 4console.log(counter.count.value); // 05 6alepha.store.set(count, { value: 42 });7console.log(counter.count.value); // 42

#State mutation events

Every store.set() call emits a "state:mutate" event:

typescript
1alepha.events.on("state:mutate", ({ key, value, prevValue }) => {2  console.log(`State "${key}" changed from`, prevValue, "to", value);3});

Atoms is not only about configuration ! This powers SSR hydration, React integration, and devtools.