Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,8 @@
import { bench, run } from "mitata";
import { packFloat16, packFloat32, packFloat64 } from "../src/number/number.js";
bench("packFloat16", () => packFloat16(3.14));
bench("packFloat32", () => packFloat32(3.14));
bench("packFloat64", () => packFloat64(3.14));
await run();

View File

@@ -0,0 +1,12 @@
import { bench, run } from "mitata";
import { deepClone, deepMerge } from "../src/object/object.js";
const small = { a: 1, b: "hello", c: true };
const medium = Object.fromEntries(Array.from({ length: 20 }, (_, i) => [`key${i}`, { val: i }]));
bench("deepMerge small", () => deepMerge(small, { a: 2 }));
bench("deepMerge medium", () => deepMerge(medium, {}));
bench("deepClone small", () => deepClone(small));
bench("deepClone medium", () => deepClone(medium));
await run();

View File

@@ -0,0 +1,130 @@
{
"name": "@suaveplan/utils",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal utility library: debug, object, platform, number, random, string, email, sound, validate",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./debug": {
"import": {
"types": "./dist/debug/index.d.ts",
"default": "./dist/debug/index.js"
}
},
"./object": {
"import": {
"types": "./dist/object/index.d.ts",
"default": "./dist/object/index.js"
}
},
"./platform": {
"import": {
"types": "./dist/platform/index.d.ts",
"default": "./dist/platform/index.js"
}
},
"./number": {
"import": {
"types": "./dist/number/index.d.ts",
"default": "./dist/number/index.js"
}
},
"./random": {
"import": {
"types": "./dist/random/index.d.ts",
"default": "./dist/random/index.js"
}
},
"./email": {
"import": {
"types": "./dist/email/index.d.ts",
"default": "./dist/email/index.js"
}
},
"./sound": {
"import": {
"types": "./dist/sound/index.d.ts",
"default": "./dist/sound/index.js"
}
},
"./string": {
"import": {
"types": "./dist/string/index.d.ts",
"default": "./dist/string/index.js"
}
},
"./validate": {
"import": {
"types": "./dist/validate/index.d.ts",
"default": "./dist/validate/index.js"
}
},
"./validation": {
"import": {
"types": "./dist/validation/index.d.ts",
"default": "./dist/validation/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,87 @@
# debug
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `debug` module provides a lightweight, injectable debug logging utility that deduplicates repeated messages using a circular buffer. Unlike `console.log`, which emits every call unconditionally, `createDebug` tracks recently seen messages and suppresses duplicates until they age out of the buffer. This makes it practical to place debug statements in hot paths without flooding logs.
The module is deliberately free of OpenTelemetry instrumentation — it is itself an instrumentation primitive and instrumenting it would create infinite recursion.
## Features
- Circular ring buffer with configurable capacity (`bufferSize`, default 10)
- Automatic eviction of the oldest entry when buffer is full
- Keyed by `JSON.stringify` of all arguments; `Error` objects are keyed by their `.message`
- Injectable log/warn/error/info functions for testing without mocking
- Module-level `debug` singleton for drop-in use
- Supports multi-argument log calls (each unique combination is tracked)
- Zero external dependencies
## Usage: Basic Example
```ts
import { debug } from "@suaveplan/utils/debug";
// Only logs once even if called many times with the same args
debug.log("Connecting to database...");
debug.log("Connecting to database..."); // suppressed
debug.warn("Low memory");
```
## Usage: Advanced Example
```ts
import { createDebug } from "@suaveplan/utils/debug";
// Custom buffer and injectable log function for testing
const captured: unknown[][] = [];
const dbg = createDebug({
bufferSize: 3,
log: (...args) => captured.push(args),
});
dbg.log("a"); // logged
dbg.log("a"); // suppressed
dbg.log("b"); // logged
dbg.log("c"); // logged — buffer now [a, b, c]
dbg.log("d"); // logged — evicts "a"
dbg.log("a"); // logged — "a" was evicted, so fresh
console.log(captured.length); // 5
```
## API Reference
### `createDebug(options?: DebugOptions): DebugInstance`
Creates a new debug instance with an independent circular buffer.
**Parameters:**
- `options.log` — `(...args: unknown[]) => void` — custom log function (default: `console.log`)
- `options.warn` — `(...args: unknown[]) => void` — custom warn function (default: `console.warn`)
- `options.error` — `(...args: unknown[]) => void` — custom error function (default: `console.error`)
- `options.info` — `(...args: unknown[]) => void` — custom info function (default: `console.info`)
- `options.bufferSize` — `number` — maximum remembered messages before eviction (default: `10`)
**Returns:** `DebugInstance` with `.log`, `.warn`, `.error`, `.info` methods.
### `debug: DebugInstance`
Module-level singleton created with default options. Suitable for direct import and use without configuration.
### `DebugOptions`
Interface defining injectable overrides for the debug factory.
### `DebugInstance`
Interface returned by `createDebug` with `log`, `warn`, `error`, `info` methods.
## Implementation Notes
The buffer is implemented as a fixed-size ring array with a head pointer. Adding a new message writes to `head % maxSize` and advances `head`. Reading iterates backward from head across `size` entries. This gives O(n) lookup — acceptable for the default size of 10, which is always tiny. Message identity is computed via `JSON.stringify` after normalizing `Error` instances to their `.message` string, so `new Error("boom")` repeated is treated as a duplicate.
Each `createDebug` call creates an independent buffer. The module-level `debug` singleton shares no state with other instances, making instances safe to use in parallel.

View File

@@ -0,0 +1,118 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createDebug } from "./debug.js";
describe("createDebug", () => {
it("logs unique messages only", { timeout: 5000 }, () => {
const logs: unknown[][] = [];
const dbg = createDebug({ log: (...args) => logs.push(args) });
dbg.log("hello");
dbg.log("hello"); // duplicate — suppressed
dbg.log("world");
expect(logs).toHaveLength(2);
expect(logs[0]).toEqual(["hello"]);
expect(logs[1]).toEqual(["world"]);
});
it("evicts oldest entry when buffer is full", { timeout: 5000 }, () => {
const logs: unknown[][] = [];
const dbg = createDebug({
log: (...args) => logs.push(args),
bufferSize: 3,
});
dbg.log("a");
dbg.log("b");
dbg.log("c");
dbg.log("d"); // evicts "a"
dbg.log("a"); // "a" is no longer in buffer — should log
expect(logs).toHaveLength(5);
});
it("exports a default debug instance", { timeout: 5000 }, async () => {
const { debug } = await import("./debug.js");
expect(typeof debug.log).toBe("function");
});
it("uses console.log by default (no crash)", { timeout: 5000 }, () => {
const dbg = createDebug();
expect(() => dbg.log("test-default")).not.toThrow();
});
it("uses console.warn by default (no crash)", { timeout: 5000 }, () => {
const dbg = createDebug();
expect(() => dbg.warn("test-warn-default")).not.toThrow();
});
it("uses console.error by default (no crash)", { timeout: 5000 }, () => {
const dbg = createDebug();
expect(() => dbg.error("test-error-default")).not.toThrow();
});
it("uses console.info by default (no crash)", { timeout: 5000 }, () => {
const dbg = createDebug();
expect(() => dbg.info("test-info-default")).not.toThrow();
});
it("warn deduplicates independently in same buffer", {
timeout: 5000,
}, () => {
const warns: unknown[][] = [];
const dbg = createDebug({ warn: (...args) => warns.push(args) });
dbg.warn("w1");
dbg.warn("w1"); // suppressed
dbg.warn("w2");
expect(warns).toHaveLength(2);
});
it("error deduplicates independently in same buffer", {
timeout: 5000,
}, () => {
const errors: unknown[][] = [];
const dbg = createDebug({ error: (...args) => errors.push(args) });
dbg.error("e1");
dbg.error("e1"); // suppressed
expect(errors).toHaveLength(1);
});
it("info deduplicates independently in same buffer", {
timeout: 5000,
}, () => {
const infos: unknown[][] = [];
const dbg = createDebug({ info: (...args) => infos.push(args) });
dbg.info("i1");
dbg.info("i1"); // suppressed
dbg.info("i2");
expect(infos).toHaveLength(2);
});
it("handles Error objects by keying on message", { timeout: 5000 }, () => {
const logs: unknown[][] = [];
const dbg = createDebug({ log: (...args) => logs.push(args) });
const err = new Error("boom");
dbg.log(err);
dbg.log(err); // same message — suppressed
expect(logs).toHaveLength(1);
});
it("handles multi-arg log calls", { timeout: 5000 }, () => {
const logs: unknown[][] = [];
const dbg = createDebug({ log: (...args) => logs.push(args) });
dbg.log("a", "b", 1);
dbg.log("a", "b", 1); // suppressed
dbg.log("a", "b", 2); // different
expect(logs).toHaveLength(2);
});
it("bufferSize=1 only keeps the most recent message", {
timeout: 5000,
}, () => {
const logs: unknown[][] = [];
const dbg = createDebug({
log: (...args) => logs.push(args),
bufferSize: 1,
});
dbg.log("x");
dbg.log("y"); // evicts "x"
dbg.log("x"); // x is gone — logs again
expect(logs).toHaveLength(3);
});
});

View File

@@ -0,0 +1,96 @@
/**
* Debug utility with circular-buffer message deduplication.
*
* Wraps console methods and suppresses repeated messages within the buffer window.
* No OTel instrumentation — this module is itself an instrumentation primitive.
*/
type LogFn = (...args: unknown[]) => void;
export interface DebugOptions {
/** Custom log function (defaults to console.log) */
log?: LogFn;
/** Custom warn function (defaults to console.warn) */
warn?: LogFn;
/** Custom error function (defaults to console.error) */
error?: LogFn;
/** Custom info function (defaults to console.info) */
info?: LogFn;
/** Maximum number of messages to remember before evicting oldest (default: 10) */
bufferSize?: number;
}
export interface DebugInstance {
log: LogFn;
warn: LogFn;
error: LogFn;
info: LogFn;
}
class CircularBuffer {
protected readonly buffer: string[];
protected readonly maxSize: number;
protected head = 0;
protected size = 0;
constructor(maxSize: number) {
this.maxSize = maxSize;
this.buffer = new Array<string>(maxSize);
}
has(key: string): boolean {
for (let i = 0; i < this.size; i++) {
const idx =
(this.head - this.size + i + this.maxSize) % this.maxSize;
if (this.buffer[idx] === key) return true;
}
return false;
}
add(key: string): void {
const idx = this.head % this.maxSize;
this.buffer[idx] = key;
this.head = (this.head + 1) % this.maxSize;
if (this.size < this.maxSize) {
this.size++;
}
}
}
function makeKey(args: unknown[]): string {
return JSON.stringify(
args.map((a) => (a instanceof Error ? a.message : a))
);
}
function makeMethod(buf: CircularBuffer, fn: LogFn): LogFn {
return (...args: unknown[]): void => {
const key = makeKey(args);
if (!buf.has(key)) {
buf.add(key);
fn(...args);
}
};
}
/**
* Create a debug instance with independent message deduplication.
*
* @param options - Optional overrides for log functions and buffer size.
* @returns A debug instance with log, warn, error, info methods.
*/
export function createDebug(options: DebugOptions = {}): DebugInstance {
const buf = new CircularBuffer(options.bufferSize ?? 10);
return {
log: makeMethod(buf, options.log ?? ((...a) => console.log(...a))),
warn: makeMethod(buf, options.warn ?? ((...a) => console.warn(...a))),
error: makeMethod(
buf,
options.error ?? ((...a) => console.error(...a))
),
info: makeMethod(buf, options.info ?? ((...a) => console.info(...a))),
};
}
/** Module-level singleton debug instance. */
export const debug = createDebug();

View File

@@ -0,0 +1,8 @@
export type {
DebugInstance,
DebugOptions,
} from "./debug.js";
export {
createDebug,
debug,
} from "./debug.js";

View File

@@ -0,0 +1,94 @@
# email
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `email` module provides a provider-aware email normalization utility that converts raw email addresses into their canonical form. Different email providers apply different rules for aliases, dots, and sub-addressing — Gmail ignores dots and uses `+` aliasing, Yahoo uses `-` aliasing, Microsoft uses `+` aliasing. By applying provider-specific canonicalization, the module enables reliable duplicate detection, login normalization, and spam-prevention heuristics across a diverse user base.
## Features
- **Gmail / Googlemail canonicalization**: strips all dots from the local part and removes `+alias` suffixes, so `t.e.s.t+tag@gmail.com` → `test@gmail.com`.
- **Yahoo canonicalization**: strips `-alias` suffixes while preserving dots, so `user-tag@yahoo.com` → `user@yahoo.com`.
- **Microsoft (Outlook / Hotmail / Live) canonicalization**: strips `+alias` suffixes while preserving dots.
- **Generic-domain canonicalization**: strips `+alias` suffix and removes all dots from the local part for any domain not in the known-provider lists.
- **Graceful malformed-input handling**: inputs without an `@` character or with an empty local part after stripping are returned unchanged (lowercased), so the function never throws.
- **Zod schemas**: `emailSchema` and `normalizedEmailSchema` are exported for schema-driven validation pipelines.
## Usage: Basic Example
```typescript
import { normalizeEmail } from "@suaveplan/utils/email";
// Gmail: dots ignored, plus alias stripped
normalizeEmail("John.Doe+newsletter@gmail.com");
// → "johndoe@gmail.com"
// Yahoo: dash alias stripped, dots kept
normalizeEmail("jane.smith-promo@yahoo.com");
// → "jane.smith@yahoo.com"
```
## Usage: Advanced Example
```typescript
import { normalizeEmail } from "@suaveplan/utils/email";
// De-duplicate user registrations
function deduplicateUsers(emails: string[]): string[] {
const seen = new Set<string>();
const unique: string[] = [];
for (const email of emails) {
const canonical = normalizeEmail(email);
if (!seen.has(canonical)) {
seen.add(canonical);
unique.push(email);
}
}
return unique;
}
const raw = [
"alice@gmail.com",
"a.l.i.c.e@gmail.com",
"alice+spam@gmail.com",
"bob@yahoo.com",
"bob-promo@yahoo.com",
];
deduplicateUsers(raw);
// → ["alice@gmail.com", "bob@yahoo.com"]
```
## API Reference
### `normalizeEmail(email: string): string`
Normalize an email address by applying provider-specific canonicalization rules.
| Parameter | Type | Description |
|-----------|----------|--------------------------------------|
| `email` | `string` | Raw email address to normalize. |
**Returns:** `string` — the canonicalized email address, always lowercased.
**Does not throw.** Malformed inputs (no `@`, empty parts) are returned as-is lowercased.
### `emailSchema`
Zod schema (`z.string().email()`) for validating that a value is a well-formed email address.
### `normalizedEmailSchema`
Zod schema (`z.string().min(1)`) for the output of `normalizeEmail`.
## Implementation Notes
The provider lookup uses three static `const` arrays (`GOOGLE_DOMAINS`, `YAHOO_DOMAINS`, `MICROSOFT_DOMAINS`) that cover the most common hosted-email providers. `Array.includes()` is O(n) but the arrays are tiny (2–3 elements each), making the cost negligible vs. a Map lookup.
The local-part stripping uses a single `split(/[+-]/)[0]` to find the first `+` or `-` delimiter. This is intentionally conservative: it does not try to validate full RFC 5321 local-part syntax, only to strip the most common alias prefixes.
The function never modifies the input domain. Domain normalization (punycode, trailing-dot removal) is outside the scope of this utility.
Known limitation: provider lists are hardcoded. Organizations with custom Google Workspace or Microsoft 365 domains are not recognized as such; they follow the generic path. A future version could accept a custom provider-config option.

View File

@@ -0,0 +1,15 @@
import z from "zod";
/**
* Zod schema for a valid email address string.
*
* Validates that the value is a non-empty string containing exactly one `@`
* character with at least one character on each side.
*/
export const emailSchema = z.string().email();
/**
* Schema for the result of `normalizeEmail`.
* Accepts any string because malformed inputs are returned as-is.
*/
export const normalizedEmailSchema = z.string().min(1);

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { normalizeEmail } from "./email.js";
describe("normalizeEmail", () => {
it("strips dots and plus alias from Gmail", { timeout: 5000 }, () => {
expect(normalizeEmail("test.user+alias@gmail.com")).toBe(
"testuser@gmail.com"
);
});
it("strips dash alias from Yahoo", { timeout: 5000 }, () => {
expect(normalizeEmail("test.user-alias@yahoo.com")).toBe(
"test.user@yahoo.com"
);
});
it("strips plus alias from Outlook", { timeout: 5000 }, () => {
expect(normalizeEmail("user+alias@outlook.com")).toBe(
"user@outlook.com"
);
});
it("strips dots and plus for generic domains", { timeout: 5000 }, () => {
expect(normalizeEmail("t.e.s.t+x@example.com")).toBe(
"test@example.com"
);
});
it("returns input unchanged for malformed email (no @)", {
timeout: 5000,
}, () => {
expect(normalizeEmail("notanemail")).toBe("notanemail");
});
it("handles at-sign only (empty local and domain)", {
timeout: 5000,
}, () => {
// "@" produces empty local part → returns lowercased original
expect(normalizeEmail("@")).toBe("@");
});
it("handles empty local part after stripping gracefully", {
timeout: 5000,
}, () => {
expect(normalizeEmail("+alias@gmail.com")).toBe("+alias@gmail.com");
});
});

View File

@@ -0,0 +1,65 @@
/**
* Email normalization utilities.
*
* Normalizes email addresses by applying provider-specific rules to strip
* aliases (plus/dash addressing) and insignificant dots from the local part.
* This enables canonical comparison of email addresses across login attempts,
* duplicate detection, and spam-prevention heuristics.
*
* @module email
*/
const GOOGLE_DOMAINS = ["gmail.com", "googlemail.com"];
const YAHOO_DOMAINS = ["yahoo.com", "yahoo.co.uk", "yahoo.co.jp"];
const MICROSOFT_DOMAINS = ["outlook.com", "hotmail.com", "live.com"];
/**
* Normalize an email address by applying provider-specific canonicalization rules.
*
* Rules applied:
* - **Gmail / Googlemail**: strip all dots from local part; strip `+alias` suffix.
* - **Yahoo**: strip `-alias` and `+alias` suffixes; preserve dots.
* - **Microsoft (Outlook/Hotmail/Live)**: strip `+alias` suffix; preserve dots.
* - **All other domains**: strip `+alias` suffix and trailing dot before alias.
*
* If the input does not contain `@`, or if stripping would produce an empty
* local part, the lowercased original is returned unchanged.
*
* @param email - Raw email address string.
* @returns Normalized email string, lowercased.
*/
export function normalizeEmail(email: string): string {
const atIndex = email.indexOf("@");
if (atIndex === -1) {
return email;
}
const localPart = email.slice(0, atIndex).toLowerCase();
const domain = email.slice(atIndex + 1).toLowerCase();
if (!localPart || !domain) {
return email.toLowerCase();
}
// Strip alias suffix (characters after first `+` or `-`)
// split() always returns at least one element so [0] is always defined
const cleanedLocalPart = localPart.split(/[+-]/)[0]!;
if (!cleanedLocalPart) {
return email.toLowerCase();
}
if (GOOGLE_DOMAINS.includes(domain)) {
// Gmail: remove all dots, strip alias
return `${cleanedLocalPart.replace(/\./g, "")}@${domain}`;
}
if (YAHOO_DOMAINS.includes(domain) || MICROSOFT_DOMAINS.includes(domain)) {
// Yahoo/Microsoft: keep dots, strip alias
return `${cleanedLocalPart}@${domain}`;
}
// Generic: strip alias, remove trailing dot (e.g. "t.e.s.t" → "test" after removing dots)
const stripped = cleanedLocalPart.replace(/\./g, "");
return `${stripped}@${domain}`;
}

View File

@@ -0,0 +1,2 @@
export { normalizeEmail } from "./email.js";
export { emailSchema, normalizedEmailSchema } from "./email.schemas.js";

View File

@@ -0,0 +1,2 @@
export type { UtilsCode } from "./utils-error.js";
export { UTILS_CODES, UtilsError } from "./utils-error.js";

View File

@@ -0,0 +1,83 @@
# errors/utils-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `utils-error` module defines the typed error class and stable error-code registry for the entire `@suaveplan/utils` package. Every runtime error produced by the package is an instance of `UtilsError`, which extends `SuaveplanError` from `@suaveplan/error`. This gives consumers a single `instanceof` check plus a stable, machine-readable `code` string to branch on — far more reliable than parsing error messages, which may change between releases.
## Features
- **`UTILS_CODES`**: a registry of all error codes in the package, defined with `defineCodes("UTILS", [...])` from `@suaveplan/error`. Each code is prefixed `UTILS/` and namespaced by subsystem (e.g. `UTILS/OBJECT.PROTOTYPE_POLLUTION`, `UTILS/SOUND.NO_VARIATIONS`).
- **`UtilsError`**: typed subclass of `SuaveplanError<UtilsCode>`. Carries `.code`, `.message`, and optional `SuaveplanErrorOptions` (cause, context, redact). The constructor visibility is `public` to allow direct instantiation anywhere in the package.
- **`UtilsCode`**: TypeScript type alias for the union of all values in `UTILS_CODES`, enabling exhaustive `switch` statements and narrowed error handling.
## Usage: Basic Example
```typescript
import { UTILS_CODES, UtilsError } from "@suaveplan/utils/errors";
throw new UtilsError(
UTILS_CODES["VALIDATION.SIZE_EXCEEDED"],
"Payload is too large",
);
```
## Usage: Advanced Example
```typescript
import { UTILS_CODES, UtilsError, type UtilsCode } from "@suaveplan/utils/errors";
function handleError(err: unknown): void {
if (!(err instanceof UtilsError)) throw err;
const code: UtilsCode = err.code;
switch (code) {
case UTILS_CODES["VALIDATION.SIZE_EXCEEDED"]:
console.error("Payload too large:", err.message);
break;
case UTILS_CODES["SOUND.NO_VARIATIONS"]:
console.warn("Audio not found:", err.message);
break;
default:
throw err; // re-throw unexpected utils errors
}
}
```
## API Reference
### `UTILS_CODES`
Object mapping dot-path keys to fully-qualified error code strings.
| Key | Code |
|-----|------|
| `"OBJECT.PROTOTYPE_POLLUTION"` | `"UTILS/OBJECT.PROTOTYPE_POLLUTION"` |
| `"OBJECT.CIRCULAR"` | `"UTILS/OBJECT.CIRCULAR"` |
| `"NUMBER.OUT_OF_RANGE"` | `"UTILS/NUMBER.OUT_OF_RANGE"` |
| `"RANDOM.EMPTY_ARRAY"` | `"UTILS/RANDOM.EMPTY_ARRAY"` |
| `"RANDOM.INDEX_OUT_OF_BOUNDS"` | `"UTILS/RANDOM.INDEX_OUT_OF_BOUNDS"` |
| `"VALIDATION.SIZE_EXCEEDED"` | `"UTILS/VALIDATION.SIZE_EXCEEDED"` |
| `"VALIDATION.PARSE_FAILED"` | `"UTILS/VALIDATION.PARSE_FAILED"` |
| `"PLATFORM.UNAVAILABLE"` | `"UTILS/PLATFORM.UNAVAILABLE"` |
| `"SOUND.NO_VARIATIONS"` | `"UTILS/SOUND.NO_VARIATIONS"` |
### `class UtilsError extends SuaveplanError<UtilsCode>`
| Parameter | Type | Description |
|-----------|------|-------------|
| `code` | `UtilsCode` | Stable error code from `UTILS_CODES`. |
| `message` | `string` | Human-readable description. |
| `options` | `SuaveplanErrorOptions?` | Optional cause, context, redact fields. |
### `type UtilsCode`
Union type of all string values in `UTILS_CODES`.
## Implementation Notes
`defineCodes` is a helper from `@suaveplan/error` that prefixes each code with the registry namespace (`"UTILS"`) and freezes the result. This ensures codes are immutable at runtime and catch typos at TypeScript compile time through the `as const` assertion on the code array.
The `public constructor` visibility is explicit (and annotated with a biome-ignore comment) to satisfy the linter's `noUselessConstructor` rule while documenting the intentional widening from the `protected` default inherited from `SuaveplanError`.

View File

@@ -0,0 +1,34 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Error code registry for `@suaveplan/utils`.
*/
export const UTILS_CODES = defineCodes("UTILS", [
"OBJECT.PROTOTYPE_POLLUTION",
"OBJECT.CIRCULAR",
"NUMBER.OUT_OF_RANGE",
"RANDOM.EMPTY_ARRAY",
"RANDOM.INDEX_OUT_OF_BOUNDS",
"VALIDATION.SIZE_EXCEEDED",
"VALIDATION.PARSE_FAILED",
"PLATFORM.UNAVAILABLE",
"SOUND.NO_VARIATIONS",
] as const);
export type UtilsCode = (typeof UTILS_CODES)[keyof typeof UTILS_CODES];
/** Typed error for `@suaveplan/utils`. */
export class UtilsError extends SuaveplanError<UtilsCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens visibility
public constructor(
code: UtilsCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
}
}

View File

@@ -0,0 +1,9 @@
// Top-level barrel — re-exports all public surfaces
export * from "./debug/index.js";
export * from "./errors/index.js";
export * from "./number/index.js";
export * from "./object/index.js";
export * from "./platform/index.js";
export * from "./random/index.js";
export * from "./types/index.js";

View File

@@ -0,0 +1,17 @@
export {
isInFloat16Range,
isInFloat32Range,
isInFloat64Range,
max16,
max32,
max64,
min16,
min32,
min64,
packFloat16,
packFloat32,
packFloat64,
unpackFloat16,
unpackFloat32,
unpackFloat64,
} from "./number.js";

View File

@@ -0,0 +1,134 @@
# number
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `number` module provides IEEE-754 float encoding and decoding utilities for Float16, Float32, and Float64 formats. JavaScript's native `number` type is always Float64, but many binary protocols, graphics APIs, and machine-learning frameworks use the more compact Float16 or Float32 representations. This module allows lossless round-tripping between JavaScript numbers and their binary wire format using `DataView`-based bit manipulation.
All pack functions emit an OTel counter increment so throughput can be observed in production without adding latency to the hot path.
## Features
- `packFloat16` / `unpackFloat16` — 2-byte IEEE-754 half-precision encoding/decoding
- `packFloat32` / `unpackFloat32` — 4-byte IEEE-754 single-precision using `DataView.setFloat32`
- `packFloat64` / `unpackFloat64` — 8-byte IEEE-754 double-precision using `DataView.setFloat64`
- Correct handling of `NaN`, `+Infinity`, `-Infinity`, `+0`, and `-0`
- Subnormal Float16 support (values below `2^-14`)
- Range guards: `isInFloat16Range`, `isInFloat32Range`, `isInFloat64Range`
- Constants: `min16`, `max16`, `min32`, `max32`, `min64`, `max64`
- OTel counters for pack call throughput
## Usage: Basic Example
```ts
import {
packFloat16,
unpackFloat16,
packFloat32,
unpackFloat32,
packFloat64,
unpackFloat64,
} from "@suaveplan/utils/number";
// Float16 round-trip
const bytes16 = packFloat16(1.5); // Uint8Array(2)
const back16 = unpackFloat16(bytes16); // ~1.5
// Float32 round-trip
const bytes32 = packFloat32(3.14);
const back32 = unpackFloat32(bytes32); // ~3.14
// Float64 round-trip (lossless)
const bytes64 = packFloat64(Math.PI);
const back64 = unpackFloat64(bytes64); // Math.PI exactly
```
## Usage: Advanced Example
```ts
import {
packFloat16,
unpackFloat16,
isInFloat16Range,
isInFloat32Range,
max16,
max32,
} from "@suaveplan/utils/number";
// Check range before packing
function safePackFloat16(value: number): Uint8Array {
if (!isInFloat16Range(value)) {
throw new RangeError(`${value} is out of Float16 range`);
}
return packFloat16(value);
}
// Handle special values
console.log(unpackFloat16(packFloat16(Infinity))); // Infinity
console.log(unpackFloat16(packFloat16(-0))); // -0
console.log(Number.isNaN(unpackFloat16(packFloat16(NaN)))); // true
// max values
console.log(max16); // 65504
console.log(max32); // ~3.4028235e+38
// Range guards
console.log(isInFloat16Range(65504)); // true
console.log(isInFloat16Range(70000)); // false
console.log(isInFloat32Range(Number.MAX_VALUE)); // false
```
## API Reference
### `packFloat16(value: number): Uint8Array`
Encodes a number as a 2-byte Float16. Handles NaN, ±Infinity, subnormals. Increments OTel counter.
### `unpackFloat16(buffer: Uint8Array): number`
Decodes a 2-byte Uint8Array to a JavaScript number. Throws if buffer.length !== 2.
### `packFloat32(value: number): Uint8Array`
Encodes a number as a 4-byte Float32 using `DataView.setFloat32`. Increments OTel counter.
### `unpackFloat32(buffer: Uint8Array): number`
Decodes a 4-byte Uint8Array to a JavaScript number using `DataView.getFloat32`.
### `packFloat64(value: number): Uint8Array`
Encodes a number as an 8-byte Float64 using `DataView.setFloat64`. Lossless. Increments OTel counter.
### `unpackFloat64(buffer: Uint8Array): number`
Decodes an 8-byte Uint8Array to a JavaScript number using `DataView.getFloat64`.
### `isInFloat16Range(value: number): boolean`
Returns true when `value` is zero, ±Infinity, NaN, or within `[min16, max16]`.
### `isInFloat32Range(value: number): boolean`
Returns true when `value` is zero, ±Infinity, NaN, or within `[min32, max32]`.
### `isInFloat64Range(value: number): boolean`
Returns true when `value` is finite. Infinity and NaN return false.
### Constants
- `min16` — `2^-14` (~6.1e-5), minimum positive normal Float16
- `max16` — `65504`, maximum finite Float16
- `min32` — `2^-126`, minimum positive normal Float32
- `max32` — `(2 - 2^-23) * 2^127`, maximum finite Float32
- `min64` — `Number.MIN_VALUE`, minimum positive subnormal Float64
- `max64` — `Number.MAX_VALUE`, maximum finite Float64
## Implementation Notes
Float16 is not natively supported by `DataView`, so `packFloat16` and `unpackFloat16` implement the IEEE-754 half-precision format manually using bit shifting. The sign bit is bit 15, exponent is bits 14–10 (5 bits, biased by 15), and mantissa is bits 9–0 (10 bits). Subnormal numbers (exponent field = 0) are handled explicitly.
Float32 and Float64 use `DataView.setFloat32`/`setFloat64` which delegate to the CPU's native IEEE-754 implementation and are essentially free. The `unpack` functions construct a `DataView` using the buffer's `byteOffset` and `byteLength` to correctly handle sliced `Uint8Array` views.

View File

@@ -0,0 +1,15 @@
import z from "zod";
/** Schema for a valid Float16 value (finite or ±Infinity or NaN represented as number). */
export const Float16Schema = z.number();
/** Schema for a valid Float32 value. */
export const Float32Schema = z.number();
/** Schema for a Float encoding format identifier. */
export const FloatFormatSchema = z.enum(["float16", "float32", "float64"]);
/** Schema for pack/unpack options. */
export const FloatPackOptionsSchema = z.object({
format: FloatFormatSchema.default("float64"),
});

View File

@@ -0,0 +1,224 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import {
isInFloat16Range,
isInFloat32Range,
isInFloat64Range,
max16,
max32,
max64,
min16,
min32,
min64,
packFloat16,
packFloat32,
packFloat64,
unpackFloat16,
unpackFloat32,
unpackFloat64,
} from "./number.js";
describe("constants", () => {
it("min16 is 2^-14", { timeout: 5000 }, () => {
expect(min16).toBe(2 ** -14);
});
it("max16 is 65504", { timeout: 5000 }, () => {
expect(max16).toBe(65504);
});
it("min32 is 2^-126", { timeout: 5000 }, () => {
expect(min32).toBe(2 ** -126);
});
it("max32 is the max 32-bit float", { timeout: 5000 }, () => {
expect(max32).toBeCloseTo((2 - 2 ** -23) * 2 ** 127, 0);
});
it("min64 is Number.MIN_VALUE", { timeout: 5000 }, () => {
expect(min64).toBe(Number.MIN_VALUE);
});
it("max64 is Number.MAX_VALUE", { timeout: 5000 }, () => {
expect(max64).toBe(Number.MAX_VALUE);
});
});
describe("Float16 round-trip", () => {
it("packs and unpacks 0, 1, -1, 0.5, min16, max16", {
timeout: 5000,
}, () => {
for (const v of [0, 1, -1, 0.5, min16, max16]) {
expect(unpackFloat16(packFloat16(v))).toBeCloseTo(v, 3);
}
});
it("handles NaN, Infinity, -Infinity", { timeout: 5000 }, () => {
expect(unpackFloat16(packFloat16(Number.NaN))).toBeNaN();
expect(unpackFloat16(packFloat16(Number.POSITIVE_INFINITY))).toBe(
Number.POSITIVE_INFINITY
);
expect(unpackFloat16(packFloat16(Number.NEGATIVE_INFINITY))).toBe(
Number.NEGATIVE_INFINITY
);
});
it("handles negative zero", { timeout: 5000 }, () => {
const packed = packFloat16(-0);
expect(Object.is(unpackFloat16(packed), -0)).toBe(true);
});
it("handles subnormal values (< min16)", { timeout: 5000 }, () => {
const sub = min16 / 4; // subnormal in float16
const result = unpackFloat16(packFloat16(sub));
expect(result).toBeGreaterThanOrEqual(0);
});
it("unpackFloat16 returns subnormal positive when exp=0 and mant>0", {
timeout: 5000,
}, () => {
// Manually craft a subnormal float16: sign=0, exp=0, mant=512 => 0x02,0x00
const buf = new Uint8Array([0x02, 0x00]);
const result = unpackFloat16(buf);
// Should be: (1) * 2^-14 * (512/1024) = 2^-15
expect(result).toBeCloseTo(2 ** -15, 10);
});
it("unpackFloat16 returns subnormal negative when sign=1 exp=0 mant>0", {
timeout: 5000,
}, () => {
// sign=1, exp=0, mant=512 => 0x82,0x00
const buf = new Uint8Array([0x82, 0x00]);
const result = unpackFloat16(buf);
expect(result).toBeCloseTo(-(2 ** -15), 10);
});
it("values beyond max16 become Infinity", { timeout: 5000 }, () => {
const result = unpackFloat16(packFloat16(max16 * 2));
expect(result).toBe(Number.POSITIVE_INFINITY);
});
it("negative large values become -Infinity", { timeout: 5000 }, () => {
const result = unpackFloat16(packFloat16(-max16 * 2));
expect(result).toBe(Number.NEGATIVE_INFINITY);
});
it("throws for invalid buffer length", { timeout: 5000 }, () => {
expect(() => unpackFloat16(new Uint8Array([0x00]))).toThrow();
});
});
describe("Float32/64 round-trip", () => {
it("packFloat32/unpackFloat32 is lossless within float32 range", {
timeout: 5000,
}, () => {
const v = 3.14;
expect(unpackFloat32(packFloat32(v))).toBeCloseTo(v, 5);
});
it("packFloat64/unpackFloat64 is lossless", { timeout: 5000 }, () => {
const v = Math.PI;
expect(unpackFloat64(packFloat64(v))).toBe(v);
});
it("packFloat32 handles NaN and Infinities", { timeout: 5000 }, () => {
expect(unpackFloat32(packFloat32(Number.NaN))).toBeNaN();
expect(unpackFloat32(packFloat32(Number.POSITIVE_INFINITY))).toBe(
Number.POSITIVE_INFINITY
);
expect(unpackFloat32(packFloat32(Number.NEGATIVE_INFINITY))).toBe(
Number.NEGATIVE_INFINITY
);
});
it("packFloat64 handles NaN and Infinities", { timeout: 5000 }, () => {
expect(unpackFloat64(packFloat64(Number.NaN))).toBeNaN();
expect(unpackFloat64(packFloat64(Number.POSITIVE_INFINITY))).toBe(
Number.POSITIVE_INFINITY
);
expect(unpackFloat64(packFloat64(Number.NEGATIVE_INFINITY))).toBe(
Number.NEGATIVE_INFINITY
);
});
it("unpackFloat32 works on non-zero offset Uint8Array", {
timeout: 5000,
}, () => {
const buf = packFloat32(1.5);
// Verify round-trip with a fresh Uint8Array view
const copy = new Uint8Array(buf.buffer.slice(0));
expect(unpackFloat32(copy)).toBeCloseTo(1.5, 5);
});
});
describe("range guards", () => {
it("isInFloat16Range returns false for out-of-range values", {
timeout: 5000,
}, () => {
expect(isInFloat16Range(max32)).toBe(false);
});
it("isInFloat16Range returns true for values in range", {
timeout: 5000,
}, () => {
expect(isInFloat16Range(1.0)).toBe(true);
expect(isInFloat16Range(0)).toBe(true);
});
it("isInFloat16Range returns true for Infinity and NaN", {
timeout: 5000,
}, () => {
expect(isInFloat16Range(Number.POSITIVE_INFINITY)).toBe(true);
expect(isInFloat16Range(Number.NEGATIVE_INFINITY)).toBe(true);
expect(isInFloat16Range(Number.NaN)).toBe(true);
});
it("isInFloat32Range returns false for out-of-range values", {
timeout: 5000,
}, () => {
expect(isInFloat32Range(Number.MAX_VALUE)).toBe(false);
});
it("isInFloat32Range returns true for values in range", {
timeout: 5000,
}, () => {
expect(isInFloat32Range(3.14)).toBe(true);
expect(isInFloat32Range(0)).toBe(true);
});
it("isInFloat32Range returns true for Infinity and NaN", {
timeout: 5000,
}, () => {
expect(isInFloat32Range(Number.POSITIVE_INFINITY)).toBe(true);
expect(isInFloat32Range(Number.NEGATIVE_INFINITY)).toBe(true);
});
it("isInFloat64Range returns true for finite numbers", {
timeout: 5000,
}, () => {
expect(isInFloat64Range(Math.PI)).toBe(true);
expect(isInFloat64Range(Number.MAX_VALUE)).toBe(true);
expect(isInFloat64Range(Number.MIN_VALUE)).toBe(true);
});
it("isInFloat64Range returns false for Infinity and NaN", {
timeout: 5000,
}, () => {
expect(isInFloat64Range(Number.POSITIVE_INFINITY)).toBe(false);
expect(isInFloat64Range(Number.NEGATIVE_INFINITY)).toBe(false);
expect(isInFloat64Range(Number.NaN)).toBe(false);
});
});
describe("property tests", () => {
it("packFloat32 round-trips in-range values", { timeout: 30000 }, () => {
fc.assert(
fc.property(
fc.float({ noNaN: true, noDefaultInfinity: true }),
(v) => {
expect(unpackFloat32(packFloat32(v))).toBeCloseTo(v, 4);
}
)
);
});
});

View File

@@ -0,0 +1,169 @@
import { metrics } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/utils/number";
function getCounter(name: string) {
return metrics.getMeter(METER_NAME).createCounter(name);
}
const packF16Counter = getCounter("utils.number.packFloat16.count");
const packF32Counter = getCounter("utils.number.packFloat32.count");
const packF64Counter = getCounter("utils.number.packFloat64.count");
// ── Constants ──────────────────────────────────────────────────────────────
/** Minimum positive normal Float16 value. */
export const min16 = 2 ** -14;
/** Maximum finite Float16 value (65504). */
export const max16 = 65504;
/** Minimum positive normal Float32 value. */
export const min32 = 2 ** -126;
/** Maximum finite Float32 value. */
export const max32 = (2 - 2 ** -23) * 2 ** 127;
/** Minimum positive Float64 value (Number.MIN_VALUE). */
export const min64 = Number.MIN_VALUE;
/** Maximum finite Float64 value (Number.MAX_VALUE). */
export const max64 = Number.MAX_VALUE;
// ── Float16 ────────────────────────────────────────────────────────────────
/**
* Pack a JavaScript number into a 2-byte Float16 Uint8Array.
*/
export function packFloat16(value: number): Uint8Array {
packF16Counter.add(1);
if (Number.isNaN(value)) return new Uint8Array([0x7e, 0x00]);
if (!Number.isFinite(value)) {
return new Uint8Array(value > 0 ? [0x7c, 0x00] : [0xfc, 0x00]);
}
if (value === 0) {
return new Uint8Array(1 / value < 0 ? [0x80, 0x00] : [0x00, 0x00]);
}
const sign = value < 0 ? 1 : 0;
const abs = Math.abs(value);
// Subnormal
if (abs < 2 ** -14) {
const mant = Math.round((abs / 2 ** -24) * 1024);
const half = (sign << 15) | mant;
return new Uint8Array([half >> 8, half & 0xff]);
}
let exp = Math.floor(Math.log2(abs));
const mant = Math.round((abs / 2 ** exp - 1) * 1024);
exp += 15;
if (exp > 30) {
return new Uint8Array(sign ? [0xfc, 0x00] : [0x7c, 0x00]);
}
const half = (sign << 15) | (exp << 10) | mant;
return new Uint8Array([half >> 8, half & 0xff]);
}
/**
* Unpack a 2-byte Uint8Array back to a JavaScript number from Float16.
*/
export function unpackFloat16(buffer: Uint8Array): number {
if (buffer.length !== 2)
throw new Error("Invalid buffer length for Float16");
const byte0 = buffer[0]!;
const byte1 = buffer[1]!;
const half = (byte0 << 8) | byte1;
const sign = (half >> 15) & 1;
const exp = (half >> 10) & 0x1f;
const mant = half & 0x3ff;
if (exp === 0) {
if (mant === 0) return sign ? -0 : 0;
return (sign ? -1 : 1) * 2 ** -14 * (mant / 1024);
}
if (exp === 31) {
return mant
? Number.NaN
: sign
? Number.NEGATIVE_INFINITY
: Number.POSITIVE_INFINITY;
}
return (sign ? -1 : 1) * 2 ** (exp - 15) * (1 + mant / 1024);
}
// ── Float32 ────────────────────────────────────────────────────────────────
/**
* Pack a JavaScript number into a 4-byte Float32 Uint8Array.
*/
export function packFloat32(value: number): Uint8Array {
packF32Counter.add(1);
const buf = new ArrayBuffer(4);
new DataView(buf).setFloat32(0, value);
return new Uint8Array(buf);
}
/**
* Unpack a 4-byte Uint8Array to a JavaScript number from Float32.
*/
export function unpackFloat32(buffer: Uint8Array): number {
return new DataView(
buffer.buffer,
buffer.byteOffset,
buffer.byteLength
).getFloat32(0);
}
// ── Float64 ────────────────────────────────────────────────────────────────
/**
* Pack a JavaScript number into an 8-byte Float64 Uint8Array.
*/
export function packFloat64(value: number): Uint8Array {
packF64Counter.add(1);
const buf = new ArrayBuffer(8);
new DataView(buf).setFloat64(0, value);
return new Uint8Array(buf);
}
/**
* Unpack an 8-byte Uint8Array to a JavaScript number from Float64.
*/
export function unpackFloat64(buffer: Uint8Array): number {
return new DataView(
buffer.buffer,
buffer.byteOffset,
buffer.byteLength
).getFloat64(0);
}
// ── Range guards ───────────────────────────────────────────────────────────
/**
* Returns true if the value is representable as a Float16 (including ±∞ and NaN).
*/
export function isInFloat16Range(value: number): boolean {
if (!Number.isFinite(value)) return true; // ±Inf and NaN are valid Float16
const abs = Math.abs(value);
return abs === 0 || (abs >= min16 && abs <= max16);
}
/**
* Returns true if the value is representable as a Float32.
*/
export function isInFloat32Range(value: number): boolean {
const abs = Math.abs(value);
return (
abs === 0 || !Number.isFinite(value) || (abs >= min32 && abs <= max32)
);
}
/**
* Returns true if the value is a finite Float64 number.
* All finite JavaScript numbers are Float64; Infinity and NaN are not.
*/
export function isInFloat64Range(value: number): boolean {
return Number.isFinite(value);
}

View File

@@ -0,0 +1,17 @@
export {
deepClone,
deepMerge,
deepMergeAll,
flattenObject,
getNestedValue,
hasPath,
isEmpty,
isObject,
isObjectOrArray,
isPlainObject,
keepKeys,
omit,
pick,
removeKeys,
setNestedValue,
} from "./object.js";

View File

@@ -0,0 +1,145 @@
# object
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `object` module provides a comprehensive set of object manipulation utilities for working with plain objects in TypeScript. It covers deep cloning, recursive merging, key projection (pick/omit), nested path access, and structural flattening. All operations are non-mutating by default and use precise generics to preserve type information without requiring `any` casts.
The module integrates OpenTelemetry spans on the most expensive operations (`deepClone`, `deepMerge`) to enable tracing in production. Prototype pollution attempts (keys like `__proto__`, `constructor`, `prototype`) are caught and converted to typed `UtilsError` instances.
## Features
- `isObject` — checks for non-null, non-array objects
- `isPlainObject` — excludes Date, RegExp, Map, Set, etc.
- `isObjectOrArray` — `typeof value === "object" && value !== null`
- `isEmpty` — true when object has no own enumerable keys
- `deepClone` — recursive clone with Date/RegExp/Map/Set/Array support; circular ref detection
- `deepMerge` — recursive merge with `skipUndefined` option; prototype pollution guard
- `deepMergeAll` — variadic merge, later args win
- `pick` / `keepKeys` — project subset of keys
- `omit` / `removeKeys` — exclude subset of keys
- `getNestedValue` — dot-notation path read with optional default
- `setNestedValue` — dot-notation path write; creates intermediate objects
- `hasPath` — tests existence of a dot-notation path
- `flattenObject` — recursive flattening to dot-notation; configurable separator and prefix
## Usage: Basic Example
```ts
import { deepMerge, pick, omit, flattenObject } from "@suaveplan/utils/object";
const base = { a: 1, b: { x: 10, y: 20 } };
const patch = { b: { x: 99 } };
const merged = deepMerge(base, patch);
// => { a: 1, b: { x: 99, y: 20 } }
const subset = pick(base, ["a"]);
// => { a: 1 }
const flat = flattenObject({ a: { b: { c: 42 } } });
// => { "a.b.c": 42 }
```
## Usage: Advanced Example
```ts
import {
deepClone,
deepMergeAll,
getNestedValue,
setNestedValue,
hasPath,
} from "@suaveplan/utils/object";
// Deep clone with circular-reference detection
const original = { data: new Map([["key", new Date()]]) };
const clone = deepClone(original);
// clone.data is an independent Map with a new Date instance
// Variadic merge — later args win, undefined from source is skipped
const config = deepMergeAll(
{ timeout: 5000, retries: 3, debug: false },
{ retries: 5 },
{ debug: true }
);
// => { timeout: 5000, retries: 5, debug: true }
// Nested path operations
const obj: Record<string, unknown> = {};
setNestedValue(obj, "server.port", 8080);
console.log(getNestedValue(obj, "server.port")); // 8080
console.log(hasPath(obj, "server.port")); // true
console.log(hasPath(obj, "server.host")); // false
```
## API Reference
### `isObject(value: unknown): value is Record<string, unknown>`
Returns true for non-null, non-array objects.
### `isPlainObject(value: unknown): value is Record<string, unknown>`
Returns true only for objects whose prototype is `null` or `Object.prototype`.
### `isObjectOrArray(value: unknown): value is Record<string, unknown> | unknown[]`
Returns true for any non-null object (including arrays).
### `isEmpty(obj: Record<string, unknown>): boolean`
Returns true if the object has no own enumerable keys.
### `deepClone<T>(obj: T): T`
Recursively clones an object. Handles `Date`, `RegExp`, `Map`, `Set`, arrays, and plain objects. Throws `UtilsError(OBJECT.CIRCULAR)` on circular references. Instruments a span via OTel.
### `deepMerge<T>(target: T, source: Partial<T>, skipUndefined?: boolean): T`
Merges `source` into `target` recursively. Arrays are replaced (not merged). Throws `UtilsError(OBJECT.PROTOTYPE_POLLUTION)` on dangerous keys. Instruments a span via OTel.
### `deepMergeAll<T>(...objects: Partial<T>[]): T`
Variadic merge. Later arguments take precedence. Skips undefined source values.
### `pick<T, K>(obj: T, keys: K[]): Pick<T, K>`
Returns a new object with only the specified keys.
### `keepKeys<T, K>(obj: T, keys: K[]): Pick<T, K>`
Alias for `pick`.
### `omit<T, K>(obj: T, keys: K[]): Omit<T, K>`
Returns a new object without the specified keys.
### `removeKeys<T, K>(obj: T, keys: K[]): Omit<T, K>`
Alias for `omit`.
### `getNestedValue<T>(obj, path, defaultValue?): T | undefined`
Reads a value at a dot-notation path. Returns `defaultValue` (or `undefined`) when the path is missing.
### `setNestedValue(obj, path, value): void`
Writes a value at a dot-notation path. Creates intermediate plain objects as needed.
### `hasPath(obj, path): boolean`
Returns true if every segment of the dot-notation path exists and is accessible.
### `flattenObject(obj, prefix?, separator?): Record<string, unknown>`
Recursively flattens nested plain objects to a single level. Default separator is `"."`.
## Implementation Notes
`deepClone` passes a `WeakSet` through the recursion to track already-seen objects; this adds minimal overhead while correctly detecting cycles rather than infinite-looping. The OTel span wraps the entire public-facing call, not the internal recursion, to avoid noisy child spans.
`deepMerge` uses object spread for a shallow copy then selectively overwrites keys. The prototype pollution guard runs on every `in source` key, not just the changed ones. The `skipUndefined` flag is useful for config layering where you want later layers to express "leave this field as-is" by passing `undefined`.
All generic functions use `Record<string, unknown>` (not `any`) for the outer constraint. Key extraction via `for...in` + `Object.hasOwn` avoids inherited property iteration.

View File

@@ -0,0 +1,18 @@
import z from "zod";
/** Schema for a flat object with string keys and unknown values. */
export const PlainObjectSchema = z.record(z.string(), z.unknown());
/** Schema for deep merge options. */
export const DeepMergeOptionsSchema = z.object({
skipUndefined: z.boolean().optional().default(false),
});
/** Schema for a dot-notation path string. */
export const DotPathSchema = z
.string()
.min(1, "Path cannot be empty")
.regex(
/^[^.]+(\.[^.]+)*$/,
"Path must be dot-separated non-empty segments"
);

View File

@@ -0,0 +1,324 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import {
deepClone,
deepMerge,
deepMergeAll,
flattenObject,
getNestedValue,
hasPath,
isEmpty,
isObject,
isObjectOrArray,
isPlainObject,
keepKeys,
omit,
pick,
removeKeys,
setNestedValue,
} from "./object.js";
describe("isObject", () => {
it("returns true for plain objects", { timeout: 5000 }, () => {
expect(isObject({})).toBe(true);
expect(isObject({ a: 1 })).toBe(true);
});
it("returns false for null, arrays, primitives", { timeout: 5000 }, () => {
expect(isObject(null)).toBe(false);
expect(isObject([])).toBe(false);
expect(isObject(42)).toBe(false);
expect(isObject("str")).toBe(false);
expect(isObject(undefined)).toBe(false);
});
it("returns true for Date and RegExp (non-array objects)", {
timeout: 5000,
}, () => {
expect(isObject(new Date())).toBe(true);
expect(isObject(/re/)).toBe(true);
});
});
describe("isPlainObject", () => {
it("returns true for plain objects", { timeout: 5000 }, () => {
expect(isPlainObject({})).toBe(true);
expect(isPlainObject(Object.create(null))).toBe(true);
});
it("returns false for non-plain objects", { timeout: 5000 }, () => {
expect(isPlainObject(new Date())).toBe(false);
expect(isPlainObject([])).toBe(false);
expect(isPlainObject(null)).toBe(false);
expect(isPlainObject(42)).toBe(false);
});
});
describe("isObjectOrArray", () => {
it("returns true for objects and arrays", { timeout: 5000 }, () => {
expect(isObjectOrArray({})).toBe(true);
expect(isObjectOrArray([])).toBe(true);
expect(isObjectOrArray(new Map())).toBe(true);
});
it("returns false for null and primitives", { timeout: 5000 }, () => {
expect(isObjectOrArray(null)).toBe(false);
expect(isObjectOrArray("str")).toBe(false);
expect(isObjectOrArray(42)).toBe(false);
});
});
describe("isEmpty", () => {
it("returns true for empty objects", { timeout: 5000 }, () => {
expect(isEmpty({})).toBe(true);
});
it("returns false for non-empty objects", { timeout: 5000 }, () => {
expect(isEmpty({ a: 1 })).toBe(false);
});
});
describe("deepMerge", () => {
it("merges nested objects", { timeout: 5000 }, () => {
const a = { x: { y: 1, z: 2 } };
const b = { x: { y: 99 } };
expect(deepMerge(a, b)).toEqual({ x: { y: 99, z: 2 } });
});
it("does not mutate inputs", { timeout: 5000 }, () => {
const a = { x: 1 };
const b = { y: 2 };
deepMerge(a as Record<string, unknown>, b as Record<string, unknown>);
expect(a).toEqual({ x: 1 });
});
it("replaces arrays (does not merge them)", { timeout: 5000 }, () => {
const a = { arr: [1, 2, 3] };
const b = { arr: [4, 5] };
expect(deepMerge(a, b)).toEqual({ arr: [4, 5] });
});
it("skipUndefined does not overwrite with undefined", {
timeout: 5000,
}, () => {
const a = { x: 1, y: 2 };
const b = { x: undefined };
expect(deepMerge(a, b as Partial<typeof a>, true)).toEqual({
x: 1,
y: 2,
});
});
it("overwrites with undefined when skipUndefined=false (default)", {
timeout: 5000,
}, () => {
const a = { x: 1 };
const b: Record<string, unknown> = { x: undefined };
const result = deepMerge(a as Record<string, unknown>, b);
expect(result).toHaveProperty("x");
expect(result.x).toBeUndefined();
});
it("throws on __proto__ key", { timeout: 5000 }, () => {
const evil = JSON.parse('{"__proto__": {"polluted": true}}') as Record<
string,
unknown
>;
expect(() => deepMerge({} as Record<string, unknown>, evil)).toThrow(
/prototype pollution/i
);
});
});
describe("deepMergeAll", () => {
it("merges multiple objects in priority order", { timeout: 5000 }, () => {
const result = deepMergeAll({ a: 1 }, { b: 2 }, { a: 99 });
expect(result).toEqual({ a: 99, b: 2 });
});
it("handles empty call", { timeout: 5000 }, () => {
expect(deepMergeAll()).toEqual({});
});
});
describe("pick / omit", () => {
it("pick returns only specified keys", { timeout: 5000 }, () => {
expect(pick({ a: 1, b: 2, c: 3 }, ["a", "c"])).toEqual({ a: 1, c: 3 });
});
it("omit removes specified keys", { timeout: 5000 }, () => {
expect(omit({ a: 1, b: 2, c: 3 }, ["b"])).toEqual({ a: 1, c: 3 });
});
it("pick handles missing keys gracefully", { timeout: 5000 }, () => {
const obj = { a: 1 };
expect(pick(obj as Record<string, unknown>, ["b"])).toEqual({});
});
});
describe("keepKeys / removeKeys", () => {
it("keepKeys is alias for pick", { timeout: 5000 }, () => {
expect(keepKeys({ a: 1, b: 2 }, ["a"])).toEqual({ a: 1 });
});
it("removeKeys is alias for omit", { timeout: 5000 }, () => {
expect(removeKeys({ a: 1, b: 2 }, ["a"])).toEqual({ b: 2 });
});
});
describe("flattenObject", () => {
it("flattens nested object with dot notation", { timeout: 5000 }, () => {
expect(flattenObject({ a: { b: { c: 1 } } })).toEqual({ "a.b.c": 1 });
});
it("preserves non-object leaf values", { timeout: 5000 }, () => {
const flat = flattenObject({ a: 1, b: "str", c: true });
expect(flat).toEqual({ a: 1, b: "str", c: true });
});
it("uses custom separator", { timeout: 5000 }, () => {
expect(flattenObject({ a: { b: 1 } }, "", "/")).toEqual({ "a/b": 1 });
});
it("uses custom prefix", { timeout: 5000 }, () => {
expect(flattenObject({ b: 1 }, "prefix")).toEqual({ "prefix.b": 1 });
});
});
describe("getNestedValue / setNestedValue / hasPath", () => {
it("gets a deeply nested value", { timeout: 5000 }, () => {
expect(getNestedValue({ a: { b: { c: 42 } } }, "a.b.c")).toBe(42);
});
it("returns undefined for missing path", { timeout: 5000 }, () => {
expect(getNestedValue({ a: 1 }, "a.b.c")).toBeUndefined();
});
it("returns defaultValue when path is missing", { timeout: 5000 }, () => {
expect(getNestedValue({ a: 1 }, "x.y", 99)).toBe(99);
});
it("handles null intermediate", { timeout: 5000 }, () => {
expect(getNestedValue({ a: null }, "a.b", "def")).toBe("def");
});
it("returns defaultValue when leaf value is undefined", {
timeout: 5000,
}, () => {
const obj: Record<string, unknown> = { a: undefined };
expect(getNestedValue(obj, "a", "fallback")).toBe("fallback");
});
it("sets a deeply nested value", { timeout: 5000 }, () => {
const obj = { a: { b: 1 } };
setNestedValue(obj, "a.b", 99);
expect(obj.a.b).toBe(99);
});
it("creates intermediate objects when setting", { timeout: 5000 }, () => {
const obj: Record<string, unknown> = {};
setNestedValue(obj, "x.y.z", "hello");
expect((obj as { x: { y: { z: string } } }).x.y.z).toBe("hello");
});
it("throws setNestedValue on empty path", { timeout: 5000 }, () => {
expect(() => setNestedValue({}, "", "val")).toThrow();
});
it("hasPath returns true when path exists", { timeout: 5000 }, () => {
expect(hasPath({ a: { b: 1 } }, "a.b")).toBe(true);
});
it("hasPath returns false when path is missing", { timeout: 5000 }, () => {
expect(hasPath({ a: 1 }, "a.b")).toBe(false);
});
});
describe("deepClone", () => {
it("clones primitives", { timeout: 5000 }, () => {
expect(deepClone(42)).toBe(42);
expect(deepClone("str")).toBe("str");
expect(deepClone(null)).toBe(null);
});
it("clones plain objects", { timeout: 5000 }, () => {
const obj = { a: 1, b: { c: 2 } };
const clone = deepClone(obj);
expect(clone).toEqual(obj);
expect(clone).not.toBe(obj);
expect(clone.b).not.toBe(obj.b);
});
it("clones arrays", { timeout: 5000 }, () => {
const arr = [1, [2, 3]];
const clone = deepClone(arr);
expect(clone).toEqual(arr);
expect(clone).not.toBe(arr);
});
it("clones Date objects", { timeout: 5000 }, () => {
const d = new Date("2024-01-01");
const clone = deepClone(d);
expect(clone.getTime()).toBe(d.getTime());
expect(clone).not.toBe(d);
});
it("clones RegExp objects", { timeout: 5000 }, () => {
const re = /abc/gi;
const clone = deepClone(re);
expect(clone.source).toBe(re.source);
expect(clone.flags).toBe(re.flags);
expect(clone).not.toBe(re);
});
it("clones Map objects", { timeout: 5000 }, () => {
const map = new Map([
["a", 1],
["b", 2],
]);
const clone = deepClone(map);
expect(clone.get("a")).toBe(1);
expect(clone).not.toBe(map);
});
it("clones Set objects", { timeout: 5000 }, () => {
const set = new Set([1, 2, 3]);
const clone = deepClone(set);
expect(clone.has(2)).toBe(true);
expect(clone).not.toBe(set);
});
it("throws on circular reference", { timeout: 5000 }, () => {
const obj: Record<string, unknown> = { a: 1 };
obj["self"] = obj;
expect(() => deepClone(obj)).toThrow(/circular/i);
});
});
describe("property tests", () => {
it("deepMerge(a, {}) is structurally equal to a", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(fc.object(), (a) => {
expect(deepMerge(a, {})).toEqual(a);
})
);
});
it("pick(obj, keys) + omit(obj, keys) together cover all keys", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(fc.object(), (obj) => {
// Only use own enumerable keys so prototype lookups don't skew results
const allKeys = Object.keys(obj) as Array<keyof typeof obj>;
const half = allKeys.slice(0, Math.ceil(allKeys.length / 2));
const picked = pick(obj, half);
const omitted = omit(obj, half);
const merged = { ...picked, ...omitted };
expect(Object.keys(merged).sort()).toEqual(allKeys.sort());
})
);
});
});

