Skip to content
Moku
@moku-labs/common pages

env

Validated, frozen environment from ordered providers, with a checked public subset. Injected as ctx.env.

On this page

What it does

env reads variables from a list of providers once, checks them against a schema, and freezes the result. Plugins read it through ctx.env. A missing required variable stops createApp, so an app never runs half-configured.

It also draws the line between secret and public. Only variables you mark public reach getPublic() and getPublicMap(), and the prefix rule makes a slip fail early.

How it works

All of it runs in onInit, inside createApp.

  1. Load. Each provider's load() runs once, in order. An empty string counts as absent. The first provider with a value wins.
  2. Cross-check the prefix. A public: true variable must start with publicPrefix, and a variable with the prefix must be public: true. Either mistake throws.
  3. Fill and require. A schema default fills a gap. A required variable with no value throws.
  4. Publish. Schema variables with public: true and a value go to the public map.
  5. Freeze. Both maps. set, delete and clear throw from now on.

Config

pluginConfigs.env. Every field has a default.

OptionTypeDefaultDescription
schemaRecord<string, EnvVarSpec>{}The rules per variable.
providersEnvProvider[][]The value sources, in priority order. Empty means no values, on every entry.
publicPrefixstring"PUBLIC_"The prefix public variables must carry.

EnvVarSpec:

FieldTypeDescription
publicbooleanRequired. Whether the value may reach the browser.
requiredbooleanThrow when no provider or default gives a value.
defaultstringThe value when no provider has one.
secretbooleanA marker for readers and tools. It has no run-time effect.

API

Mounted at ctx.env on every regular plugin, at app.env, and in the app callbacks. Values are always strings.

MethodSignatureDescription
get(key: string) => string | undefinedThe value, or undefined.
require(key: string) => stringThe value. Throws when it has none.
has(key: string) => booleanWhether it has a value.
getPublic() => Readonly<Record<string, string>>The public variables as a frozen plain object.
getPublicMap() => ReadonlyMap<string, string>The frozen public map, for a build's define step.

Providers

A provider is { name: string; load(): Record<string, string | undefined> }. Write your own in a line or two.

ProviderReadsRuns onEntry
dotenv(path?)A .env-style file, .env.local by default. A missing file gives {}.Node, Bun@moku-labs/common
processEnv()A copy of process.env.Node, Bun@moku-labs/common
workerSafeProcessEnv()A copy of process.env, or {} when there is no process.Node, Bun, Workers@moku-labs/common
cloudflareBindings()globalThis.__CLOUDFLARE_ENV__, or {}. You set that global.Workers@moku-labs/common
browserEnv(options?)import.meta.env, then globalThis.__ENV__, which wins. globalKey renames the global.Browser/browser and root

dotenv is a small parser. It handles blank lines, # comment lines, CRLF, and one pair of outer quotes. It reads the path relative to the working directory.

Events

Emits

None. A core plugin cannot emit.

Listens to

None. A core plugin cannot have hooks.

Depends on

Nothing.

Example

src/app.ts
import { dotenv, type EnvProvider, envPlugin, logPlugin, processEnv } from "@moku-labs/common";
import { createCoreConfig } from "@moku-labs/core";

type Config = {};
type Events = {};

const coreConfig = createCoreConfig<Config, Events, [typeof logPlugin, typeof envPlugin]>("demo", {
  config: {},
  plugins: [logPlugin, envPlugin]
});

const fixtures: EnvProvider = {
  name: "fixtures",
  load: () => ({ API_TOKEN: "test-token" })
};

const apiPlugin = coreConfig.createPlugin("api", {
  api: ctx => ({
    headers: () => ({ authorization: `Bearer ${ctx.env.require("API_TOKEN")}` }),
    baseUrl: () => ctx.env.get("PUBLIC_API_URL") ?? "/"
  })
});

const { createApp } = coreConfig.createCore(coreConfig, { plugins: [apiPlugin] });

const app = createApp({
  pluginConfigs: {
    env: {
      providers: process.env.NODE_ENV === "test" ? [fixtures] : [dotenv(), processEnv()],
      schema: {
        API_TOKEN: { public: false, required: true, secret: true },
        PUBLIC_API_URL: { public: true, default: "https://api.example.com" }
      }
    }
  }
});

console.log(app.api.baseUrl()); // https://api.example.com
console.log(app.env.getPublic()); // { PUBLIC_API_URL: "https://api.example.com" }. The token is not in it.
  1. Used in tests in place of the real sources.
  2. Always a string or undefined. Parse numbers and flags yourself.
  • No providers by default, also in the browser. /browser exports browserEnv but does not wire it. A framework can: @moku-labs/web does.
  • KEY= is absent. An empty value falls through to the next provider, then to the default.
  • dotenv keeps a trailing comment. A=1 # note gives the value 1 # note. Put comments on their own line.
  • dotenv does not read export. export B=2 gives a variable named export B.
  • cloudflareBindings reads once. load() runs in createApp, so set __CLOUDFLARE_ENV__ before you create the app. An app made per request sees that request's bindings. One shared app keeps the first.
  • secret does not protect anything. Nothing stops { public: true, secret: true }, and a secret is still returned by get. The rule that protects you is the prefix.
  • Errors start with [web]. In every framework: the prefix is fixed in the plugin.