mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
chore(utils): migrate to shared random/ID utilities and add enforcement linting (#4623)
* chore(utils): migrate to shared random/ID utilities and add enforcement linting - Replace all Math.random(), crypto.randomUUID(), crypto.randomBytes(), nanoid, and uuid usages with shared @sim/utils/random and @sim/utils/id helpers across 72 files - Add new @sim/utils exports: deepClone, omit, filterUndefined (object), truncate (string), backoffWithJitter, parseRetryAfter (retry), getErrorMessage (errors) - Sweep all getErrorMessage, sleep, deepClone callsites across 500+ files to use shared utilities - Add Biome noRestrictedImports rule to catch nanoid, uuid, and crypto named imports at lint time - Add scripts/check-utils-enforcement.ts to catch Math.random and crypto.* global property access - Add check:utils script to package.json * chore(utils): replace deepClone wrapper with structuredClone built-in deepClone() was a one-line wrapper around structuredClone(), which is universally available in Node 17+ and all modern browsers. Removing the abstraction reduces indirection and means contributors don't need to learn a project-specific name for a well-known built-in. - Remove deepClone from packages/utils/src/object.ts and index.ts - Replace all 17 call sites with structuredClone() directly - Update check:utils script suggestion text - Update CLAUDE.md and global.md docs * fix(utils): add missing biome noRestrictedImports rule and correct truncate docs - Add noRestrictedImports to biome.json under style — bans nanoid and uuid package imports at lint time (crypto.randomUUID/randomBytes are caught by the check:utils grep script which handles global property access) - Correct truncate() TSDoc and parameter name: sliceLength makes it clear that total output length is sliceLength + suffix.length, matching the behavior all callers were already written to expect * fix(utils): add missing getErrorMessage imports at 4 call sites The sweep agents added getErrorMessage calls without the corresponding import in 4 files, causing test failures. Added the missing imports. * fix(utils): fix build errors from getErrorMessage sweep and retry.ts Turbopack issue - Fix retry.ts cross-file import: Turbopack cannot resolve './random.js' for internal package imports; inline the jitter crypto call directly - Add missing getErrorMessage imports to 32 files where the sweep added calls without the corresponding import (caught by type-check and test runs) - Remove accidental getErrorMessage import from crowdstrike/query/route.ts which has its own domain-specific getErrorMessage for parsing CrowdStrike's JSON error format - Fix use-sub-block-value.ts type error from structuredClone narrowing: add 'as T' cast at emitValue callsite (safe — valueCopy is always a structural copy of newValue) * fix(tools): use toError in crowdstrike catch block instead of local getErrorMessage The catch block was calling the local getErrorMessage function which parses CrowdStrike API JSON responses, not JavaScript Error objects. Use toError(error).message to correctly extract the message from a caught value in this context.
This commit is contained in:
@@ -14,6 +14,10 @@
|
||||
"types": "./src/id.ts",
|
||||
"default": "./src/id.ts"
|
||||
},
|
||||
"./random": {
|
||||
"types": "./src/random.ts",
|
||||
"default": "./src/random.ts"
|
||||
},
|
||||
"./errors": {
|
||||
"types": "./src/errors.ts",
|
||||
"default": "./src/errors.ts"
|
||||
@@ -25,6 +29,18 @@
|
||||
"./formatting": {
|
||||
"types": "./src/formatting.ts",
|
||||
"default": "./src/formatting.ts"
|
||||
},
|
||||
"./object": {
|
||||
"types": "./src/object.ts",
|
||||
"default": "./src/object.ts"
|
||||
},
|
||||
"./string": {
|
||||
"types": "./src/string.ts",
|
||||
"default": "./src/string.ts"
|
||||
},
|
||||
"./retry": {
|
||||
"types": "./src/retry.ts",
|
||||
"default": "./src/retry.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -8,6 +8,20 @@ export function toError(value: unknown): Error {
|
||||
return new Error(String(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a string message from an unknown caught value.
|
||||
* Use instead of `e instanceof Error ? e.message : 'fallback'` in catch clauses.
|
||||
*
|
||||
* - Error instance → `error.message`
|
||||
* - Non-empty string → the string itself (handles `throw 'msg'` patterns)
|
||||
* - Otherwise → `fallback` if provided, or `String(value)`
|
||||
*/
|
||||
export function getErrorMessage(value: unknown, fallback?: string): string {
|
||||
if (value instanceof Error) return value.message
|
||||
if (typeof value === 'string' && value.length > 0) return value
|
||||
return fallback ?? String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns PostgreSQL error code (e.g. `23505` for unique_violation) when present on a thrown value.
|
||||
* Normalizes common Drizzle / `postgres` driver shapes and walks `cause` chains.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { getPostgresErrorCode, toError } from './errors.js'
|
||||
export { getErrorMessage, getPostgresErrorCode, toError } from './errors.js'
|
||||
export {
|
||||
formatAbsoluteDate,
|
||||
formatCompactTimestamp,
|
||||
@@ -12,3 +12,16 @@ export {
|
||||
} from './formatting.js'
|
||||
export { noop, sleep } from './helpers.js'
|
||||
export { generateId, generateShortId, isValidUuid } from './id.js'
|
||||
export { filterUndefined, omit } from './object.js'
|
||||
export {
|
||||
generateRandomBytes,
|
||||
generateRandomHex,
|
||||
generateRandomString,
|
||||
LOWERCASE_ALPHANUMERIC_ALPHABET,
|
||||
randomFloat,
|
||||
randomInt,
|
||||
randomItem,
|
||||
} from './random.js'
|
||||
export type { BackoffOptions } from './retry.js'
|
||||
export { backoffWithJitter, parseRetryAfter } from './retry.js'
|
||||
export { truncate } from './string.js'
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Returns a new object with the given keys removed.
|
||||
*
|
||||
* @example
|
||||
* omit({ a: 1, b: 2, c: 3 }, ['b', 'c']) // { a: 1 }
|
||||
*/
|
||||
export function omit<T extends object, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
|
||||
const result = { ...obj }
|
||||
for (const key of keys) {
|
||||
delete result[key]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a shallow copy of `obj` with all `undefined`-valued keys removed.
|
||||
* Useful for building query-param or request-body objects where undefined
|
||||
* fields should not be serialized.
|
||||
*
|
||||
* Replaces the common `Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))` pattern.
|
||||
*/
|
||||
export function filterUndefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
|
||||
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as Partial<T>
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Cryptographically secure random utilities built on `crypto.getRandomValues()`.
|
||||
* Works in all contexts including non-secure (HTTP) browser environments.
|
||||
*/
|
||||
|
||||
/** Lowercase alphanumeric characters used as the default alphabet for random strings. */
|
||||
export const LOWERCASE_ALPHANUMERIC_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
||||
|
||||
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
|
||||
/**
|
||||
* Generates cryptographically secure random bytes.
|
||||
* @param length - Number of bytes to generate
|
||||
* @returns Uint8Array of random bytes
|
||||
*/
|
||||
export function generateRandomBytes(length: number): Uint8Array {
|
||||
return crypto.getRandomValues(new Uint8Array(length))
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a cryptographically secure random hex string.
|
||||
* @param length - Number of hex characters (default: 16)
|
||||
* @returns Lowercase hex string of the given length
|
||||
*/
|
||||
export function generateRandomHex(length = 16): string {
|
||||
const bytes = generateRandomBytes(Math.ceil(length / 2))
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
.slice(0, length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a cryptographically secure random alphanumeric string.
|
||||
* @param length - Number of characters (default: 16)
|
||||
* @returns Random string composed of A-Z, a-z, 0-9
|
||||
*/
|
||||
export function generateRandomString(length = 16): string {
|
||||
const bytes = generateRandomBytes(length)
|
||||
return Array.from(bytes)
|
||||
.map((b) => CHARS[b % CHARS.length])
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a cryptographically secure random float in [0, 1).
|
||||
* Drop-in replacement for `Math.random()`.
|
||||
*/
|
||||
export function randomFloat(): number {
|
||||
const [value] = crypto.getRandomValues(new Uint32Array(1))
|
||||
return value / 0x100000000
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a cryptographically secure random integer in [min, max).
|
||||
* Uses rejection sampling for uniform distribution (no modulo bias).
|
||||
* @param min - Inclusive lower bound
|
||||
* @param max - Exclusive upper bound
|
||||
*/
|
||||
export function randomInt(min: number, max: number): number {
|
||||
const range = max - min
|
||||
if (range <= 0) throw new RangeError(`randomInt: max (${max}) must be greater than min (${min})`)
|
||||
const threshold = (0x100000000 - (0x100000000 % range)) >>> 0
|
||||
let value: number
|
||||
do {
|
||||
;[value] = crypto.getRandomValues(new Uint32Array(1))
|
||||
} while (value >= threshold)
|
||||
return min + (value % range)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a uniformly random element from a non-empty array.
|
||||
* @param items - Array to sample from (must have at least one element)
|
||||
*/
|
||||
export function randomItem<T>(items: readonly T[]): T {
|
||||
if (items.length === 0) throw new RangeError('randomItem: array must not be empty')
|
||||
return items[randomInt(0, items.length)]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/** Default retry pacing: 500 ms floor, 30 s ceiling. */
|
||||
const DEFAULT_BACKOFF_BASE_MS = 500
|
||||
const DEFAULT_BACKOFF_MAX_MS = 30_000
|
||||
|
||||
export interface BackoffOptions {
|
||||
baseMs?: number
|
||||
maxMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the next delay for a retry loop.
|
||||
*
|
||||
* When `retryAfterMs` is non-null (from a `Retry-After` response header), the
|
||||
* value is clamped to `[baseMs, maxMs]` so a malformed `Retry-After: 0` cannot
|
||||
* pin the loop into a tight retry. Otherwise returns exponential backoff with
|
||||
* ±20% jitter to avoid thundering-herd alignment across concurrent callers.
|
||||
* Attempt is 1-indexed.
|
||||
*/
|
||||
export function backoffWithJitter(
|
||||
attempt: number,
|
||||
retryAfterMs: number | null,
|
||||
options: BackoffOptions = {}
|
||||
): number {
|
||||
const baseMs = options.baseMs ?? DEFAULT_BACKOFF_BASE_MS
|
||||
const maxMs = options.maxMs ?? DEFAULT_BACKOFF_MAX_MS
|
||||
if (retryAfterMs !== null) {
|
||||
return Math.min(Math.max(retryAfterMs, baseMs), maxMs)
|
||||
}
|
||||
const exponential = Math.min(baseMs * 2 ** (attempt - 1), maxMs)
|
||||
// Inline crypto float to avoid cross-file imports within the package (Turbopack limitation)
|
||||
const jitter = crypto.getRandomValues(new Uint32Array(1))[0] / 0x100000000
|
||||
return exponential * (0.8 + jitter * 0.4)
|
||||
}
|
||||
|
||||
/** Maximum `Retry-After` value honored: 30 s. Prevents a misconfigured upstream from stalling callers. */
|
||||
const RETRY_AFTER_MAX_MS = 30_000
|
||||
|
||||
/**
|
||||
* Parses an HTTP `Retry-After` header (either delta-seconds or an HTTP-date)
|
||||
* into a millisecond delay, capped at 30 s.
|
||||
* Returns `null` when the header is absent or unparseable so callers can fall
|
||||
* back to their own backoff.
|
||||
*/
|
||||
export function parseRetryAfter(header: string | null): number | null {
|
||||
if (!header) return null
|
||||
const trimmed = header.trim()
|
||||
if (trimmed.length === 0) return null
|
||||
const seconds = Number(trimmed)
|
||||
if (Number.isFinite(seconds) && seconds >= 0) {
|
||||
return Math.min(Math.floor(seconds * 1000), RETRY_AFTER_MAX_MS)
|
||||
}
|
||||
const dateMs = Date.parse(trimmed)
|
||||
if (!Number.isNaN(dateMs)) {
|
||||
const delta = dateMs - Date.now()
|
||||
if (delta <= 0) return 0
|
||||
return Math.min(delta, RETRY_AFTER_MAX_MS)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Truncates `str` if it exceeds `sliceLength` characters, appending `suffix`.
|
||||
* The total output length when truncated is `sliceLength + suffix.length`.
|
||||
* Defaults suffix to `'...'`.
|
||||
*
|
||||
* @example
|
||||
* truncate('hello world', 8) // 'hello wo...' (11 chars)
|
||||
* truncate('hello world', 8, ' …') // 'hello wo …'
|
||||
* truncate('hi', 10) // 'hi'
|
||||
*/
|
||||
export function truncate(str: string, sliceLength: number, suffix = '...'): string {
|
||||
return str.length > sliceLength ? str.slice(0, sliceLength) + suffix : str
|
||||
}
|
||||
Reference in New Issue
Block a user