View File

@@ -0,0 +1,314 @@
import type { Tracer } from "@opentelemetry/api";
import { trace } from "@opentelemetry/api";
import { UTILS_CODES, UtilsError } from "../errors/utils-error.js";
const TRACER_NAME = "@suaveplan/utils/object";
function getTracer(): Tracer {
return trace.getTracer(TRACER_NAME);
}
/**
* Check if a value is an object (excludes null and arrays).
*/
export function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
/**
* Check if a value is a plain object (not Date, RegExp, Map, Set, etc.).
*/
export function isPlainObject(
value: unknown
): value is Record<string, unknown> {
if (value === null || typeof value !== "object") return false;
const proto = Object.getPrototypeOf(value) as unknown;
return proto === null || proto === Object.prototype;
}
/**
* Check if a value is an object or array.
*/
export function isObjectOrArray(
value: unknown
): value is Record<string, unknown> | unknown[] {
return value !== null && typeof value === "object";
}
/**
* Check if an object is empty (no own enumerable keys).
*/
export function isEmpty(obj: Record<string, unknown>): boolean {
return Object.keys(obj).length === 0;
}
/** Guard against prototype pollution keys. */
function assertNotPolluted(key: string): void {
if (key === "__proto__" || key === "constructor" || key === "prototype") {
throw new UtilsError(
UTILS_CODES["OBJECT.PROTOTYPE_POLLUTION"],
`Prototype pollution attempt detected on key: ${key}`
);
}
}
/**
* Deep clone an object. Handles Date, RegExp, Map, Set, and circular references.
* Throws UtilsError(OBJECT.CIRCULAR) when a circular reference is detected.
*/
export function deepClone<T>(obj: T, _seen = new WeakSet()): T {
return trace
.getTracer(TRACER_NAME)
.startActiveSpan("utils.deepClone", (span) => {
try {
return _deepCloneInternal(obj, _seen);
} finally {
span.end();
}
});
}
function _deepCloneInternal<T>(obj: T, seen: WeakSet<object>): T {
if (obj === null || typeof obj !== "object") return obj;
if (seen.has(obj as object)) {
throw new UtilsError(
UTILS_CODES["OBJECT.CIRCULAR"],
"Circular reference detected during deepClone"
);
}
seen.add(obj as object);
if (obj instanceof Date) return new Date(obj.getTime()) as T;
if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags) as T;
if (obj instanceof Map) {
const m = new Map();
for (const [k, v] of obj) m.set(k, _deepCloneInternal(v, seen));
return m as T;
}
if (obj instanceof Set) {
const s = new Set();
for (const v of obj) s.add(_deepCloneInternal(v, seen));
return s as T;
}
if (Array.isArray(obj)) {
return (obj as unknown[]).map((item) =>
_deepCloneInternal(item, seen)
) as T;
}
const result = {} as T;
const keys = Object.keys(obj as object);
for (const key of keys) {
assertNotPolluted(key);
(result as Record<string, unknown>)[key] = _deepCloneInternal(
(obj as Record<string, unknown>)[key],
seen
);
}
return result;
}
/**
* Deep merge two objects. Plain objects are recursively merged; arrays are replaced.
* Throws UtilsError(OBJECT.PROTOTYPE_POLLUTION) if a dangerous key is encountered.
*/
export function deepMerge<T extends Record<string, unknown>>(
target: T,
source: Partial<T>,
skipUndefined = false
): T {
return getTracer().startActiveSpan("utils.deepMerge", (span) => {
try {
return _deepMergeInternal(target, source, skipUndefined);
} finally {
span.end();
}
});
}
function _deepMergeInternal<T extends Record<string, unknown>>(
target: T,
source: Partial<T>,
skipUndefined: boolean
): T {
const result = { ...target } as T;
for (const key of Object.keys(source as object)) {
assertNotPolluted(key);
const srcVal = (source as Record<string, unknown>)[key];
const tgtVal = result[key as keyof T];
if (skipUndefined && srcVal === undefined) continue;
if (isPlainObject(srcVal) && isPlainObject(tgtVal)) {
result[key as keyof T] = _deepMergeInternal(
tgtVal as Record<string, unknown>,
srcVal as Record<string, unknown>,
skipUndefined
) as T[keyof T];
} else {
result[key as keyof T] = srcVal as T[keyof T];
}
}
return result;
}
/**
* Deep merge multiple objects from lowest to highest priority.
*/
export function deepMergeAll<T extends Record<string, unknown>>(
...objects: Partial<T>[]
): T {
return objects.reduce<T>(
(acc, obj) => _deepMergeInternal(acc, obj, true),
{} as T
);
}
/**
* Pick specific keys from an object.
*/
export function pick<T extends Record<string, unknown>, K extends keyof T>(
obj: T,
keys: K[]
): Pick<T, K> {
const result = {} as Pick<T, K>;
for (const key of keys) {
if (key in obj) {
// Bare `result[key] = obj[key]` treats `__proto__` as a
// prototype setter, not an own property — `defineProperty`
// copies it as a genuine own enumerable property regardless
// of the key.
Object.defineProperty(result, key, {
value: obj[key],
writable: true,
enumerable: true,
configurable: true,
});
}
}
return result;
}
/**
* Keep only specified keys from an object (alias for pick).
*/
export function keepKeys<T extends Record<string, unknown>, K extends keyof T>(
obj: T,
keys: K[]
): Pick<T, K> {
return pick(obj, keys);
}
/**
* Omit specific keys from an object.
*/
export function omit<T extends Record<string, unknown>, K extends keyof T>(
obj: T,
keys: K[]
): Omit<T, K> {
const result = { ...obj };
for (const key of keys) delete result[key];
return result;
}
/**
* Remove specified keys from an object (alias for omit).
*/
export function removeKeys<
T extends Record<string, unknown>,
K extends keyof T,
>(obj: T, keys: K[]): Omit<T, K> {
return omit(obj, keys);
}
/**
* Get a deeply nested value using dot-notation path.
*/
export function getNestedValue<T = unknown>(
obj: Record<string, unknown>,
path: string,
defaultValue?: T
): T | undefined {
const keys = path.split(".");
let current: unknown = obj;
for (const key of keys) {
if (current === null || current === undefined) return defaultValue;
current = (current as Record<string, unknown>)[key];
}
return (current === undefined ? defaultValue : current) as T | undefined;
}
/**
* Set a deeply nested value using dot-notation path. Creates intermediate objects.
*/
export function setNestedValue(
obj: Record<string, unknown>,
path: string,
value: unknown
): void {
const keys = path.split(".");
const lastKey = keys.pop();
if (!lastKey) throw new Error("Invalid path: empty string");
let current = obj;
for (const key of keys) {
assertNotPolluted(key);
if (!isObject(current[key])) current[key] = {};
current = current[key] as Record<string, unknown>;
}
assertNotPolluted(lastKey);
current[lastKey] = value;
}
/**
* Check if an object has a specific dot-notation path.
*/
export function hasPath(obj: Record<string, unknown>, path: string): boolean {
const keys = path.split(".");
let current: unknown = obj;
for (const key of keys) {
if (!isObject(current) || !(key in (current as object))) return false;
current = (current as Record<string, unknown>)[key];
}
return true;
}
/**
* Flatten a nested object to dot-notation keys.
*/
export function flattenObject(
obj: Record<string, unknown>,
prefix = "",
separator = "."
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const key of Object.keys(obj)) {
const newKey = prefix ? `${prefix}${separator}${key}` : key;
const value = obj[key];
if (isObject(value)) {
Object.assign(
result,
flattenObject(
value as Record<string, unknown>,
newKey,
separator
)
);
} else {
result[newKey] = value;
}
}
return result;
}

