# Config Config in Elements is a build primitive. It's expressed in JSOC, a language that extends JSON to make configuration easier to write across different environments. The compiler reads your config at build time, resolves environment values, and emits plain JSON. Anything that depends on a config value rebuilds when the value changes. Missing env variables, invalid types, and other config mistakes surface as build errors at the call site, not as runtime failures during a deploy. A project has one config file: `config.jsoc` at the project root. It holds three things: the project's package dependencies, the import map, and app config (database, session, email, deploy, and so on). There is no `package.json` and no `tsconfig.json`. ## Build-Time Evaluation The compiler evaluates `config.jsoc` against the current environment values and emits the result as a JSON literal. The runtime never sees JSOC and never reads environment variables directly. Three things follow from this. Missing required values fail the build. `env!("DB_HOST")` with the variable unset is a build diagnostic, not a runtime crash. Wrong types fail the build. `env("SMTP_PORT", 587)` with `SMTP_PORT=abc` errors at the call site. Config references are constant-folded. A reference to a config value becomes a literal in the emitted code. Config is part of the build output. When a config value changes, every file that imports or references that value is rebuilt and the affected modules are reloaded. `env(...)` is the build-time channel: it reads an environment variable while the config compiles and folds the value into the output, so changing it triggers a rebuild. It is not the same as `process.env`. Runtime code that must read a live environment variable (one that can differ per process without a rebuild) reads `process.env` directly, and that value is never folded into the build. Use `env(...)` for anything config-shaped; reach for `process.env` only when a value has to stay out of the build. ## Environments Your local development environment is called `development`. The default deploy environment is called `production`. You can add others; `staging` is common. An environment is a configured thing, not a free-form label you invent at the command line. `development` and `production` exist out of the box; any other name works only once it has its own `config/env/.env` and, for deploys, a `deploy.env.` block in `config.jsoc`. Commands that take an environment (`elements deploy production`, `elements deploy staging`) select one of these configured environments. Passing a name that isn't configured is an error, not a request to create a new one. The environment name ties two things together. It selects the env file that supplies values (`production` loads `config/env/production.env`), and it names the deploy target that holds the machines (`production` reads `deploy.env.production`). Everything that serves live traffic runs under `production`, so its secrets go in `production.env` and its machines go in `deploy.env.production`. Add a new environment only when it has its own machines and its own env file, like `staging`; you don't make one per app. Tests aren't a separate environment. They run inside every environment, against that environment's config. There's no test environment to keep in sync with development or production. ## JSOC JSOC is JSON with a few extensions that make it pleasant to write configuration in: - `//` line comments and `/* ... */` block comments. - Unquoted keys: `{ name: "value" }`. - Trailing commas in arrays and objects. - Single or double quoted strings. - The `env(...)` function for environment values. Everything else is JSON. ```jsoc { // line comment /* block comment */ database: { name: "my_app", host: "localhost", }, build: { typescript: { lib: ["es2022", "dom",], }, }, } ``` ## The env() Function `env(...)` reads a value from the environment at build time. The result is folded into the emitted config as a literal. There are three forms: ```jsoc env("KEY", "default") // optional, falls back to the default if unset env!("KEY") // required, build error if unset env("KEY", 5433) // type-coerced (number, boolean, string) ``` Each form guards against a common config mistake. `env!("KEY")` says the value must be present at build time. `env("KEY", default)` says the value must coerce to the given type, with targets of `number`, `boolean`, or `string`. Booleans parse `true`/`false`/`1`/`0`/`yes`/`no`/empty. The plain `env("KEY", default)` form takes a fallback when the variable is unset. ## Env Files Environment values come from two places. OS environment variables provide the initial values. Files under `config/env/` override them for the active environment: ``` config/env/ development.env # used when ENV=development (default) production.env # used when ENV=production staging.env # used when ENV=staging ``` Format is `KEY=value` per line. Comments start with `#`. No quoting required. Saving an env file triggers an automatic rebuild. JSOC files that reference the changed value re-evaluate, files that depend on those config values rebuild, and the project server reloads the affected modules. Commit `development.env`. It holds dev-safe values that every contributor can share. Don't commit `production.env`. Production secrets live there, and the file is gitignored by default. ## At a Glance `config.jsoc`: ```jsoc { // Package dependencies. The package manager resolves these into // package.lock: don't edit package.lock by hand. package: { dependencies: { "@elements/app": "1.0.0", "@elements/style": "1.0.0", }, }, // Import map. `#config` resolves to this file; `#app/*` resolves to // the app source tree, so `import home from "#app/pages/home"` works // from anywhere without relative paths. resolve: { imports: { "#config": "./config.jsoc", "#app/*": "./app/*", }, }, // Database connection. Empty/omitted fields fall back to the bundled // Postgres cluster (127.0.0.1:5433, user postgres, no password). database: { name: "my_app", host: env("DB_HOST", "127.0.0.1"), port: env("DB_PORT", 5433), user: env("DB_USER", "postgres"), password: env("DB_PASSWORD", ""), }, // Email. `live: false` writes messages to the logger instead of sending. email: { live: env("EMAIL_LIVE", false), from: "dev@example.com", host: env("SMTP_HOST"), port: env("SMTP_PORT", 587), user: env("SMTP_USER"), password: env("SMTP_PASSWORD"), startTls: true, }, // Session policy. session: { expires: "30d", }, // How the app listens. The base for every environment, including the one you // run locally. A deploy environment overrides it under deploy.env.. server: { port: env("PORT", 4000), address: env("ADDRESS", "127.0.0.1"), }, deploy: { domain: "example.com", ssh: { user: "ubuntu", identityFile: "~/.ssh/id_ed25519" }, env: { production: { machines: [ { name: "production1", publicIp: "203.0.113.10", privateIp: "10.0.0.10" }, ], }, }, }, } ``` `config/env/development.env`: ``` DB_HOST=localhost ``` `config/env/production.env`: ``` DB_HOST=127.0.0.1 ``` ## Config Blocks - **`package`**: the project's dependency lists (`dependencies`, `devDependencies`, …). Resolved into `package.lock`. - **`resolve.imports`**: the subpath import map (`#config`, `#app/*`, and any you add). - **`database`**: connection settings: `name`, `host`, `port`, `user`, `password`, `appUser`, `appPassword`. Empty fields fall back to the bundled cluster. Full topic: `elements man database`. - **`session`**: `expires` (`"close"` | a duration like `"30d"` or `"1y"`), `autoRenew`, `domain`, `reaperIntervalSeconds`. Full topic: `elements man session`. - **`email`**: flat SMTP settings: `live`, `log`, `logFormat`, `from`, `replyTo`, `host`, `port`, `user`, `password`, `secure`, `startTls`, `timeout`. Full topic: `elements man email`. - **`server`**: `port` (default 4000) and `address` (default `127.0.0.1`). This is how the app listens, and it is the base for every environment, so it is the field to change when the port your app runs on locally is already taken. `deploy` and `deploy.env.` override it in that order. - **`deploy`**: SSH, domains, machines, load balancer, services. Also `port` and `address` when a deployed environment needs something other than the `server` values. Full topic: `elements man deploy`. - **`build`**: `assetUrl`, `assetPath`, `sourceMap`, and `typescript` (a flat mirror of tsconfig's `compilerOptions`). - **`migrations`**: `allowed`, `ignoreStaleMigrations`. Full topic: `elements man migrations`. - **`workers`**: number of job/test worker processes to spawn. ## Importing Config You import config the same way everywhere: ```ts import config from "#config"; let dbName = config.database.name; ``` `#config` is an import-map entry in `config.jsoc` that resolves to the config file. Server config cannot reach the browser: database credentials, session policy, SMTP passwords. Elements will not ship them to the browser bundle. Referencing a server-only config value from browser-reachable code is a compile error at the call site. When browser-reachable code genuinely needs config, split it with a target-conditional import-map entry. Keep server config in `config.jsoc`, put a browser-safe subset in a second file, and point `#config` at each per target: ```jsoc resolve: { imports: { "#config": { "browser": "./config.browser.jsoc", "default": "./config.jsoc", }, }, } ``` The same `import config from "#config"` statement then resolves to the browser-safe file under the browser target and the full file under the server target. There's no shared block. A value needed on both sides is declared in both files. ## process.env Constant Folding `process.env.X === "value"` comparisons are constant-folded at build time. The compiler reads env values directly from the active environment's env file and folds the comparison to a literal, then eliminates the dead branch from the bundle. This is independent of JSOC; you don't have to expose a value through `config.jsoc` for `process.env.X` to fold. ```ts if (process.env.ENV === "production") { trackAnalytics(event); } ``` In a development build this becomes nothing (the whole `if` is dropped). In a production build it becomes the `trackAnalytics(event);` call. The same works for custom env variables: ```ts if (process.env.FEATURE_BETA_CHECKOUT === "true") { return betaCheckoutFlow(); } return defaultCheckoutFlow(); ``` When `FEATURE_BETA_CHECKOUT` is unset in the active environment, the beta branch and any imports it pulls in are removed entirely from the bundle. The folding handles `===`, `==`, `!==`, `!=` against string literals, nested `&&` and `||` chains where every operand is foldable, and boolean literals. Use this for environment-conditional code that shouldn't ship to a target. Anything imported only inside a folded-false branch is shaken from the bundle. ## App and Asset URLs `getAppUrl()` returns the app's external base URL for the active environment, and `getAssetUrl()` returns the asset prefix (a CDN if configured, the app URL otherwise). Both are server-only and read from the running app's config: no env-var threading. ```ts import { getAppUrl, getAssetUrl } from "@elements/app"; let verifyUrl = `${getAppUrl()}/verify?token=${id}`; // e.g. https://example.com/verify?token=... let logoUrl = `${getAssetUrl()}/logo.png`; // e.g. https://cdn.example.com/logo.png ``` **Resolution for `getAppUrl()`:** 1. If a domain is set for the active env (via `deploy.env[env].domain` or `deploy.domain`), the URL is `://`: scheme is `https` when ssl is on (the default when a domain is set) and `http` when ssl is explicitly off. No port: the deploy load balancer fronts the box. 2. If no domain is set and the active env is `development`, the URL is `http://localhost:` from the env-resolved backend port (`deploy.env..port` > `deploy.port` > `server.port` > 4000). 3. If no domain is set on a non-development env, the URL falls back to `http://localhost`: set a domain or rely on an upstream load balancer. **Resolution for `getAssetUrl()`:** 1. `build.assetUrl` (top-level in `config.jsoc`, env-substituted via `env("ASSET_URL")`) when set. 2. Otherwise `getAppUrl()`. `build.assetUrl` is the right home for a CDN URL: set `ASSET_URL=https://cdn.example.com` in `config/env/production.env` and `ASSET_URL=` (empty) in `development.env`, then `build.assetUrl: env("ASSET_URL")` in `config.jsoc`. The empty development value falls back to `getAppUrl()` so assets resolve to the development server. The active environment is determined by `getEnv()` (also exported from `@elements/app`), which reads `process.env.ENV` set by the project server when it spawns each program. ## Related - Database: `elements man database`. The `database` block and the bundled Postgres cluster. - Session: `elements man session`. The `session` block and lifetime policy. - Email: `elements man email`. The `email` block for SMTP and development-mode logging. - Deploy: `elements man deploy`. The `deploy` block for SSH and the machine list.