Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/itchy-bags-deny.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@webiny/stdlib": patch
---

Add `Env` abstraction for typed environment variable access with `getString`, `getNumber`, and `getBoolean` families (each with bare, default, and OrThrow variants). Node implementation (`ProcessEnvFeature`) reads from `process.env`; browser implementation (`BrowserEnvFeature`) accepts an injected variables object.
288 changes: 144 additions & 144 deletions .yarn/releases/yarn-4.17.0.cjs → .yarn/releases/yarn-4.17.1.cjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .yarnrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ npmMinimalAgeGate: 3d
npmPreapprovedPackages:
- "@webiny/di"

yarnPath: .yarn/releases/yarn-4.17.0.cjs
yarnPath: .yarn/releases/yarn-4.17.1.cjs
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ The package is ESM-only and ships three subpath exports. Because each is a separ
| `uuid` | RFC 4122 v4 UUID generator (native + fallback) — [docs](src/common/utils/uuid/README.md) |
| `mdbid` | MongoDB-compatible ObjectId generator — [docs](src/common/utils/mdbid/README.md) |
| `generateId` / `generateAlphaNumericId` / `generateAlphaLowerCaseId` / ... | Nanoid-based ID generators with configurable alphabets — [docs](src/common/utils/generateId/README.md) |
| `Env` | Typed environment variable access (abstraction) — [docs](src/common/features/Env/README.md) |

---

Expand All @@ -48,6 +49,7 @@ The package is ESM-only and ships three subpath exports. Because each is a separ
| `ReadStreamFactory` / `ReadStreamFactoryFeature` | Disposable `node:fs` read streams via `AsyncDisposable` — [docs](src/node/features/ReadStreamFactory/README.md) |
| `PackageJsonFileTool` / `PackageJsonFileToolFeature` | Read, validate, mutate, and write `package.json` files — [docs](src/node/features/PackageJsonFileTool/README.md) |
| `HashFolderTool` / `HashFolderToolFeature` | Deterministic SHA-256 hash of a folder's contents — [docs](src/node/features/HashFolderTool/README.md) |
| `ProcessEnvFeature` | `Env` implementation backed by `process.env` — [docs](src/node/features/ProcessEnv/README.md) |

---

Expand All @@ -56,6 +58,7 @@ The package is ESM-only and ships three subpath exports. Because each is a separ
| Feature | Description |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `LocalStorageCacheFeature` | `Cache` implementation backed by `window.localStorage` — [docs](src/browser/features/LocalStorageCache/README.md) |
| `BrowserEnvFeature` | `Env` implementation backed by an injected variables object — [docs](src/browser/features/BrowserEnv/README.md) |

---

Expand Down
112 changes: 112 additions & 0 deletions __tests__/browser/Env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach } from "vitest";
import { Container } from "@webiny/di";
import { ConsoleLoggerConfig } from "../../src/common/features/Logger/abstractions/ConsoleLoggerConfig.js";
import { ConsoleLoggerFeature } from "../../src/common/features/Logger/feature.js";
import { Env } from "../../src/common/features/Env/index.js";
import { BrowserEnvFeature } from "../../src/browser/features/BrowserEnv/index.js";

function makeContainer(vars?: Record<string, string>): Container {
const container = new Container();
container.registerInstance(ConsoleLoggerConfig, {
getConfig: () => ({ logLevel: "error" as const })
});
ConsoleLoggerFeature.register(container);
BrowserEnvFeature.register(container, vars ? { variables: vars } : undefined);
return container;
}

describe("BrowserEnv", () => {
describe("with no variables provided", () => {
let env: Env.Interface;

beforeEach(() => {
env = makeContainer().resolve(Env);
});

it("getString should return undefined", () => {
expect(env.getString("ANY_KEY")).toBeUndefined();
});

it("getString should return default", () => {
expect(env.getString("ANY_KEY", "fallback")).toBe("fallback");
});

it("getStringOrThrow should throw", () => {
expect(() => env.getStringOrThrow("ANY_KEY")).toThrow();
});

it("getNumber should return undefined", () => {
expect(env.getNumber("ANY_KEY")).toBeUndefined();
});

it("getNumber should return default", () => {
expect(env.getNumber("ANY_KEY", 42)).toBe(42);
});

it("getNumberOrThrow should throw", () => {
expect(() => env.getNumberOrThrow("ANY_KEY")).toThrow();
});

it("getBoolean should return undefined", () => {
expect(env.getBoolean("ANY_KEY")).toBeUndefined();
});

it("getBoolean should return default", () => {
expect(env.getBoolean("ANY_KEY", false)).toBe(false);
});

it("getBooleanOrThrow should throw", () => {
expect(() => env.getBooleanOrThrow("ANY_KEY")).toThrow();
});
});

describe("with variables provided", () => {
let env: Env.Interface;

beforeEach(() => {
env = makeContainer({
APP_NAME: "myapp",
PORT: "3000",
DEBUG: "true",
INVALID_NUM: "abc"
}).resolve(Env);
});

it("getString should return the value", () => {
expect(env.getString("APP_NAME")).toBe("myapp");
});

it("getNumber should parse the value", () => {
expect(env.getNumber("PORT")).toBe(3000);
});

it("getNumber should return undefined for unparseable", () => {
expect(env.getNumber("INVALID_NUM")).toBeUndefined();
});

it("getNumber should return default for unparseable", () => {
expect(env.getNumber("INVALID_NUM", 99)).toBe(99);
});

it("getBoolean should parse the value", () => {
expect(env.getBoolean("DEBUG")).toBe(true);
});

it("getStringOrThrow should return the value", () => {
expect(env.getStringOrThrow("APP_NAME")).toBe("myapp");
});

it("getNumberOrThrow should return parsed number", () => {
expect(env.getNumberOrThrow("PORT")).toBe(3000);
});

it("getNumberOrThrow should throw for unparseable", () => {
expect(() => env.getNumberOrThrow("INVALID_NUM")).toThrow();
});

it("getBooleanOrThrow should return parsed boolean", () => {
expect(env.getBooleanOrThrow("DEBUG")).toBe(true);
});
});
});
180 changes: 180 additions & 0 deletions __tests__/node/Env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Container } from "@webiny/di";
import { PinoLoggerConfig, PinoLoggerFeature } from "../../src/node/features/PinoLogger/index.js";
import { ProcessEnvFeature } from "../../src/node/features/ProcessEnv/index.js";
import { Env } from "../../src/common/features/Env/index.js";

