Cooper loads CASC config files into native JavaScript values. CASC is a hierarchical, extensible config language with imports, variables, string interpolation, environment and config references, loops, and resolvers and tags your application registers.
This is the Node.js port of cooper
(Elixir). Both read the same files and produce the same structure.
#@version = 1.0
@region = "eu-west"
server {
host = "0.0.0.0"
port = 8080
}
database {
*password = ${DB_PASSWORD}
host = "db.@{region}.internal"
pool_size = 10
timeout = 500ms
}
import { loadFile } from '@joetjen/cooper';
const config = await loadFile('config.casc', { env: { DB_PASSWORD: 'hunter2' } });
console.log(config);
// {
// server: { host: '0.0.0.0', port: 8080 },
// database: {
// password: [~~REDACTED~~],
// host: 'db.eu-west.internal',
// pool_size: 10,
// timeout: Duration(500000000ns)
// }
// }
config.database.password.reveal(); // 'hunter2'
config.database.timeout.toMilliseconds(); // 500
Blocks come back as plain objects with string keys. Durations and byte
sizes come back as Duration/ByteSize. Tuples come back as real
Tuples, never coerced to arrays (CASC.md §6.11). Any *key-prefixed
value is wrapped in a Secret, which String(), template literals,
JSON.stringify and console.log all redact. Call reveal() to get the
real value.
require('@joetjen/cooper') works the same as import.
| CASC | JavaScript |
|---|---|
block { ... } |
plain object, string keys |
list [...] |
array |
tuple (...) |
Tuple (frozen, fixed arity, toArray()) |
nil, true, false (also :nil, :true, :false) |
null, true, false |
| integer | number, or bigint beyond Number.MAX_SAFE_INTEGER |
float, inf, -inf |
number, Infinity, -Infinity (a float stays a float inside a load: "@{f}" reads 1.0, and -key = [1.0] leaves 1 alone; a tag you register receives it as a CooperFloat, so it can tell 2.0 from 2) |
atom info, :info, :inf |
Symbol.for('info') (not emitted by JSON.stringify) |
| string | string |
duration 500ms, 1h30m |
Duration (nanoseconds: bigint, toMilliseconds()) |
byte size 512MiB |
ByteSize (bytes: bigint, toNumber()) |
1979-05-27T07:32:00Z, ...07:32:00.120+05:30 |
DateTime (UTC, microseconds, the fraction digits as written; toDate()) |
1979-05-27T07:32:00 / 1979-05-27 / 07:32:00 |
LocalDateTime / LocalDate / LocalTime |
10.0.0.0/8, ::1 |
IPv4 / IPv6 (with CIDR helpers) |
*key = ... |
Secret |
!module("Acme.Payments") |
ModuleRef for what the modules option maps that name to (load() imports it) |
loadFile and loadString return a Promise. Use them whenever a
resolver, tag, or import-scheme loader you register is asynchronous;
a secrets manager, for one, is a network call:
const config = await loadFile('config.casc', {
resolvers: { vault: (path) => vault.read(path) }, // !{vault:secret/db/password}
});
loadFileSync and loadStringSync return the value directly, which
suits plain files read at startup. A registered callback that returns a
Promise there is an error naming it, never a silently unresolved value.
Every failure is a CooperError, thrown or rejected, whose stage
names where it happened: lexer, parser, action, loop, import,
merge, resolve or dotenv. Syntax errors also carry line and
column.
Loading a file runs one pipeline:
src/grammar/) port the reference implementation's grammar rule for
rule.src/pipeline/evaluate.cjs).
Literals become values, for loops expand (loop.cjs), and imports
are loaded and spliced in place (loader.cjs), so an import's
variables are visible to what follows it."@{name}" = 1 are
resolved before merging.merge.cjs), honoring the ~/+/- sigils. Every *key is
wrapped in a Secret here, so the redaction travels with the value
through any later reference.@{} variables, ${} environment reads,
%{} config references (lazily, against the final tree, with cycle
detection), !{resolver:...} and !Name(...) tags are all resolved
(resolver.cjs).See the tutorial for a walkthrough of the library and the CASC syntax, or the cheatsheet for a terse API reference.
${...} reads pick up .env, .env.<COOPER_ENV> and .env.local from the
project root automatically -- the directory of the nearest package.json
above the working directory, else the working directory itself, so a
process started from a subdirectory still finds them. dotenvDir names
another directory. No option is needed and there is no
dependency: the parser is built in and reads the same format as the
Elixir side's dotenvy. One exception: a $(command) is never executed.
The real environment outranks those files. The env option outranks
everything, but only for the names it defines. A deployment sets
variables in the environment it controls, and a file in the working
directory must not silently beat them. That is also Node's dotenv
default. Pass dotenvOverride: true to put the files on top instead,
which can help locally to shadow something exported in your shell.
${COOPER_ENV}Every Cooper implementation reads the current environment by one name, so a document selects per-environment files the same way everywhere:
import "env/${COOPER_ENV}.casc"
A real COOPER_ENV (in the environment, a .env file or the env
option) always wins, exactly as set. Unset or empty, it falls back to
NODE_ENV from those same layers, else "dev". Every Cooper names its
environments dev, staging, test and prod, and in that fallback
maps the host's spellings onto them through one table (CASC.md §7.2):
NODE_ENV |
COOPER_ENV |
|---|---|
development, local |
dev |
testing |
test |
production |
prod |
anything else (dev, staging, test, prod, qa, ...) |
unchanged |
So NODE_ENV=development reads env/dev.casc. Matches are exact and
case-sensitive.
The same COOPER_ENV names the .env.<env> file, as the real
environment, the env option and the base .env set it: .env.dev,
.env.staging, .env.test and .env.prod, in every Cooper. So
NODE_ENV=production reads .env.prod, never .env.production. An
explicit dotenvEnv still names the file outright.
loadFile caches everything up to the final reference-resolution step
by default. The cache is keyed by the modification times of the file and
everything it imports, so a repeat load of unchanged files skips parsing
entirely. ${...} values are re-resolved on every call, hit or miss.
Pass cache: false to opt out for one call.
Cache events go out on two node:diagnostics_channel channels:
cooper:cache:file_changed fires when a cached file changes.cooper:cache:env_changed fires when an environment variable the file
actually reads changes. Polling for this is on by default for files
that use ${...}, through the watchEnv option.npm install @joetjen/cooper
Node.js 20 or later. No runtime dependencies. TypeScript declarations
ship with the package, for import and require alike.
.env files,
caching, and enough CASC syntax to follow along.CASC's spec (casc/CASC.md) is
implementation-independent. Behavior is checked across implementations
by the Cooper Conformance Suite in the praxis repository, which holds
every implementation to the same cases; a behavior change needs a case
there. The Elixir reference implementation is the authority: this
library loads every case alike. A !module("Name") name is
written the same way for both; where Elixir translates it into a module
by convention, Node needs the modules option to map it, since a Node
module is a location rather than a name. That and the JS value model's
other host differences are listed in
DIVERGENCES.md.
The suite runs this implementation through its adapter:
cooper conformance REQUEST ANSWER # installed
node bin/cooper.js conformance REQUEST ANSWER # in a checkout
It reads the suite's JSON request and writes one answer per case: the loaded value in the suite's typed JSON, or the category of the error that refused it.
| Language | Package | Source |
|---|---|---|
| Elixir | cooper on Hex.pm |
joetjen/cooper |
| Node.js (this project) | cooper on npm |
joetjen/node-cooper |
npm install
npm run precommit
npm run precommit runs tsc --checkJs --noEmit to type-check the JSDoc
annotations, followed by the full Mocha test suite. This project expects
it to pass before every commit.
See CONTRIBUTING.md for how to propose changes, and CHANGELOG.md for release history.
Apache-2.0, see LICENSE.