View File

@@ -0,0 +1,19 @@
export type {
CpuUsage,
MemoryUsage,
PlatformAdapter,
PlatformEnv,
PlatformName,
ProcessLike,
} from "./platform.js";
export {
_detectBrowserOrUnknown,
_detectPlatform,
_readCpuUsage,
_readMemoryUsage,
createPlatform,
getPlatform,
isBrowser,
isBun,
isNode,
} from "./platform.js";

View File

@@ -0,0 +1,102 @@
# platform
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `platform` module provides an injectable abstraction layer for environment-specific system APIs such as memory usage, CPU usage, and platform detection. It enables universal packages to access runtime information without hard-coding assumptions about the execution environment. The default adapter reads from Node.js/Bun `process` APIs, while the injectable interface allows test code to supply any values without mocking.
The module detects Bun first (checking `typeof Bun !== "undefined"`), falls back to Node.js via `process.versions.node`, then browser (via `window`), and finally returns `"unknown"` for other environments such as Web Workers.
## Features
- `createPlatform(adapter?)` — factory that returns a platform instance with injectable adapter methods
- `getPlatform()` — returns `"bun" | "node" | "browser" | "unknown"` for the current environment
- `isNode()` — true when running in Node.js or Bun (both expose `process`)
- `isBrowser()` — true when `window` and `document` are defined
- `isBun()` — true when `typeof Bun !== "undefined"`
- Default adapter wraps `process.memoryUsage()` and `process.cpuUsage()` with OTel spans
- Throws `UtilsError(PLATFORM.UNAVAILABLE)` when the required process API is absent
- `MemoryUsage`, `CpuUsage`, `PlatformName` types and Zod schemas exported
## Usage: Basic Example
```ts
import { getPlatform, isNode, isBrowser, isBun } from "@suaveplan/utils/platform";
const env = getPlatform();
// => "node" | "bun" | "browser" | "unknown"
if (isNode()) {
console.log("Running under Node.js or Bun");
}
if (isBrowser()) {
console.log("Running in a browser");
}
```
## Usage: Advanced Example
```ts
import { createPlatform } from "@suaveplan/utils/platform";
// Inject a custom adapter for testing or custom environments
const platform = createPlatform({
getMemoryUsage: () => ({ heapUsed: 1024 * 1024, heapTotal: 4 * 1024 * 1024 }),
getCpuUsage: () => ({ user: 0, system: 0 }),
getPlatform: () => "node",
});
const mem = platform.getMemoryUsage();
console.log(`Heap used: ${mem.heapUsed} bytes`);
const cpu = platform.getCpuUsage();
console.log(`User CPU: ${cpu.user} µs`);
```
## API Reference
### `createPlatform(adapter?: Partial<PlatformAdapter>): PlatformAdapter`
Creates a platform instance. Any method not provided in `adapter` falls back to the default Node/Bun implementation.
### `getPlatform(): PlatformName`
Returns the current platform name using the module-level default adapter.
### `isNode(): boolean`
Returns true when `getPlatform()` returns `"node"` or `"bun"`.
### `isBrowser(): boolean`
Returns true when `getPlatform()` returns `"browser"`.
### `isBun(): boolean`
Returns true when `typeof Bun !== "undefined"`. More precise than `getPlatform() === "bun"`.
### `PlatformAdapter`
Interface with `getMemoryUsage(): MemoryUsage`, `getCpuUsage(): CpuUsage`, and `getPlatform(): PlatformName`.
### `MemoryUsage`
`{ heapUsed, heapTotal, external?, arrayBuffers?, rss? }` — all in bytes.
### `CpuUsage`
`{ user, system }` — CPU time in microseconds.
### `PlatformName`
`"browser" | "node" | "bun" | "unknown"`.
## Implementation Notes
The injectable adapter pattern avoids `vi.mock()` in tests — pass a custom adapter to `createPlatform()` instead. The default adapter is built lazily in `_buildDefaultAdapter()` and bound at `createPlatform()` call time so partial overrides compose correctly.
OTel spans wrap `getMemoryUsage()` and `getCpuUsage()` in the default adapter to trace latency of these syscalls in production. The spans use the TRACER_NAME `"@suaveplan/utils/platform"`.
The `isNode()` function returns true for both `"node"` and `"bun"` because Bun exposes the full `process` API and is generally compatible with Node.js code. Use `isBun()` when you specifically need to distinguish the two.

