From 5aa94d3de25fb6aa2547ee9f23b2860cb2779c7c Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 1 May 2026 11:50:43 -0400 Subject: [PATCH 1/4] feat(builtins): prefer Node 22+ builtins for glob, hash, async iter, delay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt native APIs where the option surface lines up, with feature-detect fallbacks where needed. Engines is >=22, so the builtins are present in practice; detection guards against monkey-patching and minor-version gaps. src/primordials.ts - Add `ArrayFromAsync` (ES2024). Unbound, matching `ArrayFrom` — the spec algorithm uses `this` only for the species constructor and falls back to plain Array when undefined. src/promises.ts - Add `fromAsync()` helper backed by the new `ArrayFromAsync` primordial, with a `for await` + push fallback for older runtimes. - Refactor `withResolvers` to consume the existing `PromiseWithResolvers` primordial directly instead of re-detecting. src/globs.ts - Route `glob` / `globSync` through `node:fs.glob` / `node:fs.globSync` when the caller's options reduce to `cwd` + `ignore` (mapped to `exclude`); fall back to fast-glob for the wider option surface. - `getGlobMatcher` fast-paths single non-negated patterns through `path.matchesGlob` (Node 22.5+/20.17+) and caches the result in the existing LRU. Trailing-slash normalization still applies. - Async path uses the new `fromAsync` helper instead of an IIFE. - `canUseNodeFsGlob` and `getMatchesGlob` exported as `@internal` for unit tests. Tri-state cache uses a `_probed` boolean (no `null`). src/crypto.ts (new) - `hash(algorithm, data, encoding)` helper that prefers Node's one-shot `crypto.hash` (~30% faster on small inputs) with a `createHash().update().digest()` fallback. `getNativeHash` exposed as `@internal`. Wired into `package.json` exports. src/http-request.ts - Replace 3 `await new Promise(r => setTimeout(r, ms))` sites with `await delay(ms)` from `node:timers/promises`. JSDoc example updated. src/dlx/{cache,integrity,binary}.ts - Route 4 one-shot `createHash(...).update(...).digest(...)` calls through the new `hash()` helper. `binary.ts` keeps `getCrypto()` for `timingSafeEqual`. test/unit/{globs,dlx/package}.test.mts - Mark two describes that mutate describe-scope variables as sequential. Pre-existing flakes exposed reliably by the faster `fs.glob` path (vitest default `sequence.concurrent: true` raced on shared `tmpRoot` / `process.env['SOCKET_DLX_DIR']`). test/unit/{primordials,promises,crypto}.test.mts - New tests covering `ArrayFromAsync`, `fromAsync` (native + fallback branches), `hash`, and `getNativeHash`. Coverage on modified files: 98% statements, 100% functions, 89% branches. --- docs/api-index.md | 1 + package.json | 4 + src/crypto.ts | 91 +++++++++++++++ src/dlx/binary.ts | 13 +-- src/dlx/cache.ts | 18 +-- src/dlx/integrity.ts | 8 +- src/globs.ts | 204 +++++++++++++++++++++++++++++---- src/http-request.ts | 15 ++- src/primordials.ts | 17 +++ src/promises.ts | 66 ++++++++--- test/unit/crypto.test.mts | 132 +++++++++++++++++++++ test/unit/dlx/package.test.mts | 7 +- test/unit/globs.test.mts | 6 +- test/unit/primordials.test.mts | 52 +++++++++ test/unit/promises.test.mts | 128 +++++++++++++++++++++ 15 files changed, 684 insertions(+), 78 deletions(-) create mode 100644 src/crypto.ts create mode 100644 test/unit/crypto.test.mts diff --git a/docs/api-index.md b/docs/api-index.md index 4e348d00..f8c1c793 100644 --- a/docs/api-index.md +++ b/docs/api-index.md @@ -20,6 +20,7 @@ Each entry links to the source module and shows the first sentence of its `@file | [`@socketsecurity/lib/cacache`](../src/cacache.ts) | Cacache utilities for Socket ecosystem shared content-addressable cache. | | [`@socketsecurity/lib/cache-with-ttl`](../src/cache-with-ttl.ts) | Generic TTL-based caching utility using cacache. | | [`@socketsecurity/lib/colors`](../src/colors.ts) | Color utilities for RGB color conversion and manipulation. | +| [`@socketsecurity/lib/crypto`](../src/crypto.ts) | Crypto helpers that prefer Node builtins where available. | | [`@socketsecurity/lib/debug`](../src/debug.ts) | Debug logging utilities with lazy loading and environment-based control. | | [`@socketsecurity/lib/env`](../src/env.ts) | Environment variable parsing and conversion utilities. | | [`@socketsecurity/lib/errors`](../src/errors.ts) | Error utilities with cause chain support. | diff --git a/package.json b/package.json index a3b78bf3..d8e180d4 100644 --- a/package.json +++ b/package.json @@ -219,6 +219,10 @@ "types": "./dist/cover/types.d.ts", "default": "./dist/cover/types.js" }, + "./crypto": { + "types": "./dist/crypto.d.ts", + "default": "./dist/crypto.js" + }, "./debug": { "types": "./dist/debug.d.ts", "default": "./dist/debug.js" diff --git a/src/crypto.ts b/src/crypto.ts new file mode 100644 index 00000000..f4e5f47f --- /dev/null +++ b/src/crypto.ts @@ -0,0 +1,91 @@ +/** + * @fileoverview Crypto helpers that prefer Node builtins where available. + */ + +let _crypto: typeof import('node:crypto') | undefined +// `crypto.hash(algorithm, data, outputEncoding)` was added in Node +// v21.7.0 / v20.12.0 (Stable). Engines is >=22, so it's always present +// here in practice — feature-detect anyway because the type surface +// for `node:crypto` doesn't include it on every TS lib version. +// +// `_hash` is the resolved native function (or `undefined` if absent +// or not yet probed). `_hashProbed` distinguishes the two cases so a +// missing native is detected only once. +let _hash: typeof import('node:crypto').hash | undefined +let _hashProbed = false + +/** + * Lazily load the `node:crypto` module. + * + * @private + */ +/*@__NO_SIDE_EFFECTS__*/ +function getCrypto() { + if (_crypto === undefined) { + _crypto = /*@__PURE__*/ require('node:crypto') + } + return _crypto as typeof import('node:crypto') +} + +/** + * Resolve `crypto.hash` (or `undefined` if the runtime predates it). + * + * Exported for unit tests; not part of the public API. + * + * @internal + */ +/*@__NO_SIDE_EFFECTS__*/ +export function getNativeHash(): typeof import('node:crypto').hash | undefined { + if (!_hashProbed) { + const fn = ( + getCrypto() as typeof import('node:crypto') & { + hash?: unknown + } + ).hash + if (typeof fn === 'function') { + _hash = fn as typeof import('node:crypto').hash + } + _hashProbed = true + } + return _hash +} + +/** + * Compute a one-shot cryptographic hash. + * + * Prefers Node's `crypto.hash(algorithm, data, outputEncoding)` (added + * v21.7.0 / v20.12.0), which is ~30% faster than the + * `createHash().update().digest()` chain on small-to-medium inputs + * because it skips constructing the streaming `Hash` object. Falls + * back to that chain on older runtimes. + * + * Use this only for the one-shot case where the entire input is + * available as a single buffer or string; if you need to feed chunks, + * stay on `createHash()`. + * + * @example + * ```typescript + * import { hash } from '@socketsecurity/lib/crypto' + * + * hash('sha256', 'hello', 'hex') + * // '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824' + * + * hash('sha512', someBuffer, 'base64') + * // 'z4PhNX7vuL3xVChQ1m2AB9Yg5AULVxXcg/SpIdNs6c5H0NE8XYXysP+DGNKHfuwvY7kxvUdBeoGlODJ6+SfaPg==' + * ``` + */ +/*@__NO_SIDE_EFFECTS__*/ +export function hash( + algorithm: string, + data: string | NodeJS.ArrayBufferView, + outputEncoding: 'hex' | 'base64' | 'base64url' | 'binary', +): string { + const native = getNativeHash() + if (native !== undefined) { + return native(algorithm, data, outputEncoding) as string + } + return getCrypto() + .createHash(algorithm) + .update(data as string | Buffer) + .digest(outputEncoding) +} diff --git a/src/dlx/binary.ts b/src/dlx/binary.ts index 61ebccaf..f6df3a0d 100644 --- a/src/dlx/binary.ts +++ b/src/dlx/binary.ts @@ -11,6 +11,7 @@ import { normalizePath } from '../paths/normalize' import { getSocketDlxDir } from '../paths/socket' import { processLock } from '../process-lock' import { spawn } from '../spawn' +import { hash } from '../crypto' import { generateCacheKey } from './cache' import { normalizeHash } from './integrity' @@ -614,11 +615,7 @@ export async function downloadBinaryFile( if (stats.size > 0) { // File exists, compute and return SRI integrity hash. const fileBuffer = await fs.promises.readFile(destPath) - const hash = crypto - .createHash('sha512') - .update(fileBuffer) - .digest('base64') - return `sha512-${hash}` + return `sha512-${hash('sha512', fileBuffer, 'base64')}` } } @@ -638,11 +635,7 @@ export async function downloadBinaryFile( // Compute SRI integrity hash of downloaded file. const fileBuffer = await fs.promises.readFile(destPath) - const hash = crypto - .createHash('sha512') - .update(fileBuffer) - .digest('base64') - const actualIntegrity = `sha512-${hash}` + const actualIntegrity = `sha512-${hash('sha512', fileBuffer, 'base64')}` // Verify integrity if provided (constant-time comparison). if (integrity) { diff --git a/src/dlx/cache.ts b/src/dlx/cache.ts index 9a51a005..7f3b0551 100644 --- a/src/dlx/cache.ts +++ b/src/dlx/cache.ts @@ -1,19 +1,6 @@ /** @fileoverview Cache key generation utilities for DLX package installations. */ -let _crypto: typeof import('node:crypto') | undefined -/** - * Lazily load the crypto module to avoid Webpack errors. - * @private - */ -/*@__NO_SIDE_EFFECTS__*/ -function getCrypto() { - if (_crypto === undefined) { - // Use non-'node:' prefixed require to avoid Webpack errors. - - _crypto = /*@__PURE__*/ require('node:crypto') - } - return _crypto as typeof import('node:crypto') -} +import { hash } from '../crypto' /** * Generate a cache directory name using npm/npx approach. @@ -46,6 +33,5 @@ function getCrypto() { * ``` */ export function generateCacheKey(spec: string): string { - const crypto = getCrypto() - return crypto.createHash('sha512').update(spec).digest('hex').substring(0, 16) + return hash('sha512', spec, 'hex').substring(0, 16) } diff --git a/src/dlx/integrity.ts b/src/dlx/integrity.ts index 9f6c2ae8..fc07fdbf 100644 --- a/src/dlx/integrity.ts +++ b/src/dlx/integrity.ts @@ -11,7 +11,9 @@ * form carried around internally is always the object. */ -import { createHash, timingSafeEqual } from 'node:crypto' +import { timingSafeEqual } from 'node:crypto' + +import { hash } from '../crypto' import { BufferFrom, @@ -130,8 +132,8 @@ export function normalizeHash(spec: HashSpec): NormalizedHash { * buffer of bytes. */ export function computeHashes(bytes: Buffer): ComputedHashes { - const integrity = `sha512-${createHash('sha512').update(bytes).digest('base64')}` - const checksum = createHash('sha256').update(bytes).digest('hex') + const integrity = `sha512-${hash('sha512', bytes, 'base64')}` + const checksum = hash('sha256', bytes, 'hex') return { integrity, checksum } } diff --git a/src/globs.ts b/src/globs.ts index 92fd2813..98394ae2 100644 --- a/src/globs.ts +++ b/src/globs.ts @@ -9,6 +9,7 @@ import { LICENSE_GLOB_RECURSIVE, LICENSE_ORIGINAL_GLOB_RECURSIVE, } from './paths/globs' +import { fromAsync } from './promises' import type * as fastGlobType from './external/fast-glob' import type picomatchType from './external/picomatch' @@ -59,7 +60,96 @@ export interface GlobOptions extends FastGlobOptions { export type { Pattern, FastGlobOptions } let _fastGlob: typeof fastGlobType | undefined +let _fs: typeof import('node:fs') | undefined +let _fsPromises: typeof import('node:fs/promises') | undefined +let _path: typeof import('node:path') | undefined let _picomatch: typeof picomatchType | undefined +// `path.matchesGlob` was added in Node v22.5.0 / v20.17.0 (Stable). +// Engines is >=22, so it's missing only on 22.0.x – 22.4.x. +// `_matchesGlob` is the resolved native function (or `undefined` if +// absent or not yet probed). `_matchesGlobProbed` distinguishes the +// two cases so a missing native is detected only once. +let _matchesGlob: ((p: string, pattern: string) => boolean) | undefined +let _matchesGlobProbed = false + +/** + * Lazily load the fs module to avoid Webpack errors. + * Uses non-'node:' prefixed require to prevent Webpack bundling issues. + * + * @private + */ +/*@__NO_SIDE_EFFECTS__*/ +function getFs() { + if (_fs === undefined) { + _fs = /*@__PURE__*/ require('node:fs') + } + return _fs as typeof import('node:fs') +} + +/** + * Lazily load the fs/promises module to avoid Webpack errors. + * + * @private + */ +/*@__NO_SIDE_EFFECTS__*/ +function getFsPromises() { + if (_fsPromises === undefined) { + _fsPromises = /*@__PURE__*/ require('node:fs/promises') + } + return _fsPromises as typeof import('node:fs/promises') +} + +/** + * Lazily load the path module to avoid Webpack errors. + * + * @private + */ +/*@__NO_SIDE_EFFECTS__*/ +function getPath() { + if (_path === undefined) { + _path = /*@__PURE__*/ require('node:path') + } + return _path as typeof import('node:path') +} + +/** + * Resolve `path.matchesGlob` (or `undefined` if the runtime predates it). + * Probes once and caches the result for every subsequent call. + * + * Exported for unit tests; not part of the public API. + * + * @internal + */ +/*@__NO_SIDE_EFFECTS__*/ +export function getMatchesGlob(): + | ((p: string, pattern: string) => boolean) + | undefined { + if (!_matchesGlobProbed) { + const fn = ( + getPath() as typeof import('node:path') & { + matchesGlob?: unknown + } + ).matchesGlob + if (typeof fn === 'function') { + _matchesGlob = fn as (p: string, pattern: string) => boolean + } + _matchesGlobProbed = true + } + return _matchesGlob +} + +// Builtin glob support. Engines is >=22 so: +// `fs.glob` / `fs.globSync` / `fs.promises.glob` — added v22.0.0 +// (Stable). Always present. Supports { cwd, exclude, +// withFileTypes }. Lacks fast-glob's `absolute`, `onlyFiles`, +// `dot`, `caseSensitiveMatch`, `followSymbolicLinks`, etc., so we +// only delegate when the caller passes no unsupported options. +// +// `path.matchesGlob(path, pattern)` — added v22.5.0 / v20.17.0 +// (Stable). Not present on Node 22.0.x – 22.4.x, so feature-detect +// at the call site. Single string pattern only; no negation, no +// dot/nocase options. Used only for the trivial single-pattern, +// no-options matcher case. const MATCHER_CACHE_MAX_SIZE = 100 // LRU cache. We exploit Map's insertion-order iteration so eviction is O(1): @@ -289,28 +379,51 @@ export function getGlobMatcher( } } - // Separate positive and negative patterns. - const positivePatterns = patterns.filter( - p => !StringPrototypeStartsWith(p, '!'), - ) - const negativePatterns = patterns - .filter(p => StringPrototypeStartsWith(p, '!')) - .map(p => p.slice(1)) - - // Use ignore option for negation patterns. - const matchOptions = { - dot: true, - nocase: true, - ...options, - ...(negativePatterns.length > 0 ? { ignore: negativePatterns } : {}), + // Fast path: single non-negated pattern, no ignore, no options that + // change semantics → use Node's builtin `path.matchesGlob`. Added in + // v22.5.0 / v20.17.0 (Stable). Skips loading picomatch entirely. + // Bail out if the caller asks for `dot: false` or `nocase: false`, + // since matchesGlob doesn't expose those toggles. `matchesGlob` is + // missing on 22.0.x – 22.4.x, so feature-detect. + let matcher: ((path: string) => boolean) | undefined + if ( + patterns.length === 1 && + !StringPrototypeStartsWith(patterns[0]!, '!') && + (!options || + ((options.ignore === undefined || options.ignore.length === 0) && + options.dot !== false && + options.nocase !== false)) + ) { + const matchesGlob = getMatchesGlob() + if (matchesGlob !== undefined) { + const pattern = patterns[0]! + matcher = (p: string) => matchesGlob(p, pattern) + } } + if (matcher === undefined) { + // Separate positive and negative patterns. + const positivePatterns = patterns.filter( + p => !StringPrototypeStartsWith(p, '!'), + ) + const negativePatterns = patterns + .filter(p => StringPrototypeStartsWith(p, '!')) + .map(p => p.slice(1)) - /* c8 ignore next 5 - External picomatch call */ - const picomatch = getPicomatch() - const matcher = picomatch( - positivePatterns.length > 0 ? positivePatterns : patterns, - matchOptions, - ) as (path: string) => boolean + // Use ignore option for negation patterns. + const matchOptions = { + dot: true, + nocase: true, + ...options, + ...(negativePatterns.length > 0 ? { ignore: negativePatterns } : {}), + } + + /* c8 ignore next 5 - External picomatch call */ + const picomatch = getPicomatch() + matcher = picomatch( + positivePatterns.length > 0 ? positivePatterns : patterns, + matchOptions, + ) as (path: string) => boolean + } matcherCache.set(key, matcher) return matcher @@ -326,17 +439,50 @@ export function getGlobMatcher( * console.log(files) // ['src/index.ts', 'src/utils.ts'] * ``` */ +/** + * Whether the caller's option bag is fully expressible with + * `node:fs.glob` (`cwd` + `exclude`). Any other option means we must + * fall back to fast-glob, which exposes the wider surface. + * + * Exported for unit tests; not part of the public API. + * + * @internal + */ +export function canUseNodeFsGlob( + options: FastGlobOptions | undefined, +): boolean { + if (!options) { + return true + } + for (const key of ObjectKeys(options)) { + if (key !== 'cwd' && key !== 'ignore') { + return false + } + } + return true +} + /*@__NO_SIDE_EFFECTS__*/ export function glob( patterns: Pattern | Pattern[], options?: FastGlobOptions, ): Promise { - /* c8 ignore next - External fast-glob call */ - const fastGlob = getFastGlob() // Strip trailing slashes from ignore patterns before fast-glob sees // them; otherwise `dist/` from a .gitignore-derived list silently // walks the whole subtree. See the stripTrailingSlash header above. const normalizedIgnore = normalizeIgnorePatterns(options?.ignore) + // Prefer node:fs/promises.glob (added v22.0.0, Stable) when the + // option surface lines up. Avoids loading fast-glob entirely. + if (canUseNodeFsGlob(options)) { + return fromAsync( + getFsPromises().glob(patterns as string | readonly string[], { + ...(options?.cwd ? { cwd: options.cwd } : {}), + ...(normalizedIgnore ? { exclude: normalizedIgnore } : {}), + }), + ) + } + /* c8 ignore next - External fast-glob call */ + const fastGlob = getFastGlob() return fastGlob.glob(patterns, { ...(options as import('fast-glob').Options), ...(normalizedIgnore ? { ignore: normalizedIgnore } : {}), @@ -407,11 +553,21 @@ export function globSync( patterns: Pattern | Pattern[], options?: FastGlobOptions, ): string[] { - /* c8 ignore next - External fast-glob call */ - const fastGlob = getFastGlob() // Strip trailing slashes from ignore patterns; same workaround as // the async `glob` above, see stripTrailingSlash header. const normalizedIgnore = normalizeIgnorePatterns(options?.ignore) + // Prefer node:fs.globSync (added v22.0.0, Stable) when the option + // surface lines up. Avoids loading fast-glob entirely. + if (canUseNodeFsGlob(options)) { + return [ + ...getFs().globSync(patterns as string | readonly string[], { + ...(options?.cwd ? { cwd: options.cwd } : {}), + ...(normalizedIgnore ? { exclude: normalizedIgnore } : {}), + }), + ] as string[] + } + /* c8 ignore next - External fast-glob call */ + const fastGlob = getFastGlob() return fastGlob.globSync(patterns, { ...(options as import('fast-glob').Options), ...(normalizedIgnore ? { ignore: normalizedIgnore } : {}), diff --git a/src/http-request.ts b/src/http-request.ts index 03b3d85a..6576b547 100644 --- a/src/http-request.ts +++ b/src/http-request.ts @@ -14,6 +14,8 @@ * - Zero dependencies on external HTTP libraries. */ +import { setTimeout as delay } from 'node:timers/promises' + import { SOCKET_LIB_USER_AGENT } from './constants/socket' import { safeDelete } from './fs' @@ -1464,7 +1466,7 @@ export async function httpDownload( // Retry with exponential backoff const delayMs = retryDelay * 2 ** attempt // eslint-disable-next-line no-await-in-loop - await new PromiseCtor(resolve => setTimeout(resolve, delayMs)) + await delay(delayMs) } } @@ -1686,11 +1688,11 @@ export async function httpRequest( ? MathMax(0, retryResult) : delayMs // eslint-disable-next-line no-await-in-loop - await new PromiseCtor(resolve => setTimeout(resolve, actualDelay)) + await delay(actualDelay) } else { // Default: retry with exponential backoff // eslint-disable-next-line no-await-in-loop - await new PromiseCtor(resolve => setTimeout(resolve, delayMs)) + await delay(delayMs) } } } @@ -1854,9 +1856,10 @@ export function parseChecksums(text: string): Checksums { * * @example * ```ts - * const delay = parseRetryAfterHeader(response.headers['retry-after']) - * if (delay !== undefined) { - * await new Promise(resolve => setTimeout(resolve, delay)) + * import { setTimeout as delay } from 'node:timers/promises' + * const ms = parseRetryAfterHeader(response.headers['retry-after']) + * if (ms !== undefined) { + * await delay(ms) * } * ``` */ diff --git a/src/primordials.ts b/src/primordials.ts index e2a81cba..614975ce 100644 --- a/src/primordials.ts +++ b/src/primordials.ts @@ -105,6 +105,23 @@ export const JSONStringify = JSON.stringify // ─── Array (static) ──────────────────────────────────────────────────── export const ArrayFrom = Array.from +// `Array.fromAsync` is ES2024 (Node 22.0+ / V8 ≥ 12.0). Typed as +// `Function | undefined` for safety even though Node 22+ always has it. +// Unbound: matches `ArrayFrom`. The spec algorithm uses `this` as the +// species constructor, so an undefined `this` falls back to a plain +// Array — exactly what we want. +// +// TS lib may not include `Array.fromAsync` yet (it's in ES2024 +// `lib.es2024.array.d.ts`); typed via the local signature. +export type ArrayFromAsync = ( + source: + | AsyncIterable + | Iterable> + | ArrayLike>, +) => Promise +export const ArrayFromAsync: ArrayFromAsync | undefined = ( + Array as unknown as { fromAsync?: ArrayFromAsync } +).fromAsync export const ArrayIsArray = Array.isArray export const ArrayOf = Array.of diff --git a/src/promises.ts b/src/promises.ts index 0904e616..2e3c8e20 100644 --- a/src/promises.ts +++ b/src/promises.ts @@ -8,12 +8,14 @@ import { UNDEFINED_TOKEN } from './constants/core' import { getAbortSignal } from './constants/process' import { + ArrayFromAsync, MathFloor, MathMax, MathMin, MathRandom, PromiseAllSettled, PromiseCtor, + PromiseWithResolvers as NativePromiseWithResolvers, } from './primordials' const abortSignal = getAbortSignal() @@ -785,23 +787,18 @@ export interface PromiseWithResolvers { reject: (reason?: unknown) => void } -const maybeNativeWithResolvers = ( - Promise as unknown as { - withResolvers?: unknown - } -).withResolvers - /** * Create a pending promise together with its `resolve` and `reject` * handles as first-class values, per * [ECMA-262 §27.2.4.9](https://tc39.es/ecma262/#sec-promise.withResolvers). * - * Bound to native `Promise.withResolvers` when available (Node 20.12+ / - * 21+ / 22+; V8 ≥ 12.0); otherwise falls back to a spec-equivalent - * `new Promise(executor)` implementation that captures the handles via - * closure. The returned object always has own data properties `promise`, - * `resolve`, `reject` on `Object.prototype` — writable, enumerable, and - * configurable — matching the spec's `CreateDataPropertyOrThrow` steps. + * Uses the `PromiseWithResolvers` primordial (already bound) when + * available (Node 20.12+ / 21+ / 22+; V8 ≥ 12.0); otherwise falls back + * to a spec-equivalent `new Promise(executor)` that captures the + * handles via closure. The returned object always has own data + * properties `promise`, `resolve`, `reject` on `Object.prototype` — + * writable, enumerable, and configurable — matching the spec's + * `CreateDataPropertyOrThrow` steps. * * Use this instead of the manual * `let resolve; const p = new Promise(r => { resolve = r })` dance for @@ -817,11 +814,8 @@ const maybeNativeWithResolvers = ( * ``` */ export const withResolvers: () => PromiseWithResolvers = - typeof maybeNativeWithResolvers === 'function' - ? // Bind so callers who destructure the export don't lose `this`. - ((maybeNativeWithResolvers as () => PromiseWithResolvers).bind( - Promise, - ) as () => PromiseWithResolvers) + NativePromiseWithResolvers !== undefined + ? (NativePromiseWithResolvers as () => PromiseWithResolvers) : (): PromiseWithResolvers => { // Fallback: capture resolvers via closure. The `!` asserts hold // because Promise's executor runs synchronously, so both handles @@ -834,3 +828,41 @@ export const withResolvers: () => PromiseWithResolvers = }) return { promise, resolve, reject } } + +/** + * Drain an async iterable into an array, per + * [TC39 Array.fromAsync](https://tc39.es/proposal-array-from-async/). + * + * Uses the `ArrayFromAsync` primordial (already bound) when available + * (Node 22+; V8 ≥ 12.0); otherwise falls back to a `for await…of` + + * push loop. + * + * Use this instead of the manual + * `const out = []; for await (const x of iter) out.push(x); return out` + * dance when collecting an async iterator's values. + * + * Like the native, this only handles the unary form (no `mapFn` / + * `thisArg` overload). + * + * @example + * ```typescript + * import { glob } from 'node:fs/promises' + * const files = await fromAsync(glob('**\/*.ts', { cwd: '/tmp/proj' })) + * ``` + */ +export const fromAsync: ( + source: AsyncIterable | Iterable>, +) => Promise = + ArrayFromAsync !== undefined + ? (ArrayFromAsync as ( + source: AsyncIterable | Iterable>, + ) => Promise) + : async ( + source: AsyncIterable | Iterable>, + ): Promise => { + const out: T[] = [] + for await (const item of source as AsyncIterable) { + out.push(item) + } + return out + } diff --git a/test/unit/crypto.test.mts b/test/unit/crypto.test.mts new file mode 100644 index 00000000..db2b1fe0 --- /dev/null +++ b/test/unit/crypto.test.mts @@ -0,0 +1,132 @@ +/** + * @fileoverview Unit tests for crypto helpers. + * + * Covers `hash()` and the `getNativeHash()` feature-detect: + * - one-shot hashing for sha256, sha512 across hex / base64 / base64url + * - native vs. fallback (createHash().update().digest()) parity + * - feature-detect tri-state: native present, native missing + */ + +import { Buffer } from 'node:buffer' +import { createHash } from 'node:crypto' + +import { getNativeHash, hash } from '@socketsecurity/lib/crypto' +import { afterEach, describe, expect, it, vi } from 'vitest' + +describe('crypto', () => { + describe('hash', () => { + // Reference values produced by `createHash().update().digest()` — + // the implementation we delegate to when the runtime predates + // Node's `crypto.hash`. These let us assert byte-for-byte parity + // independent of which path the export resolved to. + + it('matches createHash for sha256 hex of a string', () => { + const expected = createHash('sha256').update('hello').digest('hex') + expect(hash('sha256', 'hello', 'hex')).toBe(expected) + }) + + it('matches createHash for sha512 base64 of a string', () => { + const expected = createHash('sha512').update('socket').digest('base64') + expect(hash('sha512', 'socket', 'base64')).toBe(expected) + }) + + it('matches createHash for sha256 base64url of a buffer', () => { + const buf = Buffer.from([0xde, 0xad, 0xbe, 0xef]) + const expected = createHash('sha256').update(buf).digest('base64url') + expect(hash('sha256', buf, 'base64url')).toBe(expected) + }) + + it('matches createHash for sha512 hex of an empty string', () => { + const expected = createHash('sha512').update('').digest('hex') + expect(hash('sha512', '', 'hex')).toBe(expected) + }) + + // sha256("hello") is well-known. Pin it so a regression in the + // helper itself (not just createHash drift) gets flagged. + it('produces the canonical sha256 hex digest of "hello"', () => { + expect(hash('sha256', 'hello', 'hex')).toBe( + '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824', + ) + }) + + it('handles a moderately large buffer', () => { + const buf = Buffer.alloc(1024 * 64, 0x42) + const expected = createHash('sha512').update(buf).digest('hex') + expect(hash('sha512', buf, 'hex')).toBe(expected) + }) + }) + + describe('getNativeHash', () => { + // Exposed for tests; not part of the public API. Returns the + // native `crypto.hash` when available (Node 21.7+ / 20.12+) or + // `null` on older runtimes. + + it('returns a function on a runtime with crypto.hash', () => { + // Node 22+ always has it. + expect(typeof getNativeHash()).toBe('function') + }) + + it('returns the same value on subsequent calls (memoized)', () => { + expect(getNativeHash()).toBe(getNativeHash()) + }) + }) + + // Explicit coverage of the fallback branch. When `crypto.hash` is + // unavailable, `hash()` falls back to `createHash().update().digest()` + // and the result must still match. We delete the property and + // re-import the module fresh. + describe('hash — fallback implementation', () => { + const cryptoMod = require('node:crypto') as { + hash?: unknown + } + const hadNative = typeof cryptoMod.hash === 'function' + const nativeHash = hadNative ? cryptoMod.hash : undefined + + afterEach(() => { + if (hadNative && nativeHash !== undefined) { + cryptoMod.hash = nativeHash + } + vi.resetModules() + }) + + async function loadFallback(): Promise<{ + hash: typeof hash + getNativeHash: typeof getNativeHash + }> { + delete cryptoMod.hash + vi.resetModules() + const mod = await import('@socketsecurity/lib/crypto') + return { hash: mod.hash, getNativeHash: mod.getNativeHash } + } + + it('getNativeHash returns undefined when crypto.hash is missing', async () => { + const { getNativeHash: gnh } = await loadFallback() + expect(gnh()).toBeUndefined() + }) + + it('fallback hash() still produces correct sha256 hex', async () => { + const { hash: h } = await loadFallback() + const expected = createHash('sha256').update('hello').digest('hex') + expect(h('sha256', 'hello', 'hex')).toBe(expected) + }) + + it('fallback hash() still produces correct sha512 base64', async () => { + const { hash: h } = await loadFallback() + const expected = createHash('sha512').update('socket').digest('base64') + expect(h('sha512', 'socket', 'base64')).toBe(expected) + }) + + it('fallback handles buffer input', async () => { + const { hash: h } = await loadFallback() + const buf = Buffer.from([1, 2, 3, 4, 5]) + const expected = createHash('sha256').update(buf).digest('hex') + expect(h('sha256', buf, 'hex')).toBe(expected) + }) + + it('fallback memoizes the missing-native result', async () => { + const { getNativeHash: gnh } = await loadFallback() + expect(gnh()).toBeUndefined() + expect(gnh()).toBeUndefined() + }) + }) +}) diff --git a/test/unit/dlx/package.test.mts b/test/unit/dlx/package.test.mts index c8ba90c1..f6147a73 100644 --- a/test/unit/dlx/package.test.mts +++ b/test/unit/dlx/package.test.mts @@ -1188,7 +1188,12 @@ describe('dlx-package', () => { }) }) - describe('ensurePackageInstalled (cached path)', () => { + // `tmpDir` and `process.env['SOCKET_DLX_DIR']` are mutated at describe + // scope and beforeEach. Under vitest's default + // `sequence.concurrent: true` (off-CI), parallel `it` blocks would + // overwrite both, making the .npmrc assertion read from the wrong + // tmpDir. Force sequential here. + describe.sequential('ensurePackageInstalled (cached path)', () => { let tmpDir: string let savedDlxDir: string | undefined diff --git a/test/unit/globs.test.mts b/test/unit/globs.test.mts index d1e296b1..a7a19b11 100644 --- a/test/unit/globs.test.mts +++ b/test/unit/globs.test.mts @@ -505,7 +505,11 @@ describe('globs', () => { }) }) - describe('trailing-slash ignore patterns', () => { + // `tmpRoot` is captured at describe scope. Under vitest's default + // `sequence.concurrent: true` (off-CI), parallel `it` blocks would + // overwrite the shared variable mid-run. Force sequential here so + // each test sees its own beforeEach-created directory. + describe.sequential('trailing-slash ignore patterns', () => { let tmpRoot: string beforeEach(() => { diff --git a/test/unit/primordials.test.mts b/test/unit/primordials.test.mts index 3f63a160..b24e3247 100644 --- a/test/unit/primordials.test.mts +++ b/test/unit/primordials.test.mts @@ -18,6 +18,7 @@ import { applyBind, ArrayCtor, ArrayFrom, + ArrayFromAsync, ArrayIsArray, ArrayOf, ArrayPrototypeAt, @@ -312,6 +313,57 @@ describe('primordials', () => { it('ArrayOf composes from args', () => { expect(ArrayOf(1, 2, 3)).toEqual([1, 2, 3]) }) + + // ArrayFromAsync is typed `| undefined` because the proposal is + // ES2024; on Node 22+ it's always present. Covers the unbound + // form — the spec algorithm uses `this` only for the constructor + // and falls back to plain Array when `this` is undefined. + it('ArrayFromAsync is defined on Node 22+', () => { + expect(typeof ArrayFromAsync).toBe('function') + }) + + it('ArrayFromAsync drains an async iterable', async () => { + async function* gen() { + yield 1 + yield 2 + yield 3 + } + await expect(ArrayFromAsync!(gen())).resolves.toEqual([1, 2, 3]) + }) + + it('ArrayFromAsync awaits yielded thenables', async () => { + async function* gen() { + yield Promise.resolve('a') + yield Promise.resolve('b') + } + await expect(ArrayFromAsync!(gen())).resolves.toEqual(['a', 'b']) + }) + + it('ArrayFromAsync accepts plain iterables of awaitables', async () => { + // Spec: source can also be Iterable>. + await expect( + ArrayFromAsync!([Promise.resolve(1), Promise.resolve(2)]), + ).resolves.toEqual([1, 2]) + }) + + it('ArrayFromAsync returns a plain Array when called unbound', async () => { + const fn = ArrayFromAsync! + async function* gen() { + yield 1 + } + const out = await fn(gen()) + expect(out).toBeInstanceOf(Array) + expect(Object.getPrototypeOf(out)).toBe(Array.prototype) + }) + + it('ArrayFromAsync propagates rejection from the iterator', async () => { + const err = new Error('boom') + async function* gen() { + yield 1 + throw err + } + await expect(ArrayFromAsync!(gen())).rejects.toBe(err) + }) }) describe('Array (prototype)', () => { diff --git a/test/unit/promises.test.mts b/test/unit/promises.test.mts index 64a4a718..4bf5e63f 100644 --- a/test/unit/promises.test.mts +++ b/test/unit/promises.test.mts @@ -11,6 +11,7 @@ */ import { + fromAsync, normalizeIterationOptions, normalizeRetryOptions, pEach, @@ -1243,4 +1244,131 @@ describe('promises', () => { expect(keys).toContain('reject') }) }) + + describe('fromAsync', () => { + // Spec: https://tc39.es/proposal-array-from-async/ + // On Node 22+ the export is bound to native Array.fromAsync; older + // engines hit the closure fallback. Both paths must satisfy the spec. + + it('is a function', () => { + expect(typeof fromAsync).toBe('function') + }) + + it('drains an async iterable into an array', async () => { + async function* gen() { + yield 1 + yield 2 + yield 3 + } + await expect(fromAsync(gen())).resolves.toEqual([1, 2, 3]) + }) + + it('returns an empty array for an empty async iterable', async () => { + // eslint-disable-next-line require-yield + async function* empty() { + return + } + await expect(fromAsync(empty())).resolves.toEqual([]) + }) + + it('preserves yield order', async () => { + async function* gen() { + yield 'b' + yield 'a' + yield 'c' + } + await expect(fromAsync(gen())).resolves.toEqual(['b', 'a', 'c']) + }) + + it('awaits each yielded value before pushing', async () => { + async function* gen() { + yield Promise.resolve(1) + yield Promise.resolve(2) + } + // Spec: yielded thenables are awaited; resulting array contains + // the resolved values, not the promises. + const out = await fromAsync(gen()) + expect(out).toEqual([1, 2]) + }) + + it('propagates rejection from the iterator', async () => { + const err = new Error('boom') + async function* gen() { + yield 1 + throw err + } + await expect(fromAsync(gen())).rejects.toBe(err) + }) + + it('also drains plain (sync) iterables of awaitables', async () => { + // Spec lets fromAsync accept Iterable> too. + const out = await fromAsync([Promise.resolve('a'), Promise.resolve('b')]) + expect(out).toEqual(['a', 'b']) + }) + }) + + // Explicit coverage of the fallback branch. On Node 22+ the module + // binds to native `Array.fromAsync` at import time; here we delete + // the native method and re-import the module fresh, forcing the + // feature-detect to pick the closure fallback. + describe('fromAsync — fallback implementation', () => { + const hadNative = + typeof (Array as unknown as { fromAsync?: unknown }).fromAsync === + 'function' + const nativeFromAsync = hadNative + ? (Array as unknown as { fromAsync: unknown }).fromAsync + : undefined + + afterEach(() => { + if (hadNative && nativeFromAsync !== undefined) { + ;(Array as unknown as { fromAsync: unknown }).fromAsync = + nativeFromAsync + } + vi.resetModules() + }) + + async function loadFallback(): Promise< + ( + source: AsyncIterable | Iterable>, + ) => Promise + > { + delete (Array as unknown as { fromAsync?: unknown }).fromAsync + vi.resetModules() + const mod = await import('@socketsecurity/lib/promises') + return mod.fromAsync + } + + it('fallback is a function', async () => { + const fallback = await loadFallback() + expect(typeof fallback).toBe('function') + }) + + it('fallback drains an async iterable into an array', async () => { + const fallback = await loadFallback() + async function* gen() { + yield 'x' + yield 'y' + } + await expect(fallback(gen())).resolves.toEqual(['x', 'y']) + }) + + it('fallback returns empty array for empty iterable', async () => { + const fallback = await loadFallback() + // eslint-disable-next-line require-yield + async function* empty() { + return + } + await expect(fallback(empty())).resolves.toEqual([]) + }) + + it('fallback propagates rejection from the iterator', async () => { + const fallback = await loadFallback() + const err = new Error('fallback-boom') + async function* gen() { + yield 1 + throw err + } + await expect(fallback(gen())).rejects.toBe(err) + }) + }) }) From beeed2ea5887b703b8031160550cb8f3e1522cd7 Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 1 May 2026 11:54:14 -0400 Subject: [PATCH 2/4] chore(format): repo-wide format pass (no functional change) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure formatting output from `pnpm run fix --all`: - .git-hooks/pre-commit.mts: " → ' on a literal string - xport.schema.json: collapse single-element `required` arrays onto one line (Prettier default) --- .git-hooks/pre-commit.mts | 2 +- xport.schema.json | 29 ++++++----------------------- 2 files changed, 7 insertions(+), 24 deletions(-) diff --git a/.git-hooks/pre-commit.mts b/.git-hooks/pre-commit.mts index d4b885a4..094a771f 100755 --- a/.git-hooks/pre-commit.mts +++ b/.git-hooks/pre-commit.mts @@ -242,7 +242,7 @@ const main = (): number => { } } out( - "Use `getDefaultLogger()` from `@socketsecurity/lib/logger`. " + + 'Use `getDefaultLogger()` from `@socketsecurity/lib/logger`. ' + 'For documentation lines that need the literal call, append ' + 'the marker `# socket-hook: allow logger`.', ) diff --git a/xport.schema.json b/xport.schema.json index 719c5aad..6cbd8019 100644 --- a/xport.schema.json +++ b/xport.schema.json @@ -4,9 +4,7 @@ "title": "xport lock-step manifest", "description": "Unified lock-step manifest shared across Socket repos. One schema, all cases — `kind` discriminator on each row selects which flavor of lock-step applies.", "type": "object", - "required": [ - "rows" - ], + "required": ["rows"], "properties": { "$schema": { "type": "string" @@ -32,10 +30,7 @@ "^(.*)$": { "additionalProperties": false, "type": "object", - "required": [ - "submodule", - "repo" - ], + "required": ["submodule", "repo"], "properties": { "submodule": { "description": "Submodule path, relative to repo root.", @@ -57,9 +52,7 @@ "^(.*)$": { "additionalProperties": false, "type": "object", - "required": [ - "path" - ], + "required": ["path"], "properties": { "path": { "description": "Path to the port's root directory, relative to repo root.", @@ -212,13 +205,7 @@ "additionalProperties": false, "description": "A behavioral feature reimplemented locally to match upstream behavior. Three-pillar validation: code patterns, test patterns, fixture snapshots.", "type": "object", - "required": [ - "kind", - "id", - "upstream", - "criticality", - "local_area" - ], + "required": ["kind", "id", "upstream", "criticality", "local_area"], "properties": { "kind": { "const": "feature-parity", @@ -273,9 +260,7 @@ "additionalProperties": false, "description": "Golden-input verification. Prefer snapshot-based diffs over hardcoded counts (brittleness lesson from sdxgen's lock-step-features).", "type": "object", - "required": [ - "fixture_path" - ], + "required": ["fixture_path"], "properties": { "fixture_path": { "type": "string" @@ -425,9 +410,7 @@ "additionalProperties": false, "description": "Per-port status for a lang-parity row. `implemented` = port meets assertions; `opt-out` = port consciously skips, requires non-empty `reason`.", "type": "object", - "required": [ - "status" - ], + "required": ["status"], "properties": { "status": { "anyOf": [ From f4f4fe923a4ca6e10a5d248833bdd0f23f48aaa9 Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 1 May 2026 11:55:01 -0400 Subject: [PATCH 3/4] chore(release): 5.27.0 --- CHANGELOG.md | 15 +++++++++++++++ package.json | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50008402..9b3ccb80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [5.27.0](https://github.com/SocketDev/socket-lib/releases/tag/v5.27.0) - 2026-05-01 + +### Added + +- `crypto` (new export) — `hash(algorithm, data, encoding)` one-shot helper that prefers Node's native `crypto.hash` (added v21.7.0 / v20.12.0; ~30% faster than `createHash().update().digest()` on small inputs) with a streaming fallback. `getNativeHash` exposed as `@internal` for tests +- `promises` `fromAsync(source)` — drains an async iterable into an array, per [TC39 Array.fromAsync](https://tc39.es/proposal-array-from-async/). Backed by the new `ArrayFromAsync` primordial (Node 22+) with a `for await` + push fallback +- `primordials` `ArrayFromAsync` — ES2024 primordial. Unbound, matching `ArrayFrom` +- `globs` `getGlobMatcher` fast-paths single non-negated patterns through `path.matchesGlob` (Node 22.5+ / 20.17+) instead of compiling picomatch, with results stored in the existing LRU +- `globs` `glob` / `globSync` route through `node:fs.glob` / `node:fs.globSync` (Node 22+) when caller options reduce to `cwd` + `ignore` (mapped to `exclude`); fall back to fast-glob for the wider option surface + +### Changed + +- `http-request` retry/backoff sites use `setTimeout` from `node:timers/promises` instead of hand-rolled `new Promise(r => setTimeout(r, ms))` +- `dlx/cache`, `dlx/integrity`, `dlx/binary` — 4 one-shot hash sites switched to the new `crypto.hash()` helper + ## [5.26.2](https://github.com/SocketDev/socket-lib/releases/tag/v5.26.2) - 2026-04-30 ### Fixed diff --git a/package.json b/package.json index d8e180d4..9bd2ac55 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/lib", - "version": "5.26.2", + "version": "5.27.0", "packageManager": "pnpm@11.0.3", "license": "MIT", "description": "Core utilities and infrastructure for Socket.dev security tools", From 411aef73a01e69bf78eb91ff44f890c14f714eb6 Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 1 May 2026 12:57:47 -0400 Subject: [PATCH 4/4] chore(publish): add publishConfig {access:public, provenance:true} Provenance attestation becomes a property of the package, not a property of the workflow's --provenance CLI flag. access:public also load-bears for first-publish of @scoped packages on a fresh npm registry session. --- package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/package.json b/package.json index 9bd2ac55..3f33fb16 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,10 @@ "version": "5.27.0", "packageManager": "pnpm@11.0.3", "license": "MIT", + "publishConfig": { + "access": "public", + "provenance": true + }, "description": "Core utilities and infrastructure for Socket.dev security tools", "keywords": [ "Socket.dev",