#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".
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():
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:
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:
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:
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 envlabels 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 buildrecords them aspublicVarsindist/manifest.json, alongside the fullenvkey list. Everything onenvand not onpublicVarsis a secret.alepha platform uppushes a declassified key to Cloudflare as aplain_textbinding instead of an encryptedsecret_textone. 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 inplatform.secrets.keys, is not onpublicVarsand 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
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:
serverOnlyandpersistcannot 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):
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:
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:
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:
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.