View File

@@ -0,0 +1,19 @@
import z from "zod";
/** Zod schema for MemoryUsage. */
export const MemoryUsageSchema = z.object({
heapUsed: z.number().nonnegative(),
heapTotal: z.number().nonnegative(),
external: z.number().nonnegative().optional(),
arrayBuffers: z.number().nonnegative().optional(),
rss: z.number().nonnegative().optional(),
});
/** Zod schema for CpuUsage. */
export const CpuUsageSchema = z.object({
user: z.number().nonnegative(),
system: z.number().nonnegative(),
});
/** Zod schema for PlatformName. */
export const PlatformNameSchema = z.enum(["browser", "node", "bun", "unknown"]);

View File

@@ -0,0 +1,156 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
_detectBrowserOrUnknown,
_detectPlatform,
_readCpuUsage,
_readMemoryUsage,
createPlatform,
getPlatform,
isBrowser,
isBun,
isNode,
} from "./platform.js";
describe("platform detection (Node/Bun environment)", () => {
it("isNode returns true in this test runner", { timeout: 5000 }, () => {
expect(isNode()).toBe(true);
});
it("isBrowser returns false in this test runner", { timeout: 5000 }, () => {
expect(isBrowser()).toBe(false);
});
it("getPlatform returns 'bun' or 'node'", { timeout: 5000 }, () => {
expect(["bun", "node"]).toContain(getPlatform());
});
it("isBun returns boolean", { timeout: 5000 }, () => {
expect(typeof isBun()).toBe("boolean");
});
});
describe("createPlatform (injectable adapter)", () => {
it("accepts a mock adapter", { timeout: 5000 }, () => {
const p = createPlatform({
getMemoryUsage: () => ({ heapUsed: 100, heapTotal: 200 }),
getCpuUsage: () => ({ user: 10, system: 5 }),
getPlatform: () => "browser",
});
expect(p.getMemoryUsage().heapUsed).toBe(100);
expect(p.getPlatform()).toBe("browser");
});
it("default adapter returns real memory usage", { timeout: 5000 }, () => {
const p = createPlatform();
const mem = p.getMemoryUsage();
expect(mem.heapUsed).toBeGreaterThan(0);
expect(mem.heapTotal).toBeGreaterThan(0);
});
it("default adapter returns real CPU usage", { timeout: 5000 }, () => {
const p = createPlatform();
const cpu = p.getCpuUsage();
expect(typeof cpu.user).toBe("number");
expect(typeof cpu.system).toBe("number");
});
it("partial adapter falls back to defaults for missing methods", {
timeout: 5000,
}, () => {
const p = createPlatform({
getPlatform: () => "unknown",
});
expect(p.getPlatform()).toBe("unknown");
// getMemoryUsage and getCpuUsage should use defaults
const mem = p.getMemoryUsage();
expect(mem.heapUsed).toBeGreaterThan(0);
});
});
describe("_readMemoryUsage (internal helper)", () => {
it("returns memory stats when memoryUsage is available", {
timeout: 5000,
}, () => {
const fakeProc = {
memoryUsage: () => ({
heapUsed: 1000,
heapTotal: 2000,
external: 100,
arrayBuffers: 50,
rss: 3000,
}),
};
const mem = _readMemoryUsage(fakeProc);
expect(mem.heapUsed).toBe(1000);
expect(mem.rss).toBe(3000);
});
it("throws PLATFORM.UNAVAILABLE when memoryUsage is missing", {
timeout: 5000,
}, () => {
const fakeProc = {};
expect(() => _readMemoryUsage(fakeProc)).toThrow(/not available/i);
});
});
describe("_detectPlatform (internal helper)", () => {
it("returns bun when hasBun=true", { timeout: 5000 }, () => {
expect(_detectPlatform({ hasBun: true, hasNode: false })).toBe("bun");
});
it("returns node when hasNode=true", { timeout: 5000 }, () => {
expect(_detectPlatform({ hasBun: false, hasNode: true })).toBe("node");
});
it("calls fallback when neither bun nor node", { timeout: 5000 }, () => {
const result = _detectPlatform(
{ hasBun: false, hasNode: false },
() => "browser"
);
expect(result).toBe("browser");
});
});
describe("_detectBrowserOrUnknown (internal helper)", () => {
it("returns 'browser' when window and document are defined", {
timeout: 5000,
}, () => {
const fakeGlobal = { window: {}, document: {} };
expect(_detectBrowserOrUnknown(fakeGlobal)).toBe("browser");
});
it("returns 'unknown' when window is missing", { timeout: 5000 }, () => {
expect(_detectBrowserOrUnknown({})).toBe("unknown");
});
it("returns 'unknown' when document is missing", { timeout: 5000 }, () => {
expect(_detectBrowserOrUnknown({ window: {} })).toBe("unknown");
});
it("uses globalThis by default (returns unknown in Node)", {
timeout: 5000,
}, () => {
// In Node/Bun, globalThis has no 'window', so returns 'unknown'
expect(_detectBrowserOrUnknown()).toBe("unknown");
});
});
describe("_readCpuUsage (internal helper)", () => {
it("returns cpu stats when cpuUsage is available", {
timeout: 5000,
}, () => {
const fakeProc = {
cpuUsage: () => ({ user: 1000, system: 500 }),
};
const cpu = _readCpuUsage(fakeProc);
expect(cpu.user).toBe(1000);
expect(cpu.system).toBe(500);
});
it("throws PLATFORM.UNAVAILABLE when cpuUsage is missing", {
timeout: 5000,
}, () => {
const fakeProc = {};
expect(() => _readCpuUsage(fakeProc)).toThrow(/not available/i);
});
});