function makeContainer(): Container {
const container = new Container();
container.registerInstance(PinoLoggerConfig, {
getConfig: () => ({ logLevel: "error" as const, transport: "json" as const })
});
PinoLoggerFeature.register(container);
ProcessEnvFeature.register(container);
return container;
}

describe("ProcessEnv", () => {
let env: Env.Interface;
const TEST_PREFIX = "WBY_STDLIB_TEST_";

beforeEach(() => {
env = makeContainer().resolve(Env);
});

afterEach(() => {
for (const key of Object.keys(process.env)) {
if (key.startsWith(TEST_PREFIX)) {
delete process.env[key];
}
}
});

describe("getString", () => {
it("should return the value when set", () => {
process.env[`${TEST_PREFIX}STR`] = "hello";
expect(env.getString(`${TEST_PREFIX}STR`)).toBe("hello");
});

it("should return undefined when not set", () => {
expect(env.getString(`${TEST_PREFIX}MISSING`)).toBeUndefined();
});

it("should return default when not set and default provided", () => {
expect(env.getString(`${TEST_PREFIX}MISSING`, "fallback")).toBe("fallback");
});

it("should return the value over the default when set", () => {
process.env[`${TEST_PREFIX}STR`] = "actual";
expect(env.getString(`${TEST_PREFIX}STR`, "fallback")).toBe("actual");
});

it("should return empty string when set to empty", () => {
process.env[`${TEST_PREFIX}EMPTY`] = "";
expect(env.getString(`${TEST_PREFIX}EMPTY`)).toBe("");
});
});

describe("getStringOrThrow", () => {
it("should return the value when set", () => {
process.env[`${TEST_PREFIX}STR`] = "hello";
expect(env.getStringOrThrow(`${TEST_PREFIX}STR`)).toBe("hello");
});

it("should throw when not set", () => {
expect(() => env.getStringOrThrow(`${TEST_PREFIX}MISSING`)).toThrow();
});
});

describe("getNumber", () => {
it("should parse an integer", () => {
process.env[`${TEST_PREFIX}NUM`] = "42";
expect(env.getNumber(`${TEST_PREFIX}NUM`)).toBe(42);
});

it("should parse a float", () => {
process.env[`${TEST_PREFIX}NUM`] = "3.14";
expect(env.getNumber(`${TEST_PREFIX}NUM`)).toBe(3.14);
});

it("should parse a negative number", () => {
process.env[`${TEST_PREFIX}NUM`] = "-7";
expect(env.getNumber(`${TEST_PREFIX}NUM`)).toBe(-7);
});

it("should return undefined when not set", () => {
expect(env.getNumber(`${TEST_PREFIX}MISSING`)).toBeUndefined();
});

it("should return undefined for unparseable value", () => {
process.env[`${TEST_PREFIX}NUM`] = "abc";
expect(env.getNumber(`${TEST_PREFIX}NUM`)).toBeUndefined();
});

it("should return undefined for empty string", () => {
process.env[`${TEST_PREFIX}NUM`] = "";
expect(env.getNumber(`${TEST_PREFIX}NUM`)).toBeUndefined();
});

it("should return default when not set", () => {
expect(env.getNumber(`${TEST_PREFIX}MISSING`, 99)).toBe(99);
});

it("should return default for unparseable value", () => {
process.env[`${TEST_PREFIX}NUM`] = "abc";
expect(env.getNumber(`${TEST_PREFIX}NUM`, 99)).toBe(99);
});

it("should return parsed value over default", () => {
process.env[`${TEST_PREFIX}NUM`] = "42";
expect(env.getNumber(`${TEST_PREFIX}NUM`, 99)).toBe(42);
});

it("should return undefined for NaN-producing values", () => {
process.env[`${TEST_PREFIX}NUM`] = "NaN";
expect(env.getNumber(`${TEST_PREFIX}NUM`)).toBeUndefined();
});

it("should return undefined for Infinity", () => {
process.env[`${TEST_PREFIX}NUM`] = "Infinity";
expect(env.getNumber(`${TEST_PREFIX}NUM`)).toBeUndefined();
});
});

describe("getNumberOrThrow", () => {
it("should return parsed number when valid", () => {
process.env[`${TEST_PREFIX}NUM`] = "42";
expect(env.getNumberOrThrow(`${TEST_PREFIX}NUM`)).toBe(42);
});

it("should throw when not set", () => {
expect(() => env.getNumberOrThrow(`${TEST_PREFIX}MISSING`)).toThrow();
});

it("should throw for unparseable value", () => {
process.env[`${TEST_PREFIX}NUM`] = "abc";
expect(() => env.getNumberOrThrow(`${TEST_PREFIX}NUM`)).toThrow();
});
});

describe("getBoolean", () => {
it("should parse truthy values", () => {
for (const val of ["true", "t", "yes", "y", "on", "1", "TRUE", "Yes"]) {
process.env[`${TEST_PREFIX}BOOL`] = val;
expect(env.getBoolean(`${TEST_PREFIX}BOOL`)).toBe(true);
}
});

it("should parse falsy values", () => {
for (const val of ["false", "f", "no", "n", "off", "0", "FALSE", "No"]) {
process.env[`${TEST_PREFIX}BOOL`] = val;
expect(env.getBoolean(`${TEST_PREFIX}BOOL`)).toBe(false);
}
});

it("should return undefined when not set", () => {
expect(env.getBoolean(`${TEST_PREFIX}MISSING`)).toBeUndefined();
});

it("should return default when not set", () => {
expect(env.getBoolean(`${TEST_PREFIX}MISSING`, false)).toBe(false);
expect(env.getBoolean(`${TEST_PREFIX}MISSING`, true)).toBe(true);
});

it("should return parsed value over default", () => {
process.env[`${TEST_PREFIX}BOOL`] = "true";
expect(env.getBoolean(`${TEST_PREFIX}BOOL`, false)).toBe(true);
});
});

describe("getBooleanOrThrow", () => {
it("should return parsed boolean when set", () => {
process.env[`${TEST_PREFIX}BOOL`] = "true";
expect(env.getBooleanOrThrow(`${TEST_PREFIX}BOOL`)).toBe(true);
});

it("should throw when not set", () => {
expect(() => env.getBooleanOrThrow(`${TEST_PREFIX}MISSING`)).toThrow();
});
});
});
18 changes: 9 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,19 @@
"nanoid-dictionary": "^5.0.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"type-fest": "^5.7.0",
"type-fest": "^5.8.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@changesets/cli": "^2.31.0",
"@types/node": "^25.9.4",
"@typescript/native-preview": "^7.0.0-dev.20260702.3",
"@vitest/coverage-v8": "^4.1.9",
"@types/node": "^25.9.5",
"@vitest/coverage-v8": "^4.1.10",
"adio": "^3.0.1",
"happy-dom": "^20.10.6",
"oxfmt": "^0.57.0",
"oxlint": "^1.72.0",
"vitest": "^4.1.9"
"oxfmt": "^0.58.0",
"oxlint": "^1.73.0",
"typescript": "^7.0.2",
"vitest": "^4.1.10"
},
"scripts": {
"clean": "rm -rf dist",
Expand All @@ -59,8 +59,8 @@
"lint": "oxlint --deny-warnings",
"lint:fix": "oxlint --fix",
"check:imports": "adio",
"typecheck": "tsgo -p config/tsconfig.check.common.json && tsgo -p config/tsconfig.check.node.json && tsgo -p config/tsconfig.check.browser.json && tsgo -p config/tsconfig.check.scripts.json",
"typecheck": "tsc -p config/tsconfig.check.common.json && tsc -p config/tsconfig.check.node.json && tsc -p config/tsconfig.check.browser.json && tsc -p config/tsconfig.check.scripts.json",
"full": "yarn check:imports && yarn clean && yarn format:fix && yarn lint:fix && yarn typecheck && yarn build && yarn test:coverage"
},
"packageManager": "yarn@4.17.0"
"packageManager": "yarn@4.17.1"
}
2 changes: 1 addition & 1 deletion scripts/features/BuildPackages/Compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class CompilerImpl implements CompilerAbstraction.Interface {
}

public compile(packageRelDir: string): void {
execFileSync(bin("tsgo"), ["-b", "--force", packageRelDir], {
execFileSync(bin("tsc"), ["-b", "--force", packageRelDir], {
cwd: this.config.rootDir,
stdio: "inherit"
});
Expand Down
Loading
Loading