Config
elements man config Read as markdownConfig 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<number>("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/<name>.env and, for deploys, a
deploy.env.<name> 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.
{
// 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:
env("KEY", "default") // optional, falls back to the default if unset
env!("KEY") // required, build error if unset
env<number>("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<T>("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:
{
// 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<number>("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<boolean>("EMAIL_LIVE", false),
from: "dev@example.com",
host: env("SMTP_HOST"),
port: env<number>("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.<name>.
server: {
port: env<number>("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 intopackage.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) andaddress(default127.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.deployanddeploy.env.<name>override it in that order. -
deploy: SSH, domains, machines, load balancer, services. Alsoportandaddresswhen a deployed environment needs something other than theservervalues. Full topic:elements man deploy. -
build:assetUrl,assetPath,sourceMap, andtypescript(a flat mirror of tsconfig'scompilerOptions). -
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:
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:
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.
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:
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.
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():
- If a domain is set for the active env (via
deploy.env[env].domainordeploy.domain), the URL is<scheme>://<domain>: scheme ishttpswhen ssl is on (the default when a domain is set) andhttpwhen ssl is explicitly off. No port: the deploy load balancer fronts the box. - If no domain is set and the active env is
development, the URL ishttp://localhost:<port>from the env-resolved backend port (deploy.env.<env>.port>deploy.port>server.port> 4000). - 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():
build.assetUrl(top-level inconfig.jsoc, env-substituted viaenv("ASSET_URL")) when set.- 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. Thedatabaseblock and the bundled Postgres cluster. - Session:
elements man session. Thesessionblock and lifetime policy. - Email:
elements man email. Theemailblock for SMTP and development-mode logging. - Deploy:
elements man deploy. Thedeployblock for SSH and the machine list.