View File

@@ -0,0 +1,188 @@
import { trace } from "@opentelemetry/api";
import { UTILS_CODES, UtilsError } from "../errors/utils-error.js";
const TRACER_NAME = "@suaveplan/utils/platform";
/** Memory usage information. */
export interface MemoryUsage {
/** Heap memory currently used (bytes). */
heapUsed: number;
/** Total heap memory allocated (bytes). */
heapTotal: number;
/** External C++ object memory (bytes). Optional. */
external?: number;
/** ArrayBuffer memory (bytes). Optional. */
arrayBuffers?: number;
/** Resident Set Size (bytes). Node.js only. */
rss?: number;
}
/** CPU usage in microseconds. */
export interface CpuUsage {
/** User CPU time in microseconds. */
user: number;
/** System CPU time in microseconds. */
system: number;
}
/** Platform identifier. */
export type PlatformName = "browser" | "node" | "bun" | "unknown";
/**
* Injectable platform adapter interface.
* Implement this to provide environment-specific resource access.
*/
export interface PlatformAdapter {
getMemoryUsage(): MemoryUsage;
getCpuUsage(): CpuUsage;
getPlatform(): PlatformName;
}
/** Internal process-like interface for testability. */
export interface ProcessLike {
memoryUsage?: () => {
heapUsed: number;
heapTotal: number;
external: number;
arrayBuffers: number;
rss: number;
};
cpuUsage?: () => { user: number; system: number };
versions?: { node?: string };
}
/**
* Create a platform instance from an adapter.
* If no adapter is provided, the default Node/Bun adapter is used.
*
* @param adapter - Optional injectable adapter.
* @returns Platform instance exposing getMemoryUsage, getCpuUsage, getPlatform.
*/
export function createPlatform(
adapter?: Partial<PlatformAdapter>
): PlatformAdapter {
const defaultAdapter = _buildDefaultAdapter();
return {
getMemoryUsage:
adapter?.getMemoryUsage ?? defaultAdapter.getMemoryUsage,
getCpuUsage: adapter?.getCpuUsage ?? defaultAdapter.getCpuUsage,
getPlatform: adapter?.getPlatform ?? defaultAdapter.getPlatform,
};
}
/** @internal Exported for testing the UNAVAILABLE error path. */
export function _readMemoryUsage(proc: ProcessLike): MemoryUsage {
if (typeof proc.memoryUsage === "function") {
const m = proc.memoryUsage();
return {
heapUsed: m.heapUsed,
heapTotal: m.heapTotal,
external: m.external,
arrayBuffers: m.arrayBuffers,
rss: m.rss,
};
}
throw new UtilsError(
UTILS_CODES["PLATFORM.UNAVAILABLE"],
"process.memoryUsage() is not available in this environment"
);
}
/** @internal Exported for testing the UNAVAILABLE error path. */
export function _readCpuUsage(proc: ProcessLike): CpuUsage {
if (typeof proc.cpuUsage === "function") {
const u = proc.cpuUsage();
return { user: u.user, system: u.system };
}
throw new UtilsError(
UTILS_CODES["PLATFORM.UNAVAILABLE"],
"process.cpuUsage() is not available in this environment"
);
}
function _buildDefaultAdapter(): PlatformAdapter {
return {
getMemoryUsage(): MemoryUsage {
return trace
.getTracer(TRACER_NAME)
.startActiveSpan("utils.platform.getMemoryUsage", (span) => {
try {
return _readMemoryUsage(process as ProcessLike);
} finally {
span.end();
}
});
},
getCpuUsage(): CpuUsage {
return trace
.getTracer(TRACER_NAME)
.startActiveSpan("utils.platform.getCpuUsage", (span) => {
try {
return _readCpuUsage(process as ProcessLike);
} finally {
span.end();
}
});
},
getPlatform(): PlatformName {
return _detectPlatform();
},
};
}
// Global environment identifiers — accessed via typeof to avoid reference errors
declare const Bun: unknown;
/** @internal Exported for testing browser/unknown detection with a custom globalThis. */
export function _detectBrowserOrUnknown(
g: Record<string, unknown> = globalThis as Record<string, unknown>
): PlatformName {
return g["window"] !== undefined && g["document"] !== undefined
? "browser"
: "unknown";
}
/** @internal Environment probes for testability. */
export interface PlatformEnv {
hasBun: boolean;
hasNode: boolean;
}
/** @internal Exported for testing the full platform detection path. */
export function _detectPlatform(
env?: PlatformEnv,
fallback: () => PlatformName = _detectBrowserOrUnknown
): PlatformName {
const hasBun = env ? env.hasBun : typeof Bun !== "undefined";
const hasNode = env
? env.hasNode
: typeof process !== "undefined" && !!process.versions?.node;
if (hasBun) return "bun";
if (hasNode) return "node";
return fallback();
}
/** Default platform singleton using Node/Bun APIs. */
const _default = createPlatform();
/** Get the current platform name. */
export function getPlatform(): PlatformName {
return _default.getPlatform();
}
const NODE_PLATFORMS = new Set<PlatformName>(["node", "bun"]);
/** Returns true when running in a Node.js or Bun environment. */
export function isNode(): boolean {
return NODE_PLATFORMS.has(_detectPlatform());
}
/** Returns true when running in a browser environment. */
export function isBrowser(): boolean {
return _detectPlatform() === "browser";
}
/** Returns true when running under Bun. */
export function isBun(): boolean {
return typeof Bun !== "undefined";
}

View File

@@ -0,0 +1,2 @@
export type { PseudoRandomOptions } from "./pseudo.js";
export { pseudoRandom } from "./pseudo.js";

View File

