From 895e5dddf0790b62a3a8df7116f6ec2b50026ade Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:29:39 -0700 Subject: [PATCH 1/3] feat(env): add @executor/env package --- bun.lock | 14 + packages/core/env/CHANGELOG.md | 5 + packages/core/env/README.md | 36 +++ packages/core/env/package.json | 21 ++ packages/core/env/src/index.test.ts | 268 +++++++++++++++++ packages/core/env/src/index.ts | 443 ++++++++++++++++++++++++++++ packages/core/env/tsconfig.json | 22 ++ packages/core/env/vitest.config.ts | 7 + 8 files changed, 816 insertions(+) create mode 100644 packages/core/env/CHANGELOG.md create mode 100644 packages/core/env/README.md create mode 100644 packages/core/env/package.json create mode 100644 packages/core/env/src/index.test.ts create mode 100644 packages/core/env/src/index.ts create mode 100644 packages/core/env/tsconfig.json create mode 100644 packages/core/env/vitest.config.ts diff --git a/bun.lock b/bun.lock index b29aab4c2d..3cf5bf13e9 100644 --- a/bun.lock +++ b/bun.lock @@ -177,6 +177,18 @@ "vitest": "catalog:", }, }, + "packages/core/env": { + "name": "@executor/env", + "version": "1.4.0", + "dependencies": { + "effect": "catalog:", + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + }, + }, "packages/core/execution": { "name": "@executor/execution", "version": "1.4.0", @@ -857,6 +869,8 @@ "@executor/desktop": ["@executor/desktop@workspace:apps/desktop"], + "@executor/env": ["@executor/env@workspace:packages/core/env"], + "@executor/execution": ["@executor/execution@workspace:packages/core/execution"], "@executor/host-mcp": ["@executor/host-mcp@workspace:packages/hosts/mcp"], diff --git a/packages/core/env/CHANGELOG.md b/packages/core/env/CHANGELOG.md new file mode 100644 index 0000000000..d75966fd94 --- /dev/null +++ b/packages/core/env/CHANGELOG.md @@ -0,0 +1,5 @@ +# @executor/env + +## 1.4.0 + +- Add vendored Effect env package with t3-env style runtime validation ergonomics. diff --git a/packages/core/env/README.md b/packages/core/env/README.md new file mode 100644 index 0000000000..5c4fdc0e06 --- /dev/null +++ b/packages/core/env/README.md @@ -0,0 +1,36 @@ +# @executor/env + +Vendored environment tooling based on [`rayhanadev/effect-env`](https://github.com/rayhanadev/effect-env), with runtime ergonomics inspired by [`t3-oss/t3-env`](https://github.com/t3-oss/t3-env). + +## What this adds + +- `Env` helper constructors for Effect `Config` values +- `makeEnv` for Effect Context/Layer integration +- `createEnv` for t3-style runtime env assembly with: + - `server` / `client` / `shared` schema split + - `clientPrefix` access controls + - `runtimeEnv` or `runtimeEnvStrict` + - `onValidationError` / `onInvalidAccess` + - `skipValidation` + - `emptyStringAsUndefined` + - `extends` + - `createFinalConfig` customization hook + +## Example + +```ts +import { createEnv, Env } from "@executor/env"; + +export const env = createEnv({ + server: { + DATABASE_URL: Env.url("DATABASE_URL"), + PORT: Env.numberOr("PORT", 3000), + }, + clientPrefix: "PUBLIC_", + client: { + PUBLIC_API_URL: Env.url("PUBLIC_API_URL"), + }, + runtimeEnv: process.env, + emptyStringAsUndefined: true, +}); +``` diff --git a/packages/core/env/package.json b/packages/core/env/package.json new file mode 100644 index 0000000000..5dcf1c51a0 --- /dev/null +++ b/packages/core/env/package.json @@ -0,0 +1,21 @@ +{ + "name": "@executor/env", + "private": true, + "type": "module", + "version": "1.4.0", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "bunx tsc --noEmit -p tsconfig.json", + "test": "vitest run" + }, + "dependencies": { + "effect": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/core/env/src/index.test.ts b/packages/core/env/src/index.test.ts new file mode 100644 index 0000000000..89f966f63d --- /dev/null +++ b/packages/core/env/src/index.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Config, ConfigProvider, Effect } from "effect"; + +import { createEnv, Env, makeEnv } from "./index"; + +describe("makeEnv", () => { + it("creates a tag with an Effect Config and default layer", () => { + const AppEnv = makeEnv("AppEnv", { + PORT: Env.number("PORT"), + HOST: Env.stringOr("HOST", "localhost"), + }); + + const parsed = Effect.runSync( + Effect.withConfigProvider( + ConfigProvider.fromMap( + new Map([ + ["PORT", "8080"], + ["HOST", "0.0.0.0"], + ]), + ), + )(Effect.either(AppEnv.config)), + ); + + expect(parsed._tag).toBe("Right"); + if (parsed._tag === "Right") { + expect(parsed.right.PORT).toBe(8080); + expect(parsed.right.HOST).toBe("0.0.0.0"); + } + + expect(AppEnv.Default).toBeDefined(); + }); +}); + +describe("createEnv", () => { + it("validates server, client, and shared values", () => { + const env = createEnv({ + server: { + PORT: Env.number("PORT"), + }, + shared: { + NODE_ENV: Env.literal("NODE_ENV", "development", "production", "test"), + }, + clientPrefix: "PUBLIC_", + client: { + PUBLIC_API_URL: Env.url("PUBLIC_API_URL"), + }, + runtimeEnv: { + PORT: "3000", + NODE_ENV: "development", + PUBLIC_API_URL: "https://api.example.com", + }, + }); + + expect(env.PORT).toBe(3000); + expect(env.NODE_ENV).toBe("development"); + expect(env.PUBLIC_API_URL).toBe("https://api.example.com"); + }); + + it("throws with the default validation handler", () => { + expect(() => + createEnv({ + server: { + PORT: Env.number("PORT"), + }, + runtimeEnv: { + PORT: "not-a-number", + }, + }), + ).toThrow("Invalid environment variables"); + }); + + it("supports custom validation handlers", () => { + expect(() => + createEnv({ + server: { + PORT: Env.number("PORT"), + }, + runtimeEnv: { + PORT: "nope", + }, + onValidationError: (issues) => { + const portIssue = issues.find((issue) => issue.path.includes("PORT")); + throw new Error(`PORT invalid: ${portIssue?.message ?? "unknown"}`); + }, + }), + ).toThrow("PORT invalid:"); + }); + + it("prevents server variable access on the client", () => { + const env = createEnv({ + server: { + SECRET: Env.string("SECRET"), + }, + shared: { + NODE_ENV: Env.literal("NODE_ENV", "development", "production", "test"), + }, + clientPrefix: "PUBLIC_", + client: { + PUBLIC_SITE_NAME: Env.string("PUBLIC_SITE_NAME"), + }, + runtimeEnv: { + SECRET: "top-secret", + NODE_ENV: "development", + PUBLIC_SITE_NAME: "executor", + }, + isServer: false, + }); + + expect(() => env.SECRET).toThrow( + "❌ Attempted to access a server-side environment variable on the client", + ); + expect(env.PUBLIC_SITE_NAME).toBe("executor"); + expect(env.NODE_ENV).toBe("development"); + }); + + it("supports custom invalid-access handlers", () => { + const env = createEnv({ + server: { + SECRET: Env.string("SECRET"), + }, + clientPrefix: "PUBLIC_", + client: { + PUBLIC_SITE_NAME: Env.string("PUBLIC_SITE_NAME"), + }, + runtimeEnv: { + SECRET: "top-secret", + PUBLIC_SITE_NAME: "executor", + }, + isServer: false, + onInvalidAccess: (variable) => { + throw new Error(`Blocked ${variable}`); + }, + }); + + expect(() => env.SECRET).toThrow("Blocked SECRET"); + }); + + it("treats empty strings as undefined when requested", () => { + const withoutOption = createEnv({ + server: { + HOST: Env.stringOr("HOST", "localhost"), + }, + runtimeEnv: { + HOST: "", + }, + }); + + const withOption = createEnv({ + server: { + HOST: Env.stringOr("HOST", "localhost"), + }, + runtimeEnv: { + HOST: "", + }, + emptyStringAsUndefined: true, + }); + + expect(withoutOption.HOST).toBe(""); + expect(withOption.HOST).toBe("localhost"); + }); + + it("extends other env objects and allows local overrides", () => { + const preset = createEnv({ + server: { + PRESET_ENV: Env.literal("PRESET_ENV", "preset", "overridden"), + PRESET_SECRET: Env.string("PRESET_SECRET"), + }, + runtimeEnv: { + PRESET_ENV: "preset", + PRESET_SECRET: "preset-secret", + }, + }); + + const env = createEnv({ + server: { + PRESET_ENV: Env.literal("PRESET_ENV", "overridden"), + APP_ENV: Env.string("APP_ENV"), + }, + extends: [preset], + runtimeEnv: { + PRESET_ENV: "overridden", + APP_ENV: "local", + }, + }); + + expect(env.PRESET_ENV).toBe("overridden"); + expect(env.PRESET_SECRET).toBe("preset-secret"); + expect(env.APP_ENV).toBe("local"); + }); + + it("supports skipping validation", () => { + const env = createEnv({ + server: { + PORT: Env.number("PORT"), + }, + runtimeEnv: { + PORT: "not-a-number", + }, + skipValidation: true, + }); + + expect(env.PORT).toBe("not-a-number"); + }); + + it("supports createFinalConfig transformations", () => { + const env = createEnv({ + server: { + HOST: Env.string("HOST"), + PORT: Env.number("PORT"), + }, + runtimeEnv: { + HOST: "localhost", + PORT: "4000", + }, + createFinalConfig: (shape) => + Config.all(shape).pipe( + Config.map((value) => ({ + ...value, + BASE_URL: `http://${value.HOST}:${value.PORT}`, + })), + ), + }); + + expect(env.HOST).toBe("localhost"); + expect(env.PORT).toBe(4000); + expect(env.BASE_URL).toBe("http://localhost:4000"); + }); + + it("enforces prefix and runtimeEnvStrict at type level", () => { + createEnv({ + clientPrefix: "PUBLIC_", + server: { + SECRET: Env.string("SECRET"), + }, + client: { + PUBLIC_SITE_NAME: Env.string("PUBLIC_SITE_NAME"), + }, + runtimeEnvStrict: { + SECRET: "top-secret", + PUBLIC_SITE_NAME: "executor", + }, + }); + + if (false) { + createEnv({ + clientPrefix: "PUBLIC_", + server: { + // @ts-expect-error Server keys should not use the client prefix + PUBLIC_SECRET: Env.string("PUBLIC_SECRET"), + }, + client: {}, + runtimeEnvStrict: {}, + }); + + createEnv({ + clientPrefix: "PUBLIC_", + server: {}, + client: { + // @ts-expect-error Client keys must include the client prefix + SITE_NAME: Env.string("SITE_NAME"), + }, + runtimeEnvStrict: {}, + }); + } + + expect(true).toBe(true); + }); +}); diff --git a/packages/core/env/src/index.ts b/packages/core/env/src/index.ts new file mode 100644 index 0000000000..142f467fd4 --- /dev/null +++ b/packages/core/env/src/index.ts @@ -0,0 +1,443 @@ +import { Config, ConfigError, ConfigProvider, Context, Effect, Either, Layer } from "effect"; + +export const Env = { + string: (name: string) => Config.string(name), + number: (name: string) => Config.number(name), + boolean: (name: string) => Config.boolean(name), + redacted: (name: string) => Config.redacted(name), + + stringOr: (name: string, defaultValue: string) => + Config.string(name).pipe(Config.withDefault(defaultValue)), + numberOr: (name: string, defaultValue: number) => + Config.number(name).pipe(Config.withDefault(defaultValue)), + booleanOr: (name: string, defaultValue: boolean) => + Config.boolean(name).pipe(Config.withDefault(defaultValue)), + + optionalString: (name: string) => Config.string(name).pipe(Config.option), + optionalNumber: (name: string) => Config.number(name).pipe(Config.option), + optionalBoolean: (name: string) => Config.boolean(name).pipe(Config.option), + + literal: (name: string, ...values: readonly [T, ...T[]]) => + Config.string(name).pipe( + Config.mapOrFail((value) => + values.includes(value as T) + ? Either.right(value as T) + : Either.left(ConfigError.InvalidData([], `Expected one of: ${values.join(", ")}`)), + ), + ), + + literalOr: ( + name: string, + defaultValue: NoInfer, + ...values: readonly [T, ...T[]] + ) => + Config.string(name).pipe( + Config.withDefault(defaultValue), + Config.mapOrFail((value) => + values.includes(value as T) + ? Either.right(value as T) + : Either.left(ConfigError.InvalidData([], `Expected one of: ${values.join(", ")}`)), + ), + ), + + url: (name: string) => + Config.string(name).pipe( + Config.mapOrFail((value) => { + try { + new URL(value); + return Either.right(value); + } catch { + return Either.left(ConfigError.InvalidData([], "Invalid URL")); + } + }), + ), + + urlOr: (name: string, defaultValue: string) => + Config.string(name).pipe( + Config.withDefault(defaultValue), + Config.mapOrFail((value) => { + try { + new URL(value); + return Either.right(value); + } catch { + return Either.left(ConfigError.InvalidData([], "Invalid URL")); + } + }), + ), +}; + +type ConfigShape = Record>; + +type InferConfigShape = { + readonly [K in keyof Shape]: Config.Config.Success; +}; + +export interface EnvService< + Id extends string, + Shape extends Record>, +> extends Context.Tag, InferConfigShape> { + readonly config: Config.Config>; + readonly Default: Layer.Layer, ConfigError.ConfigError>; +} + +export const makeEnv = < + const Id extends string, + const Shape extends Record>, +>( + id: Id, + shape: Shape, +): EnvService => { + const config = Config.all(shape) as Config.Config>; + + const tag = Context.GenericTag, InferConfigShape>(id); + const Default = Layer.effect(tag, config); + + return Object.assign(tag, { config, Default }); +}; + +type ErrorMessage = T; + +type Simplify = { + [P in keyof T]: T[P]; +} & {}; + +type PossiblyUndefinedKeys = { + [K in keyof T]: undefined extends T[K] ? K : never; +}[keyof T]; + +type UndefinedOptional = Partial>> & + Omit>; + +type Impossible = Partial>; + +type Mutable = T extends Readonly ? U : T; + +type Reduce[], TAcc = object> = + TArr extends readonly [] + ? TAcc + : TArr extends readonly [infer Head, ...infer Tail] + ? Tail extends readonly Record[] + ? Mutable & Omit, keyof Head> + : never + : never; + +export type RuntimeEnvValue = string | number | boolean | undefined; +export type RuntimeEnv = Record; + +export interface ValidationIssue { + readonly type: "InvalidData" | "MissingData" | "SourceUnavailable" | "Unsupported"; + readonly path: ReadonlyArray; + readonly message: string; +} + +export const flattenConfigError = ( + error: ConfigError.ConfigError, +): ReadonlyArray => { + const issues: ValidationIssue[] = []; + + const visit = (next: ConfigError.ConfigError): void => { + switch (next._op) { + case "And": + case "Or": { + visit(next.left); + visit(next.right); + return; + } + case "InvalidData": + case "MissingData": + case "SourceUnavailable": + case "Unsupported": { + issues.push({ + type: next._op, + path: next.path, + message: next.message, + }); + } + } + }; + + visit(error); + return issues; +}; + +export interface BaseOptions< + TShared extends ConfigShape, + TExtends extends readonly Record[], +> { + isServer?: boolean; + shared?: TShared; + extends?: TExtends; + onValidationError?: ( + issues: ReadonlyArray, + error: ConfigError.ConfigError, + ) => never; + onInvalidAccess?: (variable: string) => never; + skipValidation?: boolean; + emptyStringAsUndefined?: boolean; +} + +export interface LooseOptions< + TShared extends ConfigShape, + TExtends extends readonly Record[], +> extends BaseOptions { + runtimeEnvStrict?: never; + runtimeEnv: RuntimeEnv; +} + +export interface StrictOptions< + TPrefix extends string | undefined, + TServer extends ConfigShape, + TClient extends ConfigShape, + TShared extends ConfigShape, + TExtends extends readonly Record[], +> extends BaseOptions { + runtimeEnvStrict: Record< + | { + [TKey in keyof TClient]: TPrefix extends undefined + ? never + : TKey extends `${TPrefix}${string}` + ? TKey + : never; + }[keyof TClient] + | { + [TKey in keyof TServer]: TPrefix extends undefined + ? TKey + : TKey extends `${TPrefix}${string}` + ? never + : TKey; + }[keyof TServer] + | { + [TKey in keyof TShared]: TKey extends string ? TKey : never; + }[keyof TShared], + RuntimeEnvValue + >; + runtimeEnv?: never; +} + +export interface ClientOptions< + TPrefix extends string | undefined, + TClient extends ConfigShape, +> { + clientPrefix: TPrefix; + client: Partial<{ + [TKey in keyof TClient]: TKey extends `${TPrefix}${string}` + ? TClient[TKey] + : ErrorMessage<`${TKey extends string ? TKey : never} is not prefixed with ${TPrefix}.`>; + }>; +} + +export interface ServerOptions< + TPrefix extends string | undefined, + TServer extends ConfigShape, +> { + server: Partial<{ + [TKey in keyof TServer]: TPrefix extends undefined + ? TServer[TKey] + : TPrefix extends "" + ? TServer[TKey] + : TKey extends `${TPrefix}${string}` + ? ErrorMessage<`${TKey extends `${TPrefix}${string}` + ? TKey + : never} should not prefixed with ${TPrefix}.`> + : TServer[TKey]; + }>; +} + +export interface CreateConfigOptions< + TServer extends ConfigShape, + TClient extends ConfigShape, + TShared extends ConfigShape, + TFinalConfig extends Config.Config>, +> { + createFinalConfig?: ( + shape: TServer & TClient & TShared, + isServer: boolean, + ) => TFinalConfig; +} + +export type ServerClientOptions< + TPrefix extends string | undefined, + TServer extends ConfigShape, + TClient extends ConfigShape, +> = + | (ClientOptions & ServerOptions) + | (ServerOptions & Impossible>) + | (ClientOptions & Impossible>); + +export type EnvOptions< + TPrefix extends string | undefined, + TServer extends ConfigShape, + TClient extends ConfigShape, + TShared extends ConfigShape, + TExtends extends readonly Record[], + TFinalConfig extends Config.Config>, +> = ( + | (LooseOptions & ServerClientOptions) + | (StrictOptions & + ServerClientOptions) +) & + CreateConfigOptions; + +type TPrefixFormat = string | undefined; +type TServerFormat = ConfigShape; +type TClientFormat = ConfigShape; +type TSharedFormat = ConfigShape; +type TExtendsFormat = readonly Record[]; + +export type DefaultCombinedConfig< + TServer extends TServerFormat, + TClient extends TClientFormat, + TShared extends TSharedFormat, +> = Config.Config>>; + +type InferEnvOutput> = + Config.Config.Success extends Record + ? Config.Config.Success + : never; + +export type CreateEnv< + TFinalConfig extends Config.Config>, + TExtends extends TExtendsFormat, +> = Readonly, ...TExtends]>>>; + +export const getDefaultRuntimeEnv = (): RuntimeEnv => { + const processLike = (globalThis as { process?: { env?: RuntimeEnv } }).process; + if (processLike?.env && typeof processLike.env === "object") { + return processLike.env; + } + return {}; +}; + +const normalizeRuntimeEnv = ( + runtimeEnv: RuntimeEnv, + emptyStringAsUndefined: boolean, +): RuntimeEnv => { + const normalized: RuntimeEnv = {}; + for (const [key, value] of Object.entries(runtimeEnv)) { + if (value === undefined) { + continue; + } + if (emptyStringAsUndefined && value === "") { + continue; + } + normalized[key] = value; + } + return normalized; +}; + +const toRuntimeMap = (runtimeEnv: RuntimeEnv): Map => { + const map = new Map(); + + for (const [key, value] of Object.entries(runtimeEnv)) { + if (value !== undefined) { + map.set(key, String(value)); + } + } + + return map; +}; + +const mergeExtended = (extendsEnvs: ReadonlyArray>): Record => + extendsEnvs.reduce>((acc, current) => Object.assign(acc, current), {}); + +export function createEnv< + TPrefix extends TPrefixFormat, + const TServer extends TServerFormat = Record, + const TClient extends TClientFormat = Record, + const TShared extends TSharedFormat = Record, + const TExtends extends TExtendsFormat = [], + TFinalConfig extends Config.Config> = DefaultCombinedConfig< + TServer, + TClient, + TShared + >, +>( + opts: EnvOptions, +): CreateEnv { + const runtimeEnv = (opts.runtimeEnvStrict ?? opts.runtimeEnv ?? getDefaultRuntimeEnv()) as RuntimeEnv; + + const normalizedRuntimeEnv = normalizeRuntimeEnv( + runtimeEnv, + opts.emptyStringAsUndefined ?? false, + ); + + const extendedEnv = mergeExtended(opts.extends ?? []); + + if (opts.skipValidation) { + return Object.assign(extendedEnv, normalizedRuntimeEnv) as CreateEnv; + } + + const client = (typeof opts.client === "object" ? opts.client : {}) as TClient; + const server = (typeof opts.server === "object" ? opts.server : {}) as TServer; + const shared = (typeof opts.shared === "object" ? opts.shared : {}) as TShared; + const isServer = opts.isServer ?? (!("window" in globalThis) || "Deno" in globalThis); + + const finalShape = ( + isServer + ? { + ...server, + ...shared, + ...client, + } + : { + ...client, + ...shared, + } + ) as TServer & TClient & TShared; + + const finalConfig = + opts.createFinalConfig?.(finalShape, isServer) ?? + (Config.all(finalShape) as unknown as TFinalConfig); + + const parsed = Effect.runSync( + Effect.withConfigProvider(ConfigProvider.fromMap(toRuntimeMap(normalizedRuntimeEnv)))( + Effect.either(finalConfig), + ), + ); + + const onValidationError = + opts.onValidationError ?? + ((issues: ReadonlyArray) => { + console.error("❌ Invalid environment variables:", issues); + throw new Error("Invalid environment variables"); + }); + + const onInvalidAccess = + opts.onInvalidAccess ?? + (() => { + throw new Error("❌ Attempted to access a server-side environment variable on the client"); + }); + + if (Either.isLeft(parsed)) { + const issues = flattenConfigError(parsed.left); + return onValidationError(issues, parsed.left); + } + + const isServerAccess = (prop: string) => { + if (!opts.clientPrefix) { + return true; + } + return !prop.startsWith(opts.clientPrefix) && !(prop in shared); + }; + + const isValidServerAccess = (prop: string) => isServer || !isServerAccess(prop); + + const ignoreProp = (prop: string) => prop === "__esModule" || prop === "$$typeof"; + + const fullEnv = Object.assign(extendedEnv, parsed.right); + + return new Proxy(fullEnv, { + get(target, prop) { + if (typeof prop !== "string") { + return undefined; + } + if (ignoreProp(prop)) { + return undefined; + } + if (!isValidServerAccess(prop)) { + return onInvalidAccess(prop); + } + return Reflect.get(target, prop); + }, + }) as CreateEnv; +} diff --git a/packages/core/env/tsconfig.json b/packages/core/env/tsconfig.json new file mode 100644 index 0000000000..2e35d220b2 --- /dev/null +++ b/packages/core/env/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "plugins": [ + { + "name": "@effect/language-service", + "diagnosticSeverity": {} + } + ] + }, + "include": ["src"] +} diff --git a/packages/core/env/vitest.config.ts b/packages/core/env/vitest.config.ts new file mode 100644 index 0000000000..ae847ff6d9 --- /dev/null +++ b/packages/core/env/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); From 474c065da98b803cd51c96f059a698b340a232e4 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:44:24 -0700 Subject: [PATCH 2/3] test(env): use @effect/vitest assertRight helper --- packages/core/env/src/index.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/env/src/index.test.ts b/packages/core/env/src/index.test.ts index 89f966f63d..6e97403bb9 100644 --- a/packages/core/env/src/index.test.ts +++ b/packages/core/env/src/index.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { assertRight } from "@effect/vitest/utils"; import { Config, ConfigProvider, Effect } from "effect"; import { createEnv, Env, makeEnv } from "./index"; @@ -21,11 +22,10 @@ describe("makeEnv", () => { )(Effect.either(AppEnv.config)), ); - expect(parsed._tag).toBe("Right"); - if (parsed._tag === "Right") { - expect(parsed.right.PORT).toBe(8080); - expect(parsed.right.HOST).toBe("0.0.0.0"); - } + assertRight(parsed, { + PORT: 8080, + HOST: "0.0.0.0", + }); expect(AppEnv.Default).toBeDefined(); }); From a291fa47812d8382ba57ef682e3d453a1619355b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:25:48 -0700 Subject: [PATCH 3/3] refactor(env): simplify createEnv API --- packages/core/env/README.md | 40 ++-- packages/core/env/src/index.test.ts | 291 ++++++++++++++++------------ packages/core/env/src/index.ts | 198 ++++--------------- 3 files changed, 230 insertions(+), 299 deletions(-) diff --git a/packages/core/env/README.md b/packages/core/env/README.md index 5c4fdc0e06..3b06593854 100644 --- a/packages/core/env/README.md +++ b/packages/core/env/README.md @@ -6,10 +6,9 @@ Vendored environment tooling based on [`rayhanadev/effect-env`](https://github.c - `Env` helper constructors for Effect `Config` values - `makeEnv` for Effect Context/Layer integration -- `createEnv` for t3-style runtime env assembly with: - - `server` / `client` / `shared` schema split - - `clientPrefix` access controls - - `runtimeEnv` or `runtimeEnvStrict` +- `createEnv(shape, options)` for runtime env assembly with: + - an optional `prefix` for client-safe keys + - `runtimeEnv` - `onValidationError` / `onInvalidAccess` - `skipValidation` - `emptyStringAsUndefined` @@ -21,16 +20,33 @@ Vendored environment tooling based on [`rayhanadev/effect-env`](https://github.c ```ts import { createEnv, Env } from "@executor/env"; -export const env = createEnv({ - server: { +export const shared = createEnv( + { + NODE_ENV: Env.literal("NODE_ENV", "development", "test", "production"), + }, + { + runtimeEnv: process.env, + }, +); + +export const web = createEnv( + { + PUBLIC_API_URL: Env.url("PUBLIC_API_URL"), + }, + { + prefix: "PUBLIC_", + runtimeEnv: import.meta.env, + }, +); + +export const server = createEnv( + { DATABASE_URL: Env.url("DATABASE_URL"), PORT: Env.numberOr("PORT", 3000), }, - clientPrefix: "PUBLIC_", - client: { - PUBLIC_API_URL: Env.url("PUBLIC_API_URL"), + { + runtimeEnv: process.env, + extends: [shared], }, - runtimeEnv: process.env, - emptyStringAsUndefined: true, -}); +); ``` diff --git a/packages/core/env/src/index.test.ts b/packages/core/env/src/index.test.ts index 6e97403bb9..b2cda1a1de 100644 --- a/packages/core/env/src/index.test.ts +++ b/packages/core/env/src/index.test.ts @@ -32,156 +32,200 @@ describe("makeEnv", () => { }); describe("createEnv", () => { - it("validates server, client, and shared values", () => { - const env = createEnv({ - server: { - PORT: Env.number("PORT"), - }, - shared: { + it("validates values and supports separate shared/web/server definitions", () => { + const shared = createEnv( + { NODE_ENV: Env.literal("NODE_ENV", "development", "production", "test"), }, - clientPrefix: "PUBLIC_", - client: { + { + runtimeEnv: { + NODE_ENV: "development", + }, + }, + ); + + const web = createEnv( + { PUBLIC_API_URL: Env.url("PUBLIC_API_URL"), }, - runtimeEnv: { - PORT: "3000", - NODE_ENV: "development", - PUBLIC_API_URL: "https://api.example.com", + { + prefix: "PUBLIC_", + runtimeEnv: { + PUBLIC_API_URL: "https://api.example.com", + }, }, - }); + ); + + const server = createEnv( + { + PORT: Env.number("PORT"), + }, + { + extends: [shared], + runtimeEnv: { + PORT: "3000", + }, + }, + ); - expect(env.PORT).toBe(3000); - expect(env.NODE_ENV).toBe("development"); - expect(env.PUBLIC_API_URL).toBe("https://api.example.com"); + expect(server.PORT).toBe(3000); + expect(server.NODE_ENV).toBe("development"); + expect(web.PUBLIC_API_URL).toBe("https://api.example.com"); }); it("throws with the default validation handler", () => { expect(() => - createEnv({ - server: { + createEnv( + { PORT: Env.number("PORT"), }, - runtimeEnv: { - PORT: "not-a-number", + { + runtimeEnv: { + PORT: "not-a-number", + }, }, - }), + ), ).toThrow("Invalid environment variables"); }); it("supports custom validation handlers", () => { expect(() => - createEnv({ - server: { + createEnv( + { PORT: Env.number("PORT"), }, - runtimeEnv: { - PORT: "nope", - }, - onValidationError: (issues) => { - const portIssue = issues.find((issue) => issue.path.includes("PORT")); - throw new Error(`PORT invalid: ${portIssue?.message ?? "unknown"}`); + { + runtimeEnv: { + PORT: "nope", + }, + onValidationError: (issues) => { + const portIssue = issues.find((issue) => issue.path.includes("PORT")); + throw new Error(`PORT invalid: ${portIssue?.message ?? "unknown"}`); + }, }, - }), + ), ).toThrow("PORT invalid:"); }); - it("prevents server variable access on the client", () => { - const env = createEnv({ - server: { + it("prevents non-prefixed variable access on the client", () => { + const secret = createEnv( + { SECRET: Env.string("SECRET"), }, - shared: { - NODE_ENV: Env.literal("NODE_ENV", "development", "production", "test"), + { + runtimeEnv: { + SECRET: "top-secret", + }, }, - clientPrefix: "PUBLIC_", - client: { + ); + + const env = createEnv( + { PUBLIC_SITE_NAME: Env.string("PUBLIC_SITE_NAME"), }, - runtimeEnv: { - SECRET: "top-secret", - NODE_ENV: "development", - PUBLIC_SITE_NAME: "executor", + { + prefix: "PUBLIC_", + extends: [secret], + runtimeEnv: { + PUBLIC_SITE_NAME: "executor", + }, + isServer: false, }, - isServer: false, - }); + ); expect(() => env.SECRET).toThrow( "❌ Attempted to access a server-side environment variable on the client", ); expect(env.PUBLIC_SITE_NAME).toBe("executor"); - expect(env.NODE_ENV).toBe("development"); }); it("supports custom invalid-access handlers", () => { - const env = createEnv({ - server: { + const secret = createEnv( + { SECRET: Env.string("SECRET"), }, - clientPrefix: "PUBLIC_", - client: { - PUBLIC_SITE_NAME: Env.string("PUBLIC_SITE_NAME"), + { + runtimeEnv: { + SECRET: "top-secret", + }, }, - runtimeEnv: { - SECRET: "top-secret", - PUBLIC_SITE_NAME: "executor", + ); + + const env = createEnv( + { + PUBLIC_SITE_NAME: Env.string("PUBLIC_SITE_NAME"), }, - isServer: false, - onInvalidAccess: (variable) => { - throw new Error(`Blocked ${variable}`); + { + prefix: "PUBLIC_", + extends: [secret], + runtimeEnv: { + PUBLIC_SITE_NAME: "executor", + }, + isServer: false, + onInvalidAccess: (variable) => { + throw new Error(`Blocked ${variable}`); + }, }, - }); + ); expect(() => env.SECRET).toThrow("Blocked SECRET"); }); it("treats empty strings as undefined when requested", () => { - const withoutOption = createEnv({ - server: { + const withoutOption = createEnv( + { HOST: Env.stringOr("HOST", "localhost"), }, - runtimeEnv: { - HOST: "", + { + runtimeEnv: { + HOST: "", + }, }, - }); + ); - const withOption = createEnv({ - server: { + const withOption = createEnv( + { HOST: Env.stringOr("HOST", "localhost"), }, - runtimeEnv: { - HOST: "", + { + runtimeEnv: { + HOST: "", + }, + emptyStringAsUndefined: true, }, - emptyStringAsUndefined: true, - }); + ); expect(withoutOption.HOST).toBe(""); expect(withOption.HOST).toBe("localhost"); }); it("extends other env objects and allows local overrides", () => { - const preset = createEnv({ - server: { + const preset = createEnv( + { PRESET_ENV: Env.literal("PRESET_ENV", "preset", "overridden"), PRESET_SECRET: Env.string("PRESET_SECRET"), }, - runtimeEnv: { - PRESET_ENV: "preset", - PRESET_SECRET: "preset-secret", + { + runtimeEnv: { + PRESET_ENV: "preset", + PRESET_SECRET: "preset-secret", + }, }, - }); + ); - const env = createEnv({ - server: { + const env = createEnv( + { PRESET_ENV: Env.literal("PRESET_ENV", "overridden"), APP_ENV: Env.string("APP_ENV"), }, - extends: [preset], - runtimeEnv: { - PRESET_ENV: "overridden", - APP_ENV: "local", + { + extends: [preset], + runtimeEnv: { + PRESET_ENV: "overridden", + APP_ENV: "local", + }, }, - }); + ); expect(env.PRESET_ENV).toBe("overridden"); expect(env.PRESET_SECRET).toBe("preset-secret"); @@ -189,78 +233,71 @@ describe("createEnv", () => { }); it("supports skipping validation", () => { - const env = createEnv({ - server: { + const env = createEnv( + { PORT: Env.number("PORT"), }, - runtimeEnv: { - PORT: "not-a-number", + { + runtimeEnv: { + PORT: "not-a-number", + }, + skipValidation: true, }, - skipValidation: true, - }); + ); expect(env.PORT).toBe("not-a-number"); }); it("supports createFinalConfig transformations", () => { - const env = createEnv({ - server: { + const env = createEnv( + { HOST: Env.string("HOST"), PORT: Env.number("PORT"), }, - runtimeEnv: { - HOST: "localhost", - PORT: "4000", + { + runtimeEnv: { + HOST: "localhost", + PORT: "4000", + }, + createFinalConfig: (shape) => + Config.all(shape).pipe( + Config.map((value) => ({ + ...value, + BASE_URL: `http://${value.HOST}:${value.PORT}`, + })), + ), }, - createFinalConfig: (shape) => - Config.all(shape).pipe( - Config.map((value) => ({ - ...value, - BASE_URL: `http://${value.HOST}:${value.PORT}`, - })), - ), - }); + ); expect(env.HOST).toBe("localhost"); expect(env.PORT).toBe(4000); expect(env.BASE_URL).toBe("http://localhost:4000"); }); - it("enforces prefix and runtimeEnvStrict at type level", () => { - createEnv({ - clientPrefix: "PUBLIC_", - server: { - SECRET: Env.string("SECRET"), - }, - client: { + it("enforces prefix at type level", () => { + createEnv( + { PUBLIC_SITE_NAME: Env.string("PUBLIC_SITE_NAME"), }, - runtimeEnvStrict: { - SECRET: "top-secret", - PUBLIC_SITE_NAME: "executor", + { + prefix: "PUBLIC_", + runtimeEnv: { + PUBLIC_SITE_NAME: "executor", + }, }, - }); + ); if (false) { - createEnv({ - clientPrefix: "PUBLIC_", - server: { - // @ts-expect-error Server keys should not use the client prefix - PUBLIC_SECRET: Env.string("PUBLIC_SECRET"), - }, - client: {}, - runtimeEnvStrict: {}, - }); - - createEnv({ - clientPrefix: "PUBLIC_", - server: {}, - client: { - // @ts-expect-error Client keys must include the client prefix + createEnv( + { + // @ts-expect-error Keys must include the PUBLIC_ prefix SITE_NAME: Env.string("SITE_NAME"), }, - runtimeEnvStrict: {}, - }); + { + prefix: "PUBLIC_", + runtimeEnv: {}, + }, + ); } expect(true).toBe(true); diff --git a/packages/core/env/src/index.ts b/packages/core/env/src/index.ts index 142f467fd4..af5c5f2eaf 100644 --- a/packages/core/env/src/index.ts +++ b/packages/core/env/src/index.ts @@ -108,8 +108,6 @@ type PossiblyUndefinedKeys = { type UndefinedOptional = Partial>> & Omit>; -type Impossible = Partial>; - type Mutable = T extends Readonly ? U : T; type Reduce[], TAcc = object> = @@ -160,12 +158,28 @@ export const flattenConfigError = ( return issues; }; -export interface BaseOptions< - TShared extends ConfigShape, +type EnforcePrefixedKeys< + TPrefix extends string | undefined, + TShape extends ConfigShape, +> = { + [TKey in keyof TShape]: TPrefix extends undefined + ? TShape[TKey] + : TPrefix extends "" + ? TShape[TKey] + : TKey extends `${TPrefix}${string}` + ? TShape[TKey] + : ErrorMessage<`${TKey extends string ? TKey : never} is not prefixed with ${TPrefix}.`>; +}; + +export interface CreateEnvOptions< + TPrefix extends string | undefined, + TShape extends ConfigShape, TExtends extends readonly Record[], + TFinalConfig extends Config.Config>, > { + prefix?: TPrefix; isServer?: boolean; - shared?: TShared; + runtimeEnv?: RuntimeEnv; extends?: TExtends; onValidationError?: ( issues: ReadonlyArray, @@ -174,121 +188,12 @@ export interface BaseOptions< onInvalidAccess?: (variable: string) => never; skipValidation?: boolean; emptyStringAsUndefined?: boolean; + createFinalConfig?: (shape: TShape, isServer: boolean) => TFinalConfig; } -export interface LooseOptions< - TShared extends ConfigShape, - TExtends extends readonly Record[], -> extends BaseOptions { - runtimeEnvStrict?: never; - runtimeEnv: RuntimeEnv; -} - -export interface StrictOptions< - TPrefix extends string | undefined, - TServer extends ConfigShape, - TClient extends ConfigShape, - TShared extends ConfigShape, - TExtends extends readonly Record[], -> extends BaseOptions { - runtimeEnvStrict: Record< - | { - [TKey in keyof TClient]: TPrefix extends undefined - ? never - : TKey extends `${TPrefix}${string}` - ? TKey - : never; - }[keyof TClient] - | { - [TKey in keyof TServer]: TPrefix extends undefined - ? TKey - : TKey extends `${TPrefix}${string}` - ? never - : TKey; - }[keyof TServer] - | { - [TKey in keyof TShared]: TKey extends string ? TKey : never; - }[keyof TShared], - RuntimeEnvValue - >; - runtimeEnv?: never; -} - -export interface ClientOptions< - TPrefix extends string | undefined, - TClient extends ConfigShape, -> { - clientPrefix: TPrefix; - client: Partial<{ - [TKey in keyof TClient]: TKey extends `${TPrefix}${string}` - ? TClient[TKey] - : ErrorMessage<`${TKey extends string ? TKey : never} is not prefixed with ${TPrefix}.`>; - }>; -} - -export interface ServerOptions< - TPrefix extends string | undefined, - TServer extends ConfigShape, -> { - server: Partial<{ - [TKey in keyof TServer]: TPrefix extends undefined - ? TServer[TKey] - : TPrefix extends "" - ? TServer[TKey] - : TKey extends `${TPrefix}${string}` - ? ErrorMessage<`${TKey extends `${TPrefix}${string}` - ? TKey - : never} should not prefixed with ${TPrefix}.`> - : TServer[TKey]; - }>; -} - -export interface CreateConfigOptions< - TServer extends ConfigShape, - TClient extends ConfigShape, - TShared extends ConfigShape, - TFinalConfig extends Config.Config>, -> { - createFinalConfig?: ( - shape: TServer & TClient & TShared, - isServer: boolean, - ) => TFinalConfig; -} - -export type ServerClientOptions< - TPrefix extends string | undefined, - TServer extends ConfigShape, - TClient extends ConfigShape, -> = - | (ClientOptions & ServerOptions) - | (ServerOptions & Impossible>) - | (ClientOptions & Impossible>); - -export type EnvOptions< - TPrefix extends string | undefined, - TServer extends ConfigShape, - TClient extends ConfigShape, - TShared extends ConfigShape, - TExtends extends readonly Record[], - TFinalConfig extends Config.Config>, -> = ( - | (LooseOptions & ServerClientOptions) - | (StrictOptions & - ServerClientOptions) -) & - CreateConfigOptions; - -type TPrefixFormat = string | undefined; -type TServerFormat = ConfigShape; -type TClientFormat = ConfigShape; -type TSharedFormat = ConfigShape; -type TExtendsFormat = readonly Record[]; - -export type DefaultCombinedConfig< - TServer extends TServerFormat, - TClient extends TClientFormat, - TShared extends TSharedFormat, -> = Config.Config>>; +export type DefaultCombinedConfig = Config.Config< + UndefinedOptional> +>; type InferEnvOutput> = Config.Config.Success extends Record @@ -297,7 +202,7 @@ type InferEnvOutput> = export type CreateEnv< TFinalConfig extends Config.Config>, - TExtends extends TExtendsFormat, + TExtends extends readonly Record[], > = Readonly, ...TExtends]>>>; export const getDefaultRuntimeEnv = (): RuntimeEnv => { @@ -341,23 +246,20 @@ const mergeExtended = (extendsEnvs: ReadonlyArray>): Rec extendsEnvs.reduce>((acc, current) => Object.assign(acc, current), {}); export function createEnv< - TPrefix extends TPrefixFormat, - const TServer extends TServerFormat = Record, - const TClient extends TClientFormat = Record, - const TShared extends TSharedFormat = Record, - const TExtends extends TExtendsFormat = [], - TFinalConfig extends Config.Config> = DefaultCombinedConfig< - TServer, - TClient, - TShared - >, + TPrefix extends string | undefined = undefined, + const TShape extends ConfigShape = Record, + const TExtends extends readonly Record[] = [], + TFinalConfig extends Config.Config> = DefaultCombinedConfig, >( - opts: EnvOptions, + shape: Partial>, + options?: CreateEnvOptions, ): CreateEnv { - const runtimeEnv = (opts.runtimeEnvStrict ?? opts.runtimeEnv ?? getDefaultRuntimeEnv()) as RuntimeEnv; + const opts = options ?? {}; + + const normalizedShape = (typeof shape === "object" ? shape : {}) as TShape; const normalizedRuntimeEnv = normalizeRuntimeEnv( - runtimeEnv, + opts.runtimeEnv ?? getDefaultRuntimeEnv(), opts.emptyStringAsUndefined ?? false, ); @@ -367,27 +269,11 @@ export function createEnv< return Object.assign(extendedEnv, normalizedRuntimeEnv) as CreateEnv; } - const client = (typeof opts.client === "object" ? opts.client : {}) as TClient; - const server = (typeof opts.server === "object" ? opts.server : {}) as TServer; - const shared = (typeof opts.shared === "object" ? opts.shared : {}) as TShared; const isServer = opts.isServer ?? (!("window" in globalThis) || "Deno" in globalThis); - const finalShape = ( - isServer - ? { - ...server, - ...shared, - ...client, - } - : { - ...client, - ...shared, - } - ) as TServer & TClient & TShared; - const finalConfig = - opts.createFinalConfig?.(finalShape, isServer) ?? - (Config.all(finalShape) as unknown as TFinalConfig); + opts.createFinalConfig?.(normalizedShape, isServer) ?? + (Config.all(normalizedShape) as unknown as TFinalConfig); const parsed = Effect.runSync( Effect.withConfigProvider(ConfigProvider.fromMap(toRuntimeMap(normalizedRuntimeEnv)))( @@ -413,15 +299,7 @@ export function createEnv< return onValidationError(issues, parsed.left); } - const isServerAccess = (prop: string) => { - if (!opts.clientPrefix) { - return true; - } - return !prop.startsWith(opts.clientPrefix) && !(prop in shared); - }; - - const isValidServerAccess = (prop: string) => isServer || !isServerAccess(prop); - + const prefix = opts.prefix; const ignoreProp = (prop: string) => prop === "__esModule" || prop === "$$typeof"; const fullEnv = Object.assign(extendedEnv, parsed.right); @@ -434,7 +312,7 @@ export function createEnv< if (ignoreProp(prop)) { return undefined; } - if (!isValidServerAccess(prop)) { + if (!isServer && prefix && prefix !== "" && !prop.startsWith(prefix)) { return onInvalidAccess(prop); } return Reflect.get(target, prop);