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..3b06593854 --- /dev/null +++ b/packages/core/env/README.md @@ -0,0 +1,52 @@ +# @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(shape, options)` for runtime env assembly with: + - an optional `prefix` for client-safe keys + - `runtimeEnv` + - `onValidationError` / `onInvalidAccess` + - `skipValidation` + - `emptyStringAsUndefined` + - `extends` + - `createFinalConfig` customization hook + +## Example + +```ts +import { createEnv, Env } from "@executor/env"; + +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), + }, + { + runtimeEnv: process.env, + extends: [shared], + }, +); +``` 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..b2cda1a1de --- /dev/null +++ b/packages/core/env/src/index.test.ts @@ -0,0 +1,305 @@ +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"; + +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)), + ); + + assertRight(parsed, { + PORT: 8080, + HOST: "0.0.0.0", + }); + + expect(AppEnv.Default).toBeDefined(); + }); +}); + +describe("createEnv", () => { + it("validates values and supports separate shared/web/server definitions", () => { + const shared = createEnv( + { + NODE_ENV: Env.literal("NODE_ENV", "development", "production", "test"), + }, + { + runtimeEnv: { + NODE_ENV: "development", + }, + }, + ); + + const web = createEnv( + { + PUBLIC_API_URL: Env.url("PUBLIC_API_URL"), + }, + { + prefix: "PUBLIC_", + runtimeEnv: { + PUBLIC_API_URL: "https://api.example.com", + }, + }, + ); + + const server = createEnv( + { + PORT: Env.number("PORT"), + }, + { + extends: [shared], + runtimeEnv: { + PORT: "3000", + }, + }, + ); + + 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( + { + PORT: Env.number("PORT"), + }, + { + runtimeEnv: { + PORT: "not-a-number", + }, + }, + ), + ).toThrow("Invalid environment variables"); + }); + + it("supports custom validation handlers", () => { + expect(() => + 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"}`); + }, + }, + ), + ).toThrow("PORT invalid:"); + }); + + it("prevents non-prefixed variable access on the client", () => { + const secret = createEnv( + { + SECRET: Env.string("SECRET"), + }, + { + runtimeEnv: { + SECRET: "top-secret", + }, + }, + ); + + const env = createEnv( + { + PUBLIC_SITE_NAME: Env.string("PUBLIC_SITE_NAME"), + }, + { + prefix: "PUBLIC_", + extends: [secret], + runtimeEnv: { + 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"); + }); + + it("supports custom invalid-access handlers", () => { + const secret = createEnv( + { + SECRET: Env.string("SECRET"), + }, + { + runtimeEnv: { + SECRET: "top-secret", + }, + }, + ); + + const env = createEnv( + { + PUBLIC_SITE_NAME: Env.string("PUBLIC_SITE_NAME"), + }, + { + 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( + { + HOST: Env.stringOr("HOST", "localhost"), + }, + { + runtimeEnv: { + HOST: "", + }, + }, + ); + + const withOption = createEnv( + { + 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( + { + 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( + { + 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( + { + 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( + { + 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 at type level", () => { + createEnv( + { + PUBLIC_SITE_NAME: Env.string("PUBLIC_SITE_NAME"), + }, + { + prefix: "PUBLIC_", + runtimeEnv: { + PUBLIC_SITE_NAME: "executor", + }, + }, + ); + + if (false) { + createEnv( + { + // @ts-expect-error Keys must include the PUBLIC_ prefix + SITE_NAME: Env.string("SITE_NAME"), + }, + { + prefix: "PUBLIC_", + runtimeEnv: {}, + }, + ); + } + + 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..af5c5f2eaf --- /dev/null +++ b/packages/core/env/src/index.ts @@ -0,0 +1,321 @@ +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 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; +}; + +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; + runtimeEnv?: RuntimeEnv; + extends?: TExtends; + onValidationError?: ( + issues: ReadonlyArray, + error: ConfigError.ConfigError, + ) => never; + onInvalidAccess?: (variable: string) => never; + skipValidation?: boolean; + emptyStringAsUndefined?: boolean; + createFinalConfig?: (shape: TShape, isServer: boolean) => TFinalConfig; +} + +export type DefaultCombinedConfig = Config.Config< + UndefinedOptional> +>; + +type InferEnvOutput> = + Config.Config.Success extends Record + ? Config.Config.Success + : never; + +export type CreateEnv< + TFinalConfig extends Config.Config>, + TExtends extends readonly Record[], +> = 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 string | undefined = undefined, + const TShape extends ConfigShape = Record, + const TExtends extends readonly Record[] = [], + TFinalConfig extends Config.Config> = DefaultCombinedConfig, +>( + shape: Partial>, + options?: CreateEnvOptions, +): CreateEnv { + const opts = options ?? {}; + + const normalizedShape = (typeof shape === "object" ? shape : {}) as TShape; + + const normalizedRuntimeEnv = normalizeRuntimeEnv( + opts.runtimeEnv ?? getDefaultRuntimeEnv(), + opts.emptyStringAsUndefined ?? false, + ); + + const extendedEnv = mergeExtended(opts.extends ?? []); + + if (opts.skipValidation) { + return Object.assign(extendedEnv, normalizedRuntimeEnv) as CreateEnv; + } + + const isServer = opts.isServer ?? (!("window" in globalThis) || "Deno" in globalThis); + + const finalConfig = + opts.createFinalConfig?.(normalizedShape, isServer) ?? + (Config.all(normalizedShape) 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 prefix = opts.prefix; + 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 (!isServer && prefix && prefix !== "" && !prop.startsWith(prefix)) { + 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"], + }, +});