@@ -0,0 +1,94 @@
# pseudo
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `pseudo` module provides `pseudoRandom`, a deterministic array element selector that uses a seed-driven shuffle to pick reproducible results. Unlike `Math.random()`, which is non-deterministic, `pseudoRandom` guarantees that the same array, index, and seed always produce the same output — enabling reproducible sampling in tests, content variation systems, and A/B assignments.
The function shuffles a copy of the input array using a Fisher-Yates-like algorithm driven by a hash of the seed, then returns the element at the specified index. The original array is never mutated.
## Features
- Deterministic output for the same `(arr, index, seed)` triple
- Injectable `rng` option for pure-function testing without mocking
- Throws `UtilsError(RANDOM.EMPTY_ARRAY)` for empty input arrays
- Throws `UtilsError(RANDOM.INDEX_OUT_OF_BOUNDS)` for invalid indices
- Fully generic: `pseudoRandom<T>(arr: T[], ...): T`
- OTel counter on every call for throughput observability
- Zero mutation of the source array
## Usage: Basic Example
```ts
import { pseudoRandom } from "@suaveplan/utils/random";
const themes = ["ocean", "forest", "desert", "arctic"];
// Deterministic — always returns the same theme for seed 0.42
const theme = pseudoRandom(themes, 0, 0.42);
console.log(theme); // e.g., "forest"
// Without explicit seed — uses Math.random() internally
const random = pseudoRandom(themes, 0);
```
## Usage: Advanced Example
```ts
import { pseudoRandom } from "@suaveplan/utils/random";
// Reproducible A/B variant selection based on user ID hash
function getUserVariant(userId: string, variants: string[]): string {
// Convert user ID to a seed in [0, 1)
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = (hash * 31 + userId.charCodeAt(i)) % 1_000_000;
}
const seed = hash / 1_000_000;
return pseudoRandom(variants, 0, seed);
}
const variant = getUserVariant("user-1234", ["control", "treatment-a", "treatment-b"]);
// Always the same for "user-1234"
// Injectable RNG for testing without mocking Math.random
const result = pseudoRandom(["a", "b", "c"], 1, undefined, { rng: () => 0.7 });
```
## API Reference
### `pseudoRandom<T>(arr: T[], index: number, seed?: number, options?: PseudoRandomOptions): T`
Selects an element from `arr` using a seeded shuffle.
**Parameters:**
- `arr` — `T[]` — Non-empty source array. Not mutated.
- `index` — `number` — Position in the shuffled result to return. Must be in `[0, arr.length)`.
- `seed` — `number | undefined` — Value in `[0, 1)` driving the shuffle. Defaults to `options.rng()` or `Math.random()` if omitted.
- `options.rng` — `() => number` — Optional injectable RNG that replaces `Math.random()` when `seed` is not provided.
**Returns:** `T` — The element at position `index` in the shuffled copy.
**Throws:**
- `UtilsError(RANDOM.EMPTY_ARRAY)` — when `arr.length === 0`.
- `UtilsError(RANDOM.INDEX_OUT_OF_BOUNDS)` — when `index < 0` or `index >= arr.length`.
### `PseudoRandomOptions`
```ts
interface PseudoRandomOptions {
rng?: () => number;
}
```
## Implementation Notes
The shuffle uses a simple hash function `h(x) = (sin(x * 12.9898) * 43758.5453) % 1` applied iteratively. This is not cryptographically secure and should not be used for security-sensitive randomness — use `@suaveplan/crypto` for that purpose. It is deterministic within floating-point precision, making it suitable for reproducible UI variation and test fixtures.
The `Math.abs` call in `j = Math.floor(Math.abs(hash) * (i + 1))` ensures the hash value (which can be negative) maps to a valid index range regardless of the sign produced by the sine function.
The `options.rng` parameter is separate from `seed` intentionally: `seed` is a numeric influence on the shuffle; `rng` is a replacement for `Math.random()` used only when `seed` is not provided. This distinction allows tests to both inject a deterministic seed directly and inject a deterministic RNG.

View File

@@ -0,0 +1,9 @@
import z from "zod";
/** Schema for pseudoRandom options. */
export const PseudoRandomOptionsSchema = z.object({
/** Seed value in [0, 1). */
seed: z.number().min(0).max(1).optional(),
/** Starting index in the shuffled result. */
index: z.number().int().nonnegative(),
});

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { pseudoRandom } from "./pseudo.js";
describe("pseudoRandom", () => {
it("returns a deterministic result for the same seed", {
timeout: 5000,
}, () => {
const arr = ["a", "b", "c"];
expect(pseudoRandom(arr, 0, 0.42)).toBe(pseudoRandom(arr, 0, 0.42));
});
it("throws for empty array", { timeout: 5000 }, () => {
expect(() => pseudoRandom([], 0)).toThrow();
});
it("throws for out-of-bounds index (too high)", { timeout: 5000 }, () => {
expect(() => pseudoRandom(["a"], 1)).toThrow();
});
it("throws for out-of-bounds index (negative)", { timeout: 5000 }, () => {
expect(() => pseudoRandom(["a"], -1)).toThrow();
});
it("produces different results for different seeds", {
timeout: 5000,
}, () => {
const arr = ["a", "b", "c", "d", "e", "f", "g", "h"];
const results = new Set(
Array.from({ length: 10 }, (_, i) => pseudoRandom(arr, 0, i / 10))
);
expect(results.size).toBeGreaterThan(1);
});
it("is type-safe — returns T for T[]", { timeout: 5000 }, () => {
const result: number = pseudoRandom([1, 2, 3], 0);
expect(typeof result).toBe("number");
});
it("uses injectable rng when no seed provided", { timeout: 5000 }, () => {
// Fixed rng gives deterministic result
const arr = ["x", "y", "z"];
const r1 = pseudoRandom(arr, 0, undefined, { rng: () => 0.5 });
const r2 = pseudoRandom(arr, 0, undefined, { rng: () => 0.5 });
expect(r1).toBe(r2);
});
it("uses Math.random when neither seed nor rng provided", {
timeout: 5000,
}, () => {
const arr = ["a", "b", "c"];
// Just ensure it doesn't throw and returns a valid element
const result = pseudoRandom(arr, 0);
expect(arr).toContain(result);
});
it("works with a single-element array", { timeout: 5000 }, () => {
expect(pseudoRandom(["only"], 0, 0.99)).toBe("only");
});
it("works with object arrays", { timeout: 5000 }, () => {
const objs = [{ id: 1 }, { id: 2 }, { id: 3 }];
const result = pseudoRandom(objs, 0, 0.1);
expect(objs).toContain(result);
});
it("throws with correct error code for empty array", {
timeout: 5000,
}, () => {
let code: string | undefined;
try {
pseudoRandom([], 0);
} catch (e) {
code = (e as { code?: string }).code;
}
expect(code).toContain("RANDOM");
});
it("throws with correct error code for out-of-bounds", {
timeout: 5000,
}, () => {
let code: string | undefined;
try {
pseudoRandom(["a"], 5);
} catch (e) {
code = (e as { code?: string }).code;
}
expect(code).toContain("RANDOM");
});
});
describe("property tests", () => {
it("always returns an element that exists in the array", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.array(fc.integer(), { minLength: 1 }),
fc.nat(),
fc.double({ min: 0, max: 1, noNaN: true }),
(arr, rawIndex, seed) => {
const index = rawIndex % arr.length;
expect(arr).toContain(pseudoRandom(arr, index, seed));
}
)
);
});
});

View File

@@ -0,0 +1,66 @@
import { metrics } from "@opentelemetry/api";
import { UTILS_CODES, UtilsError } from "../errors/utils-error.js";
const meter = metrics.getMeter("@suaveplan/utils/random");
const callCounter = meter.createCounter("utils.random.pseudoRandom.count");
/**
* Options for `pseudoRandom`.
*/
export interface PseudoRandomOptions {
/** Optional custom RNG function returning a number in [0, 1). Used for deterministic tests. */
rng?: () => number;
}
/**
* Select a deterministic element from an array using a seed.
*
* The function shuffles a copy of the array with a seed-driven hash and
* returns the element at `index` in the shuffled result.
*
* @param arr - Non-empty source array.
* @param index - Position in the shuffled result to return (0-based, must be < arr.length).
* @param seed - A number in [0, 1) used to drive the shuffle (default: `Math.random()`).
* @param options - Optional injectable RNG.
* @returns The selected element.
* @throws {UtilsError} RANDOM.EMPTY_ARRAY when arr is empty.
* @throws {UtilsError} RANDOM.INDEX_OUT_OF_BOUNDS when index is out of bounds.
*/
export function pseudoRandom<T>(
arr: T[],
index: number,
seed?: number,
options?: PseudoRandomOptions
): T {
callCounter.add(1);
if (arr.length === 0) {
throw new UtilsError(
UTILS_CODES["RANDOM.EMPTY_ARRAY"],
"Array cannot be empty"
);
}
if (index < 0 || index >= arr.length) {
throw new UtilsError(
UTILS_CODES["RANDOM.INDEX_OUT_OF_BOUNDS"],
`Index ${index} is out of bounds for array of length ${arr.length}`
);
}
const effectiveSeed =
seed ?? (options?.rng ? options.rng() : Math.random());
const shuffled = [...arr];
const len = shuffled.length;
let hash = effectiveSeed;
for (let i = 0; i < len; i++) {
hash = (Math.sin(hash * 12.9898) * 43758.5453) % 1;
const j = Math.floor(Math.abs(hash) * (i + 1));
const temp = shuffled[i] as T;
shuffled[i] = shuffled[j] as T;
shuffled[j] = temp;
}
return shuffled[index] as T;
}

View File

@@ -0,0 +1,12 @@
export {
convertSprites,
getSoundKey,
parseSoundVariations,
type SoundSprites,
type SoundVariations,
} from "./sound.js";
export {
soundSpriteEntrySchema,
soundSpritesSchema,
soundVariationsSchema,
} from "./sound.schemas.js";

View File

@@ -0,0 +1,107 @@
# sound
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `sound` module provides lightweight utilities for managing audio sprite variations in Howler.js-based (or compatible) audio engines. Real-world games and interactive UIs typically pack many short audio clips into a single sprite file to minimize HTTP requests. Each clip appears as a named key like `footstep_1`, `footstep_2`, and `footstep_3`. This module extracts those variation groups and provides a random-selection helper, enabling natural-sounding audio without repetition fatigue — commonly known as the "machine-gun effect" problem in audio design.
## Features
- **`parseSoundVariations`**: scans a Howler.js sprite map and groups keys by base name, producing a `SoundVariations` map of `{baseName: [1, 2, 3, ...]}`.
- **`getSoundKey`**: randomly selects one variation number from the pool and returns the full sprite key (e.g. `"footstep_2"`). Throws a typed `UtilsError` with code `SOUND.NO_VARIATIONS` when the requested name is not in the map.
- **`convertSprites`**: semantic alias for `parseSoundVariations`, matching the "convert" terminology used by audio-engine integrations.
- **`SoundSprites` type**: `Record<string, [number, number]>` describing Howler.js sprite timing data (offset, duration in milliseconds).
- **`SoundVariations` type**: `Record<string, number[]>` describing base-name to variation-number pool.
- **Zod schemas**: `soundSpriteEntrySchema`, `soundSpritesSchema`, `soundVariationsSchema` for schema-driven validation pipelines.
## Usage: Basic Example
```typescript
import { parseSoundVariations, getSoundKey } from "@suaveplan/utils/sound";
const sprites = {
footstep_1: [0, 100],
footstep_2: [100, 100],
footstep_3: [200, 100],
jump_1: [300, 150],
};
const variations = parseSoundVariations(sprites);
// { footstep: [1, 2, 3], jump: [1] }
const key = getSoundKey("footstep", variations);
// "footstep_1", "footstep_2", or "footstep_3" — randomly selected
```
## Usage: Advanced Example
```typescript
import {
convertSprites,
getSoundKey,
type SoundSprites,
} from "@suaveplan/utils/sound";
import Howl from "howler";
const rawSprites: SoundSprites = {
walk_1: [0, 400],
walk_2: [400, 400],
walk_3: [800, 400],
run_1: [1200, 200],
run_2: [1400, 200],
ambientLoop: [1600, 5000], // no underscore+number → ignored by convertSprites
};
const howl = new Howl({ src: ["audio.webm"], sprite: rawSprites });
const variations = convertSprites(rawSprites);
// { walk: [1,2,3], run: [1,2] } — ambientLoop is excluded
function playWalk(): void {
const key = getSoundKey("walk", variations);
howl.play(key);
}
```
## API Reference
### `parseSoundVariations(sprites: SoundSprites): SoundVariations`
Parse a sprite map and extract variation groups.
| Parameter | Type | Description |
|-----------|---------------|--------------------------------------|
| `sprites` | `SoundSprites` | Howler.js sprite map. |
**Returns:** `SoundVariations` — base names mapped to arrays of variation numbers. Keys that do not follow `<base>_<integer>` are silently ignored.
### `getSoundKey(name: string, variations: SoundVariations): string`
Randomly select a sprite key for a named sound.
| Parameter | Type | Description |
|--------------|------------------|------------------------------------------|
| `name` | `string` | Base sound name. |
| `variations` | `SoundVariations`| Variation map from `parseSoundVariations`.|
**Returns:** `string` — full sprite key e.g. `"footstep_2"`.
**Throws:** `UtilsError` (`SOUND.NO_VARIATIONS`) if the name is not in the map.
### `convertSprites(sprites: SoundSprites): SoundVariations`
Semantic alias for `parseSoundVariations`. Identical behavior.
### Types
- `SoundSprites = Record<string, [number, number]>` — Howler.js sprite timing.
- `SoundVariations = Record<string, number[]>` — base name → variation numbers.
## Implementation Notes
Key parsing uses `lastIndexOf("_")` rather than `split("_")` to correctly handle base names that themselves contain underscores (e.g. `ambient_rain_1` → base `ambient_rain`, variation `1`). The suffix is validated with `Number.isInteger(Number(suffix))` to reject non-numeric suffixes such as `"a"` or empty strings.
Random selection uses `Math.random()` which is sufficient for audio variation purposes. If deterministic selection is required (e.g. in tests), callers should mock `Math.random` or extract the selection logic with a custom RNG parameter in a future enhancement.
No OTel instrumentation is added here because these are pure, synchronous utility functions without side effects or external I/O. Telemetry can be added at the call site by callers who already have a span context.

View File

@@ -0,0 +1,19 @@
import z from "zod";
/**
* Zod schema for a Howler.js sprite timing entry: `[offset_ms, duration_ms]`.
*/
export const soundSpriteEntrySchema = z.tuple([z.number(), z.number()]);
/**
* Zod schema for a complete Howler.js sprite map.
*/
export const soundSpritesSchema = z.record(z.string(), soundSpriteEntrySchema);
/**
* Zod schema for a sound variation map (base name → variation numbers).
*/
export const soundVariationsSchema = z.record(
z.string(),
z.array(z.number().int())
);

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { convertSprites, getSoundKey, parseSoundVariations } from "./sound.js";
describe("parseSoundVariations", () => {
it("groups sprite keys by base name", { timeout: 5000 }, () => {
const sprites = {
footstep_1: [0, 100] as [number, number],
footstep_2: [100, 100] as [number, number],
jump_1: [200, 150] as [number, number],
};
expect(parseSoundVariations(sprites)).toEqual({
footstep: [1, 2],
jump: [1],
});
});
it("ignores keys without underscore+number suffix", {
timeout: 5000,
}, () => {
expect(
parseSoundVariations({ ambient: [0, 1000] as [number, number] })
).toEqual({});
});
it("ignores keys where base is empty (leading underscore)", {
timeout: 5000,
}, () => {
expect(
parseSoundVariations({ _1: [0, 100] as [number, number] })
).toEqual({});
});
it("ignores keys with whitespace-only suffix", {
timeout: 5000,
}, () => {
// key like "step_ " has non-empty suffix but it's all whitespace
const sprites: Record<string, [number, number]> = {};
sprites["step_ "] = [0, 100];
expect(parseSoundVariations(sprites)).toEqual({});
});
it("ignores keys with non-integer suffix", {
timeout: 5000,
}, () => {
expect(
parseSoundVariations({ step_abc: [0, 100] as [number, number] })
).toEqual({});
});
});
describe("getSoundKey", () => {
it("returns a key that exists in the variations map", {
timeout: 5000,
}, () => {
const variations = { footstep: [1, 2, 3] };
const key = getSoundKey("footstep", variations);
expect(key).toMatch(/^footstep_[123]$/);
});
it("throws UtilsError for unknown sound name", { timeout: 5000 }, () => {
expect(() => getSoundKey("unknown", { footstep: [1] })).toThrow();
});
});
describe("convertSprites", () => {
it("converts howler sprite format to variation map", {
timeout: 5000,
}, () => {
const sprites = {
step_1: [0, 100] as [number, number],
step_2: [100, 100] as [number, number],
};
expect(convertSprites(sprites)).toEqual({ step: [1, 2] });
});
});

View File

