Using CtroEnv on Cloudflare Workers
Using CtroEnv on Cloudflare Workers
Cloudflare Workers get environment variables through a binding object, not process.env. CtroEnv's workersSource() adapter bridges the gap.
The Problem
In Cloudflare Workers, env vars are passed as the second argument to the fetch handler:
export default {
async fetch(request, env, ctx) {
// env.DATABASE_URL exists here, but:
// - typeof env.DATABASE_URL is string | undefined
// - No validation at startup
// - Error surfaces when first used, not when deployed
},
}
workersSource()
workersSource() wraps the Workers env binding as an EnvSource:
import { defineEnv, string, number, workersSource } from "@ctroenv/core"
interface EnvBindings {
DATABASE_URL: string
JWT_SECRET: string
PORT: string
}
export default {
async fetch(request, env: EnvBindings, ctx) {
const config = defineEnv(
{
DATABASE_URL: string().url(),
JWT_SECRET: string().min(32).secret(),
PORT: number().port().default(3000),
},
{ source: workersSource(env as unknown as Record<string, string | undefined>) },
)
// config.DATABASE_URL — validated URL
// config.JWT_SECRET — masked at runtime
// config.PORT — parsed number, defaults to 3000
},
}
Validation at Request Time
Unlike Node.js where you validate once at startup, Workers need to validate on each request (or at cold start):
let config: ReturnType<typeof createConfig> | null = null
function createConfig(env: EnvBindings) {
return defineEnv(schema, {
source: workersSource(env as Record<string, string | undefined>),
})
}
export default {
async fetch(request, env, ctx) {
try {
config ??= createConfig(env)
} catch (e) {
return new Response("Configuration error", { status: 500 })
}
// handle request with config
},
}
The nullish coalescing (??=) means validation runs once per isolate lifetime. Subsequent requests reuse the cached result.
Secret Masking
Secrets work the same as other adapters:
const config = defineEnv(
{ JWT_SECRET: string().secret() },
{ source: workersSource(env) },
)
config.JWT_SECRET // "********"
config.meta.get("JWT_SECRET") // actual value
Why Not process.env?
Workers don't have process.env. Using workersSource() is explicit — you pass the binding object directly. If you forget, defineEnv() falls back to detectSource() which will check for import.meta.env or process.env and likely fail, giving you a clear error message.
Full Example
import { defineEnv, string, number, pick, workersSource } from "@ctroenv/core"
const schema = {
DATABASE_URL: string().url(),
JWT_SECRET: string().min(32).secret(),
ENVIRONMENT: pick(["development", "production"] as const).default("production"),
PORT: number().port().default(8787),
} as const
export default {
async fetch(request, env, ctx) {
const config = defineEnv(schema, {
source: workersSource(env as Record<string, string | undefined>),
})
const url = new URL(request.url)
if (url.pathname === "/health") {
return Response.json({ status: "ok", env: config.ENVIRONMENT })
}
return new Response("Hello from CtroEnv on Workers!")
},
}
Comparison
| Feature | Raw Workers bindings | CtroEnv + workersSource |
|---|---|---|
| Type validation | None | Full schema validation |
| Types | string | undefined | Exact inferred types |
| Secret masking | None | .secret() Proxy masking |
| Default values | Manual | .default() |
| Error messages | Generic undefined errors | Grouped, descriptive errors |