@@ -0,0 +1,95 @@
/**
* Sound sprite utilities for audio variation management.
*
* Provides helpers for parsing Howler.js-style sprite maps into variation
* groups, randomly selecting a sound key from a variation pool, and
* converting raw sprite data into canonical variation maps. These primitives
* enable smooth sound randomization in games, interactive UIs, and audio
* engines without duplicating sprite-key parsing logic across consumers.
*
* @module sound
*/
import { UTILS_CODES, UtilsError } from "../errors/utils-error.js";
/**
* A mapping of base sound names to arrays of variation numbers.
*
* @example
* ```ts
* const variations: SoundVariations = { footstep: [1, 2, 3], jump: [1] };
* ```
*/
export type SoundVariations = Record<string, number[]>;
/**
* Howler.js sprite timing format: `[offset_ms, duration_ms]`.
*/
export type SoundSprites = Record<string, [number, number]>;
/**
* Parse a Howler.js sprite map into a variation grouping.
*
* Keys must follow the convention `<baseName>_<integer>` to be recognized as
* variations. Any key that does not match this pattern (no underscore, or the
* suffix after the last underscore is not a valid integer) is silently ignored.
*
* @param sprites - Sprite map with timing tuples `[offset, duration]`.
* @returns Mapping from base name to sorted array of variation numbers.
*/
export function parseSoundVariations(sprites: SoundSprites): SoundVariations {
const variations: SoundVariations = {};
for (const key of Object.keys(sprites)) {
const underscoreIdx = key.lastIndexOf("_");
if (underscoreIdx === -1) continue;
const base = key.slice(0, underscoreIdx);
const suffix = key.slice(underscoreIdx + 1);
if (!base || !suffix) continue;
const num = Number(suffix);
if (!Number.isInteger(num) || suffix.trim() === "") continue;
if (!variations[base]) {
variations[base] = [];
}
variations[base].push(num);
}
return variations;
}
/**
* Randomly select a sprite key for a named sound from a variation pool.
*
* @param name - Base sound name (e.g. `"footstep"`).
* @param variations - Variation map produced by {@link parseSoundVariations}.
* @returns A full sprite key such as `"footstep_2"`.
* @throws {UtilsError} `SOUND.NO_VARIATIONS` if the name has no entries.
*/
export function getSoundKey(name: string, variations: SoundVariations): string {
const pool = variations[name];
if (!pool || pool.length === 0) {
throw new UtilsError(
UTILS_CODES["SOUND.NO_VARIATIONS"],
`No variations registered for sound "${name}"`
);
}
const idx = Math.floor(Math.random() * pool.length);
return `${name}_${pool[idx]}`;
}
/**
* Convert a raw Howler.js sprite map into a variation map.
*
* Delegates to {@link parseSoundVariations}; provided as a semantic alias
* that matches the "convert" terminology used in audio-engine integrations.
*
* @param sprites - Raw sprite timing data.
* @returns Variation map suitable for {@link getSoundKey}.
*/
export function convertSprites(sprites: SoundSprites): SoundVariations {
return parseSoundVariations(sprites);
}

View File

@@ -0,0 +1,11 @@
export {
type GenerateIdOptions,
generateId,
parseTextWithLinks,
type TextLink,
} from "./string.js";
export {
generateIdOptionsSchema,
idSchema,
textLinkSchema,
} from "./string.schemas.js";

View File

@@ -0,0 +1,99 @@
# string
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `string` module provides two focused, dependency-free string utilities: `generateId` for creating random, URL-safe identifier strings using `crypto.randomUUID()`, and `parseTextWithLinks` for extracting HTTP/HTTPS URLs from arbitrary plain text. Both functions are pure and work without modification in any JavaScript runtime — browser, Node.js, Bun, and edge workers — making them safe to include in universal packages with zero additional dependencies.
## Features
- **`generateId`**: generates a 32-character random hex identifier by default (UUID with dashes stripped). Accepts an optional `length` parameter to produce identifiers of any length by trimming or concatenating UUID material.
- **`parseTextWithLinks`**: scans plain text for `http://` and `https://` URLs using a conservative regex, returning an array of `TextLink` objects with `url`, `text`, and `index` fields.
- **TypeScript types**: `GenerateIdOptions` and `TextLink` are exported for use in typed call sites.
- **Zod schemas**: `idSchema`, `generateIdOptionsSchema`, and `textLinkSchema` for schema-driven validation pipelines.
- **Cross-runtime crypto**: uses `globalThis.crypto` in browser/Bun and falls back to `node:crypto` `webcrypto` in Node.js test environments.
## Usage: Basic Example
```typescript
import { generateId, parseTextWithLinks } from "@suaveplan/utils/string";
// Generate a random 32-char identifier
const id = generateId();
// e.g. "a3f7c12d9b4e8a1f0c2d5e6f7a8b9c0d"
// Generate an 8-char identifier for short codes
const shortCode = generateId({ length: 8 });
// e.g. "a3f7c12d"
// Extract URLs from text
const links = parseTextWithLinks("Visit https://example.com and https://docs.example.com/guide");
// [
// { url: "https://example.com", text: "https://example.com", index: 6 },
// { url: "https://docs.example.com/guide", text: "https://docs.example.com/guide", index: 28 }
// ]
```
## Usage: Advanced Example
```typescript
import { generateId, parseTextWithLinks } from "@suaveplan/utils/string";
// Build a short token for email verification
function createVerificationToken(): string {
return generateId({ length: 24 });
}
// Linkify a user's comment for display
function extractLinksFromComment(comment: string) {
const links = parseTextWithLinks(comment);
return links.map(link => ({
href: link.url,
label: link.text,
position: link.index,
}));
}
const comment = "Check out https://github.com/SuavePlan/suaveplan for the source!";
const extracted = extractLinksFromComment(comment);
// [{ href: "https://github.com/SuavePlan/suaveplan", label: "...", position: 10 }]
```
## API Reference
### `generateId(opts?: GenerateIdOptions): string`
Generate a random URL-safe identifier string.
| Parameter | Type | Description |
|-----------|---------------------|--------------------------------------------|
| `opts` | `GenerateIdOptions` | Optional. `{ length?: number }` — desired length. Defaults to 32. |
**Returns:** `string` — random hex string of the requested length.
**Does not throw.** Crypto is always available in supported runtimes.
### `parseTextWithLinks(text: string): TextLink[]`
Extract HTTP/HTTPS URLs from a plain-text string.
| Parameter | Type | Description |
|-----------|----------|---------------------------|
| `text` | `string` | Text to scan for URLs. |
**Returns:** `TextLink[]` — array of extracted links in order of appearance. Each entry has `url`, `text`, and `index` fields.
### Types
- `GenerateIdOptions` — `{ length?: number }`.
- `TextLink` — `{ url: string; text: string; index: number }`.
## Implementation Notes
`generateId` uses `crypto.randomUUID()` rather than `Math.random().toString(36)` because UUID generation uses a cryptographically secure PRNG, giving 122 bits of entropy per call — sufficient for database primary keys and session tokens. For lengths beyond 32 characters, additional UUIDs are generated and concatenated; this is O(⌈length/32⌉) UUID calls.
`parseTextWithLinks` creates a fresh `RegExp` instance on each call (from the source of a module-level `const` regex literal) to avoid stateful `lastIndex` bugs when the same regex is reused across calls. The URL regex conservatively stops at whitespace and a small set of shell-special characters; it is not a full RFC 3986 parser, so some edge-case URLs (e.g. those containing unescaped brackets) may be truncated.
Both functions are side-effect-free and safe to call in concurrent contexts.

View File

@@ -0,0 +1,24 @@
import z from "zod";
/**
* Zod schema for a non-empty identifier string.
*/
export const idSchema = z.string().min(1);
/**
* Zod schema for GenerateIdOptions.
*/
export const generateIdOptionsSchema = z
.object({
length: z.number().int().positive().optional(),
})
.optional();
/**
* Zod schema for a TextLink result object.
*/
export const textLinkSchema = z.object({
url: z.string().url(),
text: z.string(),
index: z.number().int().nonnegative(),
});

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { generateId, parseTextWithLinks } from "./string.js";
describe("generateId", () => {
it("returns a non-empty string", { timeout: 5000 }, () => {
expect(typeof generateId()).toBe("string");
expect(generateId().length).toBeGreaterThan(0);
});
it("generates unique values", { timeout: 5000 }, () => {
const ids = new Set(Array.from({ length: 100 }, generateId));
expect(ids.size).toBe(100);
});
it("accepts a custom length shorter than base", { timeout: 5000 }, () => {
expect(generateId({ length: 8 })).toHaveLength(8);
});
it("accepts a custom length longer than 32 chars", {
timeout: 5000,
}, () => {
const id = generateId({ length: 64 });
expect(id).toHaveLength(64);
});
});
describe("parseTextWithLinks", () => {
it("extracts URLs from plain text", { timeout: 5000 }, () => {
const result = parseTextWithLinks("Visit https://example.com for info");
expect(result).toContainEqual(
expect.objectContaining({ url: "https://example.com" })
);
});
it("returns empty array for text with no links", { timeout: 5000 }, () => {
expect(parseTextWithLinks("no links here")).toEqual([]);
});
});

View File

@@ -0,0 +1,85 @@
/**
* String utility functions.
*
* Provides `generateId` for random unique identifier creation and
* `parseTextWithLinks` for extracting URLs from arbitrary plain text.
* These primitives are intentionally dependency-free and work in all
* JavaScript environments (browser, Node.js, Bun, edge runtimes).
*
* @module string
*/
const nodeCrypto: Pick<Crypto, "randomUUID"> = globalThis.crypto;
/** Options for generateId. */
export interface GenerateIdOptions {
/**
* Exact length of the returned string.
* Defaults to 32 (UUID stripped of dashes).
*/
length?: number;
}
/** A link extracted from plain text by parseTextWithLinks. */
export interface TextLink {
/** The matched URL string. */
url: string;
/** The URL text as it appears in the source string. */
text: string;
/** Zero-based character index of the match in the original string. */
index: number;
}
const URL_PATTERN = /https?:\/\/[^\s<>"{}|\\^`[\]]+/g;
/**
* Generate a random, URL-safe identifier string.
*
* Internally uses `crypto.randomUUID()` and strips dashes to produce a
* 32-character hex string. When a custom `length` is requested the string
* is trimmed or padded with additional UUID material to the exact length.
*
* @param opts - Optional configuration.
* @returns A random string of the requested length (default 32).
*/
export function generateId(opts?: GenerateIdOptions): string {
const base = nodeCrypto.randomUUID().replace(/-/g, "");
const length = opts?.length;
if (length === undefined) return base;
if (length <= base.length) {
return base.slice(0, length);
}
let result = base;
while (result.length < length) {
result += nodeCrypto.randomUUID().replace(/-/g, "");
}
return result.slice(0, length);
}
/**
* Extract all HTTP/HTTPS URLs from a plain-text string.
*
* Returns an array of TextLink objects, each carrying the matched URL,
* the text as it appears in the source, and the zero-based character index
* of the match. Returns an empty array when no URLs are found.
*
* @param text - Plain text to scan for links.
* @returns Array of extracted link objects, in order of appearance.
*/
export function parseTextWithLinks(text: string): TextLink[] {
const results: TextLink[] = [];
const regex = new RegExp(URL_PATTERN.source, "g");
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
results.push({
url: match[0],
text: match[0],
index: match.index,
});
}
return results;
}

View File

@@ -0,0 +1,5 @@
export type {
DeepPartial,
PlainObject,
Primitive,
} from "./types.js";

View File

@@ -0,0 +1,92 @@
# types/types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `types` module declares shared TypeScript utility types used across the `@suaveplan/utils` package. These are compile-time-only definitions — there is no runtime JavaScript output from this file. By centralizing common type aliases here, individual feature modules avoid duplicating definitions and consumers get consistent, named types that appear clearly in IDE tooltips and type error messages.
## Features
- **`PlainObject`**: alias for `Record<string, unknown>` — a plain JavaScript object with string keys and arbitrary values. Used as the parameter type for object-manipulation utilities like `pick`, `omit`, `deepMerge`, and `flattenObject`.
- **`DeepPartial<T>`**: recursive utility type that makes all properties of `T` and every nested object optional. Useful for partial-update payloads and test fixture builders.
- **`Primitive`**: union of all JavaScript primitive value types (`string | number | boolean | bigint | symbol | null | undefined`). Used to guard leaf-node values in recursive object traversal.
## Usage: Basic Example
```typescript
import type { PlainObject, Primitive } from "@suaveplan/utils";
function isLeaf(value: unknown): value is Primitive {
return (
value === null ||
value === undefined ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "bigint" ||
typeof value === "symbol"
);
}
function countKeys(obj: PlainObject): number {
return Object.keys(obj).length;
}
```
## Usage: Advanced Example
```typescript
import type { DeepPartial, PlainObject } from "@suaveplan/utils";
interface Config {
server: { host: string; port: number };
database: { url: string; pool: number };
}
// Accept partial overrides for testing
function createConfig(overrides: DeepPartial<Config> = {}): Config {
return {
server: { host: "localhost", port: 3000, ...overrides.server },
database: { url: "postgres://localhost/dev", pool: 5, ...overrides.database },
};
}
// Store arbitrary metadata
const meta: PlainObject = { version: "1.0", tags: ["prod"] };
```
## API Reference
### `PlainObject`
```ts
type PlainObject = Record<string, unknown>
```
A plain JS object with string keys and `unknown` values. Equivalent to `{ [key: string]: unknown }`.
### `DeepPartial<T>`
```ts
type DeepPartial<T> = T extends object
? { [P in keyof T]?: DeepPartial<T[P]> }
: T
```
Recursively makes all properties optional. For non-object types (primitives, arrays) the type is returned unchanged.
### `Primitive`
```ts
type Primitive = string | number | boolean | bigint | symbol | null | undefined
```
The complete set of JavaScript primitive value types.
## Implementation Notes
These types ship zero runtime bytes — they exist only in the type-checking phase and are erased during compilation. The `types` directory is explicitly excluded from Istanbul coverage reporting in `vitest.config.ts` (`"src/types/**"`).
`DeepPartial` uses a conditional mapped type rather than the simpler `Partial<T>` to recurse into nested objects. It does not recurse into arrays intentionally — array elements are left as-is, which is the expected behavior for most use cases.

View File

@@ -0,0 +1,21 @@
/**
* Shared type definitions for `@suaveplan/utils`.
*/
/** A plain object with string keys and unknown values. */
export type PlainObject = Record<string, unknown>;
/** Deep partial — makes all nested properties optional. */
export type DeepPartial<T> = T extends object
? { [P in keyof T]?: DeepPartial<T[P]> }
: T;
/** Primitive JS value types. */
export type Primitive =
| string
| number
| boolean
| bigint
| symbol
| null
| undefined;

View File

@@ -0,0 +1 @@
export { isHex } from "./validate.js";

View File

@@ -0,0 +1,86 @@
# validate
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `validate` module provides lightweight, format-only type-guard validators for common binary encoding formats. Unlike full parsers that decode and reconstruct data, these guards perform only surface-level structural checks — enough to narrow the TypeScript type and reject obviously malformed inputs before passing data to more expensive operations such as cryptographic decoding, database writes, or network transmission. They are intentionally thin so callers pay no allocation cost beyond the guard itself.
## Features
- **`isHex`**: validates that a value is a non-empty, even-length string consisting exclusively of hexadecimal characters (`[A-Fa-f0-9]`). Returns `true` only when the string encodes at least one complete byte (two hex digits). Acts as a TypeScript type guard narrowing `unknown` to `string`.
- **Zero dependencies**: no external imports — just a regex and a `typeof` check.
- **TypeScript narrowing**: each guard is declared as a type-predicate (`i is string`) so TypeScript narrows the type in the `if`-branch automatically.
## Usage: Basic Example
```typescript
import { isHex } from "@suaveplan/utils/validate";
function decodePayload(raw: unknown): Uint8Array {
if (!isHex(raw)) {
throw new TypeError(`Expected hex string, got ${typeof raw}`);
}
// TypeScript now knows raw is string
const bytes = new Uint8Array(raw.length / 2);
for (let i = 0; i < raw.length; i += 2) {
bytes[i / 2] = parseInt(raw.slice(i, i + 2), 16);
}
return bytes;
}
```
## Usage: Advanced Example
```typescript
import { isHex } from "@suaveplan/utils/validate";
// Validate incoming webhook signature before crypto ops
function verifySignature(payload: string, sigHeader: unknown): boolean {
if (!isHex(sigHeader)) {
console.warn("Invalid signature format — expected hex");
return false;
}
// sigHeader is now narrowed to string
const sigBytes = Buffer.from(sigHeader, "hex");
// ... HMAC verification ...
return true;
}
// Use in Zod custom refinement
import z from "zod";
const hexStringSchema = z.string().refine(isHex, {
message: "Must be a non-empty even-length hexadecimal string",
});
```
## API Reference
### `isHex(i: unknown): i is string`
Validate that a value is a well-formed hexadecimal byte string.
| Parameter | Type | Description |
|-----------|-----------|--------------------------|
| `i` | `unknown` | Value to test. |
**Returns:** `boolean` — `true` if and only if `i` is a `string` with length > 0, even length, and only `[A-Fa-f0-9]` characters.
**Does not throw.**
**Rules:**
- Empty string → `false`.
- Odd-length string → `false` (not a complete sequence of bytes).
- Any non-hex character (`g`–`z`, `G`–`Z`, digits > 9, symbols) → `false`.
- Non-string input → `false`.
## Implementation Notes
The guard uses the regex `/^([A-Fa-f0-9]{2})+$/` which enforces both character validity and even length in a single pass. The `+` quantifier (one or more pairs) additionally rejects empty strings without a separate length check.
The function is declared as a `const` arrow (rather than a `function` declaration) to emphasize that it is a pure predicate with no side effects and no prototype overhead.
The regex is compiled once at module load time because it is a literal constant in the regex source, eliminating per-call compilation cost.

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { isHex } from "./validate.js";
describe("isHex", () => {
it("returns true for even-length hex strings", { timeout: 5000 }, () => {
expect(isHex("a1b2c3")).toBe(true);
expect(isHex("ff")).toBe(true);
expect(isHex("")).toBe(false); // empty string is not valid hex
});
it("returns false for odd-length strings", { timeout: 5000 }, () => {
expect(isHex("abc")).toBe(false);
});
it("returns false for non-hex characters", { timeout: 5000 }, () => {
expect(isHex("gg")).toBe(false);
});
it("returns false for non-strings", { timeout: 5000 }, () => {
expect(isHex(123)).toBe(false);
expect(isHex(null)).toBe(false);
});
});

View File

@@ -0,0 +1,35 @@
/**
* Low-level type-guard validators for primitive encoding formats.
*
* These utilities perform inexpensive, format-only validation without
* decoding the data. They are designed to be used as type guards in
* conditional branches, enabling TypeScript narrowing without the cost
* of a full parse-and-reconstruct cycle.
*
* @module validate
*/
/**
* Check whether a value is a valid hexadecimal-encoded byte string.
*
* A valid hex string must:
* - Be of type `string`.
* - Have a non-zero, even number of characters (each byte is two hex digits).
* - Contain only characters in `[A-Fa-f0-9]`.
*
* An empty string returns `false` because it does not encode any bytes.
*
* @param i - The value to test.
* @returns `true` if `i` is a valid hex-encoded byte string.
*
* @example
* ```ts
* isHex("deadbeef"); // true
* isHex("abc"); // false — odd length
* isHex(""); // false — empty
* isHex("gg"); // false — non-hex char
* isHex(42); // false — not a string
* ```
*/
export const isHex = (i: unknown): i is string =>
typeof i === "string" && i.length > 0 && /^([A-Fa-f0-9]{2})+$/.test(i);

View File

@@ -0,0 +1,15 @@
export {
DEFAULT_MAX_MESSAGE_SIZE,
formatSize,
getDataSize,
isWithinSizeLimit,
parseSize,
SIZE_LIMITS,
truncateToSize,
validateSize,
} from "./size.js";
export {
sizeBytesSchema,
sizeLimitsSchema,
sizeStringSchema,
} from "./size.schemas.js";

View File

@@ -0,0 +1,105 @@
# validation/size
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `validation/size` module provides consistent byte-size validation, formatting, parsing, and truncation utilities for use across transport and networking packages. Oversized payloads are a common Denial of Service (DoS) vector — a single large message can exhaust memory, starve the event loop, or cause upstream timeouts. By centralizing size enforcement in one place, every consumer gets uniform behavior, shared OTel counters (so violations appear in observability dashboards without per-package instrumentation), and no duplicated regex or threshold logic.
## Features
- **`SIZE_LIMITS`**: named constants for five common size tiers — SMALL (10 KB), MEDIUM (100 KB), LARGE (1 MB), XLARGE (10 MB), MAX (100 MB).
- **`DEFAULT_MAX_MESSAGE_SIZE`**: 1 MB, the default upper bound used by `validateSize` and `isWithinSizeLimit`.
- **`getDataSize`**: returns the byte length of a string (UTF-8 via `TextEncoder`), `Uint8Array`, `ArrayBuffer`, or `number` (always 8 bytes as IEEE 754 float64).
- **`validateSize`**: throws a typed `UtilsError` with code `VALIDATION.SIZE_EXCEEDED` when data exceeds the limit. Emits OTel counters for total calls and violation count.
- **`isWithinSizeLimit`**: non-throwing boolean version of `validateSize`.
- **`formatSize`**: converts a byte count to a human-readable string such as `"1.50 MB"`.
- **`parseSize`**: converts size strings (`"1KB"`, `"1.5MB"`, `"100"`) to byte integers. Throws `UtilsError` with code `VALIDATION.PARSE_FAILED` for unrecognised formats.
- **`truncateToSize`**: truncates a string to fit within a byte limit using binary search for correctness with multibyte UTF-8 content.
- **Zod schemas**: `sizeBytesSchema`, `sizeStringSchema`, `sizeLimitsSchema`.
## Usage: Basic Example
```typescript
import { validateSize, SIZE_LIMITS } from "@suaveplan/utils/validation";
// Validate an API request body
function handleRequest(body: string): void {
validateSize(body, SIZE_LIMITS.MEDIUM); // throws if > 100 KB
processBody(body);
}
```
## Usage: Advanced Example
```typescript
import {
formatSize,
getDataSize,
isWithinSizeLimit,
parseSize,
truncateToSize,
DEFAULT_MAX_MESSAGE_SIZE,
} from "@suaveplan/utils/validation";
// Report payload size before sending
function sendWebhook(payload: string): void {
const size = getDataSize(payload);
console.log(`Sending ${formatSize(size)} payload`);
if (!isWithinSizeLimit(payload, DEFAULT_MAX_MESSAGE_SIZE)) {
// Gracefully truncate rather than fail
const truncated = truncateToSize(payload, DEFAULT_MAX_MESSAGE_SIZE);
console.warn(`Payload truncated from ${formatSize(size)} to ${formatSize(getDataSize(truncated))}`);
deliver(truncated);
return;
}
deliver(payload);
}
// Parse a config-defined size limit
const configuredLimit = parseSize(process.env.MAX_PAYLOAD_SIZE ?? "1MB");
// → 1048576
```
## API Reference
### `getDataSize(data: string | Uint8Array | ArrayBuffer | number): number`
Return the byte size of the given data.
| Type | Measurement |
|--------------|---------------------------|
| `string` | UTF-8 bytes via TextEncoder |
| `Uint8Array` | `byteLength` |
| `ArrayBuffer`| `byteLength` |
| `number` | Always 8 (float64) |
### `validateSize(data, maxSize?): void`
Throw `UtilsError` (`VALIDATION.SIZE_EXCEEDED`) if data exceeds `maxSize` (default 1 MB). Emits OTel counters.
### `isWithinSizeLimit(data, maxSize?): boolean`
Non-throwing boolean check. Returns `true` when data fits within `maxSize`.
### `formatSize(bytes: number, decimals?: number): string`
Format bytes as `"1.00 KB"`, `"1.50 MB"`, etc. Defaults to 2 decimal places.
### `parseSize(sizeStr: string): number`
Parse `"100"`, `"1KB"`, `"1.5MB"` to byte integers. Throws `UtilsError` (`VALIDATION.PARSE_FAILED`) for invalid input.
### `truncateToSize(data: string, maxSize: number): string`
Binary-search truncation for UTF-8 safety. Returns input unchanged if already within limit.
## Implementation Notes
`getDataSize` for strings uses `TextEncoder.encode().byteLength` rather than `Blob` or `Buffer.byteLength` because `TextEncoder` is available in all modern runtimes (browser, Node.js >= 11, Bun) and is synchronous with no I/O overhead.
`truncateToSize` uses binary search (O(log n) character positions) rather than linear slicing to handle the case where multibyte characters sit exactly at the boundary. The algorithm always produces the longest valid substring within the byte limit.
OTel counters are created once at module load using the `@suaveplan/utils/validation` meter. If no OTel SDK is configured, the NoopMeter silently ignores all calls.

View File

@@ -0,0 +1,26 @@
import z from "zod";
/**
* Zod schema for a size limit value in bytes (positive integer).
*/
export const sizeBytesSchema = z.number().int().positive();
/**
* Zod schema for a human-readable size string (e.g. "1.5MB", "100KB").
*/
export const sizeStringSchema = z
.string()
.regex(/^\d+(?:\.\d+)?\s*(B|KB|MB|GB|TB)?$/i, {
message: "Must be a valid size string like '1MB', '100KB', or '512'",
});
/**
* Zod schema for the SIZE_LIMITS constants object.
*/
export const sizeLimitsSchema = z.object({
SMALL: z.number(),
MEDIUM: z.number(),
LARGE: z.number(),
XLARGE: z.number(),
MAX: z.number(),
});

View File

@@ -0,0 +1,113 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DEFAULT_MAX_MESSAGE_SIZE,
formatSize,
getDataSize,
isWithinSizeLimit,
parseSize,
SIZE_LIMITS,
truncateToSize,
validateSize,
} from "./size.js";
describe("SIZE_LIMITS", () => {
it("SMALL is 10 KB", { timeout: 5000 }, () =>
expect(SIZE_LIMITS.SMALL).toBe(10 * 1024)
);
it("LARGE is 1 MB", { timeout: 5000 }, () =>
expect(SIZE_LIMITS.LARGE).toBe(1024 * 1024)
);
});
describe("DEFAULT_MAX_MESSAGE_SIZE", () => {
it("is 1 MB", { timeout: 5000 }, () =>
expect(DEFAULT_MAX_MESSAGE_SIZE).toBe(1024 * 1024)
);
});
describe("getDataSize", () => {
it("returns byte length of ASCII string", { timeout: 5000 }, () =>
expect(getDataSize("hello")).toBe(5)
);
it("returns byte length of ArrayBuffer", { timeout: 5000 }, () => {
expect(getDataSize(new ArrayBuffer(100))).toBe(100);
});
it("handles multibyte characters", { timeout: 5000 }, () => {
expect(getDataSize("こ")).toBeGreaterThan(1);
});
it("returns byte length of Uint8Array", { timeout: 5000 }, () => {
expect(getDataSize(new Uint8Array(8))).toBe(8);
});
it("returns 8 for a number (float64)", { timeout: 5000 }, () => {
expect(getDataSize(3.14)).toBe(8);
});
});
describe("validateSize", () => {
it("does not throw when within limit", { timeout: 5000 }, () => {
expect(() => validateSize("hello", 100)).not.toThrow();
});
it("throws UtilsError when exceeding limit", { timeout: 5000 }, () => {
expect(() => validateSize("hello", 3)).toThrow();
});
it("uses default maxSize (1 MB) when not specified", {
timeout: 5000,
}, () => {
expect(() => validateSize("small")).not.toThrow();
});
});
describe("isWithinSizeLimit", () => {
it("returns true when data is within limit", { timeout: 5000 }, () => {
expect(isWithinSizeLimit("hello", 100)).toBe(true);
});
it("returns false when data exceeds limit", { timeout: 5000 }, () => {
expect(isWithinSizeLimit("hello", 3)).toBe(false);
});
it("uses default maxSize when not specified", { timeout: 5000 }, () => {
expect(isWithinSizeLimit("small")).toBe(true);
});
});
describe("formatSize", () => {
it("formats bytes as human-readable", { timeout: 5000 }, () => {
expect(formatSize(1024)).toBe("1.00 KB");
expect(formatSize(1024 * 1024)).toBe("1.00 MB");
});
it("returns '0 Bytes' for zero", { timeout: 5000 }, () => {
expect(formatSize(0)).toBe("0 Bytes");
});
it("uses custom decimal places", { timeout: 5000 }, () => {
expect(formatSize(1024, 0)).toBe("1 KB");
});
});
describe("parseSize", () => {
it("parses '1KB' to 1024", { timeout: 5000 }, () =>
expect(parseSize("1KB")).toBe(1024)
);
it("parses '1.5MB'", { timeout: 5000 }, () =>
expect(parseSize("1.5MB")).toBe(Math.floor(1.5 * 1024 * 1024))
);
it("parses plain bytes '100'", { timeout: 5000 }, () =>
expect(parseSize("100")).toBe(100)
);
it("parses '100B'", { timeout: 5000 }, () =>
expect(parseSize("100B")).toBe(100)
);
it("throws for invalid format", { timeout: 5000 }, () => {
expect(() => parseSize("not-a-size")).toThrow();
});
});
describe("truncateToSize", () => {
it("truncates string to byte limit", { timeout: 5000 }, () => {
const result = truncateToSize("Hello, world!", 5);
expect(getDataSize(result)).toBeLessThanOrEqual(5);
});
it("returns string unchanged if already within limit", {
timeout: 5000,
}, () => {
expect(truncateToSize("hi", 100)).toBe("hi");
});
});

View File

@@ -0,0 +1,178 @@
/**
* Message and data size validation utilities.
*
* Provides consistent size validation across transport and network packages
* to prevent Denial of Service (DoS) attacks via oversized payloads.
* All validators use TextEncoder for accurate UTF-8 byte counting and
* emit OTel counters so size violations appear in observability dashboards.
*
* @module validation/size
*/
import { metrics } from "@opentelemetry/api";
import { UTILS_CODES, UtilsError } from "../errors/utils-error.js";
const meter = metrics.getMeter("@suaveplan/utils/validation");
const validateCounter = meter.createCounter(
"utils.validation.validateSize.count"
);
const exceedCounter = meter.createCounter(
"utils.validation.validateSize.exceeded"
);
const encoder = new TextEncoder();
/** Default maximum message size (1 MB). */
export const DEFAULT_MAX_MESSAGE_SIZE = 1024 * 1024;
/** Recommended size limits for different use cases. */
export const SIZE_LIMITS = {
/** Chat messages, notifications (10 KB). */
SMALL: 10 * 1024,
/** API requests, config files (100 KB). */
MEDIUM: 100 * 1024,
/** File uploads, large payloads (1 MB). */
LARGE: 1024 * 1024,
/** Streaming data, media (10 MB). */
XLARGE: 10 * 1024 * 1024,
/** Maximum reasonable size (100 MB). */
MAX: 100 * 1024 * 1024,
} as const;
type SizableData = string | Uint8Array | ArrayBuffer | number;
/**
* Get the actual byte size of data.
*
* - string uses UTF-8 byte length via TextEncoder.
* - Uint8Array / ArrayBuffer uses byteLength.
* - number is always 8 bytes (IEEE 754 float64).
*
* @param data - Data to measure.
* @returns Size in bytes.
*/
export function getDataSize(data: SizableData): number {
if (typeof data === "string") {
return encoder.encode(data).byteLength;
}
if (data instanceof ArrayBuffer) {
return data.byteLength;
}
if (data instanceof Uint8Array) {
return data.byteLength;
}
// typeof data === "number"
return 8; // float64
}
/**
* Validate data size against a maximum limit.
*
* @param data - Data to validate.
* @param maxSize - Maximum allowed size in bytes (default: 1 MB).
* @throws {UtilsError} VALIDATION.SIZE_EXCEEDED if data exceeds maxSize.
*/
export function validateSize(
data: SizableData,
maxSize: number = DEFAULT_MAX_MESSAGE_SIZE
): void {
validateCounter.add(1);
const actualSize = getDataSize(data);
if (actualSize > maxSize) {
exceedCounter.add(1);
throw new UtilsError(
UTILS_CODES["VALIDATION.SIZE_EXCEEDED"],
`Data size ${actualSize} bytes exceeds maximum ${maxSize} bytes`
);
}
}
/**
* Check if data is within size limit without throwing.
*
* @param data - Data to check.
* @param maxSize - Maximum allowed size in bytes.
* @returns true if within limit, false otherwise.
*/
export function isWithinSizeLimit(
data: SizableData,
maxSize: number = DEFAULT_MAX_MESSAGE_SIZE
): boolean {
return getDataSize(data) <= maxSize;
}
/**
* Format a byte count to a human-readable string.
*
* @param bytes - Number of bytes.
* @param decimals - Decimal places (default 2).
* @returns Formatted string, e.g. "1.50 KB".
*/
export function formatSize(bytes: number, decimals = 2): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const dm = Math.max(0, decimals);
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / k ** i;
return `${value.toFixed(dm)} ${sizes[i]}`;
}
/**
* Parse a human-readable size string to bytes.
*
* Supported units (case-insensitive): B, KB, MB, GB, TB.
* A bare number is treated as bytes.
*
* @param sizeStr - Size string, e.g. "1.5MB", "100", "1 GB".
* @returns Size in bytes (floor of fractional values).
* @throws {UtilsError} VALIDATION.PARSE_FAILED for unrecognised format.
*/
export function parseSize(sizeStr: string): number {
const match = /^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)?$/i.exec(sizeStr.trim());
if (!match?.[1]) {
throw new UtilsError(
UTILS_CODES["VALIDATION.PARSE_FAILED"],
`Invalid size format: "${sizeStr}"`
);
}
const value = Number.parseFloat(match[1]);
const unit = (match[2] ?? "B").toUpperCase();
const multipliers: Record<string, number> = {
B: 1,
KB: 1024,
MB: 1024 ** 2,
GB: 1024 ** 3,
TB: 1024 ** 4,
};
// multiplier is guaranteed defined because unit comes from the regex capture group
return Math.floor(value * multipliers[unit]!);
}
/**
* Truncate a string to fit within a byte size limit.
*
* Uses binary search over character positions for accuracy with multibyte
* UTF-8 content. Returns the input unchanged if already within limit.
*
* @param data - String to truncate.
* @param maxSize - Maximum size in bytes.
* @returns Truncated string that fits within maxSize bytes.
*/
export function truncateToSize(data: string, maxSize: number): string {
if (getDataSize(data) <= maxSize) return data;
let low = 0;
let high = data.length;
while (low < high) {
const mid = Math.floor((low + high + 1) / 2);
const sub = data.slice(0, mid);
if (getDataSize(sub) <= maxSize) {
low = mid;
} else {
high = mid - 1;
}
}
return data.slice(0, low);
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/utils",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,31 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/*.schemas.ts",
"src/**/index.ts",
"src/types/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});