diff --git a/biome.json b/biome.json index f08f748c..d3880dc8 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.13/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.4/schema.json", "extends": ["@ac-essentials/biome-config"], "root": true, "files": { diff --git a/package.json b/package.json index a1af7073..1aa98e5c 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "devDependencies": { "@ac-essentials/biome-config": "workspace:*", "@ac-essentials/markdownlint-cli2-config": "workspace:*", - "@biomejs/biome": "2.3.13", + "@biomejs/biome": "2.4.4", "@vitest/coverage-v8": "4.0.18", "is-ci": "4.1.0", "lefthook": "2.0.16", diff --git a/packages/app-util/src/logger/logger-console.ts b/packages/app-util/src/logger/logger-console.ts index 53d24e92..2b998d02 100644 --- a/packages/app-util/src/logger/logger-console.ts +++ b/packages/app-util/src/logger/logger-console.ts @@ -265,7 +265,6 @@ export class LoggerConsole implements Console { } } - // biome-ignore lint/nursery/noUnnecessaryConditions: false positive const selectedProperties = properties ? properties.filter((p) => allProperties.has(p)) : Array.from(allProperties); diff --git a/packages/app-util/src/logger/printers/file-printer.ts b/packages/app-util/src/logger/printers/file-printer.ts index afacd2f3..72091db6 100644 --- a/packages/app-util/src/logger/printers/file-printer.ts +++ b/packages/app-util/src/logger/printers/file-printer.ts @@ -3,10 +3,11 @@ import { stat } from "node:fs/promises"; import { BYTES_PER_MIB, compressFile, - debounceQueue, defaults, existsAsync, + LockHold, Mutex, + serializeQueueNext, } from "@ac-essentials/misc-util"; import { ROTATE_LOG_FILES_DEFAULT_OPTIONS, @@ -63,7 +64,7 @@ export class FilePrinter implements ILoggerPrinter { private textStreamPrinter: TextStreamPrinter | null = null; private readonly streamPrinterLock = new Mutex(); private readonly handleRotationLock = new Mutex(); - private readonly handleRotationDebounced = debounceQueue(() => + private readonly handleRotationSqn = serializeQueueNext(() => this.handleRotation(), ); @@ -85,11 +86,14 @@ export class FilePrinter implements ILoggerPrinter { * and the file stream is properly closed. */ async close(): Promise { - await this.streamPrinterLock.withLock(async () => { + { + await using _textStreamPrinterLock = await LockHold.from([ + this.streamPrinterLock, + ]); await this._unprotected_close(); - }); + } - await this.handleRotationDebounced(); + await this.handleRotationSqn(); } /** @@ -98,7 +102,7 @@ export class FilePrinter implements ILoggerPrinter { async flush(): Promise { await this.textStreamPrinter?.flush(); - await this.handleRotationDebounced(); + await this.handleRotationSqn(); } /** @@ -107,7 +111,7 @@ export class FilePrinter implements ILoggerPrinter { async clear(): Promise { await this.textStreamPrinter?.clear(); - await this.handleRotationDebounced(); + await this.handleRotationSqn(); } /** @@ -116,13 +120,17 @@ export class FilePrinter implements ILoggerPrinter { * @param record The log record to print. */ async print(record: LoggerRecord): Promise { - await this.streamPrinterLock.withLock(async () => { + { + await using _textStreamPrinterLock = await LockHold.from([ + this.streamPrinterLock, + ]); + const textStreamPrinter = await this._unprotected_open(); await textStreamPrinter.print(record); - }); + } - await this.handleRotationDebounced(); + await this.handleRotationSqn(); } private async _unprotected_open() { @@ -183,32 +191,35 @@ export class FilePrinter implements ILoggerPrinter { } private async handleRotation() { - return this.handleRotationLock.withLock(async () => { - const { cutOffFileSize, maxFileAgeMs, useCompression } = this.options; - - if (cutOffFileSize || maxFileAgeMs) { - let stats: Stats | undefined; - try { - stats = await stat(this.filePath); - } catch {} - - if ( - stats && - ((cutOffFileSize && stats.size >= cutOffFileSize) || - (maxFileAgeMs && - Date.now() - stats.mtime.getTime() >= maxFileAgeMs)) - ) { - await this.streamPrinterLock.withLock(async () => { - await this._unprotected_close(); - - await rotateLogFiles(this.filePath, this.options); - }); - - if (useCompression && (await existsAsync(`${this.filePath}.1`))) { - await compressFile(`${this.filePath}.1`); - } + await using _handleRotationLock = await LockHold.from([ + this.handleRotationLock, + ]); + + const { cutOffFileSize, maxFileAgeMs, useCompression } = this.options; + + if (cutOffFileSize || maxFileAgeMs) { + let stats: Stats | undefined; + try { + stats = await stat(this.filePath); + } catch {} + + if ( + stats && + ((cutOffFileSize && stats.size >= cutOffFileSize) || + (maxFileAgeMs && Date.now() - stats.mtime.getTime() >= maxFileAgeMs)) + ) { + { + await using _streamPrinterLock = await LockHold.from([ + this.streamPrinterLock, + ]); + + await rotateLogFiles(this.filePath, this.options); + } + + if (useCompression && (await existsAsync(`${this.filePath}.1`))) { + await compressFile(`${this.filePath}.1`); } } - }); + } } } diff --git a/packages/app-util/src/logger/printers/no-repeat-printer-proxy.ts b/packages/app-util/src/logger/printers/no-repeat-printer-proxy.ts index 4cad40e8..78ca92ec 100644 --- a/packages/app-util/src/logger/printers/no-repeat-printer-proxy.ts +++ b/packages/app-util/src/logger/printers/no-repeat-printer-proxy.ts @@ -1,10 +1,11 @@ import { - debounceQueue, defaults, + LockHold, MS_PER_SECOND, Mutex, PeriodicalTimer, Queue, + serializeQueueNext, } from "@ac-essentials/misc-util"; import type { ILoggerPrinter } from "../logger-printer.js"; import type { LoggerRecord } from "../logger-record.js"; @@ -37,7 +38,7 @@ export class NoRepeatPrinterProxy implements ILoggerPrinter { private spool = new Queue(); private readonly spoolLock = new Mutex(); private readonly flushLock = new Mutex(); - private readonly flushDebounced = debounceQueue(() => this.flush()); + private readonly flushSqn = serializeQueueNext(() => this.flush()); private readonly handleRepeatCountLock = new Mutex(); /** @@ -76,41 +77,48 @@ export class NoRepeatPrinterProxy implements ILoggerPrinter { } async flush(): Promise { - return this.flushLock.withLock(async () => { - const spool = await this.spoolLock.withLock(() => { - const originalSpool = this.spool; - this.spool = new Queue(); - return originalSpool; - }); - - let record: LoggerRecord | undefined; - while ((record = spool.dequeue()) !== undefined) { - try { - await this.handleRecordPrint(record); - } catch (error) { - // add back the item to the spool if output fails - await this.spoolLock.withLock(() => { - this.spool = new Queue(spool.concat(...this.spool)); - }); - - throw error; - } + await using _flushLock = await LockHold.from([this.flushLock]); + + let spool: Queue; + { + await using _spoolLock = await LockHold.from([this.spoolLock]); + spool = this.spool; + this.spool = new Queue(); + } + + let record: LoggerRecord | undefined; + while ((record = spool.dequeue()) !== undefined) { + try { + await this.handleRecordPrint(record); + } catch (error) { + await using _spoolLock = await LockHold.from([this.spoolLock]); + + // add back the item to the spool if output fails + this.spool = new Queue(spool.concat(...this.spool)); + + throw error; } + } - return this.printer.flush(); - }); + return this.printer.flush(); } async print(record: LoggerRecord): Promise { - await this.spoolLock.withLock(() => { + { + await using _spoolLock = await LockHold.from([this.spoolLock]); this.spool.enqueue(record); - }); + } - await this.flushDebounced(); + await this.flushSqn(); } private async handleRecordPrint(record: LoggerRecord) { - const skipPrint = await this.handleRepeatCountLock.withLock(async () => { + let shouldSkipPrint: boolean; + { + await using _handleRepeatCountLock = await LockHold.from([ + this.handleRepeatCountLock, + ]); + let recordEqual: boolean = false; if (this.lastRecord) { recordEqual = loggerPrinterRecordEqual(record, this.lastRecord, false); @@ -122,24 +130,24 @@ export class NoRepeatPrinterProxy implements ILoggerPrinter { this.lastRecord = record; - const shouldSkipPrint = recordEqual && !counterResetted; + shouldSkipPrint = recordEqual && !counterResetted; if (shouldSkipPrint) { this.lastRecordSkipCount++; } + } - return shouldSkipPrint; - }); - - if (!skipPrint) { + if (!shouldSkipPrint) { await this.printer.print(record); } } private async handleTimerTick() { - await this.handleRepeatCountLock.withLock(async () => { - await this._unprotected_handleRepeatCount(); - }); + await using _handleRepeatCountLock = await LockHold.from([ + this.handleRepeatCountLock, + ]); + + await this._unprotected_handleRepeatCount(); } private async _unprotected_handleRepeatCount(forceReset = false) { diff --git a/packages/app-util/src/logger/printers/text-stream-printer.ts b/packages/app-util/src/logger/printers/text-stream-printer.ts index 20420669..21864d46 100644 --- a/packages/app-util/src/logger/printers/text-stream-printer.ts +++ b/packages/app-util/src/logger/printers/text-stream-printer.ts @@ -2,10 +2,11 @@ import type * as fs from "node:fs"; import type * as tty from "node:tty"; import { stripVTControlCharacters } from "node:util"; import { - debounceQueue, defaults, + LockHold, Mutex, Queue, + serializeQueueNext, } from "@ac-essentials/misc-util"; import type { ILoggerPrinter } from "../logger-printer.js"; import type { LoggerRecord } from "../logger-record.js"; @@ -36,7 +37,7 @@ export class TextStreamPrinter implements ILoggerPrinter { private spool = new Queue(); private readonly spoolLock = new Mutex(); private readonly flushLock = new Mutex(); - private readonly flushDebounced = debounceQueue(() => this.flush()); + private readonly flushSqn = serializeQueueNext(() => this.flush()); constructor( private readonly defaultStream: tty.WriteStream | fs.WriteStream, @@ -50,13 +51,15 @@ export class TextStreamPrinter implements ILoggerPrinter { } async clear(): Promise { - await this.spoolLock.withLock(() => { + { + await using _flushLock = await LockHold.from([this.flushLock]); + this.spool.clear(); this.spool.enqueue({ isError: false, data: "\x1Bc" }); - }); + } - await this.flushDebounced(); + await this.flushSqn(); } async close(): Promise { @@ -64,27 +67,28 @@ export class TextStreamPrinter implements ILoggerPrinter { } async flush(): Promise { - return this.flushLock.withLock(async () => { - const spool = await this.spoolLock.withLock(() => { - const originalSpool = this.spool; - this.spool = new Queue(); - return originalSpool; - }); - - let item: TextStreamPrinterSpoolItem | undefined; - while ((item = spool.dequeue()) !== undefined) { - try { - await this.outputSpoolItem(item); - } catch (error) { - // add back the items to the spool if output fails - await this.spoolLock.withLock(() => { - this.spool = new Queue(spool.concat(...this.spool)); - }); - - throw error; - } + await using _flushLock = await LockHold.from([this.flushLock]); + + let spool: Queue; + { + await using _spoolLock = await LockHold.from([this.spoolLock]); + spool = this.spool; + this.spool = new Queue(); + } + + let item: TextStreamPrinterSpoolItem | undefined; + while ((item = spool.dequeue()) !== undefined) { + try { + await this.outputSpoolItem(item); + } catch (error) { + await using _spoolLock = await LockHold.from([this.spoolLock]); + + // add back the items to the spool if output fails + this.spool = new Queue(spool.concat(...this.spool)); + + throw error; } - }); + } } async print(record: LoggerRecord): Promise { @@ -98,11 +102,12 @@ export class TextStreamPrinter implements ILoggerPrinter { protected async enqueueSpoolItem( item: TextStreamPrinterSpoolItem, ): Promise { - await this.spoolLock.withLock(() => { + { + await using _spoolLock = await LockHold.from([this.spoolLock]); this.spool.enqueue(item); - }); + } - await this.flushDebounced(); + await this.flushSqn(); } protected async outputSpoolItem( diff --git a/packages/biome-config/CHANGELOG.md b/packages/biome-config/CHANGELOG.md index aceb35ae..47e874cb 100644 --- a/packages/biome-config/CHANGELOG.md +++ b/packages/biome-config/CHANGELOG.md @@ -4,6 +4,12 @@ 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.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- Upgraded `noImportCycles` and `noUselessUndefined` nursery rules to their new sections. + ## [0.3.0] - 2025-09-20 ### Added diff --git a/packages/biome-config/biome.json b/packages/biome-config/biome.json index 3ec6d491..e4ab86ad 100644 --- a/packages/biome-config/biome.json +++ b/packages/biome-config/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.13/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.4/schema.json", "root": false, "files": { "ignoreUnknown": true @@ -12,23 +12,25 @@ "linter": { "rules": { "suspicious": { - "noAssignInExpressions": "off" + "noAssignInExpressions": "off", + "noImportCycles": { + "level": "error", + "options": { + "ignoreTypes": false + } + } + }, + "complexity": { + "noUselessUndefined": "error" }, "correctness": { "noUnusedFunctionParameters": "info" }, "nursery": { "noFloatingPromises": "error", - "noImportCycles": { - "level": "error", - "options": { - "ignoreTypes": false - } - }, "noMisusedPromises": "error", "noShadow": "info", "noUnnecessaryConditions": "warn", - "noUselessUndefined": "error", "useExhaustiveSwitchCases": "error" } } diff --git a/packages/misc-util/CHANGELOG.md b/packages/misc-util/CHANGELOG.md index 3e77c730..8d9e950e 100644 --- a/packages/misc-util/CHANGELOG.md +++ b/packages/misc-util/CHANGELOG.md @@ -8,23 +8,50 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed -- Fixed `abortable` and `abortableAsync` functions when AbortSignal is aborted -before calling the wrapped function (impact `AbortablePromise` as well). - Fixed an edge case in the `Counter::wait` method when the counter is incremented rapidly (was missing the value). +- Fixed `jsonSerialize` and `jsonStringify` functions to handle circular references correctly. - Fixed `FileLock` implementation. ### Changed -- The `AbortablePromise` now rejects itself the promise when the signal is aborted. -- All classes implementing `ILockable` now throw an `LockNotAcquiredError` when +- `debounceQueue` function is now called `serializeQueueNext` +- Renamed `ILockable` interface to `ILock`. +- All classes implementing `ILock` now throw an `LockNotAcquiredError` when `release` is called without a matching `acquire`. -- `TcpClient` and `DgramSocket` (previously `UdpSocket`) classes reimplemented. +- `TcpSocket` and `DgramSocket` (previously `TcpClient` and `UdpSocket`, respectively) classes reimplemented. +- `SubscribableEvent` function is now called `Event`. +- `Subscribable` is replaced with `IEventDispatcher` interface. +- `jsonStringifySafe` function is now called `jsonStringify` and accepts options for handling circular references. +- `jsonSerialize` function is now safe (handles circular references, BigInt and Error's serialization). -## Added +### Removed + +- Removed `MaybeAsyncCallback`, `AsyncCallback` and `Callback` types (use MaybeAsyncCallable, AsyncCallable, and Callable respectively instead). +- Removed `abortable` and `abortableAsync` functions (no replacement). +- Removed `AbortablePromise` class (no replacement). +- Removed `ILock::withLock` utility method (use `LockHold` class instead). +- Removed `IWaitable` interface and `waitNotifiable` function (no replacement). +- Removed `jsonSerializeError` (use `jsonSerialize` instead). + +### Added - Added `InetAddress` and `InetEndpoint` classes. -- Added `TcpServer` class. +- Added `TcpServer`, `StreamSocket`, `IpcSocket` and `TlsSocket` classes. +- Added `removeSafe` utility for arrays. +- Added `compact` utility for arrays. +- Added `MaybeAsyncDisposable` type. +- Added `Barrier` and `Latch` synchronization primitives. +- Added `LockHold` class. +- Added `Condition` synchronization primitive. +- Added `RwLock` synchronization class. +- Added `Channel` and `Broadcast` classes. +- Added `IEventDispatcher` and `IEventDispatcherMap` interfaces. +- Added `eventDispatcherToAsyncIterator` utility. +- Added `intersection` utility for arrays. +- Added `getObjectKeys` utility. +- Added `traverse` utility for objects. +- Added `HttpTrailers` class. ## [0.5.1] - 2025-10-06 diff --git a/packages/misc-util/src/async/counter.test.ts b/packages/misc-util/src/async/counter.test.ts deleted file mode 100644 index f6c7f166..00000000 --- a/packages/misc-util/src/async/counter.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Counter } from "./counter.js"; - -describe("Counter", () => { - it("should initialize with default value 0", () => { - const counter = new Counter(); - expect(counter.value).toBe(0); - }); - - it("should initialize with a custom value", () => { - const counter = new Counter(5); - expect(counter.value).toBe(5); - }); - - it("should increment the value and return the new value", () => { - const counter = new Counter(); - const result = counter.increment(); - expect(result).toBe(1); - expect(counter.value).toBe(1); - expect(counter.increment()).toBe(2); - }); - - it("should decrement the value and return the new value", () => { - const counter = new Counter(2); - const result = counter.decrement(); - expect(result).toBe(1); - expect(counter.value).toBe(1); - expect(counter.decrement()).toBe(0); - }); - - it("should reset the value to 0", () => { - const counter = new Counter(10); - counter.reset(); - expect(counter.value).toBe(0); - }); - - it("should wait for a target value", async () => { - const counter = new Counter(); - const waitPromise = counter.wait(2); - counter.increment(); - counter.increment(); - await expect(waitPromise).resolves.toBeUndefined(); - }); - - it("should respect the abort signal when waiting", async () => { - const counter = new Counter(); - const abortController = new AbortController(); - const waitPromise = counter.wait(1, abortController.signal); - abortController.abort(); - await expect(waitPromise).rejects.toThrow("This operation was aborted"); - }); - - it("should return immediately if the target value is already reached", async () => { - const counter = new Counter(5); - const abortController = new AbortController(); - abortController.abort(); - await expect( - counter.wait(5, abortController.signal), - ).resolves.toBeUndefined(); - }); -}); diff --git a/packages/misc-util/src/async/counter.ts b/packages/misc-util/src/async/counter.ts deleted file mode 100644 index 0fc7831d..00000000 --- a/packages/misc-util/src/async/counter.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { Callback } from "../ecma/function/types.js"; -import { AbortablePromise } from "../ecma/promise/abortable-promise.js"; -import type { ISubscribable } from "./isubscribable.js"; -import type { IWaitable } from "./iwaitable.js"; -import { Subscribable } from "./subscribable.js"; - -/** - * A simple counter that can be incremented or decremented, and allows waiting - * for it to reach a specific value. - */ -export class Counter - extends Subscribable<[number]> - implements ISubscribable<[number]>, IWaitable<[number]> -{ - private value_: number; - - /** - * Creates a new Counter instance. - * - * @param initialValue The initial value of the counter. Default is `0`. - */ - constructor(initialValue = 0) { - super(); - - this.value_ = initialValue; - } - - get value(): number { - return this.value_; - } - - /** - * Resets the counter to zero. - */ - reset(): void { - this.value_ = 0; - this.publish(this.value_); - } - - /** - * Increments the counter by one. - * - * @returns The new value of the counter after incrementing. - */ - increment(): number { - this.publish(++this.value_); - return this.value_; - } - - /** - * Decrements the counter by one. - * - * @returns The new value of the counter after decrementing. - */ - decrement(): number { - this.publish(--this.value_); - return this.value_; - } - - /** - * Waits until the counter reaches the specified target value. - * - * @param targetValue The value to wait for. - * @param signal An optional AbortSignal to cancel the wait. - */ - async wait(targetValue: number, signal?: AbortSignal | null): Promise { - if (this.value === targetValue) { - return; - } - - let unsubscribe: Callback | null = null; - - const deferred = AbortablePromise.withResolvers({ - signal, - onAbort: () => { - unsubscribe?.(); - }, - }); - - unsubscribe = this.subscribe((newValue) => { - if (newValue === targetValue) { - deferred.resolve(); - unsubscribe?.(); - } - }); - - return deferred.promise; - } -} diff --git a/packages/misc-util/src/async/events/_event-dispatcher-base.test.ts b/packages/misc-util/src/async/events/_event-dispatcher-base.test.ts new file mode 100644 index 00000000..6efc9e5f --- /dev/null +++ b/packages/misc-util/src/async/events/_event-dispatcher-base.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from "vitest"; +import { EventDispatcherBase } from "./_event-dispatcher-base.js"; + +describe("EventDispatcherBase", () => { + it("should allow subscribing and publishing values", () => { + const sub = new EventDispatcherBase<[number]>(); + const handler = vi.fn(); + sub.subscribe(handler); + // @ts-expect-error accessing protected method for test + sub.dispatch(42); + expect(handler).toHaveBeenCalledWith(42); + }); + + it("should allow unsubscribing", () => { + const sub = new EventDispatcherBase<[number]>(); + const handler = vi.fn(); + sub.subscribe(handler); + sub.unsubscribe(handler); + // @ts-expect-error accessing protected method for test + sub.dispatch(99); + expect(handler).not.toHaveBeenCalled(); + }); + + it("should support multiple subscribers", () => { + const sub = new EventDispatcherBase<[string]>(); + const h1 = vi.fn(); + const h2 = vi.fn(); + sub.subscribe(h1); + sub.subscribe(h2); + // @ts-expect-error accessing protected method for test + sub.dispatch("hello"); + expect(h1).toHaveBeenCalledWith("hello"); + expect(h2).toHaveBeenCalledWith("hello"); + }); + + it("should invoke higher priority subscribers first", () => { + const sub = new EventDispatcherBase<[string]>(); + const calls: string[] = []; + const low = (value: string) => { + calls.push(`low:${value}`); + }; + const high = (value: string) => { + calls.push(`high:${value}`); + }; + + sub.subscribe(low, { priority: 0 }); + sub.subscribe(high, { priority: 10 }); + + // @ts-expect-error accessing protected method for test + sub.dispatch("x"); + + expect(calls).toEqual(["high:x", "low:x"]); + }); + + it("should preserve insertion order for equal priority subscribers", () => { + const sub = new EventDispatcherBase<[string]>(); + const calls: string[] = []; + const first = (value: string) => { + calls.push(`first:${value}`); + }; + const second = (value: string) => { + calls.push(`second:${value}`); + }; + + sub.subscribe(first, { priority: 5 }); + sub.subscribe(second, { priority: 5 }); + + // @ts-expect-error accessing protected method for test + sub.dispatch("y"); + + expect(calls).toEqual(["first:y", "second:y"]); + }); +}); diff --git a/packages/misc-util/src/async/events/_event-dispatcher-base.ts b/packages/misc-util/src/async/events/_event-dispatcher-base.ts new file mode 100644 index 00000000..36a96c3b --- /dev/null +++ b/packages/misc-util/src/async/events/_event-dispatcher-base.ts @@ -0,0 +1,99 @@ +import { PriorityQueue } from "../../data/priority-queue.js"; +import { noThrow } from "../../ecma/function/no-throw.js"; +import type { Callable } from "../../ecma/function/types.js"; +import type { + EventDispatcherSubscribeOptions, + EventDispatcherWaitOptions, + IEventDispatcher, +} from "./ievent-dispatcher.js"; + +export class EventDispatcherBase + implements IEventDispatcher +{ + private readonly subscribers = new Map, Callable>(); + private queueSeq = 0; + private readonly queue = new PriorityQueue, [number, number]>( + undefined, + undefined, + (a, b) => { + const [ap, aseq] = a; + const [bp, bseq] = b; + if (ap !== bp) { + return ap < bp; + } + return aseq < bseq; + }, + ); + + subscribe( + subscriber: Callable, + options?: EventDispatcherSubscribeOptions, + ): Callable | null { + if (!this.subscribers.has(subscriber)) { + const priority = options?.priority ?? 0; + + if (options?.once) { + this.subscribers.set(subscriber, (...args: T) => { + this.subscribers.delete(subscriber); + this.queue.removeFirst(([v]) => v === subscriber); + + noThrow(subscriber).apply(undefined, args); + }); + } else { + this.subscribers.set(subscriber, subscriber); + } + this.queue.insert([-priority, this.queueSeq++], subscriber); + + return () => this.unsubscribe(subscriber); + } + + return null; + } + + unsubscribe(subscriber: Callable): void { + this.subscribers.delete(subscriber); + } + + isSubscribed(subscriber: Callable): boolean { + return this.subscribers.has(subscriber); + } + + async wait(options?: EventDispatcherWaitOptions): Promise { + const deferred = Promise.withResolvers(); + + const unsubscribe = this.subscribe((...args: T) => { + if (!options?.predicate || options?.predicate(...args)) { + deferred.resolve(args); + } + }); + + const handleAbort = () => { + deferred.reject(options?.signal?.reason); + }; + + let event: T; + try { + options?.signal?.throwIfAborted(); + options?.signal?.addEventListener("abort", handleAbort, { + once: true, + }); + + event = await deferred.promise; + } finally { + options?.signal?.removeEventListener("abort", handleAbort); + unsubscribe?.(); + } + + return event; + } + + protected dispatch(...args: T): void { + for (const [subscriber] of this.queue) { + const wrapped = this.subscribers.get(subscriber); + if (!wrapped) { + continue; + } + noThrow(wrapped).apply(undefined, args); + } + } +} diff --git a/packages/misc-util/src/async/events/_event-dispatcher-map-base.test.ts b/packages/misc-util/src/async/events/_event-dispatcher-map-base.test.ts new file mode 100644 index 00000000..00ff3a7d --- /dev/null +++ b/packages/misc-util/src/async/events/_event-dispatcher-map-base.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from "vitest"; +import { EventDispatcherMapBase } from "./_event-dispatcher-map-base.js"; + +describe("EventDispatcherMapBase", () => { + interface Events extends Record { + foo: [number, string]; + bar: [boolean]; + } + + class TestDispatcher extends EventDispatcherMapBase { + public emit(event: K, ...args: Events[K]) { + // Expose protected dispatch for testing + this.dispatch(event, ...args); + } + } + + it("should subscribe and emit events for different keys", () => { + const dispatcher = new TestDispatcher(); + const fooListener = vi.fn(); + const barListener = vi.fn(); + + dispatcher.subscribe("foo", fooListener); + dispatcher.subscribe("bar", barListener); + + dispatcher.emit("foo", 42, "hello"); + dispatcher.emit("bar", true); + + expect(fooListener).toHaveBeenCalledTimes(1); + expect(fooListener).toHaveBeenCalledWith(42, "hello"); + expect(barListener).toHaveBeenCalledTimes(1); + expect(barListener).toHaveBeenCalledWith(true); + }); + + it("should unsubscribe listeners", () => { + const dispatcher = new TestDispatcher(); + const fooListener = vi.fn(); + dispatcher.subscribe("foo", fooListener); + dispatcher.emit("foo", 1, "a"); + dispatcher.unsubscribe("foo", fooListener); + dispatcher.emit("foo", 2, "b"); + expect(fooListener).toHaveBeenCalledTimes(1); + expect(fooListener).toHaveBeenCalledWith(1, "a"); + }); + + it("should support once option", () => { + const dispatcher = new TestDispatcher(); + const fooListener = vi.fn(); + dispatcher.subscribe("foo", fooListener, { once: true }); + dispatcher.emit("foo", 1, "a"); + dispatcher.emit("foo", 2, "b"); + expect(fooListener).toHaveBeenCalledTimes(1); + expect(fooListener).toHaveBeenCalledWith(1, "a"); + }); + + it("should check isSubscribed", () => { + const dispatcher = new TestDispatcher(); + const fooListener = vi.fn(); + dispatcher.subscribe("foo", fooListener); + expect(dispatcher.isSubscribed("foo", fooListener)).toBe(true); + dispatcher.unsubscribe("foo", fooListener); + expect(dispatcher.isSubscribed("foo", fooListener)).toBe(false); + }); + + describe("wait", () => { + it("resolves on first event emission", async () => { + const dispatcher = new TestDispatcher(); + const promise = dispatcher.wait("bar"); + dispatcher.emit("bar", false); + const result = await promise; + expect(result).toEqual([false]); + }); + + it("resolves only when predicate matches", async () => { + const dispatcher = new TestDispatcher(); + const promise = dispatcher.wait("foo", { + predicate: (num) => num === 1, + }); + dispatcher.emit("foo", 0, "no"); + dispatcher.emit("foo", 1, "yes"); + const result = await promise; + expect(result).toEqual([1, "yes"]); + }); + }); +}); diff --git a/packages/misc-util/src/async/events/_event-dispatcher-map-base.ts b/packages/misc-util/src/async/events/_event-dispatcher-map-base.ts new file mode 100644 index 00000000..b89d2506 --- /dev/null +++ b/packages/misc-util/src/async/events/_event-dispatcher-map-base.ts @@ -0,0 +1,76 @@ +import type { Callable } from "../../ecma/function/types.js"; +import { EventDispatcherBase } from "./_event-dispatcher-base.js"; +import type { + EventDispatcherSubscribeOptions, + EventDispatcherWaitOptions, +} from "./ievent-dispatcher.js"; +import type { IEventDispatcherMap } from "./ievent-dispatcher-map.js"; + +class EventDispatcher_ extends EventDispatcherBase { + override dispatch(...args: T): void { + super.dispatch(...args); + } +} + +export class EventDispatcherMapBase< + Events extends Record, +> implements IEventDispatcherMap +{ + private readonly dispatchers: { + [K in keyof Events]?: EventDispatcher_; + } = {}; + + subscribe( + event: K, + listener: Callable, + options?: EventDispatcherSubscribeOptions, + ): Callable | null { + return this.createDispatcher(event).subscribe(listener, options); + } + + unsubscribe( + event: K, + listener: Callable, + ): void { + this.dispatchers[event]?.unsubscribe(listener); + } + + isSubscribed( + event: K, + listener: Callable, + ): boolean { + return this.dispatchers[event]?.isSubscribed(listener) ?? false; + } + + wait( + event: K, + options?: EventDispatcherWaitOptions, + ): Promise { + return this.createDispatcher(event).wait(options); + } + + /** + * Dispatches an event to all listeners for the given event name. + * + * @param event The event name. + * @param args Arguments to pass to listeners. + */ + protected dispatch( + event: K, + ...args: Events[K] + ): void { + // No need to dispatch if there are no listeners + this.dispatchers[event]?.dispatch(...args); + } + + private createDispatcher( + event: K, + ): EventDispatcher_ { + let dispatcher = this.dispatchers[event]; + if (!dispatcher) { + dispatcher = new EventDispatcher_(); + this.dispatchers[event] = dispatcher; + } + return dispatcher; + } +} diff --git a/packages/misc-util/src/async/events/event.ts b/packages/misc-util/src/async/events/event.ts new file mode 100644 index 00000000..5c3c3ccf --- /dev/null +++ b/packages/misc-util/src/async/events/event.ts @@ -0,0 +1,33 @@ +import { EventDispatcherBase } from "./_event-dispatcher-base.js"; +import type { IEventDispatcher } from "./ievent-dispatcher.js"; + +/** + * An event that can be emitted with data of type `T` and waited upon. + * + * @example + * ```ts + * const event = new Event(); + * + * // Subscriber + * event.subscribe((data) => { + * console.log("Event received with data:", data); + * }); + * + * // Emitter + * event.emit(42); + * + * // Waiter + * const data = await event.wait(); + * console.log("Waited event received with data:", data); + * ``` + * + * @template T - The type of data emitted with the event. + */ +export class Event + extends EventDispatcherBase<[T]> + implements IEventDispatcher<[T]> +{ + emit(data: T): void { + this.dispatch(data); + } +} diff --git a/packages/misc-util/src/async/events/helpers/event-dispatcher-to-async-iterator.test.ts b/packages/misc-util/src/async/events/helpers/event-dispatcher-to-async-iterator.test.ts new file mode 100644 index 00000000..a5545cdc --- /dev/null +++ b/packages/misc-util/src/async/events/helpers/event-dispatcher-to-async-iterator.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { Event } from "../event.js"; +import { eventDispatcherToAsyncIterator } from "./event-dispatcher-to-async-iterator.js"; + +describe("subscribableToAsyncIterator", () => { + it("yields published events in order", async () => { + const sub = new Event(); + const iterable = eventDispatcherToAsyncIterator(sub); + const iterator = iterable[Symbol.asyncIterator](); + + sub.emit(1); + sub.emit(2); + + const first = await iterator.next(); + const second = await iterator.next(); + + expect(first.done).toBe(false); + expect(first.value).toEqual([1]); + expect(second.done).toBe(false); + expect(second.value).toEqual([2]); + }); +}); diff --git a/packages/misc-util/src/async/events/helpers/event-dispatcher-to-async-iterator.ts b/packages/misc-util/src/async/events/helpers/event-dispatcher-to-async-iterator.ts new file mode 100644 index 00000000..629830a0 --- /dev/null +++ b/packages/misc-util/src/async/events/helpers/event-dispatcher-to-async-iterator.ts @@ -0,0 +1,60 @@ +import type { IEventDispatcher } from "../ievent-dispatcher.js"; + +/** + * Creates an async iterator over events emitted by the given {@link IEventDispatcher}. + * + * Each time the dispatcher emits an event, the iterator yields the event arguments + * as an array. + * + * @example + * ```ts + * const dispatcher = new EventDispatcher<[number, string]>(); + * + * async function consumeEvents() { + * for await (const [num, str] of eventDispatcherToAsyncIterator(dispatcher)) { + * console.log(`Received event with number: ${num} and string: ${str}`); + * } + * } + * + * consumeEvents(); + * + * dispatcher.dispatch(1, "first"); + * dispatcher.dispatch(2, "second"); + * ``` + * + * @param dispatcher The event dispatcher to convert into an async iterator. + * @returns An async iterable that yields event arguments as arrays. + */ +export function eventDispatcherToAsyncIterator( + dispatcher: IEventDispatcher, +): AsyncIterable { + const queue: T[] = []; + let nextIteratorResult: ((value: IteratorResult) => void) | null = null; + + dispatcher.subscribe((...args: T) => { + if (nextIteratorResult) { + const resolve = nextIteratorResult; + nextIteratorResult = null; + resolve({ value: args, done: false }); + } else { + queue.push(args); + } + }); + + return { + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => { + if (queue.length > 0) { + const value = queue.shift() as T; + return Promise.resolve({ value, done: false }); + } + + return new Promise>((resolve) => { + nextIteratorResult = resolve; + }); + }, + }; + }, + }; +} diff --git a/packages/misc-util/src/async/events/ievent-dispatcher-map.ts b/packages/misc-util/src/async/events/ievent-dispatcher-map.ts new file mode 100644 index 00000000..72867b61 --- /dev/null +++ b/packages/misc-util/src/async/events/ievent-dispatcher-map.ts @@ -0,0 +1,60 @@ +import type { Callable } from "../../ecma/function/types.js"; +import type { + EventDispatcherSubscribeOptions, + EventDispatcherWaitOptions, +} from "./ievent-dispatcher.js"; + +/** + * Interface representing an object that allows listeners to register for event + * dispatching. + * + * @template Events - A record mapping event names to their argument tuple types. + */ +export interface IEventDispatcherMap< + Events extends Record, +> { + /** + */ + subscribe( + event: K, + listener: Callable, + options?: EventDispatcherSubscribeOptions, + ): Callable | null; + + /** + * Unsubscribe a callback from a specific event. + * @param event - The event name. + * @param listener - The callback to remove. + */ + unsubscribe( + event: K, + listener: Callable, + ): void; + + /** + * Check if a callback is subscribed to a specific event. + * @param event - The event name. + * @param listener - The callback to check. + */ + isSubscribed( + event: K, + listener: Callable, + ): boolean; + + /** + * Wait for the next occurrence of a specific event, optionally matching a + * predicate. + * + * This is a convenience method that subscribes to the dispatcher, + * waits for the next event that matches the predicate (if provided), + * and then unsubscribes automatically. + * + * @param event The event name to wait for. + * @param options Optional settings including predicate and abort signal. + * @returns A promise that resolves with the event arguments as an array. + */ + wait( + event: K, + options?: EventDispatcherWaitOptions, + ): Promise; +} diff --git a/packages/misc-util/src/async/events/ievent-dispatcher.ts b/packages/misc-util/src/async/events/ievent-dispatcher.ts new file mode 100644 index 00000000..e6da180e --- /dev/null +++ b/packages/misc-util/src/async/events/ievent-dispatcher.ts @@ -0,0 +1,82 @@ +import type { Callable } from "../../ecma/function/types.js"; + +export type EventDispatcherSubscribeOptions = { + /** + * If true, the listener will be automatically unsubscribed after being called once. + * + * Default is false. + */ + once?: boolean; + + /** + * Optional priority used to order listeners when events are dispatched. + * + * Higher values are invoked before lower values. Listeners with the + * same priority are invoked in the order they were registered. + * + * Default is 0. + */ + priority?: number; +}; + +export type EventDispatcherWaitOptions = { + /** + * Optional AbortSignal to cancel the wait operation. + */ + signal?: AbortSignal | null; + + /** + * Optional predicate to filter events. + */ + predicate?: (...args: T) => boolean; +}; + +/** + * Interface representing an object that allows listeners to register for event dispatching. + * + * @template T - Tuple type representing the arguments that will be passed to listener functions. + */ +export interface IEventDispatcher { + /** + * Registers a listener function to be called when the event is dispatched. + * + * If the listener is already registered, this method has no effect. + * + * @param listener The listener function to call when the event is dispatched. + * @param options Optional settings for the subscription. + * @returns A function that can be called to unsubscribe the listener, or null if the listener was already registered. + */ + subscribe( + listener: Callable, + options?: EventDispatcherSubscribeOptions, + ): Callable | null; + + /** + * Unregisters a previously registered listener function. + * + * If the listener is not registered, this method has no effect. + * + * @param listener The listener function to remove. + */ + unsubscribe(listener: Callable): void; + + /** + * Tests whether a listener is registered. + * + * @param listener The listener function to check. + * @returns true if the listener is registered, false otherwise. + */ + isSubscribed(listener: Callable): boolean; + + /** + * Waits for the next occurrence of the event, optionally matching a predicate. + * + * This is a convenience method that subscribes to the dispatcher, + * waits for the next event that matches the predicate (if provided), + * and then unsubscribes automatically. + * + * @param options Optional settings including predicate and abort signal. + * @returns A promise that resolves with the next event arguments as an array. + */ + wait(options?: EventDispatcherWaitOptions): Promise; +} diff --git a/packages/misc-util/src/async/file-lock.ts b/packages/misc-util/src/async/file-lock.ts deleted file mode 100644 index 4f703f9b..00000000 --- a/packages/misc-util/src/async/file-lock.ts +++ /dev/null @@ -1,89 +0,0 @@ -import * as fs from "node:fs/promises"; -import type { AsyncCallable } from "../ecma/function/types.js"; -import { waitFor } from "../ecma/function/wait-for.js"; -import { defaults } from "../ecma/object/defaults.js"; -import { isNodeErrorWithCode } from "../node/error/node-error.js"; -import { type ILockable, LockNotAcquiredError } from "./ilockable.js"; -import { LockableBase } from "./lockable-base.js"; - -export type LockFileOptions = { - /** - * The interval in milliseconds to poll for the lock file to be released. - * Default is 50ms. - */ - pollIntervalMs?: number; -}; - -const LOCK_FILE_DEFAULT_OPTIONS: Required = { - pollIntervalMs: 50, -}; - -/** - * A simple file-based lock mechanism. - * - * Creates a `.lock` file to indicate that a resource is locked. - * The lock is acquired by creating the lock file and released by deleting it. - * If the lock file already exists, it means the resource is locked by another - * process. - * - * Example usage: - * ```ts - * const lock = new LockFile("/path/to/resource"); - * const release = await lock.acquire(); - * try { - * // Do something with the locked resource - * } finally { - * await release(); - * } - * ``` - */ -export class FileLock extends LockableBase implements ILockable { - private readonly options: Required; - private lockHandle: fs.FileHandle | null = null; - - constructor( - public readonly filePath: string, - options?: LockFileOptions, - ) { - super(); - - this.options = defaults(options, LOCK_FILE_DEFAULT_OPTIONS); - } - - get locked(): boolean { - return this.lockHandle !== null; - } - - async acquire(signal?: AbortSignal | null): Promise { - await waitFor( - async () => { - try { - // Try to create the lock file exclusively - this.lockHandle = await fs.open(`${this.filePath}.lock`, "wx"); - } catch (error) { - if (isNodeErrorWithCode(error, "EEXIST")) { - return false; - } - - throw error; - } - return true; - }, - { signal, intervalMs: this.options.pollIntervalMs }, - ); - - return () => this.release(); - } - - async release(): Promise { - if (!this.lockHandle) { - throw new LockNotAcquiredError(); - } - - const handle = this.lockHandle; - this.lockHandle = null; - - await handle.close(); - await fs.unlink(`${this.filePath}.lock`); - } -} diff --git a/packages/misc-util/src/async/ilockable.ts b/packages/misc-util/src/async/ilockable.ts deleted file mode 100644 index cf9fa93e..00000000 --- a/packages/misc-util/src/async/ilockable.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { Promisable } from "type-fest"; -import type { - Callable, - MaybeAsyncCallableNoArgs, -} from "../ecma/function/types.js"; - -/** - * Error thrown when attempting to release a lock that is not currently held. - */ -export class LockNotAcquiredError extends Error { - constructor() { - super(`Lock is not acquired`); - this.name = "LockNotAcquiredError"; - } -} - -/** - * Interface representing a lockable resource. - * - * A lockable resource can be acquired and released to ensure exclusive access. - */ -export interface ILockable { - /** - * Indicates whether the lock is currently held. - */ - readonly locked: boolean; - - /** - * Acquires the lock, waiting if necessary until it is available. - * - * @param signal An optional AbortSignal to cancel the acquire operation. - * @returns A promise that resolves to a function that releases the lock. - */ - acquire(signal?: AbortSignal | null): Promisable; - - /** - * Releases the lock. - */ - release(): Promisable; - - /** - * Acquires the lock, executes the callback, and releases the lock. - * - * @param func The callback to execute while holding the lock. - * @param signal An optional AbortSignal to cancel the acquire operation. - * @returns The result of the callback. - */ - withLock( - func: MaybeAsyncCallableNoArgs, - signal?: AbortSignal | null, - ): Promisable; -} diff --git a/packages/misc-util/src/async/isubscribable.ts b/packages/misc-util/src/async/isubscribable.ts deleted file mode 100644 index 5900aff4..00000000 --- a/packages/misc-util/src/async/isubscribable.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { Callback } from "../ecma/function/types.js"; - -export type SubscribableSubscribeOptions = { - /** - * If true, the subscriber will be automatically unsubscribed after being called once. - * - * Default is false. - */ - once?: boolean; -}; - -/** - * Interface representing an object that allows subscribers to register for notifications. - * - * @template T - Tuple type representing the arguments that will be passed to subscriber functions. - */ -export interface ISubscribable { - /** - * Registers a subscriber function to be called when the event is emitted. - * - * If the subscriber is already registered, this method has no effect. - * - * @param subscriber The subscriber function to call when the event is emitted. - * @param options Optional settings for the subscription. - * @returns A function that can be called to unsubscribe the subscriber, or null if the subscriber was already registered. - */ - subscribe( - subscriber: Callback, - options?: SubscribableSubscribeOptions, - ): Callback | null; - - /** - * Unregisters a previously registered subscriber function. - * - * If the subscriber is not registered, this method has no effect. - * - * @param subscriber The subscriber function to remove. - */ - unsubscribe(subscriber: Callback): void; - - /** - * Tests whether a subscriber is registered. - * - * @param subscriber The subscriber function to check. - * @returns true if the subscriber is registered, false otherwise. - */ - isSubscribed(subscriber: Callback): boolean; -} diff --git a/packages/misc-util/src/async/iwaitable.ts b/packages/misc-util/src/async/iwaitable.ts deleted file mode 100644 index 5356a46b..00000000 --- a/packages/misc-util/src/async/iwaitable.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Promisable } from "type-fest"; - -/** - * Interface representing an object that can be waited on. - * - * @template T - Tuple type representing the arguments that can be passed to the wait method. - * @template R - Type of the result returned by the wait method. - */ -export interface IWaitable { - /** - * Waits for an event to occur, optionally aborting the wait if the provided - * AbortSignal is triggered. - * - * @param args Arguments to pass to the wait operation. The last argument can be - * an optional AbortSignal to cancel the wait operation. - * @returns A promise that resolves when the event occurs, or rejects if the - * operation is aborted. - */ - wait(...args: [...T, signal?: AbortSignal | null]): Promisable; -} diff --git a/packages/misc-util/src/async/lockable-base.test.ts b/packages/misc-util/src/async/lockable-base.test.ts deleted file mode 100644 index 95a36f50..00000000 --- a/packages/misc-util/src/async/lockable-base.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { LockableBase } from "./lockable-base.js"; - -class DummyLockable extends LockableBase { - locked = false; - acquire = vi.fn(async () => { - this.locked = true; - return () => { - this.locked = false; - }; - }); - release = vi.fn(async () => { - this.locked = false; - }); -} - -describe("LockableBase", () => { - it("withLock should acquire, run callback, and release", async () => { - const lockable = new DummyLockable(); - const callback = vi.fn(async () => "result"); - const result = await lockable.withLock(callback); - expect(result).toBe("result"); - expect(lockable.acquire).toHaveBeenCalled(); - expect(callback).toHaveBeenCalled(); - // locked should be false after release - expect(lockable.locked).toBe(false); - }); - - it("withLock should release even if callback throws", async () => { - const lockable = new DummyLockable(); - const error = new Error("fail"); - const callback = vi.fn(async () => { - throw error; - }); - await expect(lockable.withLock(callback)).rejects.toThrow(error); - // locked should be false after release - expect(lockable.locked).toBe(false); - }); - - it("withLock should pass signal to acquire", async () => { - const lockable = new DummyLockable(); - const controller = new AbortController(); - await lockable.withLock(async () => "ok", controller.signal); - expect(lockable.acquire).toHaveBeenCalledWith(controller.signal); - }); -}); diff --git a/packages/misc-util/src/async/lockable-base.ts b/packages/misc-util/src/async/lockable-base.ts deleted file mode 100644 index 888101d4..00000000 --- a/packages/misc-util/src/async/lockable-base.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Promisable } from "type-fest"; -import type { - Callable, - MaybeAsyncCallableNoArgs, -} from "../ecma/function/types.js"; -import type { ILockable } from "./ilockable.js"; - -export abstract class LockableBase implements ILockable { - abstract readonly locked: boolean; - abstract acquire(signal?: AbortSignal | null): Promisable; - abstract release(): Promisable; - - async withLock( - callback: MaybeAsyncCallableNoArgs, - signal?: AbortSignal | null, - ): Promise { - const release = await this.acquire(signal); - - try { - return await callback(); - } finally { - release(); - } - } -} diff --git a/packages/misc-util/src/async/mutex.ts b/packages/misc-util/src/async/mutex.ts deleted file mode 100644 index bcdcd07d..00000000 --- a/packages/misc-util/src/async/mutex.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Callable } from "../ecma/function/types.js"; -import { type ILockable, LockNotAcquiredError } from "./ilockable.js"; -import { LockableBase } from "./lockable-base.js"; -import { Semaphore } from "./semaphore.js"; - -/** - * A mutex is a synchronization primitive that can be used to protect shared - * resources from concurrent access. It is similar to a semaphore with a value - * of 1. - * - * A mutex has two states: locked and unlocked. When a mutex is locked, other I/O - * operations that attempt to lock the mutex will wait until the mutex is unlocked. - * When a mutex is unlocked, it can be locked by an I/O operation. - */ -export class Mutex extends LockableBase implements ILockable { - private readonly semaphore = new Semaphore(1); - - get locked(): boolean { - return this.semaphore.value === 0; - } - - async acquire(signal?: AbortSignal | null): Promise { - const releaseFunc = await this.semaphore.acquire(1, signal); - - return () => this.handleSemaphoreRelease(releaseFunc); - } - - release(): void { - this.handleSemaphoreRelease(this.semaphore.release); - } - - private handleSemaphoreRelease(semaphoreReleaseFunc: Callable) { - try { - semaphoreReleaseFunc(); - } catch (error) { - if (error instanceof RangeError) { - throw new LockNotAcquiredError(); - } else { - throw error; - } - } - } -} diff --git a/packages/misc-util/src/async/signal.ts b/packages/misc-util/src/async/signal.ts deleted file mode 100644 index b77752c3..00000000 --- a/packages/misc-util/src/async/signal.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Queue } from "../data/queue.js"; -import { debounceQueue } from "../ecma/function/debounce-queue.js"; -import { noThrow } from "../ecma/function/no-throw.js"; -import type { Callable } from "../ecma/function/types.js"; -import { AbortablePromise } from "../ecma/promise/abortable-promise.js"; -import type { ISubscribable } from "./isubscribable.js"; -import type { IWaitable } from "./iwaitable.js"; -import { Subscribable } from "./subscribable.js"; - -/** - * A synchronization primitive that can be in a signaled or non-signaled state. - * - * When the signal is in the signaled state, calls to `wait` will resolve immediately. - * When the signal is in the non-signaled state, calls to `wait` will block until - * the signal is signaled. - * - * If `autoReset` is true, the signal will automatically reset to the non-signaled - * state after a single waiter is released. - * If `autoReset` is false, the signal will remain in the signaled state until it - * is manually reset. - */ -export class Signal - extends Subscribable<[boolean]> - implements IWaitable, ISubscribable<[boolean]> -{ - private signaled_: boolean; - private pendingWaiters = new Queue(); - private processWaitersDebounced = debounceQueue(async () => - this.processPendingWaiters(), - ); - - constructor( - private readonly autoReset = false, - initialState = false, - ) { - super(); - - this.signaled_ = initialState; - this.subscribe(() => this.processWaitersDebounced()); - } - - /** - * Indicates whether the signal is in the signaled state. - */ - get signaled(): boolean { - return this.signaled_; - } - - /** - * Set the signal to signaled state - */ - signal(): void { - this.signaled_ = true; - this.publish(true); - } - - /** - * Reset the signal to non-signaled state - */ - reset(): void { - this.signaled_ = false; - this.publish(false); - } - - /** - * Wait for the signal to be signaled. - * - * If the signal is already signaled, the listener is called immediately. - * If `autoReset` is true, the signal is reset to non-signaled state after - * notifying a listener. - * - * Note: If multiple listeners are waiting and the signal is signaled, only one - * listener will be notified if `autoReset` is true. If `autoReset` is false, - * all listeners will be notified. - * - * @param signal An AbortSignal that can be used to cancel the wait operation. - * @returns A promise that resolves when the signal is signaled, or rejects when aborted. - */ - async wait(signal?: AbortSignal | null): Promise { - const deferred = AbortablePromise.withResolvers({ - signal, - onAbort: () => { - this.pendingWaiters.removeFirst((item) => item === deferred.resolve); - }, - }); - - this.pendingWaiters.enqueue(deferred.resolve); - - // In case the signal was already signaled, process pending waiters - await this.processWaitersDebounced(); - - return deferred.promise; - } - - private processPendingWaiters() { - while (this.signaled_) { - const waiter = this.pendingWaiters.dequeue(); - if (!waiter) { - break; - } - - if (this.autoReset) { - this.signaled_ = false; - } - - noThrow(waiter)(); - } - } -} diff --git a/packages/misc-util/src/async/subscribable-event.test.ts b/packages/misc-util/src/async/subscribable-event.test.ts deleted file mode 100644 index de1daee2..00000000 --- a/packages/misc-util/src/async/subscribable-event.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { expect, suite, test, vi } from "vitest"; -import { SubscribableEvent } from "./subscribable-event.js"; - -suite("SubscribableEvent", () => { - test("should register and trigger events", () => { - const event = new SubscribableEvent<[number]>(); - - const listener = vi.fn(); - event.subscribe(listener); - - event.trigger(42); - - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenNthCalledWith(1, 42); - - event.trigger(100); - - expect(listener).toHaveBeenCalledTimes(2); - expect(listener).toHaveBeenNthCalledWith(2, 100); - }); - - test("should not register the same listener multiple times", () => { - const event = new SubscribableEvent<[number]>(); - - const listener = vi.fn(); - - event.subscribe(listener); - event.subscribe(listener); // Duplicate subscription - event.trigger(42); - - expect(listener).toHaveBeenCalledOnce(); // Should only be called once - }); - - test("should unregister listeners", () => { - const event = new SubscribableEvent<[number]>(); - - const listener = vi.fn(); - event.subscribe(listener); - - event.trigger(42); - expect(listener).toHaveBeenCalledOnce(); - - event.unsubscribe(listener); - - event.trigger(100); - expect(listener).toHaveBeenCalledOnce(); // Should not be called again - }); - - test("should register one-time listeners", () => { - const event = new SubscribableEvent<[number]>(); - - const listener = vi.fn(); - event.subscribe(listener, { once: true }); - - event.trigger(42); - expect(listener).toHaveBeenCalledOnce(); - - event.trigger(100); - expect(listener).toHaveBeenCalledOnce(); // Should not be called again - }); - - test("should not register the same one-time listener multiple times", () => { - const event = new SubscribableEvent<[number]>(); - - const listener = vi.fn(); - - event.subscribe(listener, { once: true }); - event.subscribe(listener, { once: true }); // Duplicate subscription - event.subscribe(listener); // Duplicate subscription - event.trigger(42); - - expect(listener).toHaveBeenCalledOnce(); // Should only be called once - }); - - test("should unregister one-time listeners", () => { - const event = new SubscribableEvent<[number]>(); - - const listener = vi.fn(); - event.subscribe(listener, { once: true }); - - event.trigger(42); - expect(listener).toHaveBeenCalledOnce(); - - event.subscribe(listener, { once: true }); - event.unsubscribe(listener); - - event.trigger(100); - expect(listener).toHaveBeenCalledOnce(); // Should not be called again - }); - - test("should check if a listener is registered", () => { - const event = new SubscribableEvent<[number]>(); - - const listener = (_value: number) => {}; - - expect(event.isSubscribed(listener)).toBe(false); - - event.subscribe(listener); - expect(event.isSubscribed(listener)).toBe(true); - - event.unsubscribe(listener); - expect(event.isSubscribed(listener)).toBe(false); - - event.subscribe(listener, { once: true }); - expect(event.isSubscribed(listener)).toBe(true); - - event.unsubscribe(listener); - expect(event.isSubscribed(listener)).toBe(false); - }); - - test("should wait for the next event emission", async () => { - const event = new SubscribableEvent<[number]>(); - - const waitPromise = event.wait(); - - event.trigger(42); - - const result = await waitPromise; - - expect(result).toEqual([42]); - }); - - test("should timeout if the event is not emitted in time", async () => { - const event = new SubscribableEvent<[number]>(); - - const waitPromise = event.wait(AbortSignal.timeout(0)); - - await expect(waitPromise).rejects.toThrow(); - }); -}); diff --git a/packages/misc-util/src/async/subscribable-event.ts b/packages/misc-util/src/async/subscribable-event.ts deleted file mode 100644 index ad097052..00000000 --- a/packages/misc-util/src/async/subscribable-event.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { ISubscribable } from "./isubscribable.js"; -import type { IWaitable } from "./iwaitable.js"; -import { Subscribable } from "./subscribable.js"; -import { waitNotifiable } from "./wait-notifiable.js"; - -/** - * An event that can have subscribers and can be waited on. - * - * @template T - Tuple type representing the arguments that will be passed to subscriber functions. - */ -export class SubscribableEvent - extends Subscribable - implements ISubscribable, IWaitable -{ - /** - * Trigger an event, calling all registered subscribers. - */ - trigger(...args: T): void { - this.publish(...args); - } - - /** - * Wait for the next event to be triggered. - * - * @param signal Optional abort signal to cancel the wait. - * @returns A promise that resolves when the event is triggered. - */ - async wait(signal?: AbortSignal | null): Promise { - return await waitNotifiable(this, signal); - } -} diff --git a/packages/misc-util/src/async/subscribable.test.ts b/packages/misc-util/src/async/subscribable.test.ts deleted file mode 100644 index 4c543ae4..00000000 --- a/packages/misc-util/src/async/subscribable.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { Subscribable } from "./subscribable.js"; - -describe("Subscribable", () => { - it("should allow subscribing and publishing values", () => { - const sub = new Subscribable<[number]>(); - const handler = vi.fn(); - sub.subscribe(handler); - // @ts-expect-error accessing protected method for test - sub.publish(42); - expect(handler).toHaveBeenCalledWith(42); - }); - - it("should allow unsubscribing", () => { - const sub = new Subscribable<[number]>(); - const handler = vi.fn(); - sub.subscribe(handler); - sub.unsubscribe(handler); - // @ts-expect-error accessing protected method for test - sub.publish(99); - expect(handler).not.toHaveBeenCalled(); - }); - - it("should support multiple subscribers", () => { - const sub = new Subscribable<[string]>(); - const h1 = vi.fn(); - const h2 = vi.fn(); - sub.subscribe(h1); - sub.subscribe(h2); - // @ts-expect-error accessing protected method for test - sub.publish("hello"); - expect(h1).toHaveBeenCalledWith("hello"); - expect(h2).toHaveBeenCalledWith("hello"); - }); -}); diff --git a/packages/misc-util/src/async/subscribable.ts b/packages/misc-util/src/async/subscribable.ts deleted file mode 100644 index c8044722..00000000 --- a/packages/misc-util/src/async/subscribable.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { noThrow } from "../ecma/function/no-throw.js"; -import type { Callback } from "../ecma/function/types.js"; -import type { - ISubscribable, - SubscribableSubscribeOptions, -} from "./isubscribable.js"; - -export class Subscribable - implements ISubscribable -{ - private readonly subscribers = new Map, Callback>(); - - subscribe( - subscriber: Callback, - options?: SubscribableSubscribeOptions, - ): Callback | null { - if (!this.subscribers.has(subscriber)) { - if (options?.once) { - this.subscribers.set(subscriber, (...args: T) => { - this.subscribers.delete(subscriber); - - noThrow(subscriber).apply(undefined, args); - }); - } else { - this.subscribers.set(subscriber, subscriber); - } - - return () => this.unsubscribe(subscriber); - } - - return null; - } - - unsubscribe(subscriber: Callback): void { - this.subscribers.delete(subscriber); - } - - isSubscribed(subscriber: Callback): boolean { - return this.subscribers.has(subscriber); - } - - protected publish(...args: T): void { - for (const [, subscriber] of this.subscribers) { - noThrow(subscriber).apply(undefined, args); - } - } -} diff --git a/packages/misc-util/src/async/synchro/barrier.test.ts b/packages/misc-util/src/async/synchro/barrier.test.ts new file mode 100644 index 00000000..bda7f6b9 --- /dev/null +++ b/packages/misc-util/src/async/synchro/barrier.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { Barrier } from "./barrier.js"; + +// Helper to wait for a short time +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe("Barrier", () => { + it("should resolve all waiters when count is reached", async () => { + const barrier = new Barrier(2); + let resolved1 = false; + let resolved2 = false; + const waiter1 = barrier.wait().then(() => { + resolved1 = true; + }); + const waiter2 = barrier.wait().then(() => { + resolved2 = true; + }); + await Promise.all([waiter1, waiter2]); + expect(resolved1).toBe(true); + expect(resolved2).toBe(true); + }); + + it("should reset and allow reuse for multiple rounds", async () => { + const barrier = new Barrier(2); + let round1 = false; + let round2 = false; + // First round + const w1 = barrier.wait().then(() => { + round1 = true; + }); + const w2 = barrier.wait(); + await Promise.all([w1, w2]); + expect(round1).toBe(true); + // Second round + const w3 = barrier.wait().then(() => { + round2 = true; + }); + const w4 = barrier.wait(); + await Promise.all([w3, w4]); + expect(round2).toBe(true); + }); + + it("should not resolve until enough waiters arrive", async () => { + const barrier = new Barrier(3); + let resolved = false; + const waiter = barrier.wait().then(() => { + resolved = true; + }); + await wait(10); + expect(resolved).toBe(false); + // Add remaining waiters + const w2 = barrier.wait(); + const w3 = barrier.wait(); + await Promise.all([waiter, w2, w3]); + expect(resolved).toBe(true); + }); + + it("should throw if count is not positive", () => { + expect(() => new Barrier(0)).toThrowError("Barrier count must be positive"); + expect(() => new Barrier(-1)).toThrowError( + "Barrier count must be positive", + ); + }); + + it("should support AbortSignal for wait", async () => { + const barrier = new Barrier(2); + const ac = new AbortController(); + const p = barrier.wait(ac.signal); + ac.abort(); + await expect(p).rejects.toThrowError(); + }); + + it("should resolve extra waiters in the next round if more than count waiters arrive", async () => { + const barrier = new Barrier(2); + let resolved1 = false; + let resolved2 = false; + let resolved3 = false; + // First two waiters should resolve together + const waiter1 = barrier.wait().then(() => { + resolved1 = true; + }); + const waiter2 = barrier.wait().then(() => { + resolved2 = true; + }); + // Third waiter should wait for the next round + const waiter3 = barrier.wait().then(() => { + resolved3 = true; + }); + await Promise.all([waiter1, waiter2]); + expect(resolved1).toBe(true); + expect(resolved2).toBe(true); + expect(resolved3).toBe(false); + // Add another waiter to complete the next round + const waiter4 = barrier.wait(); + await Promise.all([waiter3, waiter4]); + expect(resolved3).toBe(true); + }); +}); diff --git a/packages/misc-util/src/async/synchro/barrier.ts b/packages/misc-util/src/async/synchro/barrier.ts new file mode 100644 index 00000000..53a9b0d6 --- /dev/null +++ b/packages/misc-util/src/async/synchro/barrier.ts @@ -0,0 +1,56 @@ +import { Counter } from "./counter.js"; + +/** + * An asynchronous reusable barrier for N participants. + * + * Barriers allow multiple asynchronous tasks to wait until a specified number + * of them have reached a certain point in their execution. + * + * After the required number of tasks have called `wait()`, all waiting tasks + * are released, and the barrier resets for reuse. + * + * @example + * const barrier = new Barrier(3); + * + * async function task(id: number) { + * console.log(`Task ${id} is waiting at the barrier.`); + * await barrier.wait(); + * console.log(`Task ${id} has crossed the barrier.`); + * } + * + * // Start 3 tasks that will wait at the barrier + * task(1); + * task(2); + * task(3); + */ +export class Barrier { + private readonly counter = new Counter(0); + + /** + * Create a Barrier for the specified number of participants. + * + * @param count Number of participants required to release the barrier. + */ + constructor(private readonly count: number) { + if (count <= 0) { + throw new Error("Barrier count must be positive"); + } + } + + /** + * Wait for the barrier. + * + * After releasing all waiters, the barrier resets for reuse. + * + * @param signal An optional AbortSignal to cancel the wait operation. + * @returns A promise that resolves when the barrier is released. + */ + async wait(signal?: AbortSignal | null): Promise { + if (this.counter.increment() === this.count) { + this.counter.reset(); + return; + } + + await this.counter.wait(this.count, signal); + } +} diff --git a/packages/misc-util/src/async/synchro/broadcast.test.ts b/packages/misc-util/src/async/synchro/broadcast.test.ts new file mode 100644 index 00000000..13266800 --- /dev/null +++ b/packages/misc-util/src/async/synchro/broadcast.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import { Broadcast, BroadcastSubscriberLaggedError } from "./broadcast.js"; +import { ChannelClosedError } from "./channel.js"; + +describe("Broadcast", () => { + it("delivers messages to all subscribers", async () => { + const bc = new Broadcast(4); + const sub1 = bc.subscribe(); + const sub2 = bc.subscribe(); + + await bc.send("A"); + await bc.send("B"); + + expect(await sub1.receive()).toBe("A"); + expect(await sub1.receive()).toBe("B"); + expect(await sub2.receive()).toBe("A"); + expect(await sub2.receive()).toBe("B"); + }); + + it("does not deliver messages sent before subscription", async () => { + const bc = new Broadcast(4); + await bc.send("A"); + const sub = bc.subscribe(); + await bc.send("B"); + expect(await sub.receive()).toBe("B"); + }); + + it("throws on receive after close", async () => { + const bc = new Broadcast(4); + const sub = bc.subscribe(); + bc.close(); + await expect(sub.receive()).rejects.toThrow(ChannelClosedError); + }); + + it("throws on send after close", async () => { + const bc = new Broadcast(4); + bc.close(); + await expect(bc.send("A")).rejects.toThrow(ChannelClosedError); + }); + + it("throws BroadcastSubscriberLaggedError if subscriber lags", async () => { + const bc = new Broadcast(2); + const sub = bc.subscribe(); + await bc.send("A"); + await bc.send("B"); + await bc.send("C"); // "A" is dropped for sub + await expect(sub.receive()).rejects.toThrow(BroadcastSubscriberLaggedError); + expect(await sub.receive()).toBe("B"); + expect(await sub.receive()).toBe("C"); + }); + + it("delivers messages in order", async () => { + const bc = new Broadcast(3); + const sub = bc.subscribe(); + await bc.send(1); + await bc.send(2); + await bc.send(3); + expect(await sub.receive()).toBe(1); + expect(await sub.receive()).toBe(2); + expect(await sub.receive()).toBe(3); + }); + + it("subscriber closed returns closed true and throws on receive", async () => { + const bc = new Broadcast(2); + const sub = bc.subscribe(); + sub.close(); + expect(sub.closed).toBe(true); + await expect(sub.receive()).rejects.toThrow(ChannelClosedError); + }); + + it("unsubscribes on close and does not receive further messages", async () => { + const bc = new Broadcast(2); + const sub = bc.subscribe(); + await bc.send("A"); + sub.close(); + await bc.send("B"); + await expect(sub.receive()).rejects.toThrow(ChannelClosedError); + }); + + it("handles multiple subscribers with different receive rates", async () => { + const bc = new Broadcast(2); + const sub1 = bc.subscribe(); + const sub2 = bc.subscribe(); + await bc.send(1); + await bc.send(2); + await bc.send(3); // sub1 and sub2 both lag + await expect(sub1.receive()).rejects.toThrow( + BroadcastSubscriberLaggedError, + ); + expect(await sub1.receive()).toBe(2); + expect(await sub1.receive()).toBe(3); + await expect(sub2.receive()).rejects.toThrow( + BroadcastSubscriberLaggedError, + ); + expect(await sub2.receive()).toBe(2); + expect(await sub2.receive()).toBe(3); + }); + + it("supports aborting receive with AbortSignal", async () => { + const bc = new Broadcast(2); + const sub = bc.subscribe(); + const controller = new AbortController(); + const promise = sub.receive(controller.signal); + controller.abort(); + await expect(promise).rejects.toThrow(); + }); + + it("does not throw lag error if no messages were dropped", async () => { + const bc = new Broadcast(2); + const sub = bc.subscribe(); + await bc.send("A"); + expect(await sub.receive()).toBe("A"); + await bc.send("B"); + expect(await sub.receive()).toBe("B"); + }); + + it("continues receiving correct messages after lag error", async () => { + const bc = new Broadcast(2); + const sub = bc.subscribe(); + await bc.send("A"); + await bc.send("B"); + await bc.send("C"); // "A" is dropped + await expect(sub.receive()).rejects.toThrow(BroadcastSubscriberLaggedError); + expect(await sub.receive()).toBe("B"); + expect(await sub.receive()).toBe("C"); + await bc.send("D"); + expect(await sub.receive()).toBe("D"); + }); + + it("sets closed on subscribers when channel is closed", async () => { + const bc = new Broadcast(2); + const sub1 = bc.subscribe(); + const sub2 = bc.subscribe(); + expect(sub1.closed).toBe(false); + expect(sub2.closed).toBe(false); + bc.close(); + expect(sub1.closed).toBe(true); + expect(sub2.closed).toBe(true); + await expect(sub1.receive()).rejects.toThrow(ChannelClosedError); + await expect(sub2.receive()).rejects.toThrow(ChannelClosedError); + }); +}); diff --git a/packages/misc-util/src/async/synchro/broadcast.ts b/packages/misc-util/src/async/synchro/broadcast.ts new file mode 100644 index 00000000..6ad0ad6d --- /dev/null +++ b/packages/misc-util/src/async/synchro/broadcast.ts @@ -0,0 +1,251 @@ +import { Deque } from "../../data/deque.js"; +import { compact } from "../../ecma/array/compact.js"; +import type { Callable } from "../../ecma/function/types.js"; +import { ChannelClosedError } from "./channel.js"; +import { Condition } from "./condition.js"; +import { Mutex } from "./mutex.js"; + +/** + * Error thrown when a broadcast subscriber has lagged behind and missed messages. + * + * @example + * try { + * const msg = await subscriber.receive(); + * } catch (error) { + * if (error instanceof BroadcastSubscriberLaggedError) { + * console.error(`Missed ${error.missed} messages`); + * } else { + * throw error; + * } + * } + */ +export class BroadcastSubscriberLaggedError extends Error { + constructor(public readonly missed: number) { + super(`Missed ${missed} messages`); + this.name = "BroadcastSubscriberLaggedError"; + } +} + +/** + * A subscriber to a Broadcast channel. + */ +export interface IBroadcastSubscriber { + /** + * Indicates whether the subscriber is closed. + * + * @returns true if the subscriber is closed, false otherwise. + */ + readonly closed: boolean; + + /** + * Closes the subscriber. + * + * This will prevent further messages from being received. + * Any pending receive operations will be aborted. + */ + close(): void; + + /** + * Wait to receive a message from the broadcast channel. + * + * @throws {BroadcastSubscriberLaggedError} If the subscriber has lagged behind and missed messages. + * @param signal An optional AbortSignal to cancel the receive operation. + */ + receive(signal?: AbortSignal | null): Promise; +} + +/** + * A broadcast channel that allows multiple subscribers to receive messages. + * + * Each subscriber maintains its own message queue with a specified capacity. + * If a subscriber's queue is full when a new message is sent, the oldest message + * in that subscriber's queue is discarded to make room for the new message. + * + * @example + * const broadcast = new Broadcast(5); // Capacity of 5 messages per subscriber + * + * // Subscriber 1 + * const subscriber1 = broadcast.subscribe(); + * function listen1() { + * while (!subscriber1.closed) { + * const msg = await subscriber1.receive(); + * console.log("Subscriber 1 received:", msg); + * } + * } + * listen1(); + * + * // Subscriber 2 + * const subscriber2 = broadcast.subscribe(); + * function listen2() { + * while (!subscriber2.closed) { + * const msg = await subscriber2.receive(); + * console.log("Subscriber 2 received:", msg); + * } + * } + * listen2(); + * + * // Send messages + * await broadcast.send(1); + * await broadcast.send(2); + * + * // Close the broadcast channel + * broadcast.close(); + */ +export class Broadcast { + private readonly subscribers = new Set>(); + private readonly closeController = new AbortController(); + + /** + * Creates a new Broadcast channel. + * + * All subscribers will have their own message queue with the specified + * capacity. + * + * @throws {RangeError} If the capacity is less than or equal to zero. + * @param capacity The capacity of each subscriber's message queue. + */ + constructor(private readonly capacity: number) { + if (capacity <= 0) { + throw new RangeError("Capacity must be greater than zero"); + } + } + + /** + * Indicates whether the broadcast channel is closed. + */ + get closed(): boolean { + return this.closeController.signal.aborted; + } + + /** + * Subscribe to the broadcast channel. + * + * @returns A new subscriber to the broadcast channel. + */ + subscribe(): IBroadcastSubscriber { + if (this.closed) { + throw new ChannelClosedError(); + } + + const subscriber = new Subscriber_( + this.capacity, + this.closeController.signal, + () => { + this.subscribers.delete(subscriber); + }, + ); + + this.subscribers.add(subscriber); + + return subscriber; + } + + /** + * Sends a message to all subscribers. + * + * @param message The message to send. + * @param signal An optional AbortSignal to cancel the send operation. + * @returns A promise that resolves when the message has been sent to all subscribers. + */ + async send(message: T, signal?: AbortSignal | null): Promise { + if (this.closed) { + throw new ChannelClosedError(); + } + + const signal_ = AbortSignal.any( + compact([this.closeController.signal, signal]), + ); + + for (const subscriber of this.subscribers.values()) { + try { + await subscriber.enqueue(message, signal_); + } catch (error) { + // Ignore closed subscriptions + if (!(error instanceof ChannelClosedError)) { + throw error; + } + } + } + } + + /** + * Closes the broadcast channel. + */ + close(): void { + if (this.closed) { + return; + } + + this.closeController.abort(new ChannelClosedError()); + } +} + +class Subscriber_ implements IBroadcastSubscriber { + private readonly messages: Deque; + private readonly messagesLock = new Mutex(); + private readonly newMessage = new Condition(); + private readonly closeController = new AbortController(); + private lagCount = 0; + + constructor( + private readonly capacity: number, + private readonly parentCloseSignal: AbortSignal, + private readonly onUnsubscribe: Callable, + ) { + this.messages = new Deque([], capacity); + } + + get closed(): boolean { + return ( + this.parentCloseSignal.aborted || this.closeController.signal.aborted + ); + } + + async enqueue(message: T, signal?: AbortSignal | null): Promise { + const signal_ = AbortSignal.any( + compact([this.parentCloseSignal, this.closeController.signal, signal]), + ); + + await this.messagesLock.lock(signal_); + try { + if (this.messages.count() >= this.capacity) { + this.messages.shift(); // Remove oldest + this.lagCount++; + } + this.messages.push(message); + await this.newMessage.signal(); + } finally { + this.messagesLock.unlock(); + } + } + + async receive(signal?: AbortSignal | null): Promise { + const signal_ = AbortSignal.any( + compact([this.parentCloseSignal, this.closeController.signal, signal]), + ); + + await this.messagesLock.lock(signal_); + try { + if (this.lagCount > 0) { + const missed = this.lagCount; + this.lagCount = 0; + throw new BroadcastSubscriberLaggedError(missed); + } + let message: T | undefined; + while (!(message = this.messages.shift())) { + await this.newMessage.wait(this.messagesLock, signal_); + } + return message; + } finally { + this.messagesLock.unlock(); + } + } + + close(): void { + if (this.closed) { + return; + } + this.closeController.abort(new ChannelClosedError()); + this.onUnsubscribe(); + } +} diff --git a/packages/misc-util/src/async/synchro/channel.test.ts b/packages/misc-util/src/async/synchro/channel.test.ts new file mode 100644 index 00000000..3e1cb740 --- /dev/null +++ b/packages/misc-util/src/async/synchro/channel.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; +import { Channel, ChannelClosedError } from "./channel.js"; + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe("Channel (rendezvous, capacity = 0)", () => { + it("receive aborts with AbortSignal", async () => { + const ch = new Channel(0); + const abort = new AbortController(); + const p = ch.receive(abort.signal); + abort.abort(); + await expect(p).rejects.toThrow(); + }); + it("send aborts with AbortSignal", async () => { + const ch = new Channel(0); + const abort = new AbortController(); + const p = ch.send(42, abort.signal); + abort.abort(); + await expect(p).rejects.toThrow(); + }); + + it("send/receive rendezvous: both block until paired", async () => { + const ch = new Channel(0); + let received: number | undefined; + const recv = Promise.resolve(ch.receive()).then((v) => (received = v)); + await delay(10); // ensure receive is waiting + const send = ch.send(42); + await send; + await recv; + expect(received).toBe(42); + }); + + it("send blocks until receive arrives", async () => { + const ch = new Channel(0); + let sent = false; + const send = ch.send(7).then(() => (sent = true)); + await delay(10); + expect(sent).toBe(false); + await ch.receive(); + await send; + expect(sent).toBe(true); + }); + + it("receive blocks until send arrives", async () => { + const ch = new Channel(0); + let received: number | undefined; + const recv = Promise.resolve(ch.receive()).then((v) => (received = v)); + await delay(10); + expect(received).toBeUndefined(); + await ch.send(99); + await recv; + expect(received).toBe(99); + }); + + it("send/receive throw if closed", async () => { + const ch = new Channel(0); + ch.close(); + await expect(ch.send(1)).rejects.toThrow(ChannelClosedError); + await expect(ch.receive()).rejects.toThrow(ChannelClosedError); + }); +}); + +describe("Channel (async, capacity = Infinity)", () => { + it("receive aborts with AbortSignal", async () => { + const ch = new Channel(Infinity); + const abort = new AbortController(); + const p = ch.receive(abort.signal); + abort.abort(); + await expect(p).rejects.toThrow(); + }); + it("send aborts with AbortSignal (buffer full)", async () => { + const ch = new Channel(Infinity); + // Buffer never fills, so abort is not relevant for send in this mode + // But we can still test that aborting a send does not throw if not blocked + const abort = new AbortController(); + await expect(ch.send(1, abort.signal)).resolves.toBeUndefined(); + abort.abort(); + }); + it("send never blocks, receive gets values in order", async () => { + const ch = new Channel(Infinity); + await ch.send(1); + await ch.send(2); + await ch.send(3); + expect(await ch.receive()).toBe(1); + expect(await ch.receive()).toBe(2); + expect(await ch.receive()).toBe(3); + }); + + it("receive blocks if buffer empty", async () => { + const ch = new Channel(Infinity); + let received: number | undefined; + const recv = Promise.resolve(ch.receive()).then((v) => (received = v)); + await delay(10); + expect(received).toBeUndefined(); + await ch.send(42); + await recv; + expect(received).toBe(42); + }); + + it("send/receive throw if closed", async () => { + const ch = new Channel(Infinity); + ch.close(); + await expect(ch.send(1)).rejects.toThrow(ChannelClosedError); + await expect(ch.receive()).rejects.toThrow(ChannelClosedError); + }); +}); + +describe("Channel (synchronous, 0 < capacity < Infinity)", () => { + it("receive aborts with AbortSignal", async () => { + const ch = new Channel(2); + const abort = new AbortController(); + const p = ch.receive(abort.signal); + abort.abort(); + await expect(p).rejects.toThrow(); + }); + it("send aborts with AbortSignal (buffer full)", async () => { + const ch = new Channel(1); + await ch.send(1); + const abort = new AbortController(); + const p = ch.send(2, abort.signal); + abort.abort(); + await expect(p).rejects.toThrow(); + }); + it("send fills buffer, then blocks", async () => { + const ch = new Channel(2); + await ch.send(1); + await ch.send(2); + let sent = false; + const send = ch.send(3).then(() => (sent = true)); + await delay(10); + expect(sent).toBe(false); + expect(await ch.receive()).toBe(1); + await send; + expect(sent).toBe(true); + }); + + it("receive drains buffer, then blocks", async () => { + const ch = new Channel(2); + await ch.send(1); + await ch.send(2); + expect(await ch.receive()).toBe(1); + expect(await ch.receive()).toBe(2); + let received: number | undefined; + const recv = Promise.resolve(ch.receive()).then((v) => (received = v)); + await delay(10); + expect(received).toBeUndefined(); + await ch.send(99); + await recv; + expect(received).toBe(99); + }); + + it("send/receive throw if closed", async () => { + const ch = new Channel(2); + ch.close(); + await expect(ch.send(1)).rejects.toThrow(ChannelClosedError); + await expect(ch.receive()).rejects.toThrow(ChannelClosedError); + }); +}); diff --git a/packages/misc-util/src/async/synchro/channel.ts b/packages/misc-util/src/async/synchro/channel.ts new file mode 100644 index 00000000..6ceb9a9c --- /dev/null +++ b/packages/misc-util/src/async/synchro/channel.ts @@ -0,0 +1,237 @@ +import { Queue } from "../../data/queue.js"; +import { compact } from "../../ecma/array/compact.js"; +import type { PromiseResolve } from "../../ecma/promise/types.js"; +import { Condition } from "./condition.js"; +import { Mutex } from "./mutex.js"; + +/** + * Error thrown when attempting to use a closed Channel. + */ +export class ChannelClosedError extends Error { + constructor() { + super("Channel is closed"); + this.name = "ChannelClosedError"; + } +} + +/** + * An asynchronous channel for sending and receiving messages between tasks. + * + * Supports both rendezvous (capacity = 0) and buffered (capacity > 0) modes. + * + * @example + * const ch = new Channel(0); // Rendezvous channel + * + * // Sender task + * async function sender() { + * await ch.send(42); + * console.log("Message sent"); + * } + * + * // Receiver task + * async function receiver() { + * const msg = await ch.receive(); + * console.log("Received message:", msg); + * } + * // Start sender and receiver + * sender(); + * receiver(); + * ``` + * + * @example + * const ch = new Channel(10); // Buffered channel with capacity 10 + * + * // Sender task + * async function sender() { + * for (let i = 0; i < 20; i++) { + * await ch.send(`Message ${i}`); + * console.log(`Sent: Message ${i}`); + * } + * } + * + * // Receiver task + * async function receiver() { + * for (let i = 0; i < 20; i++) { + * const msg = await ch.receive(); + * console.log(`Received: ${msg}`); + * } + * } + * + * // Start sender and receiver + * sender(); + * receiver(); + */ +export class Channel { + private readonly receivers = new Queue>(); + private readonly receiversLock = new Mutex(); + private readonly newReceiver = new Condition(); + private readonly messages = new Queue(); + private readonly closeController = new AbortController(); + + /** + * Create a new Channel with the given capacity. + * + * A capacity of 0 creates a rendezvous channel where sends and receives + * must be paired. A capacity greater than 0 creates a buffered channel. + * + * @example + * const rendezvousChannel = new Channel(0); + * const bufferedChannel = new Channel(5); + * + * @example + * const ch = new Channel(3); // Buffered channel with capacity 3 + * await ch.send(1); + * await ch.send(2); + * await ch.send(3); + * // The next send will wait until a receive occurs + * const sendPromise = ch.send(4); + * const msg = await ch.receive(); // Receives 1 + * await sendPromise; // Now the send of 4 completes + * + * @example + * const ch = new Channel(0); // Rendezvous channel + * const sendPromise = ch.send(42); // This will wait for a receiver + * const msg = await ch.receive(); // Receives 42, unblocking the sender + * await sendPromise; // Now the send completes + * + * @param capacity The channel capacity (0 for rendezvous, >0 for buffered). + * @throws {RangeError} If capacity is negative. + */ + constructor(capacity: number = Infinity) { + if (capacity < 0) { + throw new RangeError("Channel capacity cannot be negative"); + } + + this.messages = new Queue([], capacity); + } + + /** + * Indicates whether the channel is closed. + * + * @returns True if the channel is closed, false otherwise. + */ + get closed(): boolean { + return this.closeController.signal.aborted; + } + + /** + * Send a message through the channel. + * + * If the channel capacity is zero ("rendezvous" channel), this will wait for + * a receiver to be ready. + * + * If the capacity is greater than zero (buffered), this will buffer the + * message or wait if the buffer is full. If a receiver is waiting, the + * message is delivered directly to the receiver. + * + * @param message The message to send. + * @throws {ChannelClosedError} If the channel is closed. + * @returns A promise that resolves when the message has been sent. + */ + async send(message: T, signal?: AbortSignal | null): Promise { + const signal_ = AbortSignal.any( + compact([this.closeController.signal, signal]), + ); + + if (this.messages.capacity === 0) { + await this.receiversLock.lock(signal_); + + try { + let receiver: PromiseResolve | undefined; + while (!(receiver = this.receivers.dequeue())) { + await this.newReceiver.wait(this.receiversLock, signal_); + } + + receiver(message); + } finally { + this.receiversLock.unlock(); + } + } else { + await this.receiversLock.lock(signal_); + + try { + const receiver = this.receivers.dequeue(); + + if (receiver) { + receiver(message); + return; + } + } finally { + this.receiversLock.unlock(); + } + + await this.messages.waitEnqueue([message], signal_); + } + } + + /** + * Wait to receive a message from the channel. + * + * If multiple receivers are waiting, the oldest receiver will be served + * first (FIFO). + * + * @param signal An optional AbortSignal to cancel the receive operation. + * @throws {ChannelClosedError} If the channel is closed. + * @returns A promise that resolves to the received message. + */ + async receive(signal?: AbortSignal | null): Promise { + const signal_ = AbortSignal.any( + compact([this.closeController.signal, signal]), + ); + + const deferred = Promise.withResolvers(); + + await this.receiversLock.lock(signal_); + try { + this.receivers.enqueue(deferred.resolve); + await this.newReceiver.signal(); + } finally { + this.receiversLock.unlock(); + } + + const handleAbort = () => { + deferred.reject(signal_?.reason); + }; + + let message: T | undefined; + try { + signal?.throwIfAborted(); + signal?.addEventListener("abort", handleAbort, { + once: true, + }); + + message = this.messages.dequeue(); + if (message !== undefined) { + deferred.resolve(message); + } + + message = await deferred.promise; + } finally { + signal?.removeEventListener("abort", handleAbort); + + await this.receiversLock.lock(); + try { + this.receivers.removeFirst((item) => item === deferred.resolve); + } finally { + this.receiversLock.unlock(); + } + } + + return message; + } + + /** + * Close the channel. All pending and future receives will reject. + * + * If the channel is already closed, this is a no-op. + * + * Any pending producers/receivers will be rejected with ChannelClosedError. + */ + close(): void { + if (this.closed) { + return; + } + + this.closeController.abort(new ChannelClosedError()); + } +} diff --git a/packages/misc-util/src/async/synchro/condition.test.ts b/packages/misc-util/src/async/synchro/condition.test.ts new file mode 100644 index 00000000..eb129235 --- /dev/null +++ b/packages/misc-util/src/async/synchro/condition.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { Condition } from "./condition.js"; +import { Mutex } from "./mutex.js"; + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe("Condition", () => { + it("wait resolves after signal", async () => { + const mutex = new Mutex(); + const cond = new Condition(); + let ready = false; + + async function waiter() { + await mutex.lock(); + while (!ready) { + await cond.wait(mutex); + } + mutex.unlock(); + return true; + } + + const waiterPromise = waiter(); + await delay(10); // ensure waiter is waiting + await mutex.lock(); + ready = true; + await cond.signal(); + mutex.unlock(); + expect(await waiterPromise).toBe(true); + }); + + it("broadcast wakes all waiters", async () => { + const mutex = new Mutex(); + const cond = new Condition(); + let ready = false; + let woken = 0; + + async function waiter() { + await mutex.lock(); + while (!ready) { + await cond.wait(mutex); + } + woken++; + mutex.unlock(); + } + + const promises = [waiter(), waiter(), waiter()]; + await delay(10); + await mutex.lock(); + ready = true; + await cond.broadcast(); + mutex.unlock(); + await Promise.all(promises); + expect(woken).toBe(3); + }); + + it("wait can be aborted", async () => { + const mutex = new Mutex(); + const cond = new Condition(); + await mutex.lock(); + const abort = new AbortController(); + const p = cond.wait(mutex, abort.signal); + abort.abort(); + await expect(p).rejects.toThrow(); + mutex.unlock(); + }); +}); diff --git a/packages/misc-util/src/async/synchro/condition.ts b/packages/misc-util/src/async/synchro/condition.ts new file mode 100644 index 00000000..8c4b96a9 --- /dev/null +++ b/packages/misc-util/src/async/synchro/condition.ts @@ -0,0 +1,119 @@ +import { Queue } from "../../data/queue.js"; +import type { Callable } from "../../ecma/function/types.js"; +import type { ILock } from "./ilock.js"; +import { Mutex } from "./mutex.js"; + +/** + * A condition variable primitive. + * + * Allows tasks to wait until they are signaled to continue. + * + * @example + * const mutex = new Mutex(); + * const condition = new Condition(); + * let ready = false; + * + * // Task 1 + * async function task1() { + * await mutex.lock(); + * while (!ready) { + * await condition.wait(mutex); + * } + * // Proceed with task + * mutex.unlock(); + * } + * + * // Task 2 + * async function task2() { + * await mutex.lock(); + * ready = true; + * condition.signal(); // or condition.broadcast() to wake all waiting tasks + * mutex.unlock(); + * } + */ +export class Condition { + private readonly waiters = new Queue(); + private readonly waitersLock = new Mutex(); + + /** + * Signal one waiting task, if any. + */ + async signal(): Promise { + let waiter: Callable | undefined; + + await this.waitersLock.lock(); + try { + waiter = this.waiters.dequeue(); + } finally { + this.waitersLock.unlock(); + } + + waiter?.(); + } + + /** + * Broadcast to all waiting tasks, if any. + */ + async broadcast(): Promise { + let waiters: Callable[]; + + await this.waitersLock.lock(); + try { + // Copy the waiters to avoid holding the lock while invoking them + waiters = Array.from(this.waiters.concat()); + this.waiters.clear(); + } finally { + this.waitersLock.unlock(); + } + + for (const waiter of waiters) { + waiter(); + } + } + + /** + * Wait until the condition is signaled. + * + * @param lock The lockable (mutex) to use for synchronization. + * @param signal An optional AbortSignal to cancel the wait operation. + * @returns A promise that resolves when the condition is signaled. + */ + async wait(lock: ILock, signal?: AbortSignal | null): Promise { + await lock.unlock(); + + try { + const deferred = Promise.withResolvers(); + + await this.waitersLock.lock(); + try { + this.waiters.enqueue(deferred.resolve); + } finally { + this.waitersLock.unlock(); + } + + const handleAbort = () => { + deferred.reject(signal?.reason); + }; + + try { + signal?.throwIfAborted(); + signal?.addEventListener("abort", handleAbort, { + once: true, + }); + + await deferred.promise; + } finally { + signal?.removeEventListener("abort", handleAbort); + + await this.waitersLock.lock(); + try { + this.waiters.removeFirst((item) => item === deferred.resolve); + } finally { + this.waitersLock.unlock(); + } + } + } finally { + await lock.lock(); + } + } +} diff --git a/packages/misc-util/src/async/synchro/counter.test.ts b/packages/misc-util/src/async/synchro/counter.test.ts new file mode 100644 index 00000000..4f009ca0 --- /dev/null +++ b/packages/misc-util/src/async/synchro/counter.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import { Counter } from "./counter.js"; + +describe("Counter", () => { + it("should initialize with default value 0", () => { + const counter = new Counter(); + expect(counter.value).toBe(0); + }); + + it("should initialize with a custom value", () => { + const counter = new Counter(5); + expect(counter.value).toBe(5); + }); + + it("should increment the value and return the new value", () => { + const counter = new Counter(); + const result = counter.increment(); + expect(result).toBe(1); + expect(counter.value).toBe(1); + expect(counter.increment()).toBe(2); + }); + + it("should decrement the value and return the new value", () => { + const counter = new Counter(2); + const result = counter.decrement(); + expect(result).toBe(1); + expect(counter.value).toBe(1); + expect(counter.decrement()).toBe(0); + }); + + it("should reset the value to 0", () => { + const counter = new Counter(10); + counter.reset(); + expect(counter.value).toBe(0); + }); + + it("should wait for a target value", async () => { + const counter = new Counter(); + const waitPromise = counter.wait(2); + counter.increment(); + counter.increment(); + await expect(waitPromise).resolves.toBeUndefined(); + }); + + it("should respect the abort signal when waiting", async () => { + const counter = new Counter(); + const abortController = new AbortController(); + const waitPromise = counter.wait(1, abortController.signal); + abortController.abort(); + await expect(waitPromise).rejects.toThrow("This operation was aborted"); + }); + + it("should return rejects when aborted even if the target value is already reached", async () => { + const counter = new Counter(5); + const abortController = new AbortController(); + abortController.abort(); + await expect(counter.wait(5, abortController.signal)).rejects.toThrow( + "This operation was aborted", + ); + }); + + it("should resolve all concurrent waiters when target is reached", async () => { + const counter = new Counter(); + let resolved1 = false; + let resolved2 = false; + let resolved3 = false; + const p1 = counter.wait(2).then(() => { + resolved1 = true; + }); + const p2 = counter.wait(2).then(() => { + resolved2 = true; + }); + const p3 = counter.wait(2).then(() => { + resolved3 = true; + }); + counter.increment(); + expect(resolved1).toBe(false); + expect(resolved2).toBe(false); + expect(resolved3).toBe(false); + counter.increment(); + await Promise.all([p1, p2, p3]); + expect(resolved1).toBe(true); + expect(resolved2).toBe(true); + expect(resolved3).toBe(true); + }); + + it("should handle concurrent increment and decrement calls", async () => { + const counter = new Counter(); + const increments = Array.from({ length: 10 }, () => + Promise.resolve().then(() => counter.increment()), + ); + const decrements = Array.from({ length: 5 }, () => + Promise.resolve().then(() => counter.decrement()), + ); + await Promise.all([...increments, ...decrements]); + expect(counter.value).toBe(5); + }); + + it("should handle mixed increment, decrement, and wait calls", async () => { + const counter = new Counter(1); + let reached3 = false; + let reached0 = false; + const wait3 = counter.wait(3).then(() => { + reached3 = true; + }); + const wait0 = counter.wait(0).then(() => { + reached0 = true; + }); + counter.increment(); // 2 + expect(reached3).toBe(false); + expect(reached0).toBe(false); + counter.decrement(); // 1 + expect(reached3).toBe(false); + expect(reached0).toBe(false); + counter.decrement(); // 0 + await wait0; + expect(reached0).toBe(true); + expect(reached3).toBe(false); + counter.increment(); // 1 + counter.increment(); // 2 + counter.increment(); // 3 + await wait3; + expect(reached3).toBe(true); + expect(counter.value).toBe(3); + }); +}); diff --git a/packages/misc-util/src/async/synchro/counter.ts b/packages/misc-util/src/async/synchro/counter.ts new file mode 100644 index 00000000..4482d4d9 --- /dev/null +++ b/packages/misc-util/src/async/synchro/counter.ts @@ -0,0 +1,114 @@ +import { removeSafe } from "../../ecma/array/remove-safe.js"; +import type { PromiseResolve } from "../../ecma/promise/types.js"; + +type Waiter_ = { + resolve: PromiseResolve; + targetValue: number; +}; + +/** + * A counter primitive that can be incremented, decremented, and waited upon. + * + * @example + * const counter = new Counter(0); + * + * // Increment the counter + * counter.increment(); + * // Decrement the counter + * counter.decrement(); + * + * // Wait for the counter to reach a specific value + * await counter.wait(5); + */ +export class Counter { + private value_: number; + private readonly waiters: Waiter_[] = []; + + /** + * Creates a new Counter instance. + * + * @param initialValue The initial value of the counter. Default is `0`. + */ + constructor(initialValue = 0) { + this.value_ = initialValue; + } + + get value(): number { + return this.value_; + } + + /** + * Resets the counter to zero. + */ + reset(): void { + this.value_ = 0; + this.handleWaiters(this.value_); + } + + /** + * Increments the counter by one. + * + * @returns The new value of the counter after incrementing. + */ + increment(): number { + this.handleWaiters(++this.value_); + return this.value_; + } + + /** + * Decrements the counter by one. + * + * @returns The new value of the counter after decrementing. + */ + decrement(): number { + this.handleWaiters(--this.value_); + return this.value_; + } + + /** + * Waits until the counter reaches the specified target value. + * + * @param targetValue The value to wait for. + * @param signal An optional AbortSignal to cancel the wait. + */ + async wait(targetValue: number, signal?: AbortSignal | null): Promise { + const deferred = Promise.withResolvers(); + + const waiter: Waiter_ = { + resolve: deferred.resolve, + targetValue, + }; + + this.waiters.push(waiter); + + const handleAbort = () => { + deferred.reject(signal?.reason); + }; + + try { + signal?.throwIfAborted(); + signal?.addEventListener("abort", handleAbort, { + once: true, + }); + + if (this.value === targetValue) { + deferred.resolve(); + return; + } + + await deferred.promise; + } finally { + signal?.removeEventListener("abort", handleAbort); + removeSafe(this.waiters, waiter); + } + } + + private handleWaiters(value: number): void { + // Take a snapshot of the waiters to avoid concurrency issues + for (const waiter of this.waiters.slice()) { + if (waiter.targetValue === value) { + waiter.resolve(); + } + } + } +} diff --git a/packages/misc-util/src/async/file-lock.test.ts b/packages/misc-util/src/async/synchro/file-lock.test.ts similarity index 72% rename from packages/misc-util/src/async/file-lock.test.ts rename to packages/misc-util/src/async/synchro/file-lock.test.ts index a6a0e9ed..7895b86a 100644 --- a/packages/misc-util/src/async/file-lock.test.ts +++ b/packages/misc-util/src/async/synchro/file-lock.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; import { FileLock } from "./file-lock.js"; -import { LockNotAcquiredError } from "./ilockable.js"; +import { LockNotAcquiredError } from "./ilock.js"; let TEST_DIR: string; let TEST_FILE: string; @@ -17,37 +17,37 @@ describe("FileLock", () => { it("should acquire and release a lock", async () => { const lock = new FileLock(TEST_FILE); - await lock.acquire(); + await lock.lock(); expect(lock.locked).toBe(true); - await lock.release(); + await lock.unlock(); expect(lock.locked).toBe(false); }); it("should not acquire lock if already locked", async () => { const lock1 = new FileLock(TEST_FILE); const lock2 = new FileLock(TEST_FILE); - await lock1.acquire(); - await expect(lock2.acquire(AbortSignal.timeout(100))).rejects.toThrow(); - await lock1.release(); + await lock1.lock(); + await expect(lock2.lock(AbortSignal.timeout(100))).rejects.toThrow(); + await lock1.unlock(); }); it("should allow reacquire after release", async () => { const lock = new FileLock(TEST_FILE); - await lock.acquire(); - await lock.release(); - await lock.acquire(); - await lock.release(); + await lock.lock(); + await lock.unlock(); + await lock.lock(); + await lock.unlock(); }); it("should clean up lock file on release", async () => { const lock = new FileLock(TEST_FILE); - await lock.acquire(); - await lock.release(); + await lock.lock(); + await lock.unlock(); expect(fs.existsSync(TEST_FILE)).toBe(false); }); it("should throw if release is called without acquire", async () => { const lock = new FileLock(TEST_FILE); - await expect(lock.release()).rejects.toThrow(LockNotAcquiredError); + await expect(lock.unlock()).rejects.toThrow(LockNotAcquiredError); }); }); diff --git a/packages/misc-util/src/async/synchro/file-lock.ts b/packages/misc-util/src/async/synchro/file-lock.ts new file mode 100644 index 00000000..baf2696d --- /dev/null +++ b/packages/misc-util/src/async/synchro/file-lock.ts @@ -0,0 +1,85 @@ +import * as fsPromises from "node:fs/promises"; +import { waitFor } from "../../ecma/function/wait-for.js"; +import { defaults } from "../../ecma/object/defaults.js"; +import { isNodeErrorWithCode } from "../../node/error/node-error.js"; +import { type ILock, LockNotAcquiredError } from "./ilock.js"; + +export type FileLockOptions = { + /** + * The interval in milliseconds to poll for the lock file to be released. + * Default is 50ms. + */ + pollIntervalMs?: number; +}; + +const FILE_LOCK_DEFAULT_OPTIONS: Required = { + pollIntervalMs: 50, +}; + +/** + * A simple file-based lock mechanism. + * + * Creates a `.lock` file to indicate that a resource is locked. + * The lock is acquired by creating the lock file and released by deleting it. + * If the lock file already exists, it means the resource is locked by another + * process. + * + * Example usage: + * ```ts + * const lock = new LockFile("/path/to/resource"); + * const release = await lock.acquire(); + * try { + * // Do something with the locked resource + * } finally { + * await release(); + * } + * ``` + */ +export class FileLock implements ILock { + private readonly options: Required; + private lockHandle: fsPromises.FileHandle | null = null; + + constructor( + public readonly filePath: string, + options?: FileLockOptions, + ) { + this.options = defaults(options, FILE_LOCK_DEFAULT_OPTIONS); + } + + get locked(): boolean { + return this.lockHandle !== null; + } + + async tryLock(): Promise { + try { + // Try to create the lock file exclusively + this.lockHandle = await fsPromises.open(`${this.filePath}.lock`, "wx"); + } catch (error) { + if (isNodeErrorWithCode(error, "EEXIST")) { + return false; + } + + throw error; + } + return true; + } + + async lock(signal?: AbortSignal | null): Promise { + await waitFor(() => this.tryLock(), { + signal, + intervalMs: this.options.pollIntervalMs, + }); + } + + async unlock(): Promise { + if (!this.lockHandle) { + throw new LockNotAcquiredError(); + } + + const handle = this.lockHandle; + this.lockHandle = null; + + await handle.close(); + await fsPromises.unlink(`${this.filePath}.lock`); + } +} diff --git a/packages/misc-util/src/async/synchro/helpers/lock-hold.ts b/packages/misc-util/src/async/synchro/helpers/lock-hold.ts new file mode 100644 index 00000000..4c4e7b12 --- /dev/null +++ b/packages/misc-util/src/async/synchro/helpers/lock-hold.ts @@ -0,0 +1,155 @@ +import type { ILock } from "../ilock.js"; + +const LOCK_ID_SYMBOL = Symbol.for("ac-essentials.lock-id"); + +/** + * A hold on one or more locks. + * + * This class represents a collection of locks that have been acquired. It + * provides methods to release all held locks in a safe manner. + * + * Locks are acquired in a globally consistent order to prevent deadlocks. + * + * When a LockHold is no longer needed, the `unlock` method should be called + * to release all held locks. Alternatively, it can be used with the `using` + * statement for automatic disposal. + * + * Locks are released in the reverse order of acquisition to maintain proper + * locking semantics. + * + * @example + * ```ts + * const lockA = new Mutex(); + * const lockB = new Mutex(); + * + * async function criticalSection() { + * // Acquire both locks + * const lockHold = await LockHold.from([lockA, lockB]); + * try { + * // Critical section code goes here + * } finally { + * // Release the locks + * await lockHold.unlock(); + * } + * } + * ``` + * + * @example Using with `using` statement + * ```ts + * const lockA = new Mutex(); + * const lockB = new Mutex(); + * + * async function criticalSection() { + * // Acquire both locks + * await using _ = await LockHold.from([lockA, lockB]); + * // Critical section code goes here + * } + * ``` + */ +export class LockHold implements AsyncDisposable { + private constructor(private readonly locks: Iterable) {} + + private static async _unlock(heldLocks: ILock[]): Promise { + for (const lock of [...heldLocks].reverse()) { + await lock.unlock(); + } + } + + private static lockIdCounter = 0; + private static getLockId(lockable: ILock): number { + if (!(LOCK_ID_SYMBOL in lockable)) { + Object.defineProperty(lockable, LOCK_ID_SYMBOL, { + value: ++LockHold.lockIdCounter, + writable: false, + enumerable: false, + configurable: false, + }); + } + + // @ts-expect-error: Symbol property is not typed + return lockable[LOCK_ID_SYMBOL] as number; + } + + /** + * Acquires locks on all provided locks, waiting as necessary. + * + * The locks are acquired in a globally consistent order to prevent deadlocks. + * + * If any lock cannot be acquired, all previously acquired locks are released + * and the error is thrown. + * + * @param locks The iterable of locks to acquire. + * @param signal An optional AbortSignal to cancel the acquire operation. + * @returns A promise that resolves to a LockHold when all locks are acquired. + */ + static async from( + locks: Iterable, + signal?: AbortSignal | null, + ): Promise { + const sortedLocks = [...locks].sort( + (a, b) => LockHold.getLockId(a) - LockHold.getLockId(b), + ); + + const heldLocks: ILock[] = []; + try { + for (const lock of sortedLocks) { + await lock.lock(signal); + heldLocks.push(lock); + } + } catch (error) { + await LockHold._unlock(heldLocks); + + throw error; + } + + return new LockHold(heldLocks); + } + + /** + * Tries to acquire locks on all provided locks without waiting. + * + * The locks are acquired in a globally consistent order to prevent deadlocks. + * + * If any lock is already held, all previously acquired locks are released and + * the function returns `null`. Otherwise, a LockHold is returned. + * + * @param locks The locks to acquire. + * @returns A promise that resolves to a LockHold if all locks were acquired, + * or `null` if any lock could not be acquired. + */ + static async tryFrom(...locks: ILock[]): Promise { + const sortedLocks = [...locks].sort( + (a, b) => LockHold.getLockId(a) - LockHold.getLockId(b), + ); + + const heldLocks: ILock[] = []; + try { + for (const lock of sortedLocks) { + const locked = await lock.tryLock(); + if (!locked) { + await LockHold._unlock(heldLocks); + return null; + } + + heldLocks.push(lock); + } + } catch (error) { + await LockHold._unlock(heldLocks); + + throw error; + } + + return new LockHold(heldLocks); + } + + /** + * Releases all held locks. + */ + async unlock(): Promise { + await LockHold._unlock(Array.from(this.locks)); + } + + [Symbol.asyncDispose](): PromiseLike { + return this.unlock(); + } +} diff --git a/packages/misc-util/src/async/synchro/ilock.ts b/packages/misc-util/src/async/synchro/ilock.ts new file mode 100644 index 00000000..b3ad50f7 --- /dev/null +++ b/packages/misc-util/src/async/synchro/ilock.ts @@ -0,0 +1,43 @@ +import type { Promisable } from "type-fest"; + +/** + * Error thrown when attempting to release a lock that is not currently held. + */ +export class LockNotAcquiredError extends Error { + constructor(message?: string) { + super(message ?? "Lock is not acquired"); + this.name = "LockNotAcquiredError"; + } +} + +/** + * Interface representing a lockable resource. + * + * A lockable resource can be acquired and released to control access. + */ +export interface ILock { + /** + * Indicates whether the lock is currently held. + */ + readonly locked: boolean; + + /** + * Attempts to acquire the lock without waiting. + * + * @returns True if the lock was successfully acquired, false otherwise. + */ + tryLock(): Promisable; + + /** + * Acquires the lock, waiting if necessary until it is available. + * + * @param signal An optional AbortSignal to cancel the acquire operation. + * @returns A promise that resolves when the lock is acquired. + */ + lock(signal?: AbortSignal | null): Promisable; + + /** + * Releases the lock. + */ + unlock(): Promisable; +} diff --git a/packages/misc-util/src/async/synchro/latch.test.ts b/packages/misc-util/src/async/synchro/latch.test.ts new file mode 100644 index 00000000..a2c14c08 --- /dev/null +++ b/packages/misc-util/src/async/synchro/latch.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { Latch } from "./latch.js"; + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe("Latch", () => { + it("should not resolve waiters until released", async () => { + const latch = new Latch(1); + let resolved = false; + const waiter = latch.wait().then(() => { + resolved = true; + }); + await delay(10); + expect(resolved).toBe(false); + latch.countDown(); + await waiter; + expect(resolved).toBe(true); + }); + + it("should resolve immediately if already released", async () => { + const latch = new Latch(1); + latch.countDown(); + await expect(latch.wait()).resolves.toBeUndefined(); + }); + + it("should resolve all concurrent waiters when released", async () => { + const latch = new Latch(2); + let resolved1 = false, + resolved2 = false; + const w1 = latch.wait().then(() => { + resolved1 = true; + }); + const w2 = latch.wait().then(() => { + resolved2 = true; + }); + latch.countDown(); + latch.countDown(); + await Promise.all([w1, w2]); + expect(resolved1).toBe(true); + expect(resolved2).toBe(true); + }); + + it("should support aborting wait with AbortSignal", async () => { + const latch = new Latch(1); + const ac = new AbortController(); + const p = latch.wait(ac.signal); + ac.abort(); + await expect(p).rejects.toThrow(); + }); + + it("should be one-shot: further waiters resolve immediately after release", async () => { + const latch = new Latch(1); + latch.countDown(); + await expect(latch.wait()).resolves.toBeUndefined(); + await expect(latch.wait()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/misc-util/src/async/synchro/latch.ts b/packages/misc-util/src/async/synchro/latch.ts new file mode 100644 index 00000000..50ebaac2 --- /dev/null +++ b/packages/misc-util/src/async/synchro/latch.ts @@ -0,0 +1,77 @@ +import { Counter } from "./counter.js"; + +/** + * An asynchronous reusable latch for N participants. + * + * All waiters are released together when the latch is released (countdown + * reaches zero). + * + * After releasing all waiters, the latch remains released. + * + * @example + * const latch = new Latch(3); + * + * async function task(id: number) { + * console.log(`Task ${id} is waiting at the latch.`); + * await latch.wait(); + * console.log(`Task ${id} has crossed the latch.`); + * } + * + * // Start 3 tasks that will wait at the latch + * task(1); + * task(2); + * task(3); + * + * // Simulate some work before counting down + * setTimeout(() => { + * console.log("Counting down the latch"); + * latch.countDown(); + * latch.countDown(); + * latch.countDown(); + * }, 1000); + */ +export class Latch { + private counter: Counter; + + /** + * Create a Latch for the specified number of participants. + * + * @param count Number of countDown calls required to release the latch. + */ + constructor(count: number) { + if (count <= 0) { + throw new Error("Latch count must be positive"); + } + + this.counter = new Counter(count); + } + + /** + * Returns true if the latch has been released. + */ + get released(): boolean { + return this.counter.value === 0; + } + + /** + * Wait for the latch to be released. Resolves when countDown() has been + * called count times. + * + * @param signal An optional AbortSignal to cancel the wait operation. + * @returns A promise that resolves when the latch is released. + */ + async wait(signal?: AbortSignal | null): Promise { + await this.counter.wait(0, signal); + } + + /** + * Decrement the latch. When the count reaches zero, all waiters are released. + */ + countDown(): void { + if (this.counter.value === 0) { + return; + } + + this.counter.decrement(); + } +} diff --git a/packages/misc-util/src/async/mutex.test.ts b/packages/misc-util/src/async/synchro/mutex.test.ts similarity index 63% rename from packages/misc-util/src/async/mutex.test.ts rename to packages/misc-util/src/async/synchro/mutex.test.ts index 50837ceb..cb524232 100644 --- a/packages/misc-util/src/async/mutex.test.ts +++ b/packages/misc-util/src/async/synchro/mutex.test.ts @@ -1,5 +1,5 @@ import { expect, suite, test } from "vitest"; -import { LockNotAcquiredError } from "./ilockable.js"; +import { LockNotAcquiredError } from "./ilock.js"; import { Mutex } from "./mutex.js"; suite("Mutex", () => { @@ -7,60 +7,60 @@ suite("Mutex", () => { const mutex = new Mutex(); expect(mutex.locked).toBe(false); - const release1 = await mutex.acquire(); + await mutex.lock(); expect(mutex.locked).toBe(true); - const acquirePromise = mutex.acquire(); + const acquirePromise = mutex.lock(); expect(mutex.locked).toBe(true); // Still locked, as the second acquire is waiting - release1(); - const release2 = await acquirePromise; // Now the second acquire should complete + mutex.unlock(); + await acquirePromise; // Now the second acquire should complete expect(mutex.locked).toBe(true); // Still locked, as one is acquired - release2(); + mutex.unlock(); expect(mutex.locked).toBe(false); }); test("should handle multiple concurrent acquires", async () => { const mutex = new Mutex(); - const acquire1Promise = mutex.acquire(); - const acquire2Promise = mutex.acquire(); + const acquire1Promise = mutex.lock(); + const acquire2Promise = mutex.lock(); - const release1 = await acquire1Promise; + await acquire1Promise; expect(mutex.locked).toBe(true); - release1(); - const release2 = await acquire2Promise; + mutex.unlock(); + await acquire2Promise; expect(mutex.locked).toBe(true); // Still locked, as 2 is acquired - release2(); + mutex.unlock(); expect(mutex.locked).toBe(false); }); test("should throw if release is called more than acquire", async () => { const mutex = new Mutex(); - const release = await mutex.acquire(); + await mutex.lock(); expect(mutex.locked).toBe(true); - release(); + mutex.unlock(); expect(mutex.locked).toBe(false); - expect(() => release()).toThrow(LockNotAcquiredError); + expect(() => mutex.unlock()).toThrow(LockNotAcquiredError); }); test("should support aborting acquire with AbortSignal", async () => { const mutex = new Mutex(); const controller = new AbortController(); - await mutex.acquire(); // take the only permit - const acquirePromise = mutex.acquire(controller.signal); + await mutex.lock(); // take the only permit + const acquirePromise = mutex.lock(controller.signal); controller.abort("abort-mutex"); await expect(acquirePromise).rejects.toBe("abort-mutex"); }); test("should throw LockNotAcquiredError if release called without acquire", () => { const mutex = new Mutex(); - expect(() => mutex.release()).toThrow(LockNotAcquiredError); + expect(() => mutex.unlock()).toThrow(LockNotAcquiredError); }); }); diff --git a/packages/misc-util/src/async/synchro/mutex.ts b/packages/misc-util/src/async/synchro/mutex.ts new file mode 100644 index 00000000..f01e8843 --- /dev/null +++ b/packages/misc-util/src/async/synchro/mutex.ts @@ -0,0 +1,70 @@ +import { removeSafe } from "../../ecma/array/remove-safe.js"; +import type { PromiseResolve } from "../../ecma/promise/types.js"; +import { type ILock, LockNotAcquiredError } from "./ilock.js"; + +/** + * A mutex (mutual exclusion) primitive for asynchronous tasks. + * + * A mutex allows only one task to hold the lock at a time. + * Other tasks attempting to acquire the lock will wait until it is released. + * + * The order of lock acquisition is guaranteed to be FIFO (first-in-first-out). + */ +export class Mutex implements ILock { + private locked_: boolean = false; + // we do not use a Queue data structure here because it requires the Mutex (via the Semaphore) + private readonly waiters: PromiseResolve[] = []; + + get locked(): boolean { + return this.locked_; + } + + tryLock(): boolean { + if (this.locked_) { + return false; + } + + this.locked_ = true; + return true; + } + + async lock(signal?: AbortSignal | null): Promise { + const deferred = Promise.withResolvers(); + + this.waiters.push(deferred.resolve); + + const handleAbort = () => { + deferred.reject(signal?.reason); + }; + try { + signal?.throwIfAborted(); + signal?.addEventListener("abort", handleAbort, { + once: true, + }); + + if (!this.locked_) { + this.locked_ = true; + deferred.resolve(); + } + + await deferred.promise; + } finally { + signal?.removeEventListener("abort", handleAbort); + removeSafe(this.waiters, deferred.resolve); + } + } + + unlock(): void { + if (!this.locked_) { + throw new LockNotAcquiredError(); + } + + const waiter = this.waiters.shift(); + if (waiter !== undefined) { + waiter(); + return; + } + + this.locked_ = false; + } +} diff --git a/packages/misc-util/src/async/synchro/rw-lock.test.ts b/packages/misc-util/src/async/synchro/rw-lock.test.ts new file mode 100644 index 00000000..ef6cfcd1 --- /dev/null +++ b/packages/misc-util/src/async/synchro/rw-lock.test.ts @@ -0,0 +1,367 @@ +import { describe, expect, it } from "vitest"; +import { RwLock } from "./rw-lock.js"; + +describe("RwLock", () => { + describe("basic lock/unlock", () => { + it("should allow a single reader to acquire and release the lock", async () => { + const lock = new RwLock(); + await lock.readLock(); + await lock.readUnlock(); + // Should not throw + }); + + it("should allow a single writer to acquire and release the lock", async () => { + const lock = new RwLock(); + await lock.writeLock(); + await lock.writeUnlock(); + // Should not throw + }); + + it("should not allow write lock while read lock is held", async () => { + const lock = new RwLock(); + await lock.readLock(); + let acquired = false; + const writer = lock.writeLock().then(() => { + acquired = true; + }); + // Give event loop a chance + await Promise.resolve(); + expect(acquired).toBe(false); + await lock.readUnlock(); + await writer; + expect(acquired).toBe(true); + await lock.writeUnlock(); + }); + + it("should not allow read lock while write lock is held", async () => { + const lock = new RwLock(); + await lock.writeLock(); + let acquired = false; + const reader = lock.readLock().then(() => { + acquired = true; + }); + // Give event loop a chance + await Promise.resolve(); + expect(acquired).toBe(false); + await lock.writeUnlock(); + await reader; + expect(acquired).toBe(true); + await lock.readUnlock(); + }); + + it("should allow multiple readers concurrently", async () => { + const lock = new RwLock(); + await lock.readLock(); + let reader2Acquired = false; + let reader2CanRelease: (() => void) | undefined; + const reader2Ready = new Promise((resolve) => { + reader2CanRelease = resolve; + }); + + async function acquireReader2() { + await lock.readLock(); + reader2Acquired = true; + await reader2Ready; + await lock.readUnlock(); + } + const reader2 = acquireReader2(); + + for (let i = 0; i < 10 && !reader2Acquired; i++) { + await new Promise((r) => setTimeout(r, 1)); + } + expect(reader2Acquired).toBe(true); + await lock.readUnlock(); + reader2CanRelease?.(); + await reader2; + }); + }); + + describe("tryReadLock and tryWriteLock", () => { + it("should acquire read lock if no writer or waiting writer", () => { + const lock = new RwLock(); + expect(lock.tryReadLock()).toBe(true); + }); + + it("should not acquire read lock if write lock is held", async () => { + const lock = new RwLock(); + await lock.writeLock(); + expect(lock.tryReadLock()).toBe(false); + await lock.writeUnlock(); + }); + + it("should acquire write lock if no readers or writers", () => { + const lock = new RwLock(); + expect(lock.tryWriteLock()).toBe(true); + }); + + it("should not acquire write lock if read lock is held", async () => { + const lock = new RwLock(); + await lock.readLock(); + expect(lock.tryWriteLock()).toBe(false); + await lock.readUnlock(); + }); + + it("should not acquire write lock if write lock is held", async () => { + const lock = new RwLock(); + await lock.writeLock(); + expect(lock.tryWriteLock()).toBe(false); + await lock.writeUnlock(); + }); + + it("should not acquire read lock if writer is waiting", async () => { + const lock = new RwLock(); + await lock.readLock(); + // Start a writer (will wait) + const writerPromise = lock.writeLock(); + // Give event loop a chance for writer to increment writersWaiting + await Promise.resolve(); + expect(lock.tryReadLock()).toBe(false); + await lock.readUnlock(); + await writerPromise; + await lock.writeUnlock(); + }); + }); + + describe("read/write upgrade/downgrade", () => { + it("should upgrade read lock to write lock when no other readers", async () => { + const lock = new RwLock(); + await lock.readLock(); + await lock.readToWriteLock(); + // Now holds write lock + await lock.writeUnlock(); + }); + + it("should wait to upgrade read lock to write lock if other readers exist", async () => { + const lock = new RwLock(); + await lock.readLock(); + await lock.readLock(); // second reader + let upgraded = false; + async function doUpgrade() { + await lock.readToWriteLock(); + upgraded = true; + await lock.writeUnlock(); + } + const upgradePromise = doUpgrade(); + // Give event loop a chance + await Promise.resolve(); + expect(upgraded).toBe(false); + await lock.readUnlock(); // release one reader + // Now upgrade can proceed + await upgradePromise; + expect(upgraded).toBe(true); + }); + + it("should throw if upgrading without holding a read lock", async () => { + const lock = new RwLock(); + await expect(lock.readToWriteLock()).rejects.toThrow(); + }); + + it("should downgrade write lock to read lock", async () => { + const lock = new RwLock(); + await lock.writeLock(); + await lock.writeToReadLock(); + // Now holds read lock + await lock.readUnlock(); + }); + + it("should throw if downgrading without holding a write lock", async () => { + const lock = new RwLock(); + await expect(lock.writeToReadLock()).rejects.toThrow(); + }); + }); + + describe("error cases and edge conditions", () => { + it("should throw LockNotAcquiredError when unlocking read lock not held", async () => { + const lock = new RwLock(); + await expect(lock.readUnlock()).rejects.toThrow(); + }); + + it("should throw LockNotAcquiredError when unlocking write lock not held", async () => { + const lock = new RwLock(); + await expect(lock.writeUnlock()).rejects.toThrow(); + }); + + it("should throw LockNotAcquiredError when upgrading without read lock", async () => { + const lock = new RwLock(); + await expect(lock.readToWriteLock()).rejects.toThrow(); + }); + + it("should throw LockNotAcquiredError when downgrading without write lock", async () => { + const lock = new RwLock(); + await expect(lock.writeToReadLock()).rejects.toThrow(); + }); + + it("should abort readLock if signal is aborted", async () => { + const lock = new RwLock(); + const controller = new AbortController(); + controller.abort(); + await expect(lock.readLock(controller.signal)).rejects.toThrow(); + }); + + it("should abort writeLock if signal is aborted", async () => { + const lock = new RwLock(); + const controller = new AbortController(); + try { + controller.abort(); + } catch {} + await expect(lock.writeLock(controller.signal)).rejects.toThrow(); + }); + + it("should abort readToWriteLock if signal is aborted while waiting", async () => { + const lock = new RwLock(); + await lock.readLock(); + await lock.readLock(); // second reader + const controller = new AbortController(); + let error: unknown; + async function tryUpgrade() { + try { + await lock.readToWriteLock(controller.signal); + } catch (e) { + error = e; + } + } + const upgradePromise = tryUpgrade(); + // Give event loop a chance for upgrade to block + await Promise.resolve(); + try { + controller.abort(); + } catch {} + await upgradePromise; + expect(error).toBeDefined(); + await lock.readUnlock(); + await lock.readUnlock(); + }); + }); + + describe("concurrency and fairness", () => { + it("should allow multiple readers to proceed concurrently", async () => { + const lock = new RwLock(); + let r1Acquired = false; + let r2Acquired = false; + let r1CanRelease: (() => void) | undefined; + let r2CanRelease: (() => void) | undefined; + const r1Ready = new Promise((resolve) => { + r1CanRelease = resolve; + }); + const r2Ready = new Promise((resolve) => { + r2CanRelease = resolve; + }); + + async function reader1() { + await lock.readLock(); + r1Acquired = true; + await r1Ready; + await lock.readUnlock(); + } + async function reader2() { + await lock.readLock(); + r2Acquired = true; + await r2Ready; + await lock.readUnlock(); + } + const p1 = reader1(); + const p2 = reader2(); + // Wait for both to acquire + for (let i = 0; i < 10 && (!r1Acquired || !r2Acquired); i++) { + await new Promise((r) => setTimeout(r, 1)); + } + expect(r1Acquired).toBe(true); + expect(r2Acquired).toBe(true); + r1CanRelease?.(); + r2CanRelease?.(); + await Promise.all([p1, p2]); + }); + + it("should allow only one writer at a time", async () => { + const lock = new RwLock(); + let w1Acquired = false; + let w2Acquired = false; + let w1CanRelease: (() => void) | undefined; + const w1Ready = new Promise((resolve) => { + w1CanRelease = resolve; + }); + + async function writer1() { + await lock.writeLock(); + w1Acquired = true; + await w1Ready; + await lock.writeUnlock(); + } + async function writer2() { + await lock.writeLock(); + w2Acquired = true; + await lock.writeUnlock(); + } + const p1 = writer1(); + // Wait for w1 to acquire + for (let i = 0; i < 10 && !w1Acquired; i++) { + await new Promise((r) => setTimeout(r, 1)); + } + expect(w1Acquired).toBe(true); + const p2 = writer2(); + // Give event loop a chance + await Promise.resolve(); + expect(w2Acquired).toBe(false); + w1CanRelease?.(); + await p1; + // Now w2 can acquire + for (let i = 0; i < 10 && !w2Acquired; i++) { + await new Promise((r) => setTimeout(r, 1)); + } + expect(w2Acquired).toBe(true); + await p2; + }); + + it("should grant locks in FIFO order (writer after readers)", async () => { + const lock = new RwLock(); + let r1Acquired = false; + let r2Acquired = false; + let wAcquired = false; + let r1CanRelease: (() => void) | undefined; + let r2CanRelease: (() => void) | undefined; + const r1Ready = new Promise((resolve) => { + r1CanRelease = resolve; + }); + const r2Ready = new Promise((resolve) => { + r2CanRelease = resolve; + }); + + async function reader1() { + await lock.readLock(); + r1Acquired = true; + await r1Ready; + await lock.readUnlock(); + } + async function reader2() { + await lock.readLock(); + r2Acquired = true; + await r2Ready; + await lock.readUnlock(); + } + async function writer() { + await lock.writeLock(); + wAcquired = true; + await lock.writeUnlock(); + } + const p1 = reader1(); + const p2 = reader2(); + // Wait for both readers to acquire + for (let i = 0; i < 10 && (!r1Acquired || !r2Acquired); i++) { + await new Promise((r) => setTimeout(r, 1)); + } + const pw = writer(); + // Writer should not acquire until both readers release + await Promise.resolve(); + expect(wAcquired).toBe(false); + r1CanRelease?.(); + r2CanRelease?.(); + await Promise.all([p1, p2]); + // Now writer can acquire + for (let i = 0; i < 10 && !wAcquired; i++) { + await new Promise((r) => setTimeout(r, 1)); + } + expect(wAcquired).toBe(true); + await pw; + }); + }); +}); diff --git a/packages/misc-util/src/async/synchro/rw-lock.ts b/packages/misc-util/src/async/synchro/rw-lock.ts new file mode 100644 index 00000000..c3f4f9f2 --- /dev/null +++ b/packages/misc-util/src/async/synchro/rw-lock.ts @@ -0,0 +1,211 @@ +import { Condition } from "./condition.js"; +import { LockNotAcquiredError } from "./ilock.js"; +import { Mutex } from "./mutex.js"; + +/** + * An asynchronous read-write lock (RwLock) for coordinating access to shared resources. + * + * Allows multiple concurrent readers or exclusive access for a single writer. + * + * All lock/unlock operations are asynchronous and must be awaited. + * + * WARNING: Deadlock is possible if: + * - A task attempts to acquire a lock it already holds (no reentrancy). + * - Locks are acquired in inconsistent order across tasks. + * - A task forgets to unlock after acquiring. + * + * Best practices: + * - Always use try/finally to ensure unlock. + * - Avoid holding locks across await points that may block indefinitely. + * - Never call lock methods from within unlock callbacks. + * + * Example usage: + * await rwLock.readLock(); + * try { + * // read shared resource + * } finally { + * rwLock.readUnlock(); + * } + */ +export class RwLock { + private mutex = new Mutex(); + private cond = new Condition(); + private readers = 0; + private writer = false; + private writersWaiting = 0; + + /** + * Acquire a read lock. Multiple readers may hold the lock concurrently unless a writer is waiting or active. + * + * @param signal Optional AbortSignal to cancel the wait. + * @throws Error if the lock cannot be acquired due to cancellation. + */ + async readLock(signal?: AbortSignal | null): Promise { + await this.mutex.lock(signal); + try { + while (this.writer || this.writersWaiting > 0) { + await this.cond.wait(this.mutex, signal); + } + this.readers++; + } finally { + this.mutex.unlock(); + } + } + + /** + * Acquire a write lock. Only one writer may hold the lock, and no readers may be active. + * + * @param signal Optional AbortSignal to cancel the wait. + * @throws Error if the lock cannot be acquired due to cancellation. + */ + async writeLock(signal?: AbortSignal | null): Promise { + await this.mutex.lock(signal); + this.writersWaiting++; + try { + while (this.writer || this.readers > 0) { + await this.cond.wait(this.mutex, signal); + } + this.writer = true; + } finally { + this.writersWaiting--; + this.mutex.unlock(); + } + } + + /** + * Attempt to acquire a read lock without waiting. Returns true if successful. + * + * @returns True if the read lock was acquired, false otherwise. + */ + tryReadLock(): boolean { + if (!this.mutex.tryLock()) { + return false; + } + try { + if (this.writer || this.writersWaiting > 0) { + return false; + } + this.readers++; + return true; + } finally { + this.mutex.unlock(); + } + } + + /** + * Attempt to acquire a write lock without waiting. Returns true if successful. + * + * @returns True if the write lock was acquired, false otherwise. + */ + tryWriteLock(): boolean { + if (!this.mutex.tryLock()) { + return false; + } + try { + if (this.writer || this.readers > 0) { + return false; + } + this.writer = true; + return true; + } finally { + this.mutex.unlock(); + } + } + + /** + * Upgrade a held read lock to a write lock. The caller must already hold a read lock. + * + * @param signal Optional AbortSignal to cancel the wait. + * @throws Error if the upgrade cannot be completed due to cancellation. + */ + async readToWriteLock(signal?: AbortSignal | null): Promise { + await this.mutex.lock(signal); + this.writersWaiting++; + + try { + if (this.readers <= 0) { + throw new LockNotAcquiredError("Reader lock not acquired"); + } + + this.readers--; + + if (this.readers === 0) { + await this.cond.signal(); + } + + try { + while (this.writer || this.readers > 0) { + await this.cond.wait(this.mutex, signal); + } + } catch (error) { + // Restore reader count if wait fails (e.g., abort) + this.readers++; + throw error; + } + this.writer = true; + } finally { + this.writersWaiting--; + this.mutex.unlock(); + } + } + + /** + * Downgrade a held write lock to a read lock. The caller must already hold the write lock. + * + * @param signal Optional AbortSignal to cancel the wait. + */ + async writeToReadLock(): Promise { + await this.mutex.lock(); + try { + if (!this.writer) { + throw new LockNotAcquiredError("Writer lock not acquired"); + } + + this.writer = false; + this.readers++; + await this.cond.broadcast(); + } finally { + this.mutex.unlock(); + } + } + + /** + * Release a previously acquired read lock. + * + * WARNING: Failing to call readUnlock() after readLock() will cause deadlock. + */ + async readUnlock(): Promise { + await this.mutex.lock(); + + try { + if (this.readers <= 0) { + throw new LockNotAcquiredError("Reader lock not acquired"); + } + this.readers--; + if (this.readers === 0) { + await this.cond.signal(); + } + } finally { + this.mutex.unlock(); + } + } + + /** + * Release a previously acquired write lock. + * + * WARNING: Failing to call writeUnlock() after writeLock() will cause deadlock. + */ + async writeUnlock(): Promise { + await this.mutex.lock(); + + try { + if (!this.writer) { + throw new LockNotAcquiredError("Writer lock not acquired"); + } + this.writer = false; + await this.cond.broadcast(); + } finally { + this.mutex.unlock(); + } + } +} diff --git a/packages/misc-util/src/async/semaphore.test.ts b/packages/misc-util/src/async/synchro/semaphore.test.ts similarity index 86% rename from packages/misc-util/src/async/semaphore.test.ts rename to packages/misc-util/src/async/synchro/semaphore.test.ts index 7e925879..6c0db10b 100644 --- a/packages/misc-util/src/async/semaphore.test.ts +++ b/packages/misc-util/src/async/synchro/semaphore.test.ts @@ -6,26 +6,26 @@ suite("Semaphore", () => { const semaphore = new Semaphore(2); expect(semaphore.value).toBe(2); - const release1 = await semaphore.acquire(); + await semaphore.acquire(); expect(semaphore.value).toBe(1); - const release2 = await semaphore.acquire(); + await semaphore.acquire(); expect(semaphore.value).toBe(0); const acquirePromise = semaphore.acquire(); expect(semaphore.value).toBe(0); // Still 0, as the third acquire is waiting - release1(); + semaphore.release(); await acquirePromise; // Now the third acquire should complete expect(semaphore.value).toBe(0); // Still 0, as two are acquired - release2(); + semaphore.release(); expect(semaphore.value).toBe(1); - const release3 = await semaphore.acquire(); + await semaphore.acquire(); expect(semaphore.value).toBe(0); - release3(); + semaphore.release(); expect(semaphore.value).toBe(1); }); @@ -37,7 +37,7 @@ suite("Semaphore", () => { test("should handle multiple concurrent acquires", async () => { const semaphore = new Semaphore(3); - const releases = await Promise.all([ + await Promise.all([ semaphore.acquire(), semaphore.acquire(), semaphore.acquire(), @@ -47,20 +47,20 @@ suite("Semaphore", () => { const acquirePromise = semaphore.acquire(); expect(semaphore.value).toBe(0); // Still 0, as the fourth acquire is waiting - releases[0](); + semaphore.release(); await acquirePromise; // Now the fourth acquire should complete expect(semaphore.value).toBe(0); // Still 0, as three are acquired - releases[1](); + semaphore.release(); expect(semaphore.value).toBe(1); - releases[2](); + semaphore.release(); expect(semaphore.value).toBe(2); - const release4 = await semaphore.acquire(); + await semaphore.acquire(); expect(semaphore.value).toBe(1); - release4(); + semaphore.release(); expect(semaphore.value).toBe(2); }); @@ -68,19 +68,19 @@ suite("Semaphore", () => { const semaphore = new Semaphore(2, 1); expect(semaphore.value).toBe(1); - const release1 = await semaphore.acquire(); + await semaphore.acquire(); expect(semaphore.value).toBe(0); - release1(); + semaphore.release(); expect(semaphore.value).toBe(1); - const release2 = await semaphore.acquire(); + await semaphore.acquire(); expect(semaphore.value).toBe(0); - release2(); + semaphore.release(); expect(semaphore.value).toBe(1); - semaphore.release(1); + semaphore.release(); expect(semaphore.value).toBe(2); expect(() => semaphore.release(1)).toThrow( @@ -88,7 +88,7 @@ suite("Semaphore", () => { ); }); - test("should support tryAcquire and fail when insufficient permits", () => { + test("should support tryAcquire and fail when insufficient permits", async () => { const semaphore = new Semaphore(2); expect(semaphore.tryAcquire()).toBe(true); expect(semaphore.value).toBe(1); diff --git a/packages/misc-util/src/async/semaphore.ts b/packages/misc-util/src/async/synchro/semaphore.ts similarity index 57% rename from packages/misc-util/src/async/semaphore.ts rename to packages/misc-util/src/async/synchro/semaphore.ts index f977b9be..fcd3b600 100644 --- a/packages/misc-util/src/async/semaphore.ts +++ b/packages/misc-util/src/async/synchro/semaphore.ts @@ -1,15 +1,14 @@ -import { debounceQueue } from "../ecma/function/debounce-queue.js"; -import type { Callable } from "../ecma/function/types.js"; -import { AbortablePromise } from "../ecma/promise/abortable-promise.js"; -import type { PromiseResolve } from "../ecma/promise/types.js"; +import { removeSafe } from "../../ecma/array/remove-safe.js"; +import { serializeQueueNext } from "../../ecma/function/serialize-queue-next.js"; +import type { PromiseResolve } from "../../ecma/promise/types.js"; -type SemaphorePendingAcquisition = { +type PendingAcquisition_ = { count: number; resolve: PromiseResolve; }; /** - * A counting semaphore implementation. + * A general/counting strong semaphore implementation. * * A semaphore maintains a set of permits. Each `acquire` call blocks if necessary * until a permit is available, and then takes it. Each `release` call adds a permit, @@ -17,18 +16,15 @@ type SemaphorePendingAcquisition = { * * The semaphore is initialized with a given number of permits. The number of * permits can be increased up to a maximum value. + * + * The order of permit acquisition is guaranteed to be FIFO. */ export class Semaphore { private value_: number; - - // FIFO queue using array, do not use Queue class because of circular dependency - private pendingAcquisitions: SemaphorePendingAcquisition[] = []; - - // Also, we cannot use a Mutex lock here because of circular dependency, so we - // use a debounced function, but be careful to not call processPendingAcquisitions - // directly, and to always use the debounced version. - private processPendingAcquisitionsDebounced = debounceQueue(async () => - this.processPendingAcquisitions(), + // we do not use a Queue data structure here because it requires the Semaphore + private readonly pendingAcquisitions: PendingAcquisition_[] = []; + private readonly handlePendingAcquisitionsSqn = serializeQueueNext(() => + this.handlePendingAcquisitions(), ); /** @@ -55,6 +51,8 @@ export class Semaphore { /** * The current number of available permits. + * + * @returns The current number of available permits. */ get value(): number { return this.value_; @@ -62,6 +60,8 @@ export class Semaphore { /** * The maximum number of permits. + * + * @returns The maximum number of permits. */ getMaxValue(): number { return this.maxValue; @@ -77,52 +77,70 @@ export class Semaphore { if (!Number.isFinite(count) || count <= 0) { throw new RangeError("Count must be positive"); } + if (this.value_ >= count) { this.value_ -= count; + + // mitigate concurrency issues + if (this.value_ < 0) { + this.value_ += count; + return false; + } + return true; } + return false; } /** - * Acquires a permit from the semaphore, waiting if necessary until one is available. + * Acquires a permit from the semaphore, waiting if necessary until one is + * available. * * @param signal An optional AbortSignal to cancel the acquire operation. - * @returns A promise that resolves to a function that releases the acquired permit. + * @returns A promise that resolves to a lease when the permits are acquired. */ - async acquire(count = 1, signal?: AbortSignal | null): Promise { - if (this.tryAcquire(count)) { - return () => this.release(count); + async acquire(count = 1, signal?: AbortSignal | null): Promise { + if (!Number.isFinite(count) || count <= 0) { + throw new RangeError("Count must be positive"); } - const deferred = AbortablePromise.withResolvers({ - signal, - onAbort: () => { - this.pendingAcquisitions.splice( - this.pendingAcquisitions.indexOf(pendingAcquisition), - 1, - ); - }, - }); - - const pendingAcquisition: SemaphorePendingAcquisition = { + const deferred = Promise.withResolvers(); + + const pendingAcquisition: PendingAcquisition_ = { count, resolve: deferred.resolve, }; + this.pendingAcquisitions.push(pendingAcquisition); - // Process the queue in case there are immediately available permits - await this.processPendingAcquisitionsDebounced(); + const handleAbort = () => { + deferred.reject(signal?.reason); + }; + + try { + signal?.throwIfAborted(); + signal?.addEventListener("abort", handleAbort, { + once: true, + }); - await deferred.promise; + await this.handlePendingAcquisitionsSqn(); - return () => this.release(count); + await deferred.promise; + } finally { + signal?.removeEventListener("abort", handleAbort); + removeSafe(this.pendingAcquisitions, pendingAcquisition); + } } /** - * Releases a permit, returning it to the semaphore. + * Releases permits back to the semaphore. + * + * Any pending acquisitions will be processed asynchronously after this call. + * + * @param count The number of permits to release. */ - release(count: number): void { + release(count = 1): void { if (!Number.isFinite(count) || count < 0) { throw new RangeError("Count must be positive"); } @@ -131,11 +149,12 @@ export class Semaphore { } this.value_ += count; - this.processPendingAcquisitionsDebounced(); + + void this.handlePendingAcquisitionsSqn(); } - private processPendingAcquisitions() { - let next: SemaphorePendingAcquisition | undefined; + private handlePendingAcquisitions() { + let next: PendingAcquisition_ | undefined; while ((next = this.pendingAcquisitions.shift()) !== undefined) { if (this.value_ < next.count) { this.pendingAcquisitions.unshift(next); diff --git a/packages/misc-util/src/async/signal.test.ts b/packages/misc-util/src/async/synchro/signal.test.ts similarity index 88% rename from packages/misc-util/src/async/signal.test.ts rename to packages/misc-util/src/async/synchro/signal.test.ts index 5f02a26c..6e07cf8b 100644 --- a/packages/misc-util/src/async/signal.test.ts +++ b/packages/misc-util/src/async/synchro/signal.test.ts @@ -3,7 +3,7 @@ import { Signal } from "./signal.js"; suite("Signalable", () => { test("should initialize with the correct state", () => { - const signalable1 = new Signal(); + const signalable1 = new Signal(false); expect(signalable1.signaled).toBe(false); const signalable2 = new Signal(false, false); @@ -13,8 +13,8 @@ suite("Signalable", () => { expect(signalable3.signaled).toBe(true); }); - test("should signal and reset the signalable", () => { - const signalable = new Signal(); + test("should signal and reset the signalable", async () => { + const signalable = new Signal(false); expect(signalable.signaled).toBe(false); signalable.signal(); @@ -25,7 +25,7 @@ suite("Signalable", () => { }); test("should wait for the signalable", async () => { - const signalable = new Signal(); + const signalable = new Signal(false); const waitPromises = Promise.all([signalable.wait(), signalable.wait()]); @@ -41,14 +41,14 @@ suite("Signalable", () => { }); test("should timeout when waiting for the signalable", async () => { - const signalable = new Signal(); + const signalable = new Signal(false); await expect(() => signalable.wait(AbortSignal.timeout(10)), ).rejects.toThrow(); }); test("should not wait when the signalable is already signaled", async () => { - const signalable = new Signal(); + const signalable = new Signal(false); let signaledCount = 0; const waitPromise1 = (async () => { diff --git a/packages/misc-util/src/async/synchro/signal.ts b/packages/misc-util/src/async/synchro/signal.ts new file mode 100644 index 00000000..659a4b0e --- /dev/null +++ b/packages/misc-util/src/async/synchro/signal.ts @@ -0,0 +1,179 @@ +import { Queue } from "../../data/queue.js"; +import { serializeQueueNext } from "../../ecma/function/serialize-queue-next.js"; +import type { PromiseResolve } from "../../ecma/promise/types.js"; +import { Mutex } from "./mutex.js"; + +/** + * A signal primitive for signaling between async tasks. + * + * When the signal is in the signaled state, calls to `wait` will resolve + * immediately. When the signal is in the non-signaled state, calls to `wait` + * will block until the signal is signaled. Waiters are released in FIFO order. + * + * The signal can be configured to auto-reset or manual-reset. + * + * If `autoReset` is true, the signal will automatically reset to the non-signaled + * state after a single waiter is released, acting like a binary semaphore. + * If `autoReset` is false, the signal will remain in the signaled state until + * it is manually reset. + * + * @example + * const signal = new Signal(true); // autoReset = true + * + * // Task 1 + * async function task1() { + * console.log("Waiting for signal..."); + * await signal.wait(); + * console.log("Signal received!"); + * } + * + * // Task 2 + * async function task2() { + * console.log("Signaling..."); + * signal.signal(); + * } + * + * task1(); + * task2(); + * + * @example + * const signal = new Signal(false); // autoReset = false + * + * // Task 1 + * async function task1() { + * console.log("Waiting for signal..."); + * await signal.wait(); + * console.log("Signal received!"); + * } + * + * // Task 2 + * async function task2() { + * console.log("Signaling..."); + * signal.signal(); + * } + * + * task1(); + * task2(); + * task1(); // This will also proceed immediately since autoReset is false + */ +export class Signal { + private signaled_: boolean; + private readonly waiters = new Queue>(); + private readonly waitersLock = new Mutex(); + private readonly handleWaitersSqn = serializeQueueNext(() => + this.handleWaiters(), + ); + + /** + * Creates a new Signal instance. + * + * @param autoReset If true, the signal will automatically reset after releasing a waiter. + * If false, the signal will remain signaled until manually reset. + * @param initialState The initial state of the signal. Default is false (non-signaled). + */ + constructor( + private readonly autoReset: boolean, + initialState = false, + ) { + this.signaled_ = initialState; + } + + /** + * Indicates whether the signal is in the signaled state. + */ + get signaled(): boolean { + return this.signaled_; + } + + /** + * Set the signal to signaled state + * + * Notifies waiting listeners. If `autoReset` is true, only one listener + * will be notified and the signal will reset to non-signaled state. + * If `autoReset` is false, all listeners will be notified. + * + * Listeners are notified asynchronously. + */ + signal(): void { + this.signaled_ = true; + + void this.handleWaitersSqn(); + } + + /** + * Reset the signal to non-signaled state + */ + reset(): void { + this.signaled_ = false; + } + + /** + * Wait for the signal to be signaled. + * + * If the signal is already signaled, the listener is called immediately. + * If `autoReset` is true, the signal is reset to non-signaled state after + * notifying a listener. + * + * Note: If multiple listeners are waiting and the signal is signaled, only one + * listener will be notified if `autoReset` is true. If `autoReset` is false, + * all listeners will be notified. + * + * @param signal An AbortSignal that can be used to cancel the wait operation. + * @returns A promise that resolves when the signal is signaled, or rejects when aborted. + */ + async wait(signal?: AbortSignal | null): Promise { + const deferred = Promise.withResolvers(); + + await this.waitersLock.lock(); + try { + this.waiters.enqueue(deferred.resolve); + } finally { + this.waitersLock.unlock(); + } + + const handleAbort = () => { + deferred.reject(signal?.reason); + }; + + try { + signal?.throwIfAborted(); + signal?.addEventListener("abort", handleAbort, { + once: true, + }); + + await this.handleWaitersSqn(); + + await deferred.promise; + } finally { + signal?.removeEventListener("abort", handleAbort); + + await this.waitersLock.lock(); + try { + this.waiters.removeFirst((item) => item === deferred.resolve); + } finally { + this.waitersLock.unlock(); + } + } + } + + private async handleWaiters(): Promise { + const waitersReleased: PromiseResolve[] = []; + + await this.waitersLock.lock(); + try { + let waiter: PromiseResolve | undefined; + while (this.signaled_ && (waiter = this.waiters.dequeue())) { + if (this.autoReset) { + this.signaled_ = false; + } + waitersReleased.push(waiter); + } + } finally { + this.waitersLock.unlock(); + } + + for (const waiter of waitersReleased) { + waiter(); + } + } +} diff --git a/packages/misc-util/src/async/udp-bind-lock.test.ts b/packages/misc-util/src/async/synchro/udp-bind-lock.test.ts similarity index 74% rename from packages/misc-util/src/async/udp-bind-lock.test.ts rename to packages/misc-util/src/async/synchro/udp-bind-lock.test.ts index 175b1e14..6f2a5def 100644 --- a/packages/misc-util/src/async/udp-bind-lock.test.ts +++ b/packages/misc-util/src/async/synchro/udp-bind-lock.test.ts @@ -16,9 +16,9 @@ describe("UdpBindLock", () => { }); it("should acquire and release lock", async () => { - const release = await lock.acquire(); + await lock.lock(); expect(lock.locked).toBe(true); - await release(); + await lock.unlock(); expect(lock.locked).toBe(false); }); @@ -33,24 +33,24 @@ describe("UdpBindLock", () => { udpBindPort: TEST_PORT, udpBindAddress: TEST_ADDR, }); - await lock1.acquire(); - await expect(lock2.acquire(AbortSignal.timeout(100))).rejects.toThrow(); - await lock1.release(); + await lock1.lock(); + await expect(lock2.lock(AbortSignal.timeout(100))).rejects.toThrow(); + await lock1.unlock(); }); it("should throw if release called without acquire", async () => { - await expect(lock.release()).rejects.toThrow("Lock is not acquired"); + await expect(lock.unlock()).rejects.toThrow("Lock is not acquired"); }); it("should allow re-acquire after release", async () => { - const release1 = await lock.acquire(); + await lock.lock(); expect(lock.locked).toBe(true); - await release1(); + await lock.unlock(); expect(lock.locked).toBe(false); - const release2 = await lock.acquire(); + await lock.lock(); expect(lock.locked).toBe(true); - await release2(); + await lock.unlock(); expect(lock.locked).toBe(false); }); }); diff --git a/packages/misc-util/src/async/udp-bind-lock.ts b/packages/misc-util/src/async/synchro/udp-bind-lock.ts similarity index 64% rename from packages/misc-util/src/async/udp-bind-lock.ts rename to packages/misc-util/src/async/synchro/udp-bind-lock.ts index 62ec7edb..c2a77445 100644 --- a/packages/misc-util/src/async/udp-bind-lock.ts +++ b/packages/misc-util/src/async/synchro/udp-bind-lock.ts @@ -1,10 +1,8 @@ -import type { AsyncCallable } from "../ecma/function/types.js"; -import { waitFor } from "../ecma/function/wait-for.js"; -import { defaults } from "../ecma/object/defaults.js"; -import { isNodeErrorWithCode } from "../node/error/node-error.js"; -import { DgramSocket } from "../node/net/dgram-socket.js"; -import { type ILockable, LockNotAcquiredError } from "./ilockable.js"; -import { LockableBase } from "./lockable-base.js"; +import { waitFor } from "../../ecma/function/wait-for.js"; +import { defaults } from "../../ecma/object/defaults.js"; +import { isNodeErrorWithCode } from "../../node/error/node-error.js"; +import { DgramSocket } from "../../node/net/dgram-socket.js"; +import { type ILock, LockNotAcquiredError } from "./ilock.js"; export type UdpBindLockConfig = { /** @@ -75,7 +73,7 @@ const UDP_BIND_LOCK_DEFAULT_OPTIONS: Required = { * await lock.release(); * ``` */ -export class UdpBindLock extends LockableBase implements ILockable { +export class UdpBindLock implements ILock { private readonly options: Required; private udpSocket: DgramSocket | null = null; @@ -83,8 +81,6 @@ export class UdpBindLock extends LockableBase implements ILockable { private readonly config: UdpBindLockConfig, options?: UdpBindLockOptions, ) { - super(); - this.options = defaults(options, UDP_BIND_LOCK_DEFAULT_OPTIONS); } @@ -92,46 +88,18 @@ export class UdpBindLock extends LockableBase implements ILockable { return this.udpSocket !== null; } - async acquire(signal?: AbortSignal | null): Promise { - await waitFor( - async () => { - const udpSocket = DgramSocket.from({ - type: this.config.udpSocketType, - signal: signal ?? undefined, - }); - - // Prevent the socket from keeping the Node.js process alive - udpSocket.unref(); - - try { - await udpSocket.bind( - this.config.udpBindPort, - this.config.udpBindAddress ?? undefined, - { - exclusive: true, - }, - ); - } catch (error) { - if (isNodeErrorWithCode(error, "EADDRINUSE")) { - return false; - } - - throw error; - } - - this.udpSocket = udpSocket; - return true; - }, - { - intervalMs: this.options.pollIntervalMs, - signal, - }, - ); - - return () => this.release(); + async tryLock(): Promise { + return this.tryCreateAndBindSocket(); + } + + async lock(signal?: AbortSignal | null): Promise { + await waitFor(async () => this.tryCreateAndBindSocket(signal), { + intervalMs: this.options.pollIntervalMs, + signal, + }); } - async release(): Promise { + async unlock(): Promise { if (!this.udpSocket) { throw new LockNotAcquiredError(); } @@ -139,4 +107,35 @@ export class UdpBindLock extends LockableBase implements ILockable { await this.udpSocket.close(); this.udpSocket = null; } + + private async tryCreateAndBindSocket( + signal?: AbortSignal | null, + ): Promise { + const udpSocket = DgramSocket.from({ + type: this.config.udpSocketType, + signal: signal ?? undefined, + }); + + // Prevent the socket from keeping the Node.js process alive + udpSocket.unref(); + + try { + await udpSocket.bind( + this.config.udpBindPort, + this.config.udpBindAddress ?? undefined, + { + exclusive: true, + }, + ); + } catch (error) { + if (isNodeErrorWithCode(error, "EADDRINUSE")) { + return false; + } + + throw error; + } + + this.udpSocket = udpSocket; + return true; + } } diff --git a/packages/misc-util/src/async/wait-notifiable.ts b/packages/misc-util/src/async/wait-notifiable.ts deleted file mode 100644 index a41f0ee9..00000000 --- a/packages/misc-util/src/async/wait-notifiable.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Callback } from "../ecma/function/types.js"; -import { AbortablePromise } from "../ecma/promise/abortable-promise.js"; -import type { ISubscribable } from "./isubscribable.js"; - -export async function waitNotifiable( - notifiable: ISubscribable, - signal?: AbortSignal | null, -): Promise { - let unsubscribe: Callback | null = null; - - const deferred = AbortablePromise.withResolvers({ - signal, - onAbort: () => { - unsubscribe?.(); - }, - }); - - unsubscribe = notifiable.subscribe((...args: T) => deferred.resolve(args), { - once: true, - }); - - return deferred.promise; -} diff --git a/packages/misc-util/src/data/__tests__/icollection-compliance.test.ts b/packages/misc-util/src/data/__tests__/icollection-compliance.test.ts index 2cb93fbe..74774c7b 100644 --- a/packages/misc-util/src/data/__tests__/icollection-compliance.test.ts +++ b/packages/misc-util/src/data/__tests__/icollection-compliance.test.ts @@ -1,9 +1,9 @@ import { expect, suite, test } from "vitest"; import type { Callable } from "../../ecma/function/types.js"; -import type { ICollection } from "../abstract-types/icollection.js"; import { BinaryHeap } from "../binary-heap.js"; import { Deque } from "../deque.js"; import { DoublyLinkedList } from "../doubly-linked-list.js"; +import type { ICollection } from "../icollection.js"; import { LinkedList } from "../linked-list.js"; import { NativeArray } from "../native-array.js"; import { Queue } from "../queue.js"; diff --git a/packages/misc-util/src/data/__tests__/iheap-compliance.test.ts b/packages/misc-util/src/data/__tests__/iheap-compliance.test.ts index db6714f2..5c580548 100644 --- a/packages/misc-util/src/data/__tests__/iheap-compliance.test.ts +++ b/packages/misc-util/src/data/__tests__/iheap-compliance.test.ts @@ -1,7 +1,7 @@ import { expect, suite, test } from "vitest"; import type { Callable } from "../../ecma/function/types.js"; -import type { IHeap } from "../abstract-types/iheap.js"; import { BinaryHeap } from "../binary-heap.js"; +import type { IHeap } from "../iheap.js"; /** * IHeap compliance tests for various data structures. diff --git a/packages/misc-util/src/data/__tests__/ilist-compliance.test.ts b/packages/misc-util/src/data/__tests__/ilist-compliance.test.ts index 606bd32e..aeed8f54 100644 --- a/packages/misc-util/src/data/__tests__/ilist-compliance.test.ts +++ b/packages/misc-util/src/data/__tests__/ilist-compliance.test.ts @@ -1,8 +1,8 @@ import { expect, suite, test } from "vitest"; import type { Callable } from "../../ecma/function/types.js"; import type { MaybeAsyncIterableIterator } from "../../ecma/iterator/types.js"; -import type { IList } from "../abstract-types/ilist.js"; import { DoublyLinkedList } from "../doubly-linked-list.js"; +import type { IList } from "../ilist.js"; import { LinkedList } from "../linked-list.js"; import { NativeArray } from "../native-array.js"; diff --git a/packages/misc-util/src/data/__tests__/ipriority-queue-compliance.test.ts b/packages/misc-util/src/data/__tests__/ipriority-queue-compliance.test.ts index 7cdef6ae..62e0b8ef 100644 --- a/packages/misc-util/src/data/__tests__/ipriority-queue-compliance.test.ts +++ b/packages/misc-util/src/data/__tests__/ipriority-queue-compliance.test.ts @@ -1,7 +1,7 @@ import { expect, suite, test } from "vitest"; import type { Callable } from "../../ecma/function/types.js"; -import type { ICollection } from "../abstract-types/icollection.js"; -import type { IPriorityQueue } from "../abstract-types/ipriority-queue.js"; +import type { ICollection } from "../icollection.js"; +import type { IPriorityQueue } from "../ipriority-queue.js"; import { PriorityQueue } from "../priority-queue.js"; /** diff --git a/packages/misc-util/src/data/__tests__/iqueue-compliance.test.ts b/packages/misc-util/src/data/__tests__/iqueue-compliance.test.ts index b19c0098..e7f9bddb 100644 --- a/packages/misc-util/src/data/__tests__/iqueue-compliance.test.ts +++ b/packages/misc-util/src/data/__tests__/iqueue-compliance.test.ts @@ -1,7 +1,7 @@ import { expect, suite, test } from "vitest"; import type { Callable } from "../../ecma/function/types.js"; -import { CollectionCapacityExceededError } from "../abstract-types/icollection.js"; -import type { IQueue } from "../abstract-types/iqueue.js"; +import { CollectionCapacityExceededError } from "../icollection.js"; +import type { IQueue } from "../iqueue.js"; import { Queue } from "../queue.js"; /** diff --git a/packages/misc-util/src/data/__tests__/istack-compliance.test.ts b/packages/misc-util/src/data/__tests__/istack-compliance.test.ts index d3f290e6..b5281a4c 100644 --- a/packages/misc-util/src/data/__tests__/istack-compliance.test.ts +++ b/packages/misc-util/src/data/__tests__/istack-compliance.test.ts @@ -1,6 +1,6 @@ import { expect, suite, test } from "vitest"; import type { Callable } from "../../ecma/function/types.js"; -import type { IStack } from "../abstract-types/istack.js"; +import type { IStack } from "../istack.js"; import { Stack } from "../stack.js"; /** diff --git a/packages/misc-util/src/data/collection-base/binary-heap-collection.ts b/packages/misc-util/src/data/_binary-heap-collection.ts similarity index 85% rename from packages/misc-util/src/data/collection-base/binary-heap-collection.ts rename to packages/misc-util/src/data/_binary-heap-collection.ts index 2b06df59..8e812239 100644 --- a/packages/misc-util/src/data/collection-base/binary-heap-collection.ts +++ b/packages/misc-util/src/data/_binary-heap-collection.ts @@ -1,6 +1,6 @@ -import type { Callable, Predicate } from "../../ecma/function/types.js"; -import type { ICollection } from "../abstract-types/icollection.js"; -import { BinaryHeap } from "../binary-heap.js"; +import type { Callable, Predicate } from "../ecma/function/types.js"; +import { BinaryHeap } from "./binary-heap.js"; +import type { ICollection } from "./icollection.js"; export class BinaryHeapCollection implements ICollection { protected readonly data: BinaryHeap; diff --git a/packages/misc-util/src/data/collection-base/linked-list-collection.ts b/packages/misc-util/src/data/_linked-list-collection.ts similarity index 84% rename from packages/misc-util/src/data/collection-base/linked-list-collection.ts rename to packages/misc-util/src/data/_linked-list-collection.ts index 20c370fa..dbfdc212 100644 --- a/packages/misc-util/src/data/collection-base/linked-list-collection.ts +++ b/packages/misc-util/src/data/_linked-list-collection.ts @@ -1,6 +1,6 @@ -import type { Callable, Predicate } from "../../ecma/function/types.js"; -import type { ICollection } from "../abstract-types/icollection.js"; -import { LinkedList } from "../linked-list.js"; +import type { Callable, Predicate } from "../ecma/function/types.js"; +import type { ICollection } from "./icollection.js"; +import { LinkedList } from "./linked-list.js"; export class LinkedListCollection implements ICollection { protected readonly data: LinkedList; diff --git a/packages/misc-util/src/data/collection-base/native-array-collection.ts b/packages/misc-util/src/data/_native-array-collection.ts similarity index 84% rename from packages/misc-util/src/data/collection-base/native-array-collection.ts rename to packages/misc-util/src/data/_native-array-collection.ts index c8b22962..22d27974 100644 --- a/packages/misc-util/src/data/collection-base/native-array-collection.ts +++ b/packages/misc-util/src/data/_native-array-collection.ts @@ -1,6 +1,6 @@ -import type { Callable, Predicate } from "../../ecma/function/types.js"; -import type { ICollection } from "../abstract-types/icollection.js"; -import { NativeArray } from "../native-array.js"; +import type { Callable, Predicate } from "../ecma/function/types.js"; +import type { ICollection } from "./icollection.js"; +import { NativeArray } from "./native-array.js"; export class NativeListCollection implements ICollection { protected readonly data: NativeArray; diff --git a/packages/misc-util/src/data/binary-heap.ts b/packages/misc-util/src/data/binary-heap.ts index 200379b0..ca5066b7 100644 --- a/packages/misc-util/src/data/binary-heap.ts +++ b/packages/misc-util/src/data/binary-heap.ts @@ -1,6 +1,6 @@ import type { Callable, Predicate } from "../ecma/function/types.js"; -import type { IHeap } from "./abstract-types/iheap.js"; -import { NativeListCollection } from "./collection-base/native-array-collection.js"; +import { NativeListCollection } from "./_native-array-collection.js"; +import type { IHeap } from "./iheap.js"; /** * A binary heap implementation. diff --git a/packages/misc-util/src/data/deque.ts b/packages/misc-util/src/data/deque.ts index b3f99a08..3e35e3ad 100644 --- a/packages/misc-util/src/data/deque.ts +++ b/packages/misc-util/src/data/deque.ts @@ -1,6 +1,6 @@ -import type { IDeque } from "./abstract-types/ideque.js"; -import { ListIndexOutOfBoundsError } from "./abstract-types/ilist.js"; -import { LinkedListCollection } from "./collection-base/linked-list-collection.js"; +import { LinkedListCollection } from "./_linked-list-collection.js"; +import type { IDeque } from "./ideque.js"; +import { ListIndexOutOfBoundsError } from "./ilist.js"; /** * A double-ended queue (deque) implementation. diff --git a/packages/misc-util/src/data/doubly-linked-list.ts b/packages/misc-util/src/data/doubly-linked-list.ts index efeaf2cf..ad652cf5 100644 --- a/packages/misc-util/src/data/doubly-linked-list.ts +++ b/packages/misc-util/src/data/doubly-linked-list.ts @@ -1,11 +1,8 @@ -import { Semaphore } from "../async/semaphore.js"; +import { Semaphore } from "../async/synchro/semaphore.js"; import type { Callable, Predicate } from "../ecma/function/types.js"; -import { clamp } from "../ecma/math/clamp.js"; -import { CollectionCapacityExceededError } from "./abstract-types/icollection.js"; -import { - type IList, - ListIndexOutOfBoundsError, -} from "./abstract-types/ilist.js"; +import { clamp } from "../math/clamp.js"; +import { CollectionCapacityExceededError } from "./icollection.js"; +import { type IList, ListIndexOutOfBoundsError } from "./ilist.js"; type DoublyLinkedListNode = { value: T; diff --git a/packages/misc-util/src/data/abstract-types/ibinary-tree.ts b/packages/misc-util/src/data/ibinary-tree.ts similarity index 100% rename from packages/misc-util/src/data/abstract-types/ibinary-tree.ts rename to packages/misc-util/src/data/ibinary-tree.ts diff --git a/packages/misc-util/src/data/abstract-types/icollection.ts b/packages/misc-util/src/data/icollection.ts similarity index 94% rename from packages/misc-util/src/data/abstract-types/icollection.ts rename to packages/misc-util/src/data/icollection.ts index ad5189ae..6f2562d7 100644 --- a/packages/misc-util/src/data/abstract-types/icollection.ts +++ b/packages/misc-util/src/data/icollection.ts @@ -1,6 +1,6 @@ import type { Promisable } from "type-fest"; -import type { Callable, Predicate } from "../../ecma/function/types.js"; -import type { MaybeAsyncIterableIterator } from "../../ecma/iterator/types.js"; +import type { Callable, Predicate } from "../ecma/function/types.js"; +import type { MaybeAsyncIterableIterator } from "../ecma/iterator/types.js"; /** * Error thrown when a list exceeds its capacity. diff --git a/packages/misc-util/src/data/abstract-types/ideque.ts b/packages/misc-util/src/data/ideque.ts similarity index 100% rename from packages/misc-util/src/data/abstract-types/ideque.ts rename to packages/misc-util/src/data/ideque.ts diff --git a/packages/misc-util/src/data/abstract-types/iheap.ts b/packages/misc-util/src/data/iheap.ts similarity index 100% rename from packages/misc-util/src/data/abstract-types/iheap.ts rename to packages/misc-util/src/data/iheap.ts diff --git a/packages/misc-util/src/data/abstract-types/ilist.ts b/packages/misc-util/src/data/ilist.ts similarity index 98% rename from packages/misc-util/src/data/abstract-types/ilist.ts rename to packages/misc-util/src/data/ilist.ts index bebecbe7..09ec7a24 100644 --- a/packages/misc-util/src/data/abstract-types/ilist.ts +++ b/packages/misc-util/src/data/ilist.ts @@ -1,5 +1,5 @@ import type { Promisable } from "type-fest"; -import type { MaybeAsyncIterableIterator } from "../../ecma/iterator/types.js"; +import type { MaybeAsyncIterableIterator } from "../ecma/iterator/types.js"; import type { ICollection } from "./icollection.js"; /** diff --git a/packages/misc-util/src/data/abstract-types/ipriority-queue.ts b/packages/misc-util/src/data/ipriority-queue.ts similarity index 97% rename from packages/misc-util/src/data/abstract-types/ipriority-queue.ts rename to packages/misc-util/src/data/ipriority-queue.ts index 50321cb4..a2e08122 100644 --- a/packages/misc-util/src/data/abstract-types/ipriority-queue.ts +++ b/packages/misc-util/src/data/ipriority-queue.ts @@ -1,5 +1,5 @@ import type { Promisable } from "type-fest"; -import type { Predicate } from "../../ecma/function/types.js"; +import type { Predicate } from "../ecma/function/types.js"; import type { ICollection } from "./icollection.js"; export type PriorityQueueIsHigherPriorityPredicate

= Predicate<[P, P]>; diff --git a/packages/misc-util/src/data/abstract-types/iqueue.ts b/packages/misc-util/src/data/iqueue.ts similarity index 100% rename from packages/misc-util/src/data/abstract-types/iqueue.ts rename to packages/misc-util/src/data/iqueue.ts diff --git a/packages/misc-util/src/data/abstract-types/istack.ts b/packages/misc-util/src/data/istack.ts similarity index 100% rename from packages/misc-util/src/data/abstract-types/istack.ts rename to packages/misc-util/src/data/istack.ts diff --git a/packages/misc-util/src/data/abstract-types/itree.ts b/packages/misc-util/src/data/itree.ts similarity index 100% rename from packages/misc-util/src/data/abstract-types/itree.ts rename to packages/misc-util/src/data/itree.ts diff --git a/packages/misc-util/src/data/linked-list.ts b/packages/misc-util/src/data/linked-list.ts index 5a57f320..c403088b 100644 --- a/packages/misc-util/src/data/linked-list.ts +++ b/packages/misc-util/src/data/linked-list.ts @@ -1,11 +1,8 @@ -import { Semaphore } from "../async/semaphore.js"; +import { Semaphore } from "../async/synchro/semaphore.js"; import type { Callable, Predicate } from "../ecma/function/types.js"; -import { clamp } from "../ecma/math/clamp.js"; -import { CollectionCapacityExceededError } from "./abstract-types/icollection.js"; -import { - type IList, - ListIndexOutOfBoundsError, -} from "./abstract-types/ilist.js"; +import { clamp } from "../math/clamp.js"; +import { CollectionCapacityExceededError } from "./icollection.js"; +import { type IList, ListIndexOutOfBoundsError } from "./ilist.js"; export type LinkedListNode = { value: T; diff --git a/packages/misc-util/src/data/native-array.ts b/packages/misc-util/src/data/native-array.ts index 330894e6..4016c9fe 100644 --- a/packages/misc-util/src/data/native-array.ts +++ b/packages/misc-util/src/data/native-array.ts @@ -1,11 +1,8 @@ -import { Semaphore } from "../async/semaphore.js"; +import { Semaphore } from "../async/synchro/semaphore.js"; import type { Callable, Predicate } from "../ecma/function/types.js"; -import { clamp } from "../ecma/math/clamp.js"; -import { CollectionCapacityExceededError } from "./abstract-types/icollection.js"; -import { - type IList, - ListIndexOutOfBoundsError, -} from "./abstract-types/ilist.js"; +import { clamp } from "../math/clamp.js"; +import { CollectionCapacityExceededError } from "./icollection.js"; +import { type IList, ListIndexOutOfBoundsError } from "./ilist.js"; /** * A list implementation using the JS built-in array as the underlying data @@ -65,7 +62,7 @@ export class NativeArray implements IList { removeFirst(condition: Predicate<[T]>): boolean { for (let i = 0; i < this.data.length; i++) { - // biome-ignore lint/style/noNonNullAssertion: for loop + // biome-ignore lint/style/noNonNullAssertion: not concurrent-safe if (condition(this.data[i]!)) { this.data.splice(i, 1); this.semaphore.release(1); @@ -80,7 +77,7 @@ export class NativeArray implements IList { let i = 0; while (i < this.data.length) { - // biome-ignore lint/style/noNonNullAssertion: for loop + // biome-ignore lint/style/noNonNullAssertion: not concurrent-safe const data = this.data[i]!; if (condition(data)) { @@ -97,7 +94,7 @@ export class NativeArray implements IList { replaceFirst(condition: Predicate<[T]>, newItem: T): boolean { for (let i = 0; i < this.data.length; i++) { - // biome-ignore lint/style/noNonNullAssertion: for loop + // biome-ignore lint/style/noNonNullAssertion: not concurrent-safe if (condition(this.data[i]!)) { this.data[i] = newItem; return true; @@ -113,7 +110,7 @@ export class NativeArray implements IList { const replacedItems: T[] = []; for (let i = 0; i < this.data.length; i++) { - // biome-ignore lint/style/noNonNullAssertion: for loop + // biome-ignore lint/style/noNonNullAssertion: not concurrent-safe const originalData = this.data[i]!; if (condition(originalData)) { diff --git a/packages/misc-util/src/data/priority-queue.ts b/packages/misc-util/src/data/priority-queue.ts index 65cc50ee..857d7d9b 100644 --- a/packages/misc-util/src/data/priority-queue.ts +++ b/packages/misc-util/src/data/priority-queue.ts @@ -1,8 +1,8 @@ +import { BinaryHeapCollection } from "./_binary-heap-collection.js"; import type { IPriorityQueue, PriorityQueueIsHigherPriorityPredicate, -} from "./abstract-types/ipriority-queue.js"; -import { BinaryHeapCollection } from "./collection-base/binary-heap-collection.js"; +} from "./ipriority-queue.js"; /** * A priority queue implementation. diff --git a/packages/misc-util/src/data/queue.ts b/packages/misc-util/src/data/queue.ts index 8cbded16..4218666f 100644 --- a/packages/misc-util/src/data/queue.ts +++ b/packages/misc-util/src/data/queue.ts @@ -1,6 +1,6 @@ -import { ListIndexOutOfBoundsError } from "./abstract-types/ilist.js"; -import type { IQueue } from "./abstract-types/iqueue.js"; -import { LinkedListCollection } from "./collection-base/linked-list-collection.js"; +import { LinkedListCollection } from "./_linked-list-collection.js"; +import { ListIndexOutOfBoundsError } from "./ilist.js"; +import type { IQueue } from "./iqueue.js"; /** * A FIFO queue implementation. diff --git a/packages/misc-util/src/data/stack.ts b/packages/misc-util/src/data/stack.ts index 6877eb8d..cdafae83 100644 --- a/packages/misc-util/src/data/stack.ts +++ b/packages/misc-util/src/data/stack.ts @@ -1,6 +1,6 @@ -import { ListIndexOutOfBoundsError } from "./abstract-types/ilist.js"; -import type { IStack } from "./abstract-types/istack.js"; -import { NativeListCollection } from "./collection-base/native-array-collection.js"; +import { NativeListCollection } from "./_native-array-collection.js"; +import { ListIndexOutOfBoundsError } from "./ilist.js"; +import type { IStack } from "./istack.js"; /** * The LIFO stack implementation. diff --git a/packages/misc-util/src/ecma/array/compact.test.ts b/packages/misc-util/src/ecma/array/compact.test.ts new file mode 100644 index 00000000..bb2367ef --- /dev/null +++ b/packages/misc-util/src/ecma/array/compact.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { compact } from "./compact.js"; + +describe("compact", () => { + it("removes null and undefined from a mixed array", () => { + const input = [1, null, 2, undefined, 3, null, 4, undefined]; + const result = compact(input); + expect(result).toEqual([1, 2, 3, 4]); + }); + + it("returns an empty array if all elements are null or undefined", () => { + const input = [null, undefined, undefined, null]; + const result = compact(input); + expect(result).toEqual([]); + }); + + it("returns the same array if there are no null or undefined values", () => { + const input = [1, 2, 3]; + const result = compact(input); + expect(result).toEqual([1, 2, 3]); + }); + + it("works with arrays of strings", () => { + const input = ["a", null, "b", undefined, "c"]; + const result = compact(input); + expect(result).toEqual(["a", "b", "c"]); + }); + + it("works with empty array", () => { + const input: Array = []; + const result = compact(input); + expect(result).toEqual([]); + }); +}); diff --git a/packages/misc-util/src/ecma/array/compact.ts b/packages/misc-util/src/ecma/array/compact.ts new file mode 100644 index 00000000..a5f57a76 --- /dev/null +++ b/packages/misc-util/src/ecma/array/compact.ts @@ -0,0 +1,9 @@ +/** + * Removes null and undefined values from an array. + * + * @param array The array to compact. + * @returns A new array with null and undefined values removed. + */ +export function compact(array: (T | null | undefined)[]): T[] { + return array.filter((item): item is T => item !== null && item !== undefined); +} diff --git a/packages/misc-util/src/ecma/array/intersection.test.ts b/packages/misc-util/src/ecma/array/intersection.test.ts new file mode 100644 index 00000000..27aef2c7 --- /dev/null +++ b/packages/misc-util/src/ecma/array/intersection.test.ts @@ -0,0 +1,350 @@ +import { describe, expect, it } from "vitest"; +import { intersection } from "./intersection.js"; + +describe("intersection", () => { + describe("basic functionality", () => { + it("should return intersection of two arrays with common elements", () => { + const result = intersection([1, 2, 3], [2, 3, 4]); + + expect(result).toEqual([2, 3]); + }); + + it("should return empty array when arrays have no common elements", () => { + const result = intersection([1, 2, 3], [4, 5, 6]); + + expect(result).toEqual([]); + }); + + it("should return empty array for empty input arrays", () => { + const result = intersection([], []); + + expect(result).toEqual([]); + }); + + it("should return empty array when one array is empty", () => { + const result = intersection([1, 2, 3], []); + + expect(result).toEqual([]); + }); + + it("should handle single array", () => { + const result = intersection([1, 2, 3]); + + expect(result).toEqual([]); + }); + + it("should return empty array when called with no arguments", () => { + const result = intersection(); + + expect(result).toEqual([]); + }); + }); + + describe("multiple arrays", () => { + it("should return intersection of three arrays", () => { + const result = intersection([1, 2, 3, 4], [2, 3, 4, 5], [3, 4, 5, 6]); + + expect(result).toEqual([3, 4]); + }); + + it("should return intersection of four arrays", () => { + const result = intersection([1, 2, 3], [2, 3, 4], [3, 4, 5], [3, 5, 6]); + + expect(result).toEqual([3]); + }); + + it("should return empty array if any array is empty", () => { + const result = intersection([1, 2, 3], [2, 3, 4], [], [3, 4, 5]); + + expect(result).toEqual([]); + }); + + it("should handle many arrays", () => { + const result = intersection( + [1, 2, 3, 4, 5], + [2, 3, 4, 5, 6], + [3, 4, 5, 6, 7], + [4, 5, 6, 7, 8], + [5, 6, 7, 8, 9], + ); + + expect(result).toEqual([5]); + }); + }); + + describe("duplicate handling", () => { + it("should preserve duplicates from first array", () => { + const result = intersection([1, 2, 2, 3], [2, 3, 4]); + + expect(result).toEqual([2, 2, 3]); + }); + + it("should handle duplicates in multiple arrays", () => { + const result = intersection([1, 1, 2, 2, 3], [1, 2, 2, 3], [1, 1, 2]); + + expect(result).toEqual([1, 1, 2, 2]); + }); + + it("should maintain order from first array with duplicates", () => { + const result = intersection([3, 2, 2, 1], [1, 2, 3]); + + expect(result).toEqual([3, 2, 2, 1]); + }); + }); + + describe("type handling", () => { + it("should handle string arrays", () => { + const result = intersection(["a", "b", "c"], ["b", "c", "d"]); + + expect(result).toEqual(["b", "c"]); + }); + + it("should handle boolean arrays", () => { + const result = intersection([true, false], [false, true]); + + expect(result).toEqual([true, false]); + }); + + it("should handle mixed primitive types", () => { + const result = intersection([1, "2", true, null], ["2", true, null, 3]); + + expect(result).toEqual(["2", true, null]); + }); + + it("should handle arrays of objects by reference", () => { + const obj1 = { id: 1 }; + const obj2 = { id: 2 }; + const obj3 = { id: 3 }; + + const result = intersection([obj1, obj2, obj3], [obj2, obj3, { id: 4 }]); + + expect(result).toEqual([obj2, obj3]); + }); + + it("should not match objects by value, only by reference", () => { + const result = intersection( + [{ id: 1 }, { id: 2 }], + [{ id: 1 }, { id: 2 }], + ); + + expect(result).toEqual([]); + }); + + it("should handle arrays with undefined values", () => { + const result = intersection([1, undefined, 3], [undefined, 2, 3]); + + expect(result).toEqual([undefined, 3]); + }); + + it("should handle arrays with null values", () => { + const result = intersection([1, null, 3], [null, 2, 3]); + + expect(result).toEqual([null, 3]); + }); + + it("should handle NaN values", () => { + const result = intersection([1, Number.NaN, 3], [Number.NaN, 2, 3]); + + // NaN !== NaN, so NaN will not be in intersection + expect(result).toEqual([3]); + }); + + it("should handle Symbol values", () => { + const sym1 = Symbol("test"); + const sym2 = Symbol("test"); + const sym3 = Symbol("other"); + + const result = intersection([sym1, sym2], [sym1, sym3]); + + expect(result).toEqual([sym1]); + }); + + it("should handle BigInt values", () => { + const result = intersection( + [BigInt(1), BigInt(2), BigInt(3)], + [BigInt(2), BigInt(3), BigInt(4)], + ); + + // BigInt comparison works by value + expect(result).toEqual([BigInt(2), BigInt(3)]); + }); + }); + + describe("edge cases", () => { + it("should handle arrays with zero", () => { + const result = intersection([0, 1, 2], [0, 2, 3]); + + expect(result).toEqual([0, 2]); + }); + + it("should handle arrays with negative zero", () => { + const result = intersection([0, -0, 1], [-0, 1, 2]); + + // 0 === -0 in JavaScript + expect(result).toEqual([0, -0, 1]); + }); + + it("should handle empty strings", () => { + const result = intersection(["", "a", "b"], ["", "b", "c"]); + + expect(result).toEqual(["", "b"]); + }); + + it("should handle arrays with false and 0", () => { + const result = intersection([false, 0, 1], [0, false, 2]); + + expect(result).toEqual([false, 0]); + }); + + it("should handle very long arrays", () => { + const arr1 = Array.from({ length: 10000 }, (_, i) => i); + const arr2 = Array.from({ length: 10000 }, (_, i) => i + 5000); + + const result = intersection(arr1, arr2); + + expect(result).toHaveLength(5000); + expect(result[0]).toBe(5000); + expect(result[4999]).toBe(9999); + }); + + it("should handle nested arrays", () => { + const arr1 = [1, 2, 3]; + const arr2 = [4, 5, 6]; + + const result = intersection([arr1, arr2, 7], [arr1, 8, 9]); + + expect(result).toEqual([arr1]); + expect(result[0]).toBe(arr1); // Same reference + }); + + it("should handle Date objects by reference", () => { + const date1 = new Date("2026-01-11"); + const date2 = new Date("2026-01-12"); + + const result = intersection( + [date1, date2], + [date1, new Date("2026-01-13")], + ); + + expect(result).toEqual([date1]); + }); + + it("should handle RegExp objects by reference", () => { + const regex1 = /test/g; + const regex2 = /test/i; + + const result = intersection([regex1, regex2], [regex1, /other/]); + + expect(result).toEqual([regex1]); + }); + + it("should handle functions by reference", () => { + const fn1 = () => {}; + const fn2 = () => {}; + + const result = intersection([fn1, fn2], [fn1, () => {}]); + + expect(result).toEqual([fn1]); + }); + + it("should preserve order from first array", () => { + const result = intersection([5, 4, 3, 2, 1], [1, 2, 3, 4, 5]); + + expect(result).toEqual([5, 4, 3, 2, 1]); + }); + + it("should handle identical arrays", () => { + const result = intersection([1, 2, 3], [1, 2, 3]); + + expect(result).toEqual([1, 2, 3]); + }); + + it("should handle single element arrays", () => { + const result = intersection([1], [1]); + + expect(result).toEqual([1]); + }); + + it("should handle arrays where first array is subset", () => { + const result = intersection([1, 2], [1, 2, 3, 4, 5]); + + expect(result).toEqual([1, 2]); + }); + + it("should handle arrays where second array is subset", () => { + const result = intersection([1, 2, 3, 4, 5], [2, 3]); + + expect(result).toEqual([2, 3]); + }); + + it("should handle Map objects by reference", () => { + const map1 = new Map([["a", 1]]); + const map2 = new Map([["a", 1]]); + + const result = intersection([map1, map2], [map1]); + + expect(result).toEqual([map1]); + }); + + it("should handle Set objects by reference", () => { + const set1 = new Set([1, 2]); + const set2 = new Set([1, 2]); + + const result = intersection([set1, set2], [set1]); + + expect(result).toEqual([set1]); + }); + + it("should handle Error objects by reference", () => { + const error1 = new Error("test"); + const error2 = new Error("test"); + + const result = intersection([error1, error2], [error1]); + + expect(result).toEqual([error1]); + }); + + it("should handle typed arrays by reference", () => { + const typed1 = new Uint8Array([1, 2, 3]); + const typed2 = new Uint8Array([1, 2, 3]); + + const result = intersection([typed1, typed2], [typed1]); + + expect(result).toEqual([typed1]); + }); + + it("should handle WeakMap and WeakSet by reference", () => { + const weak1 = new WeakMap(); + const weak2 = new WeakSet(); + + const result = intersection([weak1, weak2], [weak1]); + + expect(result).toEqual([weak1]); + }); + }); + + describe("performance characteristics", () => { + it("should handle first array with all unique elements", () => { + const arr1 = [1, 2, 3, 4, 5]; + const arr2 = [6, 7, 8, 9, 10]; + + const result = intersection(arr1, arr2); + + expect(result).toEqual([]); + }); + + it("should handle complete overlap", () => { + const arr = [1, 2, 3, 4, 5]; + + const result = intersection(arr, arr, arr); + + expect(result).toEqual(arr); + }); + + it("should handle alternating matches", () => { + const result = intersection([1, 2, 1, 2, 1, 2], [1, 1, 1]); + + expect(result).toEqual([1, 1, 1]); + }); + }); +}); diff --git a/packages/misc-util/src/ecma/array/intersection.ts b/packages/misc-util/src/ecma/array/intersection.ts new file mode 100644 index 00000000..6d9ebfbf --- /dev/null +++ b/packages/misc-util/src/ecma/array/intersection.ts @@ -0,0 +1,39 @@ +/** + * Recursively compute the intersection of element types from multiple arrays. + */ +type IntersectArrayElements = + T extends readonly [ + infer First extends readonly unknown[], + ...infer Rest extends readonly unknown[][], + ] + ? Rest extends readonly [] + ? First[number] + : Extract> + : never; + +/** + * Compute the intersection of multiple arrays. + * + * @param arrays The arrays to intersect. + * @returns An array containing the elements common to all input arrays. + */ +export function intersection( + ...arrays: T +): IntersectArrayElements[] { + if (arrays.length === 0 || arrays.length === 1) { + return []; + } + + const [first, ...rest] = arrays; + return rest.reduce( + (acc, curr) => + acc.filter((item) => { + // Use strict equality check for NaN (NaN !== NaN) + if (Number.isNaN(item)) { + return false; + } + return curr.includes(item); + }), + first as unknown[], + ) as IntersectArrayElements[]; +} diff --git a/packages/misc-util/src/ecma/array/remove-safe.test.ts b/packages/misc-util/src/ecma/array/remove-safe.test.ts new file mode 100644 index 00000000..c9a85b1b --- /dev/null +++ b/packages/misc-util/src/ecma/array/remove-safe.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { removeSafe } from "./remove-safe.js"; + +// Helper to create deep equality for objects +function createObj(val: number) { + return { val }; +} + +describe("removeSafe", () => { + it("removes all occurrences of a primitive value", () => { + const arr = [1, 2, 3, 2, 4, 2, 5]; + const removed = removeSafe(arr, 2); + expect(removed).toEqual([2, 2, 2]); + expect(arr).toEqual([1, 3, 4, 5]); + }); + + it("removes all occurrences of an object reference", () => { + const obj = createObj(1); + const arr = [obj, { val: 1 }, obj, { val: 2 }, obj]; + const removed = removeSafe(arr, obj); + expect(removed).toEqual([obj, obj, obj]); + // Only the objects not strictly equal to obj remain + expect(arr).toEqual([{ val: 1 }, { val: 2 }]); + }); + + it("returns an empty array if item is not found", () => { + const arr = [1, 2, 3]; + const removed = removeSafe(arr, 4); + expect(removed).toEqual([]); + expect(arr).toEqual([1, 2, 3]); + }); + + it("removes all items if all match", () => { + const arr = [7, 7, 7]; + const removed = removeSafe(arr, 7); + expect(removed).toEqual([7, 7, 7]); + expect(arr).toEqual([]); + }); + + it("does not remove items that are equal but not strictly equal (objects)", () => { + const arr = [{ a: 1 }, { a: 1 }]; + const removed = removeSafe(arr, { a: 1 }); + expect(removed).toEqual([]); + expect(arr).toEqual([{ a: 1 }, { a: 1 }]); + }); + + it("handles empty array", () => { + const arr: number[] = []; + const removed = removeSafe(arr, 1); + expect(removed).toEqual([]); + expect(arr).toEqual([]); + }); + + it("does not re-insert if removed item is undefined", () => { + const arr = [undefined, 1, undefined]; + const removed = removeSafe(arr, undefined); + expect(removed).toEqual([undefined, undefined]); + expect(arr).toEqual([1]); + }); + + it("removes only exact matches (NaN)", () => { + const arr = [NaN, 1, NaN, 2]; + // NaN !== NaN, so removeSafe will not remove any NaN + const removed = removeSafe(arr, NaN); + expect(removed).toEqual([]); + expect(arr).toEqual([NaN, 1, NaN, 2]); + }); + + // Simulated concurrency test: interleaved mutation + it("is robust to interleaved mutation (simulated concurrency)", () => { + const arr = [1, 2, 3, 2, 4, 2, 5]; + let callCount = 0; + const origSplice = Array.prototype.splice; + Array.prototype.splice = function (start, deleteCount, ...items) { + callCount++; + if (callCount === 2) { + arr[1] = 99; + } + // @ts-expect-error + return origSplice.apply(this, [start, deleteCount, ...items]); + }; + try { + const removed = removeSafe(arr, 2); + expect(removed).toEqual([2, 2, 2]); + expect(arr).toEqual([1, 99, 4, 5]); + } finally { + Array.prototype.splice = origSplice; + } + }); + + it("handles insertion before target during removal (simulated concurrency)", () => { + const arr = [1, 2, 3, 2, 4, 2, 5]; + let callCount = 0; + const origSplice = Array.prototype.splice; + Array.prototype.splice = function (start, deleteCount, ...items) { + callCount++; + if (callCount === 2) { + const next2 = arr.indexOf(2); + if (next2 !== -1) { + arr.splice(next2, 0, 42); + } + } + // @ts-expect-error + return origSplice.apply(this, [start, deleteCount, ...items]); + }; + try { + const removed = removeSafe(arr, 2); + expect(removed).toEqual([2, 2, 2]); + expect(arr).toEqual([1, 3, 42, 4, 5]); + } finally { + Array.prototype.splice = origSplice; + } + }); +}); diff --git a/packages/misc-util/src/ecma/array/remove-safe.ts b/packages/misc-util/src/ecma/array/remove-safe.ts new file mode 100644 index 00000000..346f6ea2 --- /dev/null +++ b/packages/misc-util/src/ecma/array/remove-safe.ts @@ -0,0 +1,41 @@ +import type { Predicate } from "../function/types.js"; +import { type IsEqualWellKnownStrategy, isEqual } from "../object/is-equal.js"; + +/** + * Removes all occurrences of an item from an array safely, ensuring that only + * exact matches are removed. + * + * In concurrent scenarios, it's possible that the item at a found index may have + * changed before the removal occurs. This function checks that the removed item + * is indeed the one intended for removal, and if not, it re-inserts it back into + * the array. + * + * @param array The array to remove items from. + * @param item The item to remove. + * @returns An array of removed items. + */ +export function removeSafe( + array: T[], + item: T, + strategy: IsEqualWellKnownStrategy | Predicate<[T, T]> = "strict", +): T[] { + const removedItems: T[] = []; + + let index: number; + while ((index = array.indexOf(item)) !== -1) { + const removed = array.splice(index, 1); + + if (removed.length > 0) { + // biome-ignore lint/style/noNonNullAssertion: non-concurrent scenario guarantees removed has at least one item + if (isEqual(removed[0]!, item, strategy)) { + // biome-ignore lint/style/noNonNullAssertion: non-concurrent scenario guarantees removed has at least one item + removedItems.push(removed[0]!); + } else { + // Re-insert the item if it does not match + array.splice(index, 0, ...removed); + } + } + } + + return removedItems; +} diff --git a/packages/misc-util/src/ecma/dispose/types.ts b/packages/misc-util/src/ecma/dispose/types.ts new file mode 100644 index 00000000..2cb28068 --- /dev/null +++ b/packages/misc-util/src/ecma/dispose/types.ts @@ -0,0 +1 @@ +export type MaybeAsyncDisposable = Disposable | AsyncDisposable; diff --git a/packages/misc-util/src/ecma/error/aggregate-error.ts b/packages/misc-util/src/ecma/error/aggregate-error.ts index 0859aa69..29cfed43 100644 --- a/packages/misc-util/src/ecma/error/aggregate-error.ts +++ b/packages/misc-util/src/ecma/error/aggregate-error.ts @@ -1,4 +1,4 @@ -import type { RequiredKeysOf } from "type-fest"; +import type { RequiredKeysOf, SetFieldType } from "type-fest"; import { isErrorLike } from "./error.js"; /** @@ -9,7 +9,7 @@ import { isErrorLike } from "./error.js"; * referring to an object that looks like an AggregateError but is not an instance * of the AggregateError class. */ -export type IAggregateError = AggregateError; +export type IAggregateError = SetFieldType; /** * Test if value is AggregateError-like (has name, message and errors properties) diff --git a/packages/misc-util/src/ecma/error/suppressed-error.ts b/packages/misc-util/src/ecma/error/suppressed-error.ts index 834b085c..7e4ef499 100644 --- a/packages/misc-util/src/ecma/error/suppressed-error.ts +++ b/packages/misc-util/src/ecma/error/suppressed-error.ts @@ -1,4 +1,4 @@ -import type { RequiredKeysOf } from "type-fest"; +import type { RequiredKeysOf, SetFieldType } from "type-fest"; import { isErrorLike } from "./error.js"; /** @@ -9,7 +9,11 @@ import { isErrorLike } from "./error.js"; * referring to an object that looks like an SuppressedError but is not an instance * of the SuppressedError class. */ -export type ISuppressedError = SuppressedError; +export type ISuppressedError = SetFieldType< + SuppressedError, + "error" | "suppressed", + unknown +>; /** * Test if value is SuppressedError-like (has name, message, error and suppressed properties) diff --git a/packages/misc-util/src/ecma/function/abortable.test.ts b/packages/misc-util/src/ecma/function/abortable.test.ts deleted file mode 100644 index f2fb4f61..00000000 --- a/packages/misc-util/src/ecma/function/abortable.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { abortable, abortableAsync } from "./abortable.js"; - -describe("abortable", () => { - it("calls original function when not aborted", () => { - const fn = vi.fn(() => "ok"); - const onAbort = vi.fn(); - const controller = new AbortController(); - const wrapped = abortable(fn, { onAbort, signal: controller.signal }); - const result = wrapped(); - expect(result).toBe("ok"); - expect(fn).toHaveBeenCalled(); - expect(onAbort).not.toHaveBeenCalled(); - }); - - it("calls onAbort if signal is aborted before call", () => { - const fn = vi.fn(); - const onAbort = vi.fn(); - const controller = new AbortController(); - controller.abort(); - const wrapped = abortable(fn, { onAbort, signal: controller.signal }); - wrapped(); - expect(onAbort).toHaveBeenCalled(); - expect(fn).toHaveBeenCalled(); - }); - - it("calls onAbort if signal is aborted during execution", () => { - const fn = vi.fn(() => { - controller.abort(); - return "aborted"; - }); - const onAbort = vi.fn(); - const controller = new AbortController(); - const wrapped = abortable(fn, { onAbort, signal: controller.signal }); - wrapped(); - expect(onAbort).toHaveBeenCalled(); - expect(fn).toHaveBeenCalled(); - }); -}); - -describe("abortableAsync", () => { - it("calls original async function when not aborted", async () => { - const fn = vi.fn(async () => "async-ok"); - const onAbort = vi.fn(); - const controller = new AbortController(); - const wrapped = abortableAsync(fn, { onAbort, signal: controller.signal }); - const result = await wrapped(); - expect(result).toBe("async-ok"); - expect(fn).toHaveBeenCalled(); - expect(onAbort).not.toHaveBeenCalled(); - }); - - it("calls onAbort if signal is aborted before async call", async () => { - const fn = vi.fn(async () => "should-run"); - const onAbort = vi.fn(); - const controller = new AbortController(); - controller.abort(); - const wrapped = abortableAsync(fn, { onAbort, signal: controller.signal }); - await wrapped(); - expect(onAbort).toHaveBeenCalled(); - expect(fn).toHaveBeenCalled(); - }); - - it("calls onAbort if signal is aborted during async execution", async () => { - const fn = vi.fn(async () => { - controller.abort(); - return "aborted-async"; - }); - const onAbort = vi.fn(); - const controller = new AbortController(); - const wrapped = abortableAsync(fn, { onAbort, signal: controller.signal }); - await wrapped(); - expect(onAbort).toHaveBeenCalled(); - expect(fn).toHaveBeenCalled(); - }); -}); diff --git a/packages/misc-util/src/ecma/function/abortable.ts b/packages/misc-util/src/ecma/function/abortable.ts deleted file mode 100644 index 0f03710c..00000000 --- a/packages/misc-util/src/ecma/function/abortable.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { noThrow } from "./no-throw.js"; -import type { AsyncCallable, Callable, MaybeAsyncCallable } from "./types.js"; - -export type AbortableProps = { - /** - * The callback to call when the operation is aborted. - */ - onAbort: (error?: unknown) => void; - - /** - * The abort signal to listen to. - */ - signal?: AbortSignal | null; -}; - -/** - * Wraps a function to make it abortable. The wrapped function will listen to the - * provided abort signal and call the onAbort callback if the signal is aborted. - * - * If the signal is already aborted, the onAbort callback will be called - * BEFORE calling the original function. - * - * Note: This function should not be used to wrap functions that return promises. - * Use `abortableAsync` instead. - * - * @param func The function to wrap. - * @param options The abortable options. - * @returns A new function that is abortable. - */ -export function abortable( - func: Callable, - { onAbort, signal }: AbortableProps, -): Callable { - const noThrowOnAbort = noThrow(onAbort); - - const handleAbort = () => { - let abortError: unknown; - try { - signal?.throwIfAborted?.(); - } catch (error) { - abortError = error; - } - noThrowOnAbort(abortError); - }; - - return function (this, ...args) { - try { - if (signal?.aborted) { - handleAbort(); - } else { - signal?.addEventListener("abort", handleAbort, { once: true }); - } - - return func.apply(this, args); - } finally { - signal?.removeEventListener("abort", handleAbort); - } - }; -} - -/** - * Wraps a function to make it abortable. The wrapped function will listen to the - * provided abort signal and call the onAbort callback if the signal is aborted. - * - * If the signal is already aborted, the onAbort callback will be called - * BEFORE calling the original function. - * - * @param func The function to wrap. - * @param options The abortable options. - * @returns A new function that is abortable. - */ -export function abortableAsync( - func: MaybeAsyncCallable, - { onAbort, signal }: AbortableProps, -): AsyncCallable { - const noThrowOnAbort = noThrow(onAbort); - - const handleAbort = () => { - let abortError: unknown; - try { - signal?.throwIfAborted?.(); - } catch (error) { - abortError = error; - } - noThrowOnAbort(abortError); - }; - - return async function (this, ...args) { - try { - if (signal?.aborted) { - handleAbort(); - } else { - signal?.addEventListener("abort", handleAbort, { once: true }); - } - - return await func.apply(this, args); - } finally { - signal?.removeEventListener("abort", handleAbort); - } - }; -} diff --git a/packages/misc-util/src/ecma/function/debounce-queue.test.ts b/packages/misc-util/src/ecma/function/serialize-queue-next.test.ts similarity index 67% rename from packages/misc-util/src/ecma/function/debounce-queue.test.ts rename to packages/misc-util/src/ecma/function/serialize-queue-next.test.ts index d51d8598..b05eddba 100644 --- a/packages/misc-util/src/ecma/function/debounce-queue.test.ts +++ b/packages/misc-util/src/ecma/function/serialize-queue-next.test.ts @@ -1,17 +1,17 @@ import { expect, suite, test } from "vitest"; import { sleep } from "../timers/sleep.js"; -import { debounceQueue } from "./debounce-queue.js"; +import { serializeQueueNext } from "./serialize-queue-next.js"; -suite("debounceQueue", () => { +suite("serializeQueueNext", () => { test("should execute the function immediately if not already running", async () => { let callCount = 0; const func = async () => { callCount++; return callCount; }; - const debouncedFunc = debounceQueue(func); + const funcSqn = serializeQueueNext(func); - const result = await debouncedFunc(); + const result = await funcSqn(); expect(result).toBe(1); expect(callCount).toBe(1); }); @@ -23,11 +23,11 @@ suite("debounceQueue", () => { await sleep(10); return callCount; }; - const debouncedFunc = debounceQueue(func); + const funcSqn = serializeQueueNext(func); - const promise1 = debouncedFunc(); - const promise2 = debouncedFunc(); - const promise3 = debouncedFunc(); + const promise1 = funcSqn(); + const promise2 = funcSqn(); + const promise3 = funcSqn(); const results = await Promise.all([promise1, promise2, promise3]); expect(results).toEqual([1, 2, 2]); @@ -44,11 +44,11 @@ suite("debounceQueue", () => { } return callCount; }; - const debouncedFunc = debounceQueue(func); + const funcSqn = serializeQueueNext(func); - await expect(() => debouncedFunc()).rejects.toThrow(error); + await expect(() => funcSqn()).rejects.toThrow(error); - const result = await debouncedFunc(); + const result = await funcSqn(); expect(result).toBe(2); expect(callCount).toBe(2); }); @@ -63,11 +63,11 @@ suite("debounceQueue", () => { } return callCount; }; - const debouncedFunc = debounceQueue(func); + const funcSqn = serializeQueueNext(func); - await expect(() => debouncedFunc()).rejects.toThrow(error); + await expect(() => funcSqn()).rejects.toThrow(error); - const result = await debouncedFunc(); + const result = await funcSqn(); expect(result).toBe(2); expect(callCount).toBe(2); }); @@ -79,11 +79,11 @@ suite("debounceQueue", () => { await sleep(10); return callCount; }; - const debouncedFunc = debounceQueue(func); + const funcSqn = serializeQueueNext(func); - const promise1 = debouncedFunc(); - const promise2 = debouncedFunc(); - const promise3 = debouncedFunc(); + const promise1 = funcSqn(); + const promise2 = funcSqn(); + const promise3 = funcSqn(); const results = await Promise.all([promise1, promise2, promise3]); expect(results).toEqual([1, 2, 2]); diff --git a/packages/misc-util/src/ecma/function/debounce-queue.ts b/packages/misc-util/src/ecma/function/serialize-queue-next.ts similarity index 60% rename from packages/misc-util/src/ecma/function/debounce-queue.ts rename to packages/misc-util/src/ecma/function/serialize-queue-next.ts index 6a50258d..571f8661 100644 --- a/packages/misc-util/src/ecma/function/debounce-queue.ts +++ b/packages/misc-util/src/ecma/function/serialize-queue-next.ts @@ -1,26 +1,25 @@ -import type { AsyncCallable, CallableNoArgs } from "./types.js"; +import type { AsyncCallable, MaybeAsyncCallableNoArgs } from "./types.js"; /** - * Wraps an asynchronous function to ensure that only one instance of it runs - * at a time. + * Wraps an asynchronous function to ensure that only one instance runs + * at a time, and if called again while running, only one additional call + * is queued to run immediately after the current one completes. * - * If the wrapped function is called while a previous call is still in progress, - * the new call is queued to run immediately after the current one completes. + * This pattern serializes async operations and queues at most one next call. + * If multiple calls are made while an operation is in progress, they all + * share the same promise for the next execution. * - * This is useful for debouncing async operations that should not overlap, such as - * network requests or file operations. + * Useful for preventing overlapping async operations (such as network or file + * requests) while ensuring that the latest requested operation is not lost. * - * When called, the function returns a promise that resolves or rejects with the - * result of the next queued call. - * - * This is a specialized form a debounce function, with a zero wait time, and no - * arguments. + * This is a specialized form of serialization with a single-call queue, + * zero wait time, and no arguments. * * @param func The asynchronous function to wrap. * @returns The wrapped function. */ -export function debounceQueue( - func: CallableNoArgs>, +export function serializeQueueNext( + func: MaybeAsyncCallableNoArgs, ): AsyncCallable<[signal?: AbortSignal | null], T> { let currentDeferred: PromiseWithResolvers | undefined; let nextDeferred: PromiseWithResolvers | undefined; @@ -28,7 +27,7 @@ export function debounceQueue( const execute = (deferred: PromiseWithResolvers): void => { currentDeferred = deferred; - func() + Promise.try(func) .then(deferred.resolve, deferred.reject) .finally(() => { currentDeferred = undefined; diff --git a/packages/misc-util/src/ecma/function/types.ts b/packages/misc-util/src/ecma/function/types.ts index ab576971..336e66b7 100644 --- a/packages/misc-util/src/ecma/function/types.ts +++ b/packages/misc-util/src/ecma/function/types.ts @@ -29,22 +29,6 @@ export type AsyncCallableNoArgs = CallableNoArgs< T >; -export type Callback = Callable< - A, - void, - T ->; - -export type MaybeAsyncCallback< - A extends unknown[] = never[], - T = unknown, -> = MaybeAsyncCallable; - -export type AsyncCallback< - A extends unknown[] = never[], - T = unknown, -> = AsyncCallable; - export type Predicate = Callable< A, boolean, diff --git a/packages/misc-util/src/ecma/json/json-serialize-error.ts b/packages/misc-util/src/ecma/json/json-serialize-error.ts deleted file mode 100644 index 0bc4a575..00000000 --- a/packages/misc-util/src/ecma/json/json-serialize-error.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { JsonObject, JsonValue } from "type-fest"; -import { isAggregateErrorLike } from "../error/aggregate-error.js"; -import { type IError, isErrorLike } from "../error/error.js"; -import { isSuppressedErrorLike } from "../error/suppressed-error.js"; -import { jsonSerialize } from "./json-serialize.js"; -import { makeJsonReplacerFunction } from "./make-json-replacer-function.js"; - -export type JsonError = JsonObject & { - name: string; - message: string; - stack?: string; - cause?: JsonError | JsonValue; -}; - -export type JsonAggregateError = JsonError & { - errors: (JsonError | JsonValue)[]; -}; - -export type JsonSuppressedError = JsonError & { - error: JsonError | JsonValue; - suppressed: JsonError | JsonValue; -}; - -/** - * Serialize an Error object to a JSON-compatible format. - * - * The function extracts standard properties like `name`, `message`, `stack`, - * and `cause`, as well as any enumerable own properties of the error object. - * - * If the error is an AggregateError, it also serializes the `errors` property. - * - * @param error The Error object to serialize. - * @param replacer An optional replacer function to customize the serialization of values. - * @returns A JSON representation of the error. - */ -export function jsonSerializeError( - error: T, - replacer?: JsonReplacer, -): JsonError { - const { name, message, stack, cause, ...restError } = error; - - const result: JsonError = { - name, - message, - }; - - if (stack !== undefined) { - result.stack = stack; - } - - const internalReplacer = makeJsonReplacerFunction( - (_key: string, value: unknown) => { - if (isErrorLike(value)) { - return jsonSerializeError(value, replacer); - } - - return value; - }, - replacer, - ); - - const serializedCause = jsonSerialize(cause, internalReplacer); - if (serializedCause !== undefined) { - result.cause = serializedCause; - } - - for (const [k, v] of Object.entries(restError)) { - const serializedValue = jsonSerialize(v, internalReplacer); - if (serializedValue !== undefined) { - result[k] = serializedValue; - } - } - - if (isAggregateErrorLike(error)) { - result.errors = error.errors.reduce((acc, innerError) => { - const serializedInnerError = jsonSerialize(innerError, internalReplacer); - if (serializedInnerError !== undefined) { - acc.push(serializedInnerError); - } - return acc; - }, []); - } - - if (isSuppressedErrorLike(error)) { - result.error = jsonSerialize(error.error, internalReplacer); - result.suppressed = jsonSerialize(error.suppressed, internalReplacer); - } - - return result; -} diff --git a/packages/misc-util/src/ecma/json/json-serialize.test.ts b/packages/misc-util/src/ecma/json/json-serialize.test.ts index b9544540..fd5fb212 100644 --- a/packages/misc-util/src/ecma/json/json-serialize.test.ts +++ b/packages/misc-util/src/ecma/json/json-serialize.test.ts @@ -47,17 +47,6 @@ suite("jsonSerialize", () => { expect(jsonSerialize(obj)).toEqual({ value: 84 }); }); - test("should handle circular references by throwing an error", () => { - const obj: any = {}; - obj.self = obj; - expect(() => jsonSerialize(obj)).toThrow(TypeError); - }); - - test("should handle big integer by throwing an error", () => { - const obj = { big: BigInt(10) }; - expect(() => jsonSerialize(obj)).toThrow(TypeError); - }); - test("should use the replacer function if provided", () => { const obj = { a: 1, b: 2, c: 3 }; const replacer: JsonReplacer = (_key, value) => { diff --git a/packages/misc-util/src/ecma/json/json-serialize.ts b/packages/misc-util/src/ecma/json/json-serialize.ts index fe00a277..e3f35a3d 100644 --- a/packages/misc-util/src/ecma/json/json-serialize.ts +++ b/packages/misc-util/src/ecma/json/json-serialize.ts @@ -1,4 +1,10 @@ import type { Jsonify } from "type-fest"; +import { + type JsonMakeAllReplacersFunctionOptions, + jsonMakeAllReplacersFunction, +} from "./replacers/all-replacers.js"; + +export type JsonSerializeOptions = JsonMakeAllReplacersFunctionOptions; /** * Serialize a value to a JSON-compatible format. @@ -6,16 +12,19 @@ import type { Jsonify } from "type-fest"; * The function follows the same rules as `JSON.stringify`, but instead of * returning a string, it returns the serialized value directly. * - * * @param value The value to serialize. * @param replacer A function that transforms the result + * @param options Options to customize the behavior of the serialization, such as handling circular references. * @returns A JSON representation of the value, or `undefined` if a "pure" value has been passed in argument. */ export function jsonSerialize( value: T, replacer?: JsonReplacer, + options?: JsonSerializeOptions, ): Jsonify | undefined { - const jsonString = JSON.stringify(value, replacer); + const allReplacers = jsonMakeAllReplacersFunction(replacer, options); + + const jsonString = JSON.stringify(value, allReplacers); if (jsonString === undefined) { return; diff --git a/packages/misc-util/src/ecma/json/json-stringify-safe.ts b/packages/misc-util/src/ecma/json/json-stringify-safe.ts deleted file mode 100644 index f0d9743a..00000000 --- a/packages/misc-util/src/ecma/json/json-stringify-safe.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { jsonSerializeError } from "./json-serialize-error.js"; -import { makeJsonReplacerFunction } from "./make-json-replacer-function.js"; - -/** - * Stringifies a value to JSON, handling circular references, Error objects, and BigInt values. - * - * @param value The value to stringify. - * @param replacer A function that alters the behavior of the stringification process, or - * an array of String and Number objects that serve as a whitelist for selecting/filtering - * the properties of the value object to be included in the JSON string. If this value is null - * or not provided, all properties of the object are included in the resulting JSON string. - * @param space The number of spaces to use for indentation or a string to use as whitespace. - * @returns A JSON string representation of the value, or undefined if the value cannot be stringified. - */ -export function jsonStringifySafe( - value: unknown, - replacer?: JsonReplacer, - space?: string | number, -): string | undefined { - const visited = new WeakSet(); - - return JSON.stringify( - value, - makeJsonReplacerFunction((_k: string, v: unknown): unknown => { - // Handle circular references - if (v && typeof v === "object") { - if (visited.has(v as object)) { - return "[Circular]"; - } - visited.add(v as object); - } - - // Handle Error objects - if (v instanceof Error) { - return jsonSerializeError(v); - } - - // Handle BigInt values - if (v instanceof BigInt || typeof v === "bigint") { - const num = v.valueOf(); - - if ( - num > BigInt(Number.MIN_SAFE_INTEGER) && - num < BigInt(Number.MAX_SAFE_INTEGER) - ) { - return Number(num); - } - - return num.toString(); - } - - return v; - }, replacer), - space, - ); -} diff --git a/packages/misc-util/src/ecma/json/json-stringify-safe.test.ts b/packages/misc-util/src/ecma/json/json-stringify.test.ts similarity index 85% rename from packages/misc-util/src/ecma/json/json-stringify-safe.test.ts rename to packages/misc-util/src/ecma/json/json-stringify.test.ts index de33d10b..0e1ab228 100644 --- a/packages/misc-util/src/ecma/json/json-stringify-safe.test.ts +++ b/packages/misc-util/src/ecma/json/json-stringify.test.ts @@ -1,10 +1,10 @@ import { expect, suite, test } from "vitest"; -import { jsonStringifySafe } from "./json-stringify-safe.js"; +import { jsonStringify } from "./json-stringify.js"; suite("jsonStringifySafe", () => { test("should stringify a simple object", () => { const obj = { a: 1, b: "test", c: true }; - const result = jsonStringifySafe(obj); + const result = jsonStringify(obj); expect(result).toBe(JSON.stringify(obj)); }); @@ -12,7 +12,7 @@ suite("jsonStringifySafe", () => { // biome-ignore lint/suspicious/noExplicitAny: test const obj: any = { a: 1 }; obj.self = obj; - const result = jsonStringifySafe(obj); + const result = jsonStringify(obj); expect(result).toBe('{"a":1,"self":"[Circular]"}'); }); @@ -20,7 +20,7 @@ suite("jsonStringifySafe", () => { // biome-ignore lint/suspicious/noExplicitAny: test const obj: any = { a: { b: { c: {} } } }; obj.a.b.c.self = obj.a; - const result = jsonStringifySafe(obj); + const result = jsonStringify(obj); expect(result).toBe('{"a":{"b":{"c":{"self":"[Circular]"}}}}'); }); @@ -28,7 +28,7 @@ suite("jsonStringifySafe", () => { // biome-ignore lint/suspicious/noExplicitAny: test const arr: any[] = [1, 2, 3]; arr.push(arr); - const result = jsonStringifySafe(arr); + const result = jsonStringify(arr); expect(result).toBe('[1,2,3,"[Circular]"]'); }); @@ -37,7 +37,7 @@ suite("jsonStringifySafe", () => { const obj: any = { name: "root" }; obj.child1 = { name: "child1", parent: obj }; obj.child2 = { name: "child2", parent: obj }; - const result = jsonStringifySafe(obj); + const result = jsonStringify(obj); expect(result).toBe( '{"name":"root","child1":{"name":"child1","parent":"[Circular]"},"child2":{"name":"child2","parent":"[Circular]"}}', ); @@ -50,7 +50,7 @@ suite("jsonStringifySafe", () => { num: 42, str: "hello", }; - const result = jsonStringifySafe(obj); + const result = jsonStringify(obj); expect(result).toBe('{"num":42,"str":"hello"}'); }); @@ -60,14 +60,14 @@ suite("jsonStringifySafe", () => { b: undefined, c: "test", }; - const result = jsonStringifySafe(obj); + const result = jsonStringify(obj); expect(result).toBe('{"a":null,"c":"test"}'); }); test("should handle Date objects", () => { const date = new Date("2023-01-01T00:00:00Z"); const obj = { date }; - const result = jsonStringifySafe(obj); + const result = jsonStringify(obj); expect(result).toBe(`{"date":"${date.toISOString()}"}`); }); @@ -78,7 +78,7 @@ suite("jsonStringifySafe", () => { toJSON: () => "custom", }, }; - const result = jsonStringifySafe(obj); + const result = jsonStringify(obj); expect(result).toBe('{"a":1,"b":"custom"}'); }); @@ -86,7 +86,7 @@ suite("jsonStringifySafe", () => { // biome-ignore lint/suspicious/noExplicitAny: test const obj: any = { level1: { level2: { level3: {} } } }; obj.level1.level2.level3.self = obj.level1; - const result = jsonStringifySafe(obj); + const result = jsonStringify(obj); expect(result).toBe( '{"level1":{"level2":{"level3":{"self":"[Circular]"}}}}', ); @@ -97,7 +97,7 @@ suite("jsonStringifySafe", () => { const obj: any = { a: 1, b: 2 }; obj.self = obj; obj.nested = { parent: obj }; - const result = jsonStringifySafe(obj); + const result = jsonStringify(obj); expect(result).toBe( '{"a":1,"b":2,"self":"[Circular]","nested":{"parent":"[Circular]"}}', ); @@ -110,7 +110,7 @@ suite("jsonStringifySafe", () => { const obj2: any = { name: "obj2", ref: obj1 }; obj1.ref = obj2; const arr = [obj1, obj2]; - const result = jsonStringifySafe(arr); + const result = jsonStringify(arr); expect(result).toBe( '[{"name":"obj1","ref":{"name":"obj2","ref":"[Circular]"}},{"name":"obj2","ref":{"name":"obj1","ref":"[Circular]"}}]', ); @@ -126,7 +126,7 @@ suite("jsonStringifySafe", () => { nested: { a: "b" }, }; obj.self = obj; - const result = jsonStringifySafe(obj); + const result = jsonStringify(obj); expect(result).toBe( '{"num":1,"str":"test","bool":true,"arr":[1,2,3],"nested":{"a":"b"},"self":"[Circular]"}', ); @@ -138,7 +138,7 @@ suite("jsonStringifySafe", () => { value: 2, enumerable: false, }); - const result = jsonStringifySafe(obj); + const result = jsonStringify(obj); expect(result).toBe('{"a":1}'); }); test("should handle objects with symbol properties", () => { @@ -146,7 +146,7 @@ suite("jsonStringifySafe", () => { // biome-ignore lint/suspicious/noExplicitAny: test const obj: any = { a: 1 }; obj[sym] = 2; - const result = jsonStringifySafe(obj); + const result = jsonStringify(obj); expect(result).toBe('{"a":1}'); }); test("should handle objects with large depth", () => { @@ -155,7 +155,7 @@ suite("jsonStringifySafe", () => { return { next: createDeepObject(depth - 1) }; }; const obj = createDeepObject(1000); - const result = jsonStringifySafe(obj); + const result = jsonStringify(obj); expect(result).toBeDefined(); }); test("should handle objects with circular references in arrays", () => { @@ -163,7 +163,7 @@ suite("jsonStringifySafe", () => { const obj: any = { name: "root" }; const arr = [obj]; obj.arr = arr; - const result = jsonStringifySafe(obj); + const result = jsonStringify(obj); expect(result).toBe('{"name":"root","arr":["[Circular]"]}'); }); }); diff --git a/packages/misc-util/src/ecma/json/json-stringify.ts b/packages/misc-util/src/ecma/json/json-stringify.ts new file mode 100644 index 00000000..3b050e36 --- /dev/null +++ b/packages/misc-util/src/ecma/json/json-stringify.ts @@ -0,0 +1,29 @@ +import { + type JsonMakeAllReplacersFunctionOptions, + jsonMakeAllReplacersFunction, +} from "./replacers/all-replacers.js"; + +export type JsonStringifyOptions = JsonMakeAllReplacersFunctionOptions; + +/** + * Stringify a value to a JSON string, handling circular references, Error objects, and BigInt values gracefully. + * + * @param value The value to stringify. + * @param replacer A function that alters the behavior of the stringification process, or + * an array of String and Number objects that serve as a whitelist for selecting/filtering + * the properties of the value object to be included in the JSON string. If this value is null + * or not provided, all properties of the object are included in the resulting JSON string. + * @param space The number of spaces to use for indentation or a string to use as whitespace. + * @param options Options to customize the behavior of the stringification, such as handling circular references. + * @returns A JSON string representation of the value, or undefined if the value cannot be stringified. + */ +export function jsonStringify( + value: unknown, + replacer?: JsonReplacer, + space?: string | number, + options?: JsonStringifyOptions, +): string | undefined { + const allReplacers = jsonMakeAllReplacersFunction(replacer, options); + + return JSON.stringify(value, allReplacers, space); +} diff --git a/packages/misc-util/src/ecma/json/make-json-replacer-function.ts b/packages/misc-util/src/ecma/json/make-replacer-function.ts similarity index 96% rename from packages/misc-util/src/ecma/json/make-json-replacer-function.ts rename to packages/misc-util/src/ecma/json/make-replacer-function.ts index 72f588fa..cff67844 100644 --- a/packages/misc-util/src/ecma/json/make-json-replacer-function.ts +++ b/packages/misc-util/src/ecma/json/make-replacer-function.ts @@ -9,7 +9,7 @@ * @param userReplacer An optional user-defined replacer (function or property list). * @returns A combined JSON replacer function. */ -export function makeJsonReplacerFunction( +export function jsonMakeReplacerFunction( baseReplacerFunction: JsonReplacerFunction, userReplacer?: JsonReplacer, ): JsonReplacerFunction { diff --git a/packages/misc-util/src/ecma/json/replacers/all-replacers.ts b/packages/misc-util/src/ecma/json/replacers/all-replacers.ts new file mode 100644 index 00000000..219fe190 --- /dev/null +++ b/packages/misc-util/src/ecma/json/replacers/all-replacers.ts @@ -0,0 +1,20 @@ +import { jsonMakeBigIntReplacerFunction } from "./big-int.js"; +import { + type JsonMakeCircularReferenceReplacerFunctionOptions, + jsonMakeCircularReferenceReplacerFunction, +} from "./circular-reference.js"; +import { jsonMakeErrorReplacerFunction } from "./error.js"; + +export type JsonMakeAllReplacersFunctionOptions = { + circularReference?: JsonMakeCircularReferenceReplacerFunctionOptions; +}; + +export function jsonMakeAllReplacersFunction( + replacer?: JsonReplacer, + options?: JsonMakeAllReplacersFunctionOptions, +): JsonReplacerFunction { + return jsonMakeCircularReferenceReplacerFunction( + jsonMakeBigIntReplacerFunction(jsonMakeErrorReplacerFunction(replacer)), + options?.circularReference, + ); +} diff --git a/packages/misc-util/src/ecma/json/replacers/big-int.test.ts b/packages/misc-util/src/ecma/json/replacers/big-int.test.ts new file mode 100644 index 00000000..e50c69b0 --- /dev/null +++ b/packages/misc-util/src/ecma/json/replacers/big-int.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { jsonMakeBigIntReplacerFunction } from "./big-int.js"; + +describe("jsonMakeBigIntReplacerFunction", () => { + const replacer = jsonMakeBigIntReplacerFunction(); + + it("should convert BigInt within safe range to number", () => { + const bigIntValue = BigInt(42); + const result = replacer("key", bigIntValue); + expect(result).toBe(42); + }); + + it("should convert BigInt outside safe range to string", () => { + const bigIntValue = BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1); + const result = replacer("key", bigIntValue); + expect(result).toBe( + (BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1)).toString(), + ); + }); + + it("should convert negative BigInt within safe range to number", () => { + const bigIntValue = BigInt(-42); + const result = replacer("key", bigIntValue); + expect(result).toBe(-42); + }); + + it("should convert negative BigInt outside safe range to string", () => { + const bigIntValue = BigInt(Number.MIN_SAFE_INTEGER) - BigInt(1); + const result = replacer("key", bigIntValue); + expect(result).toBe( + (BigInt(Number.MIN_SAFE_INTEGER) - BigInt(1)).toString(), + ); + }); + + it("should leave non-BigInt values unchanged", () => { + const value = "test"; + const result = replacer("key", value); + expect(result).toBe(value); + }); +}); diff --git a/packages/misc-util/src/ecma/json/replacers/big-int.ts b/packages/misc-util/src/ecma/json/replacers/big-int.ts new file mode 100644 index 00000000..d0787928 --- /dev/null +++ b/packages/misc-util/src/ecma/json/replacers/big-int.ts @@ -0,0 +1,33 @@ +import { jsonMakeReplacerFunction } from "../make-replacer-function.js"; + +/** + * Create a JSON replacer function that converts BigInt values to either numbers + * (if within safe range) or strings. + * + * This function can be used as a base replacer to ensure that BigInt values are + * properly handled during JSON serialization, and can be combined with a user-defined + * replacer for additional customization. + * + * @param replacer An optional user-defined replacer (function or property list) to apply after the BigInt replacer. + * @returns A JSON replacer function that handles BigInt values and applies the user-defined replacer if provided. + */ +export function jsonMakeBigIntReplacerFunction( + replacer?: JsonReplacer, +): JsonReplacerFunction { + return jsonMakeReplacerFunction((_key, value) => { + if (value instanceof BigInt || typeof value === "bigint") { + const num = value.valueOf(); + + if ( + num >= BigInt(Number.MIN_SAFE_INTEGER) && + num <= BigInt(Number.MAX_SAFE_INTEGER) + ) { + return Number(num); + } + + return num.toString(); + } + + return value; + }, replacer); +} diff --git a/packages/misc-util/src/ecma/json/replacers/circular-reference.test.ts b/packages/misc-util/src/ecma/json/replacers/circular-reference.test.ts new file mode 100644 index 00000000..a327473c --- /dev/null +++ b/packages/misc-util/src/ecma/json/replacers/circular-reference.test.ts @@ -0,0 +1,77 @@ +/** biome-ignore-all lint/style/noNonNullAssertion: tests */ +import { describe, expect, it } from "vitest"; +import { jsonMakeCircularReferenceReplacerFunction } from "./circular-reference.js"; + +describe("jsonMakeCircularReferenceReplacerFunction", () => { + it("should replace circular references with the default placeholder", () => { + const replacer = jsonMakeCircularReferenceReplacerFunction(); + // biome-ignore lint/suspicious/noExplicitAny: test + const obj: any = { a: 1 }; + obj.self = obj; + const result = JSON.parse(JSON.stringify(obj, replacer)!); + expect(result).toEqual({ a: 1, self: "[Circular]" }); + }); + + it("should replace circular references with a custom placeholder", () => { + const replacer = jsonMakeCircularReferenceReplacerFunction(undefined, { + placeholder: "[CIRCULAR_REF]", + }); + // biome-ignore lint/suspicious/noExplicitAny: test + const obj: any = { a: 1 }; + obj.self = obj; + const result = JSON.parse(JSON.stringify(obj, replacer)!); + expect(result).toEqual({ a: 1, self: "[CIRCULAR_REF]" }); + }); + + it("should not modify non-circular objects", () => { + const replacer = jsonMakeCircularReferenceReplacerFunction(); + const obj = { a: 1, b: { c: 2 } }; + const result = JSON.parse(JSON.stringify(obj, replacer)!); + expect(result).toEqual(obj); + }); + + it("should work with nested circular references", () => { + const replacer = jsonMakeCircularReferenceReplacerFunction(); + // biome-ignore lint/suspicious/noExplicitAny: test + const obj: any = { a: 1 }; + obj.self = obj; + obj.nested = { parent: obj }; + const result = JSON.parse(JSON.stringify(obj, replacer)!); + expect(result).toEqual({ + a: 1, + self: "[Circular]", + nested: { parent: "[Circular]" }, + }); + }); + + it("should work with arrays containing circular references", () => { + const replacer = jsonMakeCircularReferenceReplacerFunction(); + // biome-ignore lint/suspicious/noExplicitAny: test + const arr: any[] = [1, 2]; + arr.push(arr); + const result = JSON.parse(JSON.stringify(arr, replacer)!); + expect(result).toEqual([1, 2, "[Circular]"]); + }); + + it("should work with complex objects containing circular references", () => { + const replacer = jsonMakeCircularReferenceReplacerFunction(); + // biome-ignore lint/suspicious/noExplicitAny: test + const obj: any = { name: "Alice" }; + obj.self = obj; + obj.friends = [{ name: "Bob", friend: obj }]; + const result = JSON.parse(JSON.stringify(obj, replacer)!); + expect(result).toEqual({ + name: "Alice", + self: "[Circular]", + friends: [{ name: "Bob", friend: "[Circular]" }], + }); + }); + + it("should not replace reference to the same object if it is not circular", () => { + const replacer = jsonMakeCircularReferenceReplacerFunction(); + const sharedObj = { value: 42 }; + const obj = { a: sharedObj, b: sharedObj }; + const result = JSON.parse(JSON.stringify(obj, replacer)!); + expect(result).toEqual({ a: { value: 42 }, b: { value: 42 } }); + }); +}); diff --git a/packages/misc-util/src/ecma/json/replacers/circular-reference.ts b/packages/misc-util/src/ecma/json/replacers/circular-reference.ts new file mode 100644 index 00000000..18261b09 --- /dev/null +++ b/packages/misc-util/src/ecma/json/replacers/circular-reference.ts @@ -0,0 +1,64 @@ +import { defaults } from "../../object/defaults.js"; +import { jsonMakeReplacerFunction } from "../make-replacer-function.js"; + +export type JsonMakeCircularReferenceReplacerFunctionOptions = { + placeholder?: string; +}; + +const JSON_MAKE_CIRCULAR_REFERENCE_REPLACER_FUNCTION_DEFAULT_OPTIONS: Required = + { + placeholder: "[Circular]", + }; + +/** + * Create a JSON replacer function that safely handles circular references by + * replacing them with a specified placeholder string. + * + * This function uses a WeakMap to track parent objects during serialization, + * allowing it to detect circular references and replace them with the provided + * placeholder. It can be combined with an optional user-defined replacer for + * additional customization. + * + * @param replacer An optional user-defined replacer (function or property list) to apply after the circular reference handling. + * @param options Options for configuring the circular safe replacer function, including the placeholder string to use for circular references. + * @returns A JSON replacer function that handles circular references and applies the user-defined replacer if provided. + */ +export function jsonMakeCircularReferenceReplacerFunction( + replacer?: JsonReplacer, + options?: JsonMakeCircularReferenceReplacerFunctionOptions, +): JsonReplacerFunction { + const { placeholder } = defaults( + options, + JSON_MAKE_CIRCULAR_REFERENCE_REPLACER_FUNCTION_DEFAULT_OPTIONS, + ); + + const parentsMap = new WeakMap(); + + function jsonReplacerCircularReplacer( + this: unknown, + _key: string, + value: unknown, + ): unknown { + if (typeof value === "object" && value !== null) { + const parent = this as object | null; + + if (typeof parent === "object" && parent !== null) { + let current: object | null | undefined = parent; + while (current) { + if (current === value) { + return placeholder; + } + current = parentsMap.get(current); + } + + if (!parentsMap.has(value)) { + parentsMap.set(value, parent); + } + } + } + + return value; + } + + return jsonMakeReplacerFunction(jsonReplacerCircularReplacer, replacer); +} diff --git a/packages/misc-util/src/ecma/json/json-serialize-error.test.ts b/packages/misc-util/src/ecma/json/replacers/error.test.ts similarity index 70% rename from packages/misc-util/src/ecma/json/json-serialize-error.test.ts rename to packages/misc-util/src/ecma/json/replacers/error.test.ts index 10cbfe14..6334ff7f 100644 --- a/packages/misc-util/src/ecma/json/json-serialize-error.test.ts +++ b/packages/misc-util/src/ecma/json/replacers/error.test.ts @@ -1,5 +1,6 @@ -import { expect, suite, test } from "vitest"; -import { jsonSerializeError } from "./json-serialize-error.js"; +/** biome-ignore-all lint/style/noNonNullAssertion: tests */ +import { describe, expect, it } from "vitest"; +import { jsonMakeErrorReplacerFunction } from "./error.js"; const error = new Error("Test error"); const nestedError = new Error("Test error with cause", { cause: error }); @@ -23,19 +24,21 @@ const suppressedError = new SuppressedError( "Suppressed error message", ); -suite("jsonSerializeError", () => { - test("should serialize a simple error", () => { - const serialized = jsonSerializeError(error); - expect(serialized).toEqual({ +describe("jsonMakeErrorReplacerFunction", () => { + const replacer = jsonMakeErrorReplacerFunction(); + + it("should serialize a simple error", () => { + const result = JSON.parse(JSON.stringify(error, replacer)!); + expect(result).toEqual({ name: "Error", message: "Test error", stack: expect.stringContaining("Error: Test error"), }); }); - test("should serialize an error with a cause", () => { - const serialized = jsonSerializeError(nestedError); - expect(serialized).toEqual({ + it("should serialize an error with a cause", () => { + const result = JSON.parse(JSON.stringify(nestedError, replacer)!); + expect(result).toEqual({ name: "Error", message: "Test error with cause", stack: expect.stringContaining("Error: Test error with cause"), @@ -46,9 +49,10 @@ suite("jsonSerializeError", () => { }, }); }); - test("should serialize an extended error with additional properties", () => { - const serialized = jsonSerializeError(extendedError); - expect(serialized).toEqual({ + + it("should serialize an extended error with additional properties", () => { + const result = JSON.parse(JSON.stringify(extendedError, replacer)!); + expect(result).toEqual({ name: "Error", message: "Extended error", stack: expect.stringContaining("Error: Extended error"), @@ -57,18 +61,18 @@ suite("jsonSerializeError", () => { }); }); - test("should serialize a custom error", () => { - const serialized = jsonSerializeError(customError); - expect(serialized).toEqual({ + it("should serialize a custom error", () => { + const result = JSON.parse(JSON.stringify(customError, replacer)!); + expect(result).toEqual({ name: "CustomError", message: "Custom error", stack: expect.stringContaining("CustomError: Custom error"), }); }); - test("should serialize an AggregateError with multiple errors", () => { - const serialized = jsonSerializeError(aggregateError); - expect(serialized).toEqual({ + it("should serialize an AggregateError with multiple errors", () => { + const result = JSON.parse(JSON.stringify(aggregateError, replacer)!); + expect(result).toEqual({ name: "AggregateError", message: "Aggregate error", stack: expect.stringContaining("AggregateError: Aggregate error"), @@ -104,9 +108,9 @@ suite("jsonSerializeError", () => { }); }); - test("should serialize a SuppressedError", () => { - const serialized = jsonSerializeError(suppressedError); - expect(serialized).toEqual({ + it("should serialize a SuppressedError", () => { + const result = JSON.parse(JSON.stringify(suppressedError, replacer)!); + expect(result).toEqual({ name: "SuppressedError", message: "Suppressed error message", stack: expect.stringContaining( @@ -130,14 +134,14 @@ suite("jsonSerializeError", () => { }); }); - test("should serialize a SuppressedError with custom errors", () => { + it("should serialize a SuppressedError with custom errors", () => { const customSuppressedError = new SuppressedError( customError, extendedError, "Custom suppressed", ); - const serialized = jsonSerializeError(customSuppressedError); - expect(serialized).toEqual({ + const result = JSON.parse(JSON.stringify(customSuppressedError, replacer)!); + expect(result).toEqual({ name: "SuppressedError", message: "Custom suppressed", stack: expect.stringContaining("SuppressedError: Custom suppressed"), diff --git a/packages/misc-util/src/ecma/json/replacers/error.ts b/packages/misc-util/src/ecma/json/replacers/error.ts new file mode 100644 index 00000000..13e323c5 --- /dev/null +++ b/packages/misc-util/src/ecma/json/replacers/error.ts @@ -0,0 +1,57 @@ +import type { IAggregateError } from "../../error/aggregate-error.js"; +import type { ISuppressedError } from "../../error/suppressed-error.js"; +import { jsonMakeReplacerFunction } from "../make-replacer-function.js"; + +/** + * Create a JSON replacer function that converts Error objects into plain objects + * containing their non-enumerable properties (name, message, stack, cause, etc.) + * along with any enumerable properties. + * + * This function can be used as a base replacer to ensure that Error objects are + * properly serialized during JSON serialization, and can be combined with a user-defined + * replacer for additional customization. + * + * @param replacer An optional user-defined replacer (function or property list) to apply after the Error replacer. + * @returns A JSON replacer function that handles Error objects and applies the user-defined replacer if provided. + */ +export function jsonMakeErrorReplacerFunction( + replacer?: JsonReplacer, +): JsonReplacerFunction { + return jsonMakeReplacerFunction((_key, value) => { + if (value instanceof Error) { + const { + // Error non-enumerable properties + name, + message, + stack, + cause, + + // AggregateError non-enumerable properties + errors, + + // SuppressedError non-enumerable properties + suppressed, + error, + + // Other enumerable properties + ...restError + } = value as Error & + Partial & + Partial & + Record; + + return { + name, + message, + stack, + cause, + errors, + suppressed, + error, + ...restError, + }; + } + + return value; + }, replacer); +} diff --git a/packages/misc-util/src/ecma/number/to-fixed-length.ts b/packages/misc-util/src/ecma/number/to-fixed-length.ts index 4217c828..79c7a17d 100644 --- a/packages/misc-util/src/ecma/number/to-fixed-length.ts +++ b/packages/misc-util/src/ecma/number/to-fixed-length.ts @@ -1,5 +1,5 @@ -import { clamp } from "../math/clamp.js"; -import { type RoundingMethod, round } from "../math/round.js"; +import { clamp } from "../../math/clamp.js"; +import { type RoundingMethod, round } from "../../math/round.js"; import { defaults } from "../object/defaults.js"; export type ToFixedLengthOptions = { diff --git a/packages/misc-util/src/ecma/object/get-object-keys.test.ts b/packages/misc-util/src/ecma/object/get-object-keys.test.ts new file mode 100644 index 00000000..76f953bc --- /dev/null +++ b/packages/misc-util/src/ecma/object/get-object-keys.test.ts @@ -0,0 +1,485 @@ +import { describe, expect, it } from "vitest"; +import { getObjectKeys } from "./get-object-keys.js"; + +describe("getObjectKeys", () => { + describe("basic functionality", () => { + it("should return string keys from a simple object", () => { + const obj = { a: 1, b: 2, c: 3 }; + + const keys = getObjectKeys(obj, { + includeSymbolKeys: false, + includeNonEnumerable: false, + includePrototypeChain: false, + }); + + expect(keys).toEqual([ + { property: "a", prototype: obj }, + { property: "b", prototype: obj }, + { property: "c", prototype: obj }, + ]); + }); + + it("should return empty array for empty object", () => { + const obj = {}; + + const keys = getObjectKeys(obj, { + includeSymbolKeys: false, + includeNonEnumerable: false, + includePrototypeChain: false, + }); + + expect(keys).toEqual([]); + }); + + it("should respect object key order", () => { + const obj = { z: 1, a: 2, m: 3 }; + + const keys = getObjectKeys(obj, { + includeSymbolKeys: false, + includeNonEnumerable: false, + includePrototypeChain: false, + }); + + expect(keys.map((k) => k.property)).toEqual(["z", "a", "m"]); + }); + }); + + describe("includeSymbolKeys option", () => { + it("should include symbol keys when includeSymbolKeys is true", () => { + const sym1 = Symbol("sym1"); + const sym2 = Symbol("sym2"); + const obj = { a: 1, [sym1]: "value1", [sym2]: "value2" }; + + const keys = getObjectKeys(obj, { + includeSymbolKeys: true, + includeNonEnumerable: false, + includePrototypeChain: false, + }); + + expect(keys).toHaveLength(3); + expect(keys.filter((k) => typeof k.property === "symbol")).toHaveLength( + 2, + ); + expect(keys.some((k) => k.property === sym1)).toBe(true); + expect(keys.some((k) => k.property === sym2)).toBe(true); + }); + + it("should exclude symbol keys when includeSymbolKeys is false", () => { + const sym = Symbol("sym"); + const obj = { a: 1, [sym]: "value" }; + + const keys = getObjectKeys(obj, { + includeSymbolKeys: false, + includeNonEnumerable: false, + includePrototypeChain: false, + }); + + expect(keys).toEqual([{ property: "a", prototype: obj }]); + }); + + it("should handle well-known symbols", () => { + const obj = { [Symbol.iterator]: function* () {}, regular: 1 }; + + const keys = getObjectKeys(obj, { + includeSymbolKeys: true, + includeNonEnumerable: false, + includePrototypeChain: false, + }); + + expect(keys).toHaveLength(2); + expect(keys.some((k) => k.property === Symbol.iterator)).toBe(true); + }); + }); + + describe("includeNonEnumerable option", () => { + it("should include non-enumerable properties when includeNonEnumerable is true", () => { + const obj = { enumerable: 1 }; + Object.defineProperty(obj, "nonEnum", { + value: "hidden", + enumerable: false, + }); + + const keys = getObjectKeys(obj, { + includeSymbolKeys: false, + includeNonEnumerable: true, + includePrototypeChain: false, + }); + + expect(keys).toHaveLength(2); + expect(keys.find((k) => k.property === "enumerable")).toEqual({ + property: "enumerable", + prototype: obj, + }); + expect(keys.find((k) => k.property === "nonEnum")).toEqual({ + property: "nonEnum", + prototype: obj, + nonEnumerable: true, + }); + }); + + it("should exclude non-enumerable properties when includeNonEnumerable is false", () => { + const obj = { enumerable: 1 }; + Object.defineProperty(obj, "nonEnum", { + value: "hidden", + enumerable: false, + }); + + const keys = getObjectKeys(obj, { + includeSymbolKeys: false, + includeNonEnumerable: false, + includePrototypeChain: false, + }); + + expect(keys).toEqual([{ property: "enumerable", prototype: obj }]); + }); + + it("should handle multiple non-enumerable properties", () => { + const obj = { enumerable: 1 }; + Object.defineProperty(obj, "nonEnum1", { + value: "hidden1", + enumerable: false, + }); + Object.defineProperty(obj, "nonEnum2", { + value: "hidden2", + enumerable: false, + }); + + const keys = getObjectKeys(obj, { + includeSymbolKeys: false, + includeNonEnumerable: true, + includePrototypeChain: false, + }); + + expect(keys).toHaveLength(3); + expect(keys.filter((k) => k.nonEnumerable)).toHaveLength(2); + }); + }); + + describe("includePrototypeChain option", () => { + it("should include properties from prototype chain when includePrototypeChain is true", () => { + const parent = { parentProp: "parent" }; + const child = Object.create(parent); + child.childProp = "child"; + + const keys = getObjectKeys(child, { + includeSymbolKeys: false, + includeNonEnumerable: false, + includePrototypeChain: true, + }); + + expect(keys).toHaveLength(2); + expect(keys.some((k) => k.property === "childProp")).toBe(true); + expect(keys.some((k) => k.property === "parentProp")).toBe(true); + }); + + it("should exclude properties from prototype chain when includePrototypeChain is false", () => { + const parent = { parentProp: "parent" }; + const child = Object.create(parent); + child.childProp = "child"; + + const keys = getObjectKeys(child, { + includeSymbolKeys: false, + includeNonEnumerable: false, + includePrototypeChain: false, + }); + + expect(keys).toEqual([{ property: "childProp", prototype: child }]); + }); + + it("should handle multiple levels in prototype chain", () => { + const grandparent = { gpProp: "grandparent" }; + const parent = Object.create(grandparent); + parent.parentProp = "parent"; + const child = Object.create(parent); + child.childProp = "child"; + + const keys = getObjectKeys(child, { + includeSymbolKeys: false, + includeNonEnumerable: false, + includePrototypeChain: true, + }); + + expect(keys).toHaveLength(3); + expect(keys.some((k) => k.property === "childProp")).toBe(true); + expect(keys.some((k) => k.property === "parentProp")).toBe(true); + expect(keys.some((k) => k.property === "gpProp")).toBe(true); + }); + + it("should not include Object.prototype properties", () => { + const obj = { own: "value" }; + + const keys = getObjectKeys(obj, { + includeSymbolKeys: false, + includeNonEnumerable: false, + includePrototypeChain: true, + }); + + // Should not include toString, hasOwnProperty, etc. + expect( + keys.every( + (k) => typeof k.property === "string" && k.property === "own", + ), + ).toBe(true); + }); + }); + + describe("combined options", () => { + it("should handle all options enabled together", () => { + const parent = { parentProp: "parent" }; + const sym = Symbol("test"); + const child = Object.create(parent); + child.enumerable = "visible"; + child[sym] = "symbol value"; + Object.defineProperty(child, "nonEnum", { + value: "hidden", + enumerable: false, + }); + + const keys = getObjectKeys(child, { + includeSymbolKeys: true, + includeNonEnumerable: true, + includePrototypeChain: true, + }); + + expect(keys.length).toBeGreaterThanOrEqual(4); + expect(keys.some((k) => k.property === "enumerable")).toBe(true); + expect( + keys.some((k) => k.property === "nonEnum" && k.nonEnumerable), + ).toBe(true); + expect(keys.some((k) => k.property === sym)).toBe(true); + expect(keys.some((k) => k.property === "parentProp")).toBe(true); + }); + + it("should handle all options disabled together", () => { + const parent = { parentProp: "parent" }; + const sym = Symbol("test"); + const child = Object.create(parent); + child.enumerable = "visible"; + child[sym] = "symbol value"; + Object.defineProperty(child, "nonEnum", { + value: "hidden", + enumerable: false, + }); + + const keys = getObjectKeys(child, { + includeSymbolKeys: false, + includeNonEnumerable: false, + includePrototypeChain: false, + }); + + expect(keys).toEqual([{ property: "enumerable", prototype: child }]); + }); + }); + + describe("edge cases", () => { + it("should handle objects with numeric keys", () => { + const obj = { 0: "zero", 1: "one", 10: "ten", a: "letter" }; + + const keys = getObjectKeys(obj, { + includeSymbolKeys: false, + includeNonEnumerable: false, + includePrototypeChain: false, + }); + + // Numeric keys are sorted first + expect(keys.map((k) => k.property)).toEqual(["0", "1", "10", "a"]); + }); + + it("should handle arrays", () => { + const arr = [10, 20, 30]; + + const keys = getObjectKeys(arr, { + includeSymbolKeys: false, + includeNonEnumerable: false, + includePrototypeChain: false, + }); + + expect(keys.map((k) => k.property)).toEqual(["0", "1", "2"]); + }); + + it("should handle sparse arrays", () => { + // biome-ignore lint/suspicious/noSparseArray: test + const sparse = [1, , 3]; + + const keys = getObjectKeys(sparse, { + includeSymbolKeys: false, + includeNonEnumerable: false, + includePrototypeChain: false, + }); + + // Holes are not enumerable + expect(keys.map((k) => k.property)).toEqual(["0", "2"]); + }); + + it("should handle objects created with Object.create(null)", () => { + const obj = Object.create(null); + obj.prop = "value"; + + const keys = getObjectKeys(obj, { + includeSymbolKeys: false, + includeNonEnumerable: false, + includePrototypeChain: true, + }); + + expect(keys).toEqual([{ property: "prop", prototype: obj }]); + }); + + it("should handle frozen objects", () => { + const obj = Object.freeze({ a: 1, b: 2 }); + + const keys = getObjectKeys(obj, { + includeSymbolKeys: false, + includeNonEnumerable: false, + includePrototypeChain: false, + }); + + expect(keys).toHaveLength(2); + }); + + it("should handle sealed objects", () => { + const obj = Object.seal({ a: 1, b: 2 }); + + const keys = getObjectKeys(obj, { + includeSymbolKeys: false, + includeNonEnumerable: false, + includePrototypeChain: false, + }); + + expect(keys).toHaveLength(2); + }); + + it("should handle objects with only symbol keys", () => { + const sym1 = Symbol("1"); + const sym2 = Symbol("2"); + const obj = { [sym1]: "a", [sym2]: "b" }; + + const keys = getObjectKeys(obj, { + includeSymbolKeys: true, + includeNonEnumerable: false, + includePrototypeChain: false, + }); + + expect(keys).toHaveLength(2); + expect(keys.every((k) => typeof k.property === "symbol")).toBe(true); + }); + + it("should handle getters and setters", () => { + const obj = { + _value: 0, + get value() { + return this._value; + }, + set value(v) { + this._value = v; + }, + }; + + const keys = getObjectKeys(obj, { + includeSymbolKeys: false, + includeNonEnumerable: false, + includePrototypeChain: false, + }); + + expect(keys.map((k) => k.property)).toContain("_value"); + expect(keys.map((k) => k.property)).toContain("value"); + }); + + it("should handle class instances", () => { + class TestClass { + public instanceProp = "instance"; + // biome-ignore lint/correctness/noUnusedPrivateClassMembers: test + private privateProp = "private"; + + method() { + return "method"; + } + } + + const instance = new TestClass(); + + const keys = getObjectKeys(instance, { + includeSymbolKeys: false, + includeNonEnumerable: false, + includePrototypeChain: false, + }); + + // Only enumerable own properties + expect(keys.map((k) => k.property)).toEqual( + expect.arrayContaining(["instanceProp", "privateProp"]), + ); + }); + + it("should handle Date objects", () => { + const date = new Date(); + + const keys = getObjectKeys(date, { + includeSymbolKeys: false, + includeNonEnumerable: false, + includePrototypeChain: false, + }); + + // Date objects have no enumerable own properties + expect(keys).toEqual([]); + }); + + it("should handle RegExp objects", () => { + const regex = /test/gi; + + const keys = getObjectKeys(regex, { + includeSymbolKeys: false, + includeNonEnumerable: false, + includePrototypeChain: false, + }); + + // RegExp objects have no enumerable own properties by default + expect(keys).toEqual([]); + }); + + it("should handle Map objects", () => { + const map = new Map([ + ["a", 1], + ["b", 2], + ]); + + const keys = getObjectKeys(map, { + includeSymbolKeys: false, + includeNonEnumerable: false, + includePrototypeChain: false, + }); + + // Map has no enumerable own properties (entries are internal) + expect(keys).toEqual([]); + }); + + it("should handle Set objects", () => { + const set = new Set([1, 2, 3]); + + const keys = getObjectKeys(set, { + includeSymbolKeys: false, + includeNonEnumerable: false, + includePrototypeChain: false, + }); + + // Set has no enumerable own properties + expect(keys).toEqual([]); + }); + + it("should preserve the prototype reference for each key", () => { + const parent = { parentProp: "parent" }; + const child = Object.create(parent); + child.childProp = "child"; + + const keys = getObjectKeys(child, { + includeSymbolKeys: false, + includeNonEnumerable: false, + includePrototypeChain: true, + }); + + const childKey = keys.find((k) => k.property === "childProp"); + const parentKey = keys.find((k) => k.property === "parentProp"); + + expect(childKey?.prototype).toBe(child); + expect(parentKey?.prototype).toBe(parent); + }); + }); +}); diff --git a/packages/misc-util/src/ecma/object/get-object-keys.ts b/packages/misc-util/src/ecma/object/get-object-keys.ts new file mode 100644 index 00000000..ff31efa2 --- /dev/null +++ b/packages/misc-util/src/ecma/object/get-object-keys.ts @@ -0,0 +1,77 @@ +export type GetObjectKeysOptions = { + /** + * Include symbol keys + */ + includeSymbolKeys?: boolean; + + /** + * Include non-enumerable keys + */ + includeNonEnumerable?: boolean; + + /** + * Include keys from the prototype chain + */ + includePrototypeChain?: boolean; +}; + +/** + * + */ +export type ObjectKey = { + property: string | symbol; + prototype: unknown; + nonEnumerable?: boolean; +}; + +/** + * Get the keys of an object according to the specified options + * + * @param object The object to get the keys from + * @param options The options for getting the keys + * @returns The keys of the object + */ +export function getObjectKeys( + object: object, + options: Required, +): ObjectKey[] { + const keys: ObjectKey[] = []; + + keys.push( + ...Object.keys(object).map((key) => ({ property: key, prototype: object })), + ); + + if (options.includeNonEnumerable) { + const allKeys = Object.getOwnPropertyNames(object); + const enumerableKeys = new Set(Object.keys(object)); + const nonEnumerableKeys = allKeys.filter((key) => !enumerableKeys.has(key)); + keys.push( + ...nonEnumerableKeys.map((key) => ({ + property: key, + prototype: object, + nonEnumerable: true, + })), + ); + } + + if (options.includeSymbolKeys) { + keys.push( + ...Object.getOwnPropertySymbols(object).map((key) => ({ + property: key, + prototype: object, + })), + ); + } + + if (options.includePrototypeChain) { + let proto = Object.getPrototypeOf(object); + while (proto && proto !== Object.prototype) { + keys.push( + ...getObjectKeys(proto, { ...options, includePrototypeChain: false }), + ); + proto = Object.getPrototypeOf(proto); + } + } + + return keys; +} diff --git a/packages/misc-util/src/ecma/object/traverse.test.ts b/packages/misc-util/src/ecma/object/traverse.test.ts new file mode 100644 index 00000000..22e0f059 --- /dev/null +++ b/packages/misc-util/src/ecma/object/traverse.test.ts @@ -0,0 +1,3803 @@ +import { describe, expect, it, vi } from "vitest"; +import { + TraverseBreak, + TraverseContinue, + TraverseHalt, + TraverseSkip, + type TraverseVisitor, + traverse, +} from "./traverse.js"; + +const TEST_ARRAY = [10, 20, [30, 40], 50]; +const TEST_SYMBOL = Symbol("test symbol"); +const TEST_OBJECT = { + a: 1, + b: { b1: 21, b2: 22 }, + c: 3, + [TEST_SYMBOL]: "symbol value", +}; +Object.defineProperty(TEST_OBJECT, "nonEnum", { + value: "hidden", + enumerable: false, +}); + +const TEST_OBJECT_PROTO_OBJECT = { + protoProp: "protoValue", +}; + +const TEST_OBJECT_PROTO = Object.create(TEST_OBJECT_PROTO_OBJECT, { + enumProp: { + value: "enumValue", + enumerable: true, + }, + nonEnumProp: { + value: "nonEnumValue", + enumerable: false, + }, +}); +const TEST_MAP = new Map>([ + ["key1", 100], + ["key2", 200], + [ + "key3", + new Map<[number, number], string>([ + [[11, 12], "one"], + [[21, 22], "two"], + ]), + ], + ["key4", 400], +]); + +const TEST_SET = new Set>([ + 10, + 20, + new Set([1, 2]), + 30, +]); + +describe("traverse", () => { + describe("control flow", () => { + describe("TraverseBreak", () => { + it("should stop processing siblings in arrays", () => { + const array = TEST_ARRAY; + + const visitor = vi.fn().mockImplementation((value) => { + if (value === 20) { + return TraverseBreak; + } + return TraverseContinue; + }); + + traverse(array, visitor, { traverseArrays: true }); + + expect(visitor).toHaveBeenCalledTimes(3); + expect(visitor).toHaveBeenNthCalledWith( + 1, + array, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 10, + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 0 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + 20, + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 1 }, + parentPath: [], + }), + ); + }); + + it("should only stop current nesting level in arrays", () => { + const array = TEST_ARRAY; + + const visitor = vi.fn().mockImplementation((value) => { + if (value === 30) { + return TraverseBreak; + } + return TraverseContinue; + }); + + traverse(array, visitor, { traverseArrays: true }); + + expect(visitor).toHaveBeenCalledTimes(6); + expect(visitor).toHaveBeenNthCalledWith( + 1, + array, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 10, + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 0 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + 20, + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 1 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 4, + array[2], + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 2 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 5, + 30, + expect.objectContaining({ + parent: array[2], + key: { kind: "array", index: 0 }, + parentPath: [{ kind: "array", index: 2 }], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 6, + 50, + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 3 }, + parentPath: [], + }), + ); + }); + + it("should stop processing siblings in objects", () => { + const object = TEST_OBJECT; + + const visitor = vi.fn().mockImplementation((value) => { + if (value === object.b) { + return TraverseBreak; + } + return TraverseContinue; + }); + + traverse(object, visitor, {}); + + expect(visitor).toHaveBeenCalledTimes(3); + expect(visitor).toHaveBeenNthCalledWith( + 1, + object, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 1, + expect.objectContaining({ + parent: object, + key: { kind: "object", property: "a", prototype: object }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + object.b, + expect.objectContaining({ + parent: object, + key: { kind: "object", property: "b", prototype: object }, + parentPath: [], + }), + ); + }); + + it("should only stop current nesting level in objects", () => { + const object = TEST_OBJECT; + + const visitor = vi.fn().mockImplementation((value) => { + if (value === object.b.b1) { + return TraverseBreak; + } + return TraverseContinue; + }); + + traverse(object, visitor, {}); + + expect(visitor).toHaveBeenCalledTimes(5); + expect(visitor).toHaveBeenNthCalledWith( + 1, + object, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 1, + expect.objectContaining({ + parent: object, + key: { kind: "object", property: "a", prototype: object }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + object.b, + expect.objectContaining({ + parent: object, + key: { kind: "object", property: "b", prototype: object }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 4, + 21, + expect.objectContaining({ + parent: object.b, + key: { kind: "object", property: "b1", prototype: object.b }, + parentPath: [{ kind: "object", property: "b", prototype: object }], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 5, + 3, + expect.objectContaining({ + parent: object, + key: { kind: "object", property: "c", prototype: object }, + parentPath: [], + }), + ); + }); + + it("should stop processing siblings in Maps", () => { + const map = TEST_MAP; + + const visitor = vi.fn().mockImplementation((value) => { + if (value === 200) { + return TraverseBreak; + } + return TraverseContinue; + }); + + traverse(map, visitor); + + expect(visitor).toHaveBeenCalledTimes(3); + expect(visitor).toHaveBeenNthCalledWith( + 1, + map, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 100, + expect.objectContaining({ + parent: map, + key: { kind: "map", key: "key1" }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + 200, + expect.objectContaining({ + parent: map, + key: { kind: "map", key: "key2" }, + parentPath: [], + }), + ); + }); + + it("should only stop current nesting level in Maps", () => { + const map = TEST_MAP; + const nestedMap = map.get("key3") as Map<[number, number], string>; + + const visitor = vi.fn().mockImplementation((value) => { + if (value === "one") { + return TraverseBreak; + } + return TraverseContinue; + }); + + traverse(map, visitor); + + expect(visitor).toHaveBeenCalledTimes(6); + expect(visitor).toHaveBeenNthCalledWith( + 1, + map, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 100, + expect.objectContaining({ + parent: map, + key: { kind: "map", key: "key1" }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + 200, + expect.objectContaining({ + parent: map, + key: { kind: "map", key: "key2" }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 4, + nestedMap, + expect.objectContaining({ + parent: map, + key: { kind: "map", key: "key3" }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 5, + "one", + expect.objectContaining({ + parent: nestedMap, + key: { kind: "map", key: [11, 12] }, + parentPath: [{ kind: "map", key: "key3" }], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 6, + 400, + expect.objectContaining({ + parent: map, + key: { kind: "map", key: "key4" }, + parentPath: [], + }), + ); + }); + + it("should stop processing siblings in Sets", () => { + const set = TEST_SET; + + const visitor = vi.fn().mockImplementation((value) => { + if (value === 20) { + return TraverseBreak; + } + return TraverseContinue; + }); + + traverse(set, visitor); + + expect(visitor).toHaveBeenCalledTimes(3); + expect(visitor).toHaveBeenNthCalledWith( + 1, + set, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 10, + expect.objectContaining({ + parent: set, + key: { kind: "set", value: 10 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + 20, + expect.objectContaining({ + parent: set, + key: { kind: "set", value: 20 }, + parentPath: [], + }), + ); + }); + + it("should only stop current nesting level in Sets", () => { + const set = TEST_SET; + const nestedSet = Array.from(set.values()).find( + (v) => v instanceof Set, + ) as Set; + + const visitor = vi.fn().mockImplementation((value) => { + if (value === 1) { + return TraverseBreak; + } + return TraverseContinue; + }); + + traverse(set, visitor); + + expect(visitor).toHaveBeenCalledTimes(6); + expect(visitor).toHaveBeenNthCalledWith( + 1, + set, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 10, + expect.objectContaining({ + parent: set, + key: { kind: "set", value: 10 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + 20, + expect.objectContaining({ + parent: set, + key: { kind: "set", value: 20 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 4, + nestedSet, + expect.objectContaining({ + parent: set, + key: { kind: "set", value: nestedSet }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 5, + 1, + expect.objectContaining({ + parent: nestedSet, + key: { kind: "set", value: 1 }, + parentPath: [{ kind: "set", value: nestedSet }], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 6, + 30, + expect.objectContaining({ + parent: set, + key: { kind: "set", value: 30 }, + parentPath: [], + }), + ); + }); + }); + + describe("TraverseHalt", () => { + it("should halt entire traversal in arrays", () => { + const array = TEST_ARRAY; + + const visitor = vi.fn().mockImplementation((value) => { + if (value === 30) { + return TraverseHalt; + } + return TraverseContinue; + }); + + traverse(array, visitor, { traverseArrays: true }); + + expect(visitor).toHaveBeenCalledTimes(5); + expect(visitor).toHaveBeenNthCalledWith( + 1, + array, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 10, + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 0 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + 20, + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 1 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 4, + array[2], + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 2 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 5, + 30, + expect.objectContaining({ + parent: array[2], + key: { kind: "array", index: 0 }, + parentPath: [{ kind: "array", index: 2 }], + }), + ); + }); + + it("should halt entire traversal in objects", () => { + const object = TEST_OBJECT; + + const visitor = vi.fn().mockImplementation((value) => { + if (value === object.b.b1) { + return TraverseHalt; + } + return TraverseContinue; + }); + + traverse(object, visitor, {}); + + expect(visitor).toHaveBeenCalledTimes(4); + expect(visitor).toHaveBeenNthCalledWith( + 1, + object, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 1, + expect.objectContaining({ + parent: object, + key: { kind: "object", property: "a", prototype: object }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + object.b, + expect.objectContaining({ + parent: object, + key: { kind: "object", property: "b", prototype: object }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 4, + 21, + expect.objectContaining({ + parent: object.b, + key: { kind: "object", property: "b1", prototype: object.b }, + parentPath: [{ kind: "object", property: "b", prototype: object }], + }), + ); + }); + + it("should halt entire traversal in Maps", () => { + const map = TEST_MAP; + const nestedMap = map.get("key3") as Map<[number, number], string>; + + const visitor = vi.fn().mockImplementation((value) => { + if (value === "one") { + return TraverseHalt; + } + return TraverseContinue; + }); + + traverse(map, visitor); + + expect(visitor).toHaveBeenCalledTimes(5); + expect(visitor).toHaveBeenNthCalledWith( + 1, + map, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 100, + expect.objectContaining({ + parent: map, + key: { kind: "map", key: "key1" }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + 200, + expect.objectContaining({ + parent: map, + key: { kind: "map", key: "key2" }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 4, + nestedMap, + expect.objectContaining({ + parent: map, + key: { kind: "map", key: "key3" }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 5, + "one", + expect.objectContaining({ + parent: nestedMap, + key: { kind: "map", key: [11, 12] }, + parentPath: [{ kind: "map", key: "key3" }], + }), + ); + }); + + it("should halt entire traversal in Sets", () => { + const set = TEST_SET; + const nestedSet = Array.from(set.values()).find( + (v) => v instanceof Set, + ) as Set; + + const visitor = vi.fn().mockImplementation((value) => { + if (value === 1) { + return TraverseHalt; + } + return TraverseContinue; + }); + + traverse(set, visitor); + + expect(visitor).toHaveBeenCalledTimes(5); + expect(visitor).toHaveBeenNthCalledWith( + 1, + set, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 10, + expect.objectContaining({ + parent: set, + key: { kind: "set", value: 10 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + 20, + expect.objectContaining({ + parent: set, + key: { kind: "set", value: 20 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 4, + nestedSet, + expect.objectContaining({ + parent: set, + key: { kind: "set", value: nestedSet }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 5, + 1, + expect.objectContaining({ + parent: nestedSet, + key: { kind: "set", value: 1 }, + parentPath: [{ kind: "set", value: nestedSet }], + }), + ); + }); + + it("should return partially mutated object at top level", () => { + const input = { a: 1, b: 2, c: 3, d: 4 }; + + const result = traverse(input, (value, context) => { + if (context.key?.kind === "object" && context.key.property === "c") { + return TraverseHalt; + } + if (typeof value === "number") { + context.replace(value * 10); + } + return TraverseContinue; + }); + + expect(result).toBe(input); // Same reference, mutated in-place + expect(result).toEqual({ a: 10, b: 20, c: 3, d: 4 }); // c was visited but not transformed, d not visited + }); + }); + + describe("TraverseSkip", () => { + it("should keep value and skip recursion", () => { + const input = { a: 1, b: { c: 2, d: 3 }, e: 4 }; + + const visitor = vi.fn().mockImplementation((value) => { + if (typeof value === "object" && value !== null && "c" in value) { + return TraverseSkip; + } + return TraverseContinue; + }); + + const result = traverse(input, visitor); + + expect(result).toBe(input); + expect(visitor).toHaveBeenCalledWith( + input, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenCalledWith(1, expect.anything()); + expect(visitor).toHaveBeenCalledWith(input.b, expect.anything()); + expect(visitor).not.toHaveBeenCalledWith(2, expect.anything()); + expect(visitor).not.toHaveBeenCalledWith(3, expect.anything()); + expect(visitor).toHaveBeenCalledWith(4, expect.anything()); + }); + + it("should work with arrays", () => { + const input = [1, [2, 3], 4]; + + const visitor = vi.fn().mockImplementation((value) => { + if (Array.isArray(value) && value.length === 2) { + return TraverseSkip; + } + return TraverseContinue; + }); + + const result = traverse(input, visitor, { traverseArrays: true }); + + expect(result).toBe(input); + expect(visitor).not.toHaveBeenCalledWith(2, expect.anything()); + expect(visitor).not.toHaveBeenCalledWith(3, expect.anything()); + }); + }); + + describe("TraverseContinue", () => { + it("should continue normal traversal", () => { + const input = { a: 1, b: { c: 2 } }; + const visitor = vi + .fn() + .mockReturnValue(TraverseContinue); + + traverse(input, visitor); + + expect(visitor).toHaveBeenCalledTimes(4); + }); + }); + + describe("TraverseRemove", () => { + it("should remove properties from objects", () => { + const input = { a: 1, b: 2, c: 3 }; + + const result = traverse(input, (value, context) => { + if (value === 2) { + context.remove(); + } + return TraverseContinue; + }); + + expect(result).toBe(input); + expect(result).toEqual({ a: 1, c: 3 }); + }); + + it("should remove items from arrays", () => { + const input = [1, 2, 3, 4]; + + const result = traverse(input, (value, context) => { + if (typeof value === "number" && value % 2 === 0) { + context.remove(); + } + return TraverseContinue; + }); + + expect(result).toBe(input); + expect(result).toEqual([1, 3]); + }); + + it("should remove entries from Maps", () => { + const input = new Map([ + ["a", 1], + ["b", 2], + ["c", 3], + ]); + + const result = traverse(input, (_, context) => { + if (context.key?.kind === "map" && context.key.key === "b") { + context.remove(); + } + return TraverseContinue; + }); + + expect(result).toBe(input); + expect(result).toEqual( + new Map([ + ["a", 1], + ["c", 3], + ]), + ); + }); + + it("should remove values from Sets", () => { + const input = new Set([1, 2, 3, 4]); + + const result = traverse(input, (value, context) => { + if (typeof value === "number" && value % 2 === 0) { + context.remove(); + } + return TraverseContinue; + }); + + expect(result).toBe(input); + expect(result).toEqual(new Set([1, 3])); + }); + + it("should remove nested structures", () => { + const input = { + users: [ + { name: "Alice", age: 30 }, + { name: "Bob", age: 25 }, + ], + }; + + const result = traverse(input, (value, context) => { + if ( + typeof value === "object" && + value !== null && + "name" in value && + value.name === "Alice" + ) { + context.remove(); + } + return TraverseContinue; + }); + + expect(result).toEqual({ + users: [{ name: "Bob", age: 25 }], + }); + }); + }); + }); + + describe("array", () => { + it("visits all items", () => { + const array = TEST_ARRAY; + + const visitor = vi + .fn() + .mockImplementation(() => TraverseContinue); + + traverse(array, visitor); + + expect(visitor).toHaveBeenCalledTimes(7); + expect(visitor).toHaveBeenNthCalledWith( + 1, + array, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 10, + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 0 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + 20, + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 1 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 4, + array[2], + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 2 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 5, + 30, + expect.objectContaining({ + parent: array[2], + key: { kind: "array", index: 0 }, + parentPath: [{ kind: "array", index: 2 }], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 6, + 40, + expect.objectContaining({ + parent: array[2], + key: { kind: "array", index: 1 }, + parentPath: [{ kind: "array", index: 2 }], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 7, + 50, + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 3 }, + parentPath: [], + }), + ); + }); + + it("skips items when visitArrayIndices is false", () => { + const array = TEST_ARRAY; + const visitor = vi + .fn() + .mockImplementation(() => TraverseContinue); + + traverse(array, visitor, { traverseArrays: false }); + + expect(visitor).toHaveBeenCalledTimes(1); + expect(visitor).toHaveBeenNthCalledWith( + 1, + array, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + }); + + it("skips primitive items when visitPrimitives is false", () => { + const array = TEST_ARRAY; + const visitor = vi + .fn() + .mockImplementation(() => TraverseContinue); + + traverse(array, visitor, { + traverseArrays: true, + visitPrimitives: false, + }); + + expect(visitor).toHaveBeenCalledTimes(2); + expect(visitor).toHaveBeenNthCalledWith( + 1, + array, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + array[2], + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 2 }, + parentPath: [], + }), + ); + }); + + it("should not visit sparse array indices", () => { + // biome-ignore lint/suspicious/noSparseArray: test + const array = [10, , 30]; // sparse array with a missing element at index 1 + + const visitor = vi + .fn() + .mockImplementation(() => TraverseContinue); + + traverse(array, visitor, { traverseArrays: true }); + + expect(visitor).toHaveBeenCalledTimes(3); + expect(visitor).toHaveBeenNthCalledWith( + 1, + array, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 10, + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 0 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + 30, + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 2 }, + parentPath: [], + }), + ); + }); + + it("respects TraverseBreak", () => { + const array = TEST_ARRAY; + + const visitor = vi.fn().mockImplementation((value) => { + if (value === 20) { + return TraverseBreak; + } + return TraverseContinue; + }); + + traverse(array, visitor, { traverseArrays: true }); + + expect(visitor).toHaveBeenCalledTimes(3); + expect(visitor).toHaveBeenNthCalledWith( + 1, + array, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 10, + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 0 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + 20, + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 1 }, + parentPath: [], + }), + ); + }); + + it("respects TraverseBreak in nested arrays", () => { + const array = TEST_ARRAY; + const visitor = vi.fn().mockImplementation((value) => { + if (value === 30) { + return TraverseBreak; + } + return TraverseContinue; + }); + + traverse(array, visitor, { traverseArrays: true }); + + expect(visitor).toHaveBeenCalledTimes(6); + expect(visitor).toHaveBeenNthCalledWith( + 1, + array, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 10, + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 0 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + 20, + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 1 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 4, + array[2], + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 2 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 5, + 30, + expect.objectContaining({ + parent: array[2], + key: { kind: "array", index: 0 }, + parentPath: [{ kind: "array", index: 2 }], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 6, + 50, + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 3 }, + parentPath: [], + }), + ); + }); + + it("respects TraverseHalt", () => { + const array = TEST_ARRAY; + + const visitor = vi.fn().mockImplementation((value) => { + if (value === 30) { + return TraverseHalt; + } + return TraverseContinue; + }); + + traverse(array, visitor, { traverseArrays: true }); + + expect(visitor).toHaveBeenCalledTimes(5); + expect(visitor).toHaveBeenNthCalledWith( + 1, + array, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 10, + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 0 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + 20, + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 1 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 4, + array[2], + expect.objectContaining({ + parent: array, + key: { kind: "array", index: 2 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 5, + 30, + expect.objectContaining({ + parent: array[2], + key: { kind: "array", index: 0 }, + parentPath: [{ kind: "array", index: 2 }], + }), + ); + }); + }); + + describe("object", () => { + it("visits all properties", () => { + const object = TEST_OBJECT; + + const visitor = vi + .fn() + .mockImplementation(() => TraverseContinue); + + traverse(object, visitor, {}); + + expect(visitor).toHaveBeenCalledTimes(6); + expect(visitor).toHaveBeenNthCalledWith( + 1, + object, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 1, + expect.objectContaining({ + parent: object, + key: { kind: "object", property: "a", prototype: object }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + object.b, + expect.objectContaining({ + parent: object, + key: { kind: "object", property: "b", prototype: object }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 4, + 21, + expect.objectContaining({ + parent: object.b, + key: { kind: "object", property: "b1", prototype: object.b }, + parentPath: [{ kind: "object", property: "b", prototype: object }], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 5, + 22, + expect.objectContaining({ + parent: object.b, + key: { kind: "object", property: "b2", prototype: object.b }, + parentPath: [{ kind: "object", property: "b", prototype: object }], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 6, + 3, + expect.objectContaining({ + parent: object, + key: { kind: "object", property: "c", prototype: object }, + parentPath: [], + }), + ); + }); + + it("skips primitive properties when visitPrimitives is false", () => { + const object = TEST_OBJECT; + const visitor = vi + .fn() + .mockImplementation(() => TraverseContinue); + + traverse(object, visitor, { visitPrimitives: false }); + + expect(visitor).toHaveBeenCalledTimes(2); + expect(visitor).toHaveBeenNthCalledWith( + 1, + object, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + object.b, + expect.objectContaining({ + parent: object, + key: { kind: "object", property: "b", prototype: object }, + parentPath: [], + }), + ); + }); + + it("visits non-enumerable properties when includeNonEnumerable is true", () => { + const object = TEST_OBJECT; + + const visitor = vi.fn().mockImplementation(() => { + // Transform non-enumerable property would fail due to read-only + // Just visit without transforming + return TraverseContinue; + }); + + traverse(object, visitor, { includeNonEnumerable: true }); + + expect(visitor).toHaveBeenCalledWith( + "hidden", + expect.objectContaining({ + parent: object, + key: { + kind: "object", + property: "nonEnum", + prototype: object, + nonEnumerable: true, + }, + parentPath: [], + }), + ); + }); + + it("visits symbol-keyed properties when includeSymbolKeys is true", () => { + const object = TEST_OBJECT; + + const visitor = vi + .fn() + .mockImplementation(() => TraverseContinue); + + traverse(object, visitor, { includeSymbolKeys: true }); + + expect(visitor).toHaveBeenCalledWith( + "symbol value", + expect.objectContaining({ + parent: object, + key: { kind: "object", property: TEST_SYMBOL, prototype: object }, + parentPath: [], + }), + ); + }); + + it("respects TraverseBreak", () => { + const object = TEST_OBJECT; + + const visitor = vi.fn().mockImplementation((value) => { + if (value === object.b) { + return TraverseBreak; + } + return TraverseContinue; + }); + + traverse(object, visitor, {}); + + expect(visitor).toHaveBeenCalledTimes(3); + expect(visitor).toHaveBeenNthCalledWith( + 1, + object, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 1, + expect.objectContaining({ + parent: object, + key: { kind: "object", property: "a", prototype: object }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + object.b, + expect.objectContaining({ + parent: object, + key: { kind: "object", property: "b", prototype: object }, + parentPath: [], + }), + ); + }); + + it("respects TraverseBreak in nested objects", () => { + const object = TEST_OBJECT; + + const visitor = vi.fn().mockImplementation((value) => { + if (value === 21) { + return TraverseBreak; + } + return TraverseContinue; + }); + + traverse(object, visitor, {}); + + expect(visitor).toHaveBeenCalledTimes(5); + expect(visitor).toHaveBeenNthCalledWith( + 1, + object, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 1, + expect.objectContaining({ + parent: object, + key: { kind: "object", property: "a", prototype: object }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + object.b, + expect.objectContaining({ + parent: object, + key: { kind: "object", property: "b", prototype: object }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 4, + 21, + expect.objectContaining({ + parent: object.b, + key: { kind: "object", property: "b1", prototype: object.b }, + parentPath: [{ kind: "object", property: "b", prototype: object }], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 5, + 3, + expect.objectContaining({ + parent: object, + key: { kind: "object", property: "c", prototype: object }, + parentPath: [], + }), + ); + }); + + it("respects TraverseHalt", () => { + const object = TEST_OBJECT; + + const visitor = vi.fn().mockImplementation((value) => { + if (value === 21) { + return TraverseHalt; + } + return TraverseContinue; + }); + + traverse(object, visitor, {}); + + expect(visitor).toHaveBeenCalledTimes(4); + expect(visitor).toHaveBeenNthCalledWith( + 1, + object, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 1, + expect.objectContaining({ + parent: object, + key: { kind: "object", property: "a", prototype: object }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + object.b, + expect.objectContaining({ + parent: object, + key: { kind: "object", property: "b", prototype: object }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 4, + 21, + expect.objectContaining({ + parent: object.b, + key: { kind: "object", property: "b1", prototype: object.b }, + parentPath: [{ kind: "object", property: "b", prototype: object }], + }), + ); + }); + }); + + describe("object proto", () => { + it("visits all properties", () => { + const object = TEST_OBJECT_PROTO; + + const visitor = vi.fn().mockImplementation(() => { + // Don't transform, just visit (properties are read-only) + return TraverseContinue; + }); + + traverse(object, visitor, { + traverseCustomObjects: (value) => + Object.getPrototypeOf(value) === TEST_OBJECT_PROTO_OBJECT, + }); + + expect(visitor).toHaveBeenCalledTimes(3); + expect(visitor).toHaveBeenNthCalledWith( + 1, + object, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + "enumValue", + expect.objectContaining({ + parent: object, + key: { kind: "object", property: "enumProp", prototype: object }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + "protoValue", + expect.objectContaining({ + parent: object, + key: { + kind: "object", + property: "protoProp", + prototype: TEST_OBJECT_PROTO_OBJECT, + }, + parentPath: [], + }), + ); + }); + + it("visits non-enumerable properties when includeNonEnumerable is true", () => { + const object = TEST_OBJECT_PROTO; + + const visitor = vi.fn().mockImplementation(() => { + // Don't transform, just visit (properties are read-only) + return TraverseContinue; + }); + + traverse(object, visitor, { + includeNonEnumerable: true, + traverseCustomObjects: (value) => + Object.getPrototypeOf(value) === TEST_OBJECT_PROTO_OBJECT, + }); + + expect(visitor).toHaveBeenCalledWith( + "nonEnumValue", + expect.objectContaining({ + parent: object, + key: { + kind: "object", + property: "nonEnumProp", + prototype: object, + nonEnumerable: true, + }, + parentPath: [], + }), + ); + }); + + it("skips prototype properties when includePrototypeChain is false", () => { + const object = TEST_OBJECT_PROTO; + + const visitor = vi.fn().mockImplementation(() => { + // Don't transform, just visit (properties are read-only) + return TraverseContinue; + }); + + traverse(object, visitor, { + includePrototypeChain: false, + traverseCustomObjects: (value) => + Object.getPrototypeOf(value) === TEST_OBJECT_PROTO_OBJECT, + }); + + expect(visitor).toHaveBeenCalledTimes(2); + expect(visitor).toHaveBeenNthCalledWith( + 1, + object, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + "enumValue", + expect.objectContaining({ + parent: object, + key: { kind: "object", property: "enumProp", prototype: object }, + parentPath: [], + }), + ); + }); + }); + + describe("Map", () => { + it("visits all entries", () => { + const map = TEST_MAP; + + const visitor = vi + .fn() + .mockImplementation(() => TraverseContinue); + + traverse(map, visitor); + + expect(visitor).toHaveBeenCalledTimes(7); + expect(visitor).toHaveBeenNthCalledWith( + 1, + map, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 100, + expect.objectContaining({ + parent: map, + key: { kind: "map", key: "key1" }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + 200, + expect.objectContaining({ + parent: map, + key: { kind: "map", key: "key2" }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 4, + map.get("key3"), + expect.objectContaining({ + parent: map, + key: { kind: "map", key: "key3" }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 5, + "one", + expect.objectContaining({ + parent: map.get("key3"), + key: { kind: "map", key: [11, 12] }, + parentPath: [{ kind: "map", key: "key3" }], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 6, + "two", + expect.objectContaining({ + parent: map.get("key3"), + key: { kind: "map", key: [21, 22] }, + parentPath: [{ kind: "map", key: "key3" }], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 7, + 400, + expect.objectContaining({ + parent: map, + key: { kind: "map", key: "key4" }, + parentPath: [], + }), + ); + }); + + it("visits map keys when visitMapKeys is true", () => { + const map = TEST_MAP; + + const visitor = vi + .fn() + .mockImplementation(() => TraverseContinue); + + traverse(map, visitor, { traverseMapKeys: true }); + + expect(visitor).toHaveBeenCalledTimes(17); + expect(visitor).toHaveBeenNthCalledWith( + 2, + "key1", + expect.objectContaining({ + parent: map, + key: { kind: "map-key", name: "key1" }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 4, + "key2", + expect.objectContaining({ + parent: map, + key: { kind: "map-key", name: "key2" }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 6, + "key3", + expect.objectContaining({ + parent: map, + key: { kind: "map-key", name: "key3" }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 8, + [11, 12], + expect.objectContaining({ + parent: map.get("key3"), + key: { kind: "map-key", name: [11, 12] }, + parentPath: [{ kind: "map", key: "key3" }], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 9, + 11, + expect.objectContaining({ + parent: [11, 12], + key: { kind: "array", index: 0 }, + parentPath: [ + { kind: "map", key: "key3" }, + { kind: "map-key", name: [11, 12] }, + ], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 10, + 12, + expect.objectContaining({ + parent: [11, 12], + key: { kind: "array", index: 1 }, + parentPath: [ + { kind: "map", key: "key3" }, + { kind: "map-key", name: [11, 12] }, + ], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 12, + [21, 22], + expect.objectContaining({ + parent: map.get("key3"), + key: { kind: "map-key", name: [21, 22] }, + parentPath: [{ kind: "map", key: "key3" }], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 13, + 21, + expect.objectContaining({ + parent: [21, 22], + key: { kind: "array", index: 0 }, + parentPath: [ + { kind: "map", key: "key3" }, + { kind: "map-key", name: [21, 22] }, + ], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 14, + 22, + expect.objectContaining({ + parent: [21, 22], + key: { kind: "array", index: 1 }, + parentPath: [ + { kind: "map", key: "key3" }, + { kind: "map-key", name: [21, 22] }, + ], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 16, + "key4", + expect.objectContaining({ + parent: map, + key: { kind: "map-key", name: "key4" }, + parentPath: [], + }), + ); + }); + + it("skips primitive entries when visitPrimitives is false", () => { + const map = TEST_MAP; + + const visitor = vi + .fn() + .mockImplementation(() => TraverseContinue); + + traverse(map, visitor, { visitPrimitives: false }); + + expect(visitor).toHaveBeenCalledTimes(2); + expect(visitor).toHaveBeenNthCalledWith( + 1, + map, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + map.get("key3"), + expect.objectContaining({ + parent: map, + key: { kind: "map", key: "key3" }, + parentPath: [], + }), + ); + }); + + it("respects TraverseBreak", () => { + const map = TEST_MAP; + + const visitor = vi.fn().mockImplementation((value) => { + if (value === map.get("key3")) { + return TraverseBreak; + } + return TraverseContinue; + }); + + traverse(map, visitor); + + expect(visitor).toHaveBeenCalledTimes(4); + expect(visitor).toHaveBeenNthCalledWith( + 1, + map, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 100, + expect.objectContaining({ + parent: map, + key: { kind: "map", key: "key1" }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + 200, + expect.objectContaining({ + parent: map, + key: { kind: "map", key: "key2" }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 4, + map.get("key3"), + expect.objectContaining({ + parent: map, + key: { kind: "map", key: "key3" }, + parentPath: [], + }), + ); + }); + + it("respects TraverseBreak in nested maps", () => { + const map = TEST_MAP; + + const visitor = vi.fn().mockImplementation((value) => { + if (value === "one") { + return TraverseBreak; + } + return TraverseContinue; + }); + + traverse(map, visitor); + + expect(visitor).toHaveBeenCalledTimes(6); + expect(visitor).toHaveBeenNthCalledWith( + 1, + map, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 100, + expect.objectContaining({ + parent: map, + key: { kind: "map", key: "key1" }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + 200, + expect.objectContaining({ + parent: map, + key: { kind: "map", key: "key2" }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 4, + map.get("key3"), + expect.objectContaining({ + parent: map, + key: { kind: "map", key: "key3" }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 5, + "one", + expect.objectContaining({ + parent: map.get("key3"), + key: { kind: "map", key: [11, 12] }, + parentPath: [{ kind: "map", key: "key3" }], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 6, + 400, + expect.objectContaining({ + parent: map, + key: { kind: "map", key: "key4" }, + parentPath: [], + }), + ); + }); + + it("respects TraverseHalt", () => { + const map = TEST_MAP; + + const visitor = vi.fn().mockImplementation((value) => { + if (value === "one") { + return TraverseHalt; + } + return TraverseContinue; + }); + + traverse(map, visitor); + + expect(visitor).toHaveBeenCalledTimes(5); + expect(visitor).toHaveBeenNthCalledWith( + 1, + map, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 100, + expect.objectContaining({ + parent: map, + key: { kind: "map", key: "key1" }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + 200, + expect.objectContaining({ + parent: map, + key: { kind: "map", key: "key2" }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 4, + map.get("key3"), + expect.objectContaining({ + parent: map, + key: { kind: "map", key: "key3" }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 5, + "one", + expect.objectContaining({ + parent: map.get("key3"), + key: { kind: "map", key: [11, 12] }, + parentPath: [{ kind: "map", key: "key3" }], + }), + ); + }); + }); + + describe("Set", () => { + it("visits all values", () => { + const set = TEST_SET; + + const visitor = vi + .fn() + .mockImplementation(() => TraverseContinue); + + traverse(set, visitor); + + expect(visitor).toHaveBeenCalledTimes(7); + expect(visitor).toHaveBeenNthCalledWith( + 1, + set, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 10, + expect.objectContaining({ + parent: set, + key: { kind: "set", value: 10 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + 20, + expect.objectContaining({ + parent: set, + key: { kind: "set", value: 20 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 4, + new Set([1, 2]), + expect.objectContaining({ + parent: set, + key: { kind: "set", value: new Set([1, 2]) }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 5, + 1, + expect.objectContaining({ + parent: new Set([1, 2]), + key: { kind: "set", value: 1 }, + parentPath: [{ kind: "set", value: new Set([1, 2]) }], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 6, + 2, + expect.objectContaining({ + parent: new Set([1, 2]), + key: { kind: "set", value: 2 }, + parentPath: [{ kind: "set", value: new Set([1, 2]) }], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 7, + 30, + expect.objectContaining({ + parent: set, + key: { kind: "set", value: 30 }, + parentPath: [], + }), + ); + }); + + it("skips set values when visitSetValues is false", () => { + const set = TEST_SET; + + const visitor = vi + .fn() + .mockImplementation(() => TraverseContinue); + + traverse(set, visitor, { traverseSets: false }); + + expect(visitor).toHaveBeenCalledTimes(1); + expect(visitor).toHaveBeenNthCalledWith( + 1, + set, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + }); + + it("skips primitive values when visitPrimitives is false", () => { + const set = TEST_SET; + + const visitor = vi + .fn() + .mockImplementation(() => TraverseContinue); + + traverse(set, visitor, { visitPrimitives: false }); + expect(visitor).toHaveBeenCalledTimes(2); + expect(visitor).toHaveBeenNthCalledWith( + 1, + set, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + new Set([1, 2]), + expect.objectContaining({ + parent: set, + key: { kind: "set", value: new Set([1, 2]) }, + parentPath: [], + }), + ); + }); + + it("respects TraverseBreak", () => { + const set = TEST_SET; + + const breakValue = Array.from(set)[2]; // the Set [1, 2] + const visitor = vi.fn().mockImplementation((value) => { + if (value === breakValue) { + return TraverseBreak; + } + return TraverseContinue; + }); + + traverse(set, visitor); + expect(visitor).toHaveBeenCalledTimes(4); + expect(visitor).toHaveBeenNthCalledWith( + 1, + set, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 10, + expect.objectContaining({ + parent: set, + key: { kind: "set", value: 10 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + 20, + expect.objectContaining({ + parent: set, + key: { kind: "set", value: 20 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 4, + new Set([1, 2]), + expect.objectContaining({ + parent: set, + key: { kind: "set", value: new Set([1, 2]) }, + parentPath: [], + }), + ); + }); + + it("respects TraverseBreak in nested sets", () => { + const set = TEST_SET; + + const visitor = vi.fn().mockImplementation((value) => { + if (value === 1) { + return TraverseBreak; + } + return TraverseContinue; + }); + + traverse(set, visitor); + expect(visitor).toHaveBeenCalledTimes(6); + expect(visitor).toHaveBeenNthCalledWith( + 1, + set, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 10, + expect.objectContaining({ + parent: set, + key: { kind: "set", value: 10 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + 20, + expect.objectContaining({ + parent: set, + key: { kind: "set", value: 20 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 4, + new Set([1, 2]), + expect.objectContaining({ + parent: set, + key: { kind: "set", value: new Set([1, 2]) }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 5, + 1, + expect.objectContaining({ + parent: new Set([1, 2]), + key: { kind: "set", value: 1 }, + parentPath: [{ kind: "set", value: new Set([1, 2]) }], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 6, + 30, + expect.objectContaining({ + parent: set, + key: { kind: "set", value: 30 }, + parentPath: [], + }), + ); + }); + + it("respects TraverseHalt", () => { + const set = TEST_SET; + + const visitor = vi.fn().mockImplementation((value) => { + if (value === 1) { + return TraverseHalt; + } + return TraverseContinue; + }); + + traverse(set, visitor); + expect(visitor).toHaveBeenCalledTimes(5); + expect(visitor).toHaveBeenNthCalledWith( + 1, + set, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + 10, + expect.objectContaining({ + parent: set, + key: { kind: "set", value: 10 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + 20, + expect.objectContaining({ + parent: set, + key: { kind: "set", value: 20 }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 4, + new Set([1, 2]), + expect.objectContaining({ + parent: set, + key: { kind: "set", value: new Set([1, 2]) }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 5, + 1, + expect.objectContaining({ + parent: new Set([1, 2]), + key: { kind: "set", value: 1 }, + parentPath: [{ kind: "set", value: new Set([1, 2]) }], + }), + ); + }); + }); + + describe("complex nested structures", () => { + it("visits all values", () => { + const complexObject = { + arr: [ + 1, + { + map: new Map([ + ["set", new Set([true, { val: "end" }])], + ]), + }, + ], + } as const; + + const visitor = vi + .fn() + .mockImplementation(() => TraverseContinue); + + traverse(complexObject, visitor); + + expect(visitor).toHaveBeenCalledTimes(9); + expect(visitor).toHaveBeenNthCalledWith( + 1, + complexObject, + expect.objectContaining({ + parent: undefined, + key: null, + parentPath: null, + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 2, + complexObject.arr, + expect.objectContaining({ + parent: complexObject, + key: { kind: "object", property: "arr", prototype: complexObject }, + parentPath: [], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 3, + 1, + expect.objectContaining({ + parent: complexObject.arr, + key: { kind: "array", index: 0 }, + parentPath: [ + { kind: "object", property: "arr", prototype: complexObject }, + ], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 4, + complexObject.arr[1], + expect.objectContaining({ + parent: complexObject.arr, + key: { kind: "array", index: 1 }, + parentPath: [ + { kind: "object", property: "arr", prototype: complexObject }, + ], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 5, + complexObject.arr[1].map, + expect.objectContaining({ + parent: complexObject.arr[1], + key: { + kind: "object", + property: "map", + prototype: complexObject.arr[1], + }, + parentPath: [ + { kind: "object", property: "arr", prototype: complexObject }, + { kind: "array", index: 1 }, + ], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 6, + complexObject.arr[1].map.get("set"), + expect.objectContaining({ + parent: complexObject.arr[1].map, + key: { kind: "map", key: "set" }, + parentPath: [ + { kind: "object", property: "arr", prototype: complexObject }, + { kind: "array", index: 1 }, + { + kind: "object", + property: "map", + prototype: complexObject.arr[1], + }, + ], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 7, + true, + expect.objectContaining({ + parent: complexObject.arr[1].map.get("set"), + key: { kind: "set", value: true }, + parentPath: [ + { kind: "object", property: "arr", prototype: complexObject }, + { kind: "array", index: 1 }, + { + kind: "object", + property: "map", + prototype: complexObject.arr[1], + }, + { kind: "map", key: "set" }, + ], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 8, + { val: "end" }, + expect.objectContaining({ + parent: complexObject.arr[1].map.get("set"), + key: { kind: "set", value: { val: "end" } }, + parentPath: [ + { kind: "object", property: "arr", prototype: complexObject }, + { kind: "array", index: 1 }, + { + kind: "object", + property: "map", + prototype: complexObject.arr[1], + }, + { kind: "map", key: "set" }, + ], + }), + ); + expect(visitor).toHaveBeenNthCalledWith( + 9, + "end", + expect.objectContaining({ + parent: { val: "end" }, + key: { kind: "object", property: "val", prototype: { val: "end" } }, + parentPath: [ + { kind: "object", property: "arr", prototype: complexObject }, + { kind: "array", index: 1 }, + { + kind: "object", + property: "map", + prototype: complexObject.arr[1], + }, + { kind: "map", key: "set" }, + { kind: "set", value: { val: "end" } }, + ], + }), + ); + }); + }); + + describe("value transformation", () => { + it("should replace and recurse into replacement value", () => { + const obj = { a: 1, b: { c: 2 } }; + const result = traverse(obj, (value, context) => { + if (value === 1) { + context.replace({ nested: 100 }); + } + if (value === 100) { + context.replace(1000); + } + return TraverseContinue; + }); + + expect(result).toEqual({ a: { nested: 1000 }, b: { c: 2 } }); + }); + + it("should transform arrays", () => { + const arr = [1, 2, 3, 4]; + const result = traverse(arr, (value, context) => { + if (typeof value === "number") { + context.replace(value * 2); + } + return TraverseContinue; + }); + + expect(result).toEqual([2, 4, 6, 8]); + }); + + it("should transform Maps", () => { + const map = new Map([ + ["a", 1], + ["b", 2], + ]); + const result = traverse(map, (value, context) => { + if (typeof value === "number") { + context.replace(value * 10); + } + return TraverseContinue; + }); + + expect(result).toEqual( + new Map([ + ["a", 10], + ["b", 20], + ]), + ); + }); + + it("should transform Sets", () => { + const set = new Set([1, 2, 3]); + const result = traverse(set, (value, context) => { + if (typeof value === "number") { + context.replace(value * 10); + } + return TraverseContinue; + }); + + expect(result).toEqual(new Set([10, 20, 30])); + }); + + it("should allow returning non-control symbols as values", () => { + const customSymbol = Symbol("custom"); + const obj = { a: 1 }; + const result = traverse(obj, (value, context) => { + if (value === 1) { + context.replace(customSymbol); + } + return TraverseContinue; + }); + + expect(result).toEqual({ a: customSymbol }); + }); + }); + + describe("container replacement", () => { + it("should replace array with object", () => { + const obj = { data: [1, 2, 3] }; + const visitor = vi.fn((value, context) => { + if (Array.isArray(value)) { + context.replace({ converted: "from array" }); + } + return TraverseContinue; + }); + + const result = traverse(obj, visitor); + + expect(result).toEqual({ data: { converted: "from array" } }); + // Replaced containers are not re-visited, but their contents are traversed + expect(visitor).toHaveBeenCalledWith("from array", expect.anything()); + }); + + it("should replace array with Map", () => { + const obj = { data: [1, 2, 3] }; + const visitor = vi.fn((value, context) => { + if (Array.isArray(value)) { + context.replace(new Map([["key", "value"]])); + } + return TraverseContinue; + }); + + const result = traverse(obj, visitor); + + expect(result).toEqual({ data: new Map([["key", "value"]]) }); + // Replaced containers are not re-visited, but their contents are traversed + expect(visitor).toHaveBeenCalledWith("value", expect.anything()); + }); + + it("should replace array with Set", () => { + const obj = { data: [1, 2, 3] }; + const visitor = vi.fn((value, context) => { + if (Array.isArray(value)) { + context.replace(new Set(["a", "b"])); + } + return TraverseContinue; + }); + + const result = traverse(obj, visitor); + + expect(result).toEqual({ data: new Set(["a", "b"]) }); + // Replaced containers are not re-visited, but their contents are traversed + expect(visitor).toHaveBeenCalledWith("a", expect.anything()); + expect(visitor).toHaveBeenCalledWith("b", expect.anything()); + }); + + it("should replace array with primitive", () => { + const obj = { data: [1, 2, 3] }; + const visitor = vi.fn((value, context) => { + if (Array.isArray(value)) { + context.replace("replaced"); + } + return TraverseContinue; + }); + + const result = traverse(obj, visitor); + + expect(result).toEqual({ data: "replaced" }); + // Should NOT visit the replaced primitive value by default (visitPrimitive: false) + expect(visitor).not.toHaveBeenCalledWith("replaced", expect.anything()); + }); + + it("should replace object with array", () => { + const obj = { data: { a: 1, b: 2 } }; + const visitor = vi.fn((_, context) => { + if (context.key?.kind === "object" && context.key.property === "data") { + context.replace([10, 20, 30]); + } + return TraverseContinue; + }); + + const result = traverse(obj, visitor); + + expect(result).toEqual({ data: [10, 20, 30] }); + // Replaced containers are not re-visited, but their contents are traversed + expect(visitor).toHaveBeenCalledWith(10, expect.anything()); + expect(visitor).toHaveBeenCalledWith(20, expect.anything()); + expect(visitor).toHaveBeenCalledWith(30, expect.anything()); + }); + + it("should replace object with Map", () => { + const obj = { data: { a: 1, b: 2 } }; + const visitor = vi.fn((_, context) => { + if (context.key?.kind === "object" && context.key.property === "data") { + context.replace(new Map([["x", 100]])); + } + return TraverseContinue; + }); + + const result = traverse(obj, visitor); + + expect(result).toEqual({ data: new Map([["x", 100]]) }); + // Replaced containers are not re-visited, but their contents are traversed + expect(visitor).toHaveBeenCalledWith(100, expect.anything()); + }); + + it("should replace object with Set", () => { + const obj = { data: { a: 1, b: 2 } }; + const visitor = vi.fn((_, context) => { + if (context.key?.kind === "object" && context.key.property === "data") { + context.replace(new Set([1, 2, 3])); + } + return TraverseContinue; + }); + + const result = traverse(obj, visitor); + + expect(result).toEqual({ data: new Set([1, 2, 3]) }); + // Replaced containers are not re-visited, but their contents are traversed + expect(visitor).toHaveBeenCalledWith(1, expect.anything()); + expect(visitor).toHaveBeenCalledWith(2, expect.anything()); + expect(visitor).toHaveBeenCalledWith(3, expect.anything()); + }); + + it("should replace object with primitive", () => { + const obj = { data: { a: 1, b: 2 } }; + const visitor = vi.fn((_, context) => { + if (context.key?.kind === "object" && context.key.property === "data") { + context.replace(42); + } + return TraverseContinue; + }); + + const result = traverse(obj, visitor); + + expect(result).toEqual({ data: 42 }); + // Should NOT visit the replaced primitive value by default (visitPrimitive: false) + expect(visitor).not.toHaveBeenCalledWith(42, expect.anything()); + }); + + it("should replace Map with array", () => { + const obj = { data: new Map([["a", 1]]) }; + const visitor = vi.fn((value, context) => { + if (value instanceof Map) { + context.replace([100, 200]); + } + return TraverseContinue; + }); + + const result = traverse(obj, visitor); + + expect(result).toEqual({ data: [100, 200] }); + // Replaced containers are not re-visited, but their contents are traversed + expect(visitor).toHaveBeenCalledWith(100, expect.anything()); + expect(visitor).toHaveBeenCalledWith(200, expect.anything()); + }); + + it("should replace Map with object", () => { + const obj = { data: new Map([["a", 1]]) }; + const visitor = vi.fn((value, context) => { + if (value instanceof Map) { + context.replace({ converted: true }); + } + return TraverseContinue; + }); + + const result = traverse(obj, visitor); + + expect(result).toEqual({ data: { converted: true } }); + // Replaced containers are not re-visited, but their contents are traversed + expect(visitor).toHaveBeenCalledWith(true, expect.anything()); + }); + + it("should replace Map with Set", () => { + const obj = { data: new Map([["a", 1]]) }; + const visitor = vi.fn((value, context) => { + if (value instanceof Map) { + context.replace(new Set(["x", "y"])); + } + return TraverseContinue; + }); + + const result = traverse(obj, visitor); + + expect(result).toEqual({ data: new Set(["x", "y"]) }); + // Replaced containers are not re-visited, but their contents are traversed + expect(visitor).toHaveBeenCalledWith("x", expect.anything()); + expect(visitor).toHaveBeenCalledWith("y", expect.anything()); + }); + + it("should replace Map with primitive", () => { + const obj = { data: new Map([["a", 1]]) }; + const visitor = vi.fn((value, context) => { + if (value instanceof Map) { + context.replace("map replaced"); + } + return TraverseContinue; + }); + + const result = traverse(obj, visitor); + + expect(result).toEqual({ data: "map replaced" }); + // Should NOT visit the replaced primitive value by default (visitPrimitive: false) + expect(visitor).not.toHaveBeenCalledWith( + "map replaced", + expect.anything(), + ); + }); + + it("should replace Set with array", () => { + const obj = { data: new Set([1, 2, 3]) }; + const visitor = vi.fn((value, context) => { + if (value instanceof Set) { + context.replace([10, 20]); + } + return TraverseContinue; + }); + + const result = traverse(obj, visitor); + + expect(result).toEqual({ data: [10, 20] }); + // Replaced containers are not re-visited, but their contents are traversed + expect(visitor).toHaveBeenCalledWith(10, expect.anything()); + expect(visitor).toHaveBeenCalledWith(20, expect.anything()); + }); + + it("should replace Set with object", () => { + const obj = { data: new Set([1, 2, 3]) }; + const visitor = vi.fn((value, context) => { + if (value instanceof Set) { + context.replace({ from: "set" }); + } + return TraverseContinue; + }); + + const result = traverse(obj, visitor); + + expect(result).toEqual({ data: { from: "set" } }); + // Replaced containers are not re-visited, but their contents are traversed + expect(visitor).toHaveBeenCalledWith("set", expect.anything()); + }); + + it("should replace Set with Map", () => { + const obj = { data: new Set([1, 2, 3]) }; + const visitor = vi.fn((value, context) => { + if (value instanceof Set) { + context.replace(new Map([["k", "v"]])); + } + return TraverseContinue; + }); + + const result = traverse(obj, visitor); + + expect(result).toEqual({ data: new Map([["k", "v"]]) }); + // Replaced containers are not re-visited, but their contents are traversed + expect(visitor).toHaveBeenCalledWith("v", expect.anything()); + }); + + it("should replace Set with primitive", () => { + const obj = { data: new Set([1, 2, 3]) }; + const visitor = vi.fn((value, context) => { + if (value instanceof Set) { + context.replace(null); + } + return TraverseContinue; + }); + + const result = traverse(obj, visitor); + + expect(result).toEqual({ data: null }); + // Should NOT visit the replaced primitive value by default (visitPrimitive: false) + expect(visitor).not.toHaveBeenCalledWith(null, expect.anything()); + }); + + it("should replace primitive with array", () => { + const obj = { data: 42 }; + const visitor = vi.fn((value, context) => { + if (value === 42) { + context.replace([1, 2, 3]); + } + return TraverseContinue; + }); + + const result = traverse(obj, visitor, { visitPrimitives: true }); + + expect(result).toEqual({ data: [1, 2, 3] }); + // Should visit the original primitive value + expect(visitor).toHaveBeenCalledWith(42, expect.anything()); + // Replaced containers are not re-visited, but their contents are traversed + expect(visitor).toHaveBeenCalledWith(1, expect.anything()); + expect(visitor).toHaveBeenCalledWith(2, expect.anything()); + expect(visitor).toHaveBeenCalledWith(3, expect.anything()); + }); + + it("should replace primitive with object", () => { + const obj = { data: "text" }; + const visitor = vi.fn((value, context) => { + if (value === "text") { + context.replace({ expanded: true }); + } + return TraverseContinue; + }); + + const result = traverse(obj, visitor, { visitPrimitives: true }); + + expect(result).toEqual({ data: { expanded: true } }); + // Should visit the original primitive value + expect(visitor).toHaveBeenCalledWith("text", expect.anything()); + // Replaced containers not re-visited, but their contents are traversed + expect(visitor).toHaveBeenCalledWith(true, expect.anything()); + }); + + it("should replace primitive with Map", () => { + const obj = { data: 123 }; + const visitor = vi.fn((value, context) => { + if (value === 123) { + context.replace(new Map([["key", "value"]])); + } + return TraverseContinue; + }); + + const result = traverse(obj, visitor, { visitPrimitives: true }); + + expect(result).toEqual({ data: new Map([["key", "value"]]) }); + // Should visit the original primitive value + expect(visitor).toHaveBeenCalledWith(123, expect.anything()); + // Replaced containers not re-visited, but their contents are traversed + expect(visitor).toHaveBeenCalledWith("value", expect.anything()); + }); + + it("should replace primitive with Set", () => { + const obj = { data: true }; + const visitor = vi.fn((value, context) => { + if (value === true) { + context.replace(new Set([1, 2])); + } + return TraverseContinue; + }); + + const result = traverse(obj, visitor, { visitPrimitives: true }); + + expect(result).toEqual({ data: new Set([1, 2]) }); + // Should visit the original primitive value + expect(visitor).toHaveBeenCalledWith(true, expect.anything()); + // Replaced containers not re-visited, but their contents are traversed + expect(visitor).toHaveBeenCalledWith(1, expect.anything()); + expect(visitor).toHaveBeenCalledWith(2, expect.anything()); + }); + + it("should recurse into replaced container", () => { + const obj = { data: [1, 2, 3] }; + const result = traverse(obj, (value, context) => { + if (Array.isArray(value) && value[0] === 1) { + context.replace({ nested: { value: 10 } }); + } + if (value === 10) { + context.replace(100); + } + return TraverseContinue; + }); + + expect(result).toEqual({ data: { nested: { value: 100 } } }); + }); + + it("should handle nested container replacements", () => { + const obj = { + level1: { + level2: [1, 2, 3], + }, + }; + const result = traverse(obj, (value, context) => { + if ( + context.key?.kind === "object" && + context.key.property === "level2" + ) { + context.replace(new Map([["converted", new Set([10, 20])]])); + } + if (value instanceof Set) { + context.replace([100, 200]); + } + return TraverseContinue; + }); + + expect(result).toEqual({ + level1: { + level2: new Map([["converted", [100, 200]]]), + }, + }); + }); + + it("should replace container in array", () => { + const arr = [1, { a: 2 }, 3]; + const result = traverse(arr, (_, context) => { + if (context.key?.kind === "array" && context.key.index === 1) { + context.replace(new Map([["key", "val"]])); + } + return TraverseContinue; + }); + + expect(result).toEqual([1, new Map([["key", "val"]]), 3]); + }); + + it("should replace container in Map", () => { + const map = new Map([ + ["key1", [1, 2, 3]], + ["key2", "value"], + ]); + const result = traverse(map, (value, context) => { + if (Array.isArray(value)) { + context.replace(new Set(value)); + } + return TraverseContinue; + }); + + expect(result).toEqual( + new Map | string>([ + ["key1", new Set([1, 2, 3])], + ["key2", "value"], + ]), + ); + }); + + it("should replace container in Set", () => { + const set = new Set([1, [2, 3], 4]); + const result = traverse(set, (value, context) => { + if (Array.isArray(value)) { + context.replace(new Map([["x", 10]])); + } + return TraverseContinue; + }); + + expect(result).toEqual(new Set([1, new Map([["x", 10]]), 4])); + }); + }); + + describe("value removal", () => { + it("should remove properties from objects when TraverseRemove is returned", () => { + const obj = { a: 1, b: 2, c: 3 }; + const result = traverse(obj, (value, context) => { + if (value === 2) { + context.remove(); + } + return TraverseContinue; + }); + + expect(result).toEqual({ a: 1, c: 3 }); + }); + + it("should remove items from arrays when TraverseRemove is returned", () => { + const arr = [1, 2, 3, 4]; + const result = traverse(arr, (value, context) => { + if (value === 2 || value === 4) { + context.remove(); + } + return TraverseContinue; + }); + + expect(result).toEqual([1, 3]); + }); + + it("should remove entries from Maps when TraverseRemove is returned", () => { + const map = new Map([ + ["a", 1], + ["b", 2], + ["c", 3], + ]); + const result = traverse(map, (value, context) => { + if (value === 2) { + context.remove(); + } + return TraverseContinue; + }); + + expect(result).toEqual( + new Map([ + ["a", 1], + ["c", 3], + ]), + ); + }); + + it("should remove values from Sets when TraverseRemove is returned", () => { + const set = new Set([1, 2, 3, 4]); + const result = traverse(set, (value, context) => { + if (value === 2 || value === 4) { + context.remove(); + } + return TraverseContinue; + }); + + expect(result).toEqual(new Set([1, 3])); + }); + + it("should remove nested structures", () => { + const obj = { a: 1, b: { c: 2, d: 3 }, e: 4 }; + const result = traverse(obj, (value, context) => { + if (typeof value === "object" && value !== null && "c" in value) { + context.remove(); + } + return TraverseContinue; + }); + + expect(result).toEqual({ a: 1, e: 4 }); + }); + }); + + describe("TraverseSkip", () => { + it("should keep value and skip recursion", () => { + const obj = { a: 1, b: { c: 2, d: 3 } }; + const visitor = vi.fn((value) => { + if (typeof value === "object" && value !== null && "c" in value) { + return TraverseSkip; + } + return TraverseContinue; + }); + + const result = traverse(obj, visitor); + + expect(result).toEqual({ a: 1, b: { c: 2, d: 3 } }); + // Should not visit c or d properties + expect(visitor).not.toHaveBeenCalledWith(2, expect.anything()); + expect(visitor).not.toHaveBeenCalledWith(3, expect.anything()); + }); + + it("should work with arrays", () => { + const arr = [1, [2, 3], 4]; + const visitor = vi.fn((value) => { + if (Array.isArray(value) && value[0] === 2) { + return TraverseSkip; + } + return TraverseContinue; + }); + + const result = traverse(arr, visitor); + + expect(result).toEqual([1, [2, 3], 4]); + // Should not visit 2 or 3 + expect(visitor).not.toHaveBeenCalledWith(2, expect.anything()); + expect(visitor).not.toHaveBeenCalledWith(3, expect.anything()); + }); + }); + + describe("combined transformations", () => { + it("should combine transformation and removal", () => { + const obj = { a: 1, b: 2, c: 3, d: 4 }; + const result = traverse(obj, (value, context) => { + if (value === 2) { + context.remove(); + } else if (typeof value === "number") { + context.replace(value * 10); + } + return TraverseContinue; + }); + + expect(result).toEqual({ a: 10, c: 30, d: 40 }); + }); + + it("should handle complex nested transformations", () => { + const data = { + users: [ + { name: "Alice", age: 30, internal: true }, + { name: "Bob", age: 25, internal: false }, + { name: "Charlie", age: 35, internal: true }, + ], + }; + + const result = traverse(data, (value, context) => { + // Remove internal users + if ( + typeof value === "object" && + value !== null && + "internal" in value && + value.internal === true + ) { + context.remove(); + } + // Remove internal property + if ( + context.key?.kind === "object" && + context.key.property === "internal" + ) { + context.remove(); + } + return TraverseContinue; + }); + + expect(result).toEqual({ + users: [{ name: "Bob", age: 25 }], + }); + }); + }); + + describe("edge cases", () => { + it("should handle null values", () => { + const input = { a: null, b: { c: null } }; + + const result = traverse(input, () => TraverseContinue); + + expect(result).toEqual(input); + }); + + it("should handle empty objects and arrays with undefined return", () => { + const input = { empty: {}, arr: [] }; + + const result = traverse(input, () => TraverseContinue); + + expect(result).toEqual(input); + }); + + it.skip("should handle circular references without infinite loop", () => { + const circular: { a: number; self?: unknown } = { a: 1 }; + circular.self = circular; + + // This should not throw or hang + const visitCount = { count: 0 }; + traverse(circular, () => { + visitCount.count++; + if (visitCount.count > 100) { + return TraverseHalt; // Safety cutoff + } + return TraverseContinue; + }); + + expect(visitCount.count).toBeGreaterThan(2); + expect(visitCount.count).toBeLessThan(100); + }); + + it("should handle NaN values", () => { + const input = { a: Number.NaN, b: 2 }; + + const result = traverse(input, (value, context) => { + if (typeof value === "number" && Number.isNaN(value)) { + context.replace(0); // Transform NaN to 0 + } + return TraverseContinue; + }); + + expect(result).toEqual({ a: 0, b: 2 }); + }); + + it("should handle Infinity values", () => { + const input = { + a: Number.POSITIVE_INFINITY, + b: Number.NEGATIVE_INFINITY, + }; + + const result = traverse(input, () => TraverseContinue); + + expect(result).toEqual(input); + }); + + it("should handle BigInt values", () => { + const input = { a: BigInt(123), b: 2 }; + + const result = traverse(input, (value, context) => { + if (typeof value === "bigint") { + context.replace(value * BigInt(2)); + } + return TraverseContinue; + }); + + expect(result).toEqual({ a: BigInt(246), b: 2 }); + }); + + it("should handle Date objects", () => { + const date = new Date("2026-01-11"); + const input = { created: date }; + + const result = traverse(input, () => TraverseContinue); + + expect(result).toEqual(input); + }); + + it("should handle RegExp objects", () => { + const regex = /test/gi; + const input = { pattern: regex }; + + const result = traverse(input, () => TraverseContinue); + + expect(result).toEqual(input); + }); + + it("should handle Error objects", () => { + const error = new Error("test error"); + const input = { error }; + + const result = traverse(input, () => TraverseContinue); + + expect(result).toEqual(input); + }); + + it("should handle typed arrays", () => { + const typedArray = new Uint8Array([1, 2, 3]); + const input = { buffer: typedArray }; + + const result = traverse(input, () => TraverseContinue); + + expect(result).toEqual(input); + }); + + it("should handle deeply nested structures", () => { + let deep: { value?: number; nested?: unknown; level?: number } = { + value: 0, + }; + for (let i = 0; i < 50; i++) { + deep = { nested: deep, level: i }; + } + + const result = traverse(deep, (value, context) => { + if (typeof value === "number") { + context.replace(value + 1); + } + return TraverseContinue; + }); + + // Should traverse and transform all number values + type DeepNested = { value?: number; nested?: DeepNested; level?: number }; + let current = result as DeepNested; + for (let i = 49; i >= 0; i--) { + expect(current.level).toBe(i + 1); + current = current.nested as DeepNested; + } + expect(current.value).toBe(1); + }); + + it("should handle objects with prototype chain", () => { + const parent = { inherited: "value" }; + const child = Object.create(parent); + child.own = "property"; + + const result = traverse(child, () => TraverseContinue); + + expect(result).toBe(child); // Mutates in-place + expect(result).toHaveProperty("own", "property"); + // Inherited properties are visited but not own properties + expect(Object.hasOwn(result as object, "inherited")).toBe(false); + }); + + it("should handle objects with getters/setters", () => { + let backingValue = 42; + const input = { + get value() { + return backingValue; + }, + set value(v: number) { + backingValue = v; + }, + }; + + const result = traverse(input, (value, context) => { + if (typeof value === "number") { + context.replace(value * 2); + } + return TraverseContinue; + }); + + expect(result).toBe(input); // Mutates in-place + expect((result as typeof input).value).toBe(84); // Setter was called + expect(backingValue).toBe(84); // Backing value changed via setter + }); + + it("should handle WeakMap and WeakSet gracefully", () => { + const obj = { a: 1 }; + const weakMap = new WeakMap([[obj, "value"]]); + const weakSet = new WeakSet([obj]); + const input = { weakMap, weakSet }; + + // WeakMap and WeakSet are not iterable, should be treated as opaque objects + const result = traverse(input, () => TraverseContinue); + + expect(result).toEqual(input); + }); + + it("should handle boolean primitive wrapper objects", () => { + const input = { wrapped: new Boolean(true), primitive: false }; + + const result = traverse(input, () => TraverseContinue); + + expect(result).toEqual(input); + }); + + it("should handle string primitive wrapper objects", () => { + const input = { wrapped: new String("test"), primitive: "test" }; + + const result = traverse(input, () => TraverseContinue); + + expect(result).toEqual(input); + }); + + it("should handle number primitive wrapper objects", () => { + const input = { wrapped: new Number(42), primitive: 42 }; + + const result = traverse(input, () => TraverseContinue); + + expect(result).toEqual(input); + }); + + it("should handle zero values", () => { + const input = { positive: 0, negative: -0 }; + + const result = traverse(input, () => TraverseContinue); + + expect(result).toEqual(input); + type ZeroResult = { positive: number; negative: number }; + expect(Object.is((result as ZeroResult).positive, 0)).toBe(true); + expect(Object.is((result as ZeroResult).negative, -0)).toBe(true); + }); + + it("should handle empty strings", () => { + const input = { empty: "", nonempty: "test" }; + + const result = traverse(input, () => TraverseContinue); + + expect(result).toEqual(input); + }); + + it("should handle false and true values", () => { + const input = { t: true, f: false }; + + const result = traverse(input, () => TraverseContinue); + + expect(result).toEqual(input); + }); + + it("should handle mixed Map key types", () => { + const objKey = { id: 1 }; + const map = new Map([ + ["string", 1], + [42, 2], + [objKey, 3], + [true, 4], + ]); + + const result = traverse(map, () => TraverseContinue, { + traverseMapKeys: true, + }); + + expect(result).toEqual(map); + }); + + it("should handle Set with mixed types", () => { + const set = new Set([1, "string", true, null, undefined, { obj: 1 }]); + + const result = traverse(set, () => TraverseContinue, { + traverseSets: true, + }); + + expect(result).toEqual(set); + }); + + it("should preserve array holes when not visiting indices", () => { + // biome-ignore lint/suspicious/noSparseArray: test + const sparse = [1, , 3]; + + const result = traverse(sparse, () => TraverseContinue, { + traverseArrays: false, + }); + + expect(result).toHaveLength(3); + expect(1 in (result as number[])).toBe(false); // Hole should be preserved + }); + + it("should handle functions as object properties", () => { + const input = { + method: () => "hello", + data: 42, + }; + + const result = traverse(input, () => TraverseContinue); + + expect(result).toEqual(input); + type FuncResult = { method: () => string; data: number }; + expect(typeof (result as FuncResult).method).toBe("function"); + }); + + it("should handle Symbol properties when includeSymbolKeys is true", () => { + const sym = Symbol("test"); + const input = { [sym]: "symbol value", normal: "normal value" }; + + const result = traverse(input, () => TraverseContinue, { + includeSymbolKeys: true, + }); + + expect(result).toEqual(input); + type SymResult = { [key: symbol]: string; normal: string }; + expect((result as SymResult)[sym]).toBe("symbol value"); + }); + + it("should transform values at different nesting levels independently", () => { + const input = { + level1: 1, + nested: { + level2: 2, + deep: { + level3: 3, + }, + }, + }; + + const result = traverse(input, (value, context) => { + if (typeof value === "number") { + const depth = + (context.parentPath?.length ?? 0) + (context.key ? 1 : 0); + context.replace(value * 10 ** depth); + } + return TraverseContinue; + }); + + expect(result).toEqual({ + level1: 10, + nested: { + level2: 200, + deep: { + level3: 3000, + }, + }, + }); + }); + }); + + describe("primitive-like objects", () => { + it("should not traverse into Date objects", () => { + const date = new Date("2026-01-11"); + const input = { created: date, updated: new Date("2026-01-12") }; + + const visitor = vi + .fn() + .mockReturnValue(TraverseContinue); + const result = traverse(input, visitor); + + // Visitor should be called for: input object, "created" date, "updated" date + // But NOT for Date internal properties + expect(visitor).toHaveBeenCalledTimes(3); + expect(visitor).toHaveBeenCalledWith(input, expect.any(Object)); + expect(visitor).toHaveBeenCalledWith( + date, + expect.objectContaining({ + key: { kind: "object", property: "created", prototype: input }, + }), + ); + expect(visitor).toHaveBeenCalledWith( + input.updated, + expect.objectContaining({ + key: { kind: "object", property: "updated", prototype: input }, + }), + ); + expect(result).toEqual(input); + expect((result as typeof input).created).toBe(date); + }); + + it("should not traverse into RegExp objects", () => { + const regex = /test/gi; + const input = { pattern: regex, anotherPattern: /foo/i }; + + const visitor = vi + .fn() + .mockReturnValue(TraverseContinue); + const result = traverse(input, visitor); + + // Visitor should be called for: input object, "pattern" regex, "anotherPattern" regex + // But NOT for RegExp internal properties + expect(visitor).toHaveBeenCalledTimes(3); + expect(visitor).toHaveBeenCalledWith(input, expect.any(Object)); + expect(visitor).toHaveBeenCalledWith(regex, expect.any(Object)); + expect(visitor).toHaveBeenCalledWith( + input.anotherPattern, + expect.any(Object), + ); + expect(result).toEqual(input); + expect((result as typeof input).pattern).toBe(regex); + }); + + it("should not traverse into Error objects", () => { + const error = new Error("test error"); + const input = { error, otherError: new TypeError("type error") }; + + const visitor = vi + .fn() + .mockReturnValue(TraverseContinue); + const result = traverse(input, visitor); + + // Visitor should be called for: input object, "error" Error, "otherError" TypeError + // But NOT for Error internal properties (message, stack, etc.) + expect(visitor).toHaveBeenCalledTimes(3); + expect(visitor).toHaveBeenCalledWith(input, expect.any(Object)); + expect(visitor).toHaveBeenCalledWith(error, expect.any(Object)); + expect(visitor).toHaveBeenCalledWith( + input.otherError, + expect.any(Object), + ); + expect(result).toEqual(input); + expect((result as typeof input).error).toBe(error); + }); + + it("should not traverse into ArrayBuffer objects", () => { + const buffer = new ArrayBuffer(16); + const input = { buffer, otherBuffer: new ArrayBuffer(8) }; + + const visitor = vi + .fn() + .mockReturnValue(TraverseContinue); + const result = traverse(input, visitor); + + // Visitor should be called for: input object, "buffer" ArrayBuffer, "otherBuffer" ArrayBuffer + // But NOT for ArrayBuffer internal properties + expect(visitor).toHaveBeenCalledTimes(3); + expect(visitor).toHaveBeenCalledWith(input, expect.any(Object)); + expect(visitor).toHaveBeenCalledWith(buffer, expect.any(Object)); + expect(visitor).toHaveBeenCalledWith( + input.otherBuffer, + expect.any(Object), + ); + expect(result).toEqual(input); + expect((result as typeof input).buffer).toBe(buffer); + }); + + it("should not traverse into TypedArray objects (Uint8Array)", () => { + const typedArray = new Uint8Array([1, 2, 3]); + const input = { buffer: typedArray }; + + const visitor = vi + .fn() + .mockReturnValue(TraverseContinue); + const result = traverse(input, visitor); + + // Visitor should be called for: input object, "buffer" Uint8Array + // But NOT for array indices or internal properties + expect(visitor).toHaveBeenCalledTimes(2); + expect(visitor).toHaveBeenCalledWith(input, expect.any(Object)); + expect(visitor).toHaveBeenCalledWith(typedArray, expect.any(Object)); + expect(result).toEqual(input); + expect((result as typeof input).buffer).toBe(typedArray); + }); + + it("should not traverse into TypedArray objects (Int32Array)", () => { + const typedArray = new Int32Array([100, 200, 300]); + const input = { data: typedArray }; + + const visitor = vi + .fn() + .mockReturnValue(TraverseContinue); + const result = traverse(input, visitor); + + // Visitor should be called for: input object, "data" Int32Array + // But NOT for array indices or internal properties + expect(visitor).toHaveBeenCalledTimes(2); + expect(visitor).toHaveBeenCalledWith(input, expect.any(Object)); + expect(visitor).toHaveBeenCalledWith(typedArray, expect.any(Object)); + expect(result).toEqual(input); + expect((result as typeof input).data).toBe(typedArray); + }); + + it("should not traverse into DataView objects", () => { + const buffer = new ArrayBuffer(16); + const dataView = new DataView(buffer); + const input = { view: dataView }; + + const visitor = vi + .fn() + .mockReturnValue(TraverseContinue); + const result = traverse(input, visitor); + + // Visitor should be called for: input object, "view" DataView + // But NOT for DataView internal properties + expect(visitor).toHaveBeenCalledTimes(2); + expect(visitor).toHaveBeenCalledWith(input, expect.any(Object)); + expect(visitor).toHaveBeenCalledWith(dataView, expect.any(Object)); + expect(result).toEqual(input); + expect((result as typeof input).view).toBe(dataView); + }); + + it("should handle transformation of primitive-like objects", () => { + const date = new Date("2026-01-11"); + const newDate = new Date("2026-01-12"); + const input = { created: date }; + + const result = traverse(input, (value, context) => { + if (value instanceof Date) { + context.replace(newDate); + } + return TraverseContinue; + }); + + expect((result as typeof input).created).toBe(newDate); + }); + + it("should handle nested structures with primitive-like objects", () => { + const date = new Date("2026-01-11"); + const regex = /test/gi; + const error = new Error("error"); + const typedArray = new Uint8Array([1, 2, 3]); + + const input = { + metadata: { + timestamp: date, + pattern: regex, + }, + errorInfo: { + lastError: error, + }, + bufferData: typedArray, + }; + + const visitor = vi + .fn() + .mockReturnValue(TraverseContinue); + const result = traverse(input, visitor); + + // Should visit: input, metadata, timestamp, pattern, errorInfo, lastError, bufferData + expect(visitor).toHaveBeenCalledTimes(7); + expect(result).toEqual(input); + }); + + it("should not traverse into Promise objects", () => { + const promise = Promise.resolve(42); + const input = { asyncValue: promise }; + + const visitor = vi + .fn() + .mockReturnValue(TraverseContinue); + const result = traverse(input, visitor); + + // Should visit: input, asyncValue (Promise) + // But NOT Promise internal properties + expect(visitor).toHaveBeenCalledTimes(2); + expect(visitor).toHaveBeenCalledWith(input, expect.any(Object)); + expect(visitor).toHaveBeenCalledWith(promise, expect.any(Object)); + expect((result as typeof input).asyncValue).toBe(promise); + }); + + it("should not traverse into URL objects", () => { + const url = new URL("https://example.com"); + const input = { link: url }; + + const visitor = vi + .fn() + .mockReturnValue(TraverseContinue); + const result = traverse(input, visitor); + + // Should visit: input, link (URL) + // But NOT URL internal properties + expect(visitor).toHaveBeenCalledTimes(2); + expect(visitor).toHaveBeenCalledWith(input, expect.any(Object)); + expect(visitor).toHaveBeenCalledWith(url, expect.any(Object)); + expect((result as typeof input).link).toBe(url); + }); + + it("should not traverse into URLSearchParams objects", () => { + const params = new URLSearchParams("foo=bar&baz=qux"); + const input = { params }; + + const visitor = vi + .fn() + .mockReturnValue(TraverseContinue); + const result = traverse(input, visitor); + + // Should visit: input, params (URLSearchParams) + // But NOT URLSearchParams internal properties + expect(visitor).toHaveBeenCalledTimes(2); + expect(visitor).toHaveBeenCalledWith(input, expect.any(Object)); + expect(visitor).toHaveBeenCalledWith(params, expect.any(Object)); + expect((result as typeof input).params).toBe(params); + }); + + it("should not traverse into primitive wrapper objects", () => { + const input = { + bool: new Boolean(true), + num: new Number(42), + str: new String("test"), + }; + + const visitor = vi + .fn() + .mockReturnValue(TraverseContinue); + const result = traverse(input, visitor); + + // Should visit: input, bool, num, str + // But NOT their internal properties + expect(visitor).toHaveBeenCalledTimes(4); + expect(result).toEqual(input); + }); + + it("should traverse user-defined class instances", () => { + class UserClass { + constructor( + public value: number, + public nested: { data: string }, + ) {} + } + + const instance = new UserClass(42, { data: "test" }); + const input = { user: instance }; + + const visitor = vi + .fn() + .mockReturnValue(TraverseContinue); + traverse(input, visitor, { traverseCustomObjects: [UserClass] }); + + // Should visit: input, user (UserClass instance), value (number), nested (object), data (string) + expect(visitor).toHaveBeenCalledTimes(5); + expect(visitor).toHaveBeenCalledWith(input, expect.any(Object)); + expect(visitor).toHaveBeenCalledWith(instance, expect.any(Object)); + expect(visitor).toHaveBeenCalledWith(42, expect.any(Object)); + expect(visitor).toHaveBeenCalledWith(instance.nested, expect.any(Object)); + expect(visitor).toHaveBeenCalledWith("test", expect.any(Object)); + }); + + it("should traverse plain objects created with Object.create(null)", () => { + const nullProtoObj = Object.create(null); + nullProtoObj.key = "value"; + nullProtoObj.nested = { data: 123 }; + const input = { custom: nullProtoObj }; + + const visitor = vi + .fn() + .mockReturnValue(TraverseContinue); + const result = traverse(input, visitor); + + // Should visit: input, custom, key, nested, data + expect(visitor).toHaveBeenCalledTimes(5); + expect(result).toEqual(input); + }); + }); +}); diff --git a/packages/misc-util/src/ecma/object/traverse.ts b/packages/misc-util/src/ecma/object/traverse.ts new file mode 100644 index 00000000..a4682d1c --- /dev/null +++ b/packages/misc-util/src/ecma/object/traverse.ts @@ -0,0 +1,602 @@ +import type { Except, TaggedUnion } from "type-fest"; +import { defaults } from "./defaults.js"; +import { + type GetObjectKeysOptions, + getObjectKeys, + type ObjectKey, +} from "./get-object-keys.js"; + +export const TraverseSkip: symbol = Symbol("TraverseSkip"); // Skip recursion into children while continuing traversal +export const TraverseBreak: symbol = Symbol("TraverseBreak"); // Skip visiting sibling properties and ascend to parent +export const TraverseHalt: symbol = Symbol("TraverseHalt"); // Halt visiting entirely +export const TraverseContinue: symbol = Symbol("TraverseContinue"); // Continue traversal as normal + +export type TraverseKey = TaggedUnion< + "kind", + { + array: { index: number }; + object: ObjectKey; + map: { key: unknown }; + "map-key": { name: unknown }; + set: { value: unknown }; + } +>; + +export type TraverseControl = + | typeof TraverseContinue + | typeof TraverseSkip + | typeof TraverseBreak + | typeof TraverseHalt; + +type TraverseAction = { type: "replace"; value: unknown } | { type: "remove" }; + +export type TraverseVisitorContext = { + key: TraverseKey | null; + parent: unknown | undefined; + parentPath: TraverseKey[] | null; + + /** + * Replace the current value with a new value (including undefined) + * If called multiple times, the last call takes precedence + */ + replace(value: unknown): void; + + /** + * Remove the current value from its container + * If called multiple times, the last call takes precedence + */ + remove(): void; +}; + +export type TraverseVisitor = ( + value: unknown, + context: TraverseVisitorContext, +) => TraverseControl; + +export type TraverseCustomObjectsOption = + | false + // biome-ignore lint/complexity/noBannedTypes: intentional + | Function[] + | ((value: object) => boolean); + +export type TraverseOptions = GetObjectKeysOptions & { + /** + * Whether to call the visitor on primitive values (string, number, boolean, null, undefined, symbol, bigint) + * @default true + */ + visitPrimitives?: boolean; + + /** + * Whether to call the visitor on object values (arrays, maps, sets, plain objects, custom objects) + * @default true + */ + visitObjects?: boolean; + + /** + * Whether to traverse into array elements + * @default true + */ + traverseArrays?: boolean; + + /** + * Whether to traverse into Map keys and values + * @default true + */ + traverseMaps?: boolean; + + /** + * Whether to traverse into Map keys specifically + * Only applies when traverseMaps is true + * @default false + */ + traverseMapKeys?: boolean; + + /** + * Whether to traverse into Set values + * @default true + */ + traverseSets?: boolean; + + /** + * Whether to traverse into plain object (POJO) properties + * @default true + */ + traversePlainObjects?: boolean; + + /** + * Controls traversal into custom object (user-defined class instances) properties + * - false: Don't traverse custom objects (treat as opaque) + * - Constructor[]: Only traverse instances of these classes (or their descendants) + * - (value: object) => boolean: Custom predicate to determine traversability + * @default false + */ + traverseCustomObjects?: TraverseCustomObjectsOption; +}; + +export const TRAVERSE_DEFAULT_OPTIONS: Required = { + includeSymbolKeys: false, + includeNonEnumerable: false, + includePrototypeChain: true, + + visitPrimitives: true, + visitObjects: true, + traverseArrays: true, + traverseMaps: true, + traverseMapKeys: false, + traverseSets: true, + traversePlainObjects: true, + traverseCustomObjects: false, +}; + +const CONTROL_SYMBOLS = new Set([TraverseSkip, TraverseBreak, TraverseHalt]); + +export function traverse( + value: unknown, + callback: TraverseVisitor, + options?: TraverseOptions, +): unknown { + const effectiveOptions = defaults(options, TRAVERSE_DEFAULT_OPTIONS); + + const context = wrapContext({ + key: null, + parent: undefined, + parentPath: null, + }); + const result = traverseValue_(value, callback, effectiveOptions, context); + + // Check if top-level value was removed + if (context.action_?.type === "remove") { + return; + } + + // If halt occurred, return the partially-mutated original value + if (result === TraverseHalt) { + return value; + } + + return result; +} + +function traverseValue_( + value: unknown, + visitor: TraverseVisitor, + options: Required, + context: Context_, +): unknown { + // Check if we should visit this value based on its type + const isObject = typeof value === "object" && value !== null; + const shouldVisit = isObject ? options.visitObjects : options.visitPrimitives; + + if (!shouldVisit) { + return value; + } + + // Reset action state before calling visitor + context.action_ = null; + + const result = visitor(value, context); + + // Check context action first (takes precedence) + const action = context.action_ as TraverseAction | null; + if (action) { + if (action.type === "remove") { + return TraverseHalt; // Use TraverseHalt as removal signal internally + } + // action.type === "replace" + value = (action as { type: "replace"; value: unknown }).value; + } + + // Check if result is a control symbol + if (isControlSymbol(result)) { + if (result === TraverseSkip) { + return value; + } + if (result === TraverseBreak || result === TraverseHalt) { + return result; // Propagate control flow + } + } + + // Now potentially traverse into the value if it's an object + if (typeof value === "object" && value !== null) { + const parentPath = [...(context.parentPath ?? [])]; + if (context.key !== null) { + parentPath.push(context.key); + } + + if (Array.isArray(value)) { + if (options.traverseArrays) { + return traverseArrayIndices_(value, visitor, options, parentPath); + } + // Don't traverse if option is false + return value; + } + + if (value instanceof Set) { + if (options.traverseSets) { + return traverseSetValues_(value, visitor, options, parentPath); + } + // Don't traverse if option is false + return value; + } + + if (value instanceof Map) { + if (options.traverseMaps) { + return traverseMapEntries_(value, visitor, options, parentPath); + } + // Don't traverse if option is false + return value; + } + + // Check if we should traverse this object's properties + const shouldTraverseObject = + (isPlainObject(value) && options.traversePlainObjects) || + (!isPlainObject(value) && + shouldTraverseCustomObject(value, options.traverseCustomObjects)); + + if (shouldTraverseObject) { + return traverseObjectProperties_(value, visitor, options, parentPath); + } + + // Non-traversable object, return as-is + return value; + } + + return value; +} + +function traverseArrayIndices_( + array: unknown[], + visitor: TraverseVisitor, + options: Required, + parentPath: TraverseKey[], +): unknown[] | typeof TraverseHalt { + // Snapshot indices before iteration to handle sparse arrays and mutations + const indices: number[] = []; + for (const indexStr in array) { + indices.push(+indexStr); + } + + const indicesToRemove: number[] = []; + let halted = false; + + for (const index of indices) { + const context = wrapContext({ + key: { kind: "array", index }, + parent: array, + parentPath, + }); + + const transformed = traverseValue_(array[index], visitor, options, context); + + if (transformed === TraverseHalt) { + // Check if it's a remove signal or actual halt + if (context.action_?.type === "remove") { + indicesToRemove.push(index); + } else { + halted = true; + break; + } + continue; + } + + if (transformed === TraverseBreak) { + break; + } + + // Check for replacement via context + if (context.action_?.type === "replace") { + array[index] = context.action_.value; + } else if (transformed !== array[index]) { + array[index] = transformed; + } + } + + // Remove marked indices in reverse order to maintain correct indices + for (let i = indicesToRemove.length - 1; i >= 0; i--) { + const indexToRemove = indicesToRemove[i]; + if (indexToRemove !== undefined) { + array.splice(indexToRemove, 1); + } + } + + if (halted) { + return TraverseHalt; + } + + return array; +} + +function traverseMapEntries_( + map: Map, + visitor: TraverseVisitor, + options: Required, + parentPath: TraverseKey[], +): Map | typeof TraverseHalt { + // Snapshot entries before iteration to prevent iterator invalidation + const entries = Array.from(map.entries()); + + const entriesToUpdate: Array<{ + oldKey: unknown; + newKey: unknown; + newValue: unknown; + }> = []; + const keysToRemove: unknown[] = []; + let halted = false; + + for (const [key, value] of entries) { + let transformedKey = key; + + // Traverse into Map keys only if traverseMapKeys is enabled + if (options.traverseMapKeys) { + const keyContext = wrapContext({ + key: { kind: "map-key", name: key }, + parent: map, + parentPath, + }); + transformedKey = traverseValue_(key, visitor, options, keyContext); + + if (transformedKey === TraverseHalt) { + if (keyContext.action_?.type === "remove") { + keysToRemove.push(key); + continue; + } + halted = true; + break; + } + + if (transformedKey === TraverseBreak) { + break; + } + + if (keyContext.action_?.type === "replace") { + transformedKey = keyContext.action_.value; + } + } + + // Visit value + const valueContext = wrapContext({ + key: { kind: "map", key }, + parent: map, + parentPath, + }); + const transformedValue = traverseValue_( + value, + visitor, + options, + valueContext, + ); + + if (transformedValue === TraverseHalt) { + if (valueContext.action_?.type === "remove") { + keysToRemove.push(key); + continue; + } + halted = true; + break; + } + + if (transformedValue === TraverseBreak) { + break; + } + + let finalValue = value; + if (valueContext.action_?.type === "replace") { + finalValue = valueContext.action_.value; + } else if (transformedValue !== value) { + finalValue = transformedValue; + } + + if (transformedKey !== key || finalValue !== value) { + entriesToUpdate.push({ + oldKey: key, + newKey: transformedKey, + newValue: finalValue, + }); + } + } + + // Apply changes to the map + for (const key of keysToRemove) { + map.delete(key); + } + + for (const { oldKey, newKey, newValue } of entriesToUpdate) { + if (oldKey !== newKey) { + map.delete(oldKey); + } + map.set(newKey, newValue); + } + + if (halted) { + return TraverseHalt; + } + + return map; +} + +function traverseSetValues_( + set: Set, + visitor: TraverseVisitor, + options: Required, + parentPath: TraverseKey[], +): Set | typeof TraverseHalt { + // Snapshot values before iteration to prevent iterator invalidation + const values = Array.from(set.values()); + + const valuesToRemove: unknown[] = []; + const valuesToAdd: Array<{ old: unknown; new: unknown }> = []; + let halted = false; + + for (const item of values) { + const context = wrapContext({ + key: { kind: "set", value: item }, + parent: set, + parentPath, + }); + + const transformed = traverseValue_(item, visitor, options, context); + + if (transformed === TraverseHalt) { + if (context.action_?.type === "remove") { + valuesToRemove.push(item); + continue; + } + halted = true; + break; + } + + if (transformed === TraverseBreak) { + break; + } + + if (context.action_?.type === "replace") { + valuesToAdd.push({ old: item, new: context.action_.value }); + } else if (transformed !== item) { + valuesToAdd.push({ old: item, new: transformed }); + } + } + + // Apply changes to the set + for (const value of valuesToRemove) { + set.delete(value); + } + + for (const { old: oldValue, new: newValue } of valuesToAdd) { + set.delete(oldValue); + set.add(newValue); + } + + if (halted) { + return TraverseHalt; + } + + return set; +} + +/** + * Traverse object properties + * + * @param object - The object to traverse + * @param visitor - The visitor callback + * @param options - Traverse options + * @param parentPath - The path to the parent object + * @returns + */ +function traverseObjectProperties_( + object: object, + visitor: TraverseVisitor, + options: Required, + parentPath: TraverseKey[], +): object | typeof TraverseHalt { + const keys = getObjectKeys(object, options); + let halted = false; + + for (const key of keys) { + const context = wrapContext({ + key: { kind: "object", ...key }, + parent: object, + parentPath, + }); + + const transformed = traverseValue_( + (object as Record)[key.property], + visitor, + options, + context, + ); + + if (transformed === TraverseHalt) { + if (context.action_?.type === "remove") { + delete (object as Record)[key.property]; + continue; + } + halted = true; + break; + } + + if (transformed === TraverseBreak) { + break; + } + + const currentValue = (object as Record)[ + key.property + ]; + if (context.action_?.type === "replace") { + (object as Record)[key.property] = + context.action_.value; + } else if (transformed !== currentValue) { + (object as Record)[key.property] = transformed; + } + } + + if (halted) { + return TraverseHalt; + } + + return object; +} + +function isControlSymbol(value: unknown): value is TraverseControl { + return typeof value === "symbol" && CONTROL_SYMBOLS.has(value); +} + +/** + * Internal context with action state + * @internal + */ +type Context_ = TraverseVisitorContext & { + action_: TraverseAction | null; +}; + +/** + * Wrap visitor context to add action methods + */ +function wrapContext( + visitorContext: Except, +): Context_ { + const context: Context_ = { + ...visitorContext, + action_: null, + replace(value: unknown) { + context.action_ = { type: "replace", value }; + }, + remove() { + context.action_ = { type: "remove" }; + }, + }; + + return context; +} + +/** + * Check if a value is a plain object (POJO) + */ +function isPlainObject(value: object): boolean { + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** + * Check if a custom object should be traversed based on options + */ +function shouldTraverseCustomObject( + value: object, + options: TraverseCustomObjectsOption, +): boolean { + if (options === false) { + return false; + } + + if (typeof options === "function") { + return options(value); + } + + // Array of constructor functions - check instanceof + for (const ctor of options) { + if (value instanceof ctor) { + return true; + } + } + + return false; +} diff --git a/packages/misc-util/src/ecma/promise/abortable-promise.test.ts b/packages/misc-util/src/ecma/promise/abortable-promise.test.ts deleted file mode 100644 index fe1b3c64..00000000 --- a/packages/misc-util/src/ecma/promise/abortable-promise.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { AbortablePromise } from "./abortable-promise.js"; - -// Helper for promise-based setTimeout -function wait(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -describe("AbortablePromise", () => { - it("resolves when not aborted", async () => { - const controller = new AbortController(); - const promise = new AbortablePromise( - async (resolve) => { - await wait(10); - resolve("done"); - }, - { - onAbort: vi.fn(), - signal: controller.signal, - }, - ); - await expect(promise).resolves.toBe("done"); - }); - - it("rejects and calls onAbort if aborted before resolve", async () => { - const controller = new AbortController(); - const onAbort = vi.fn(); - const promise = new AbortablePromise( - async (resolve) => { - await wait(20); - resolve("should not resolve"); - }, - { - onAbort, - signal: controller.signal, - }, - ); - controller.abort("abort-reason"); - await expect(promise).rejects.toBe("abort-reason"); - expect(onAbort).toHaveBeenCalledWith("abort-reason"); - }); - - it("calls onAbort and rejects immediately if signal already aborted", async () => { - const controller = new AbortController(); - controller.abort("already-aborted"); - const onAbort = vi.fn(); - const promise = new AbortablePromise( - async (resolve) => { - await wait(10); - resolve("should not resolve"); - }, - { - onAbort, - signal: controller.signal, - }, - ); - await expect(promise).rejects.toBe("already-aborted"); - expect(onAbort).toHaveBeenCalledWith("already-aborted"); - }); - - it("executor is called even if aborted", async () => { - const controller = new AbortController(); - controller.abort(); - const executor = vi.fn(); - const promise = new AbortablePromise(executor, { - onAbort: vi.fn(), - signal: controller.signal, - }); - await expect(promise).rejects.toBeDefined(); - expect(executor).toHaveBeenCalled(); - }); - - it("then/catch/finally work as expected", async () => { - const controller = new AbortController(); - const promise = new AbortablePromise( - async (resolve) => { - await wait(10); - resolve("ok"); - }, - { - onAbort: vi.fn(), - signal: controller.signal, - }, - ); - const thenResult = await promise.then((v) => `${v}!`); - expect(thenResult).toBe("ok!"); - const catchResult = await promise.catch(() => "fallback"); - expect(catchResult).toBe("ok"); - let finallyCalled = false; - await promise.finally(() => { - finallyCalled = true; - }); - expect(finallyCalled).toBe(true); - }); -}); - -describe("AbortablePromise.withResolvers", () => { - it("resolves via resolve()", async () => { - const controller = new AbortController(); - const onAbort = vi.fn(); - const { promise, resolve } = AbortablePromise.withResolvers({ - onAbort, - signal: controller.signal, - }); - resolve("resolved-value"); - await expect(promise).resolves.toBe("resolved-value"); - expect(onAbort).not.toHaveBeenCalled(); - }); - - it("rejects via reject()", async () => { - const controller = new AbortController(); - const onAbort = vi.fn(); - const { promise, reject } = AbortablePromise.withResolvers({ - onAbort, - signal: controller.signal, - }); - reject("rejected-value"); - await expect(promise).rejects.toBe("rejected-value"); - expect(onAbort).not.toHaveBeenCalled(); - }); - - it("rejects and calls onAbort if aborted before resolve", async () => { - const controller = new AbortController(); - const onAbort = vi.fn(); - const { promise } = AbortablePromise.withResolvers({ - onAbort, - signal: controller.signal, - }); - controller.abort("abort-withResolvers"); - await expect(promise).rejects.toBe("abort-withResolvers"); - expect(onAbort).toHaveBeenCalledWith("abort-withResolvers"); - }); - - it("calls onAbort and rejects immediately if signal already aborted", async () => { - const controller = new AbortController(); - controller.abort("already-aborted-withResolvers"); - const onAbort = vi.fn(); - const { promise } = AbortablePromise.withResolvers({ - onAbort, - signal: controller.signal, - }); - await expect(promise).rejects.toBe("already-aborted-withResolvers"); - expect(onAbort).toHaveBeenCalledWith("already-aborted-withResolvers"); - }); -}); diff --git a/packages/misc-util/src/ecma/promise/abortable-promise.ts b/packages/misc-util/src/ecma/promise/abortable-promise.ts deleted file mode 100644 index 84279d59..00000000 --- a/packages/misc-util/src/ecma/promise/abortable-promise.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { type AbortableProps, abortableAsync } from "../function/abortable.js"; -import type { - PromiseExecutor, - PromiseOnFinally, - PromiseOnFulfilled, - PromiseOnRejected, - PromiseReject, - PromiseResolve, -} from "./types.js"; - -/** - * A Promise that is abortable via an AbortSignal. - * - * When the signal is aborted, the onAbort callback is called and the promise - * is rejected. If the signal is already aborted when the promise is created, the - * onAbort callback is called immediately and the promise is rejected. The executor - * function is still called in all cases to maintain consistency with the standard - * Promise behavior. - * - * The executor function is called immediately (synchronously) upon construction, - * similar to a standard Promise. - * - * @template T The type of the promise result. - */ -export class AbortablePromise implements Promise { - /** - * Creates a new AbortablePromise with resolvers. - * - * @param props The abortable properties. - * @returns A new AbortablePromise with resolvers. - */ - static withResolvers(props: AbortableProps): PromiseWithResolvers { - let resolve: PromiseResolve; - let reject: PromiseReject; - - const promise = new AbortablePromise((resolve_, reject_) => { - resolve = resolve_; - reject = reject_; - }, props); - - // biome-ignore lint/style/noNonNullAssertion: callback will be called synchronously - return { promise, resolve: resolve!, reject: reject! }; - } - - private readonly deferred = Promise.withResolvers(); - - /** - * Creates a new AbortablePromise. - * - * @param executor The promise executor function. - * @param props The abortable properties. - */ - constructor( - executor: PromiseExecutor, - readonly props: AbortableProps, - ) { - const { onAbort, ...restProps } = this.props; - - const abortableDeferred = Promise.withResolvers(); - - let executorCalled = false; - abortableAsync( - () => { - executor(abortableDeferred.resolve, abortableDeferred.reject); - executorCalled = true; - - return abortableDeferred.promise; - }, - { - ...restProps, - onAbort: (error) => { - onAbort(error); - abortableDeferred.reject(error); - }, - }, - )().then(this.deferred.resolve, this.deferred.reject); - - // wait for the executor to be called synchronously to reproduce Promise behavior - while (!executorCalled) {} - } - - get [Symbol.toStringTag](): string { - return "AbortablePromise"; - } - - // biome-ignore lint/suspicious/noThenProperty: implementing Promise interface - then( - onfulfilled?: PromiseOnFulfilled | null, - onrejected?: PromiseOnRejected | null, - ): Promise { - return this.deferred.promise.then(onfulfilled, onrejected); - } - - catch( - onrejected?: PromiseOnRejected | null, - ): Promise { - return this.deferred.promise.catch(onrejected); - } - - finally(onfinally?: PromiseOnFinally | null): Promise { - return this.deferred.promise.finally(onfinally); - } -} diff --git a/packages/misc-util/src/ecma/string/string-pointer.ts b/packages/misc-util/src/ecma/string/string-pointer.ts index 9e5af008..8d85b624 100644 --- a/packages/misc-util/src/ecma/string/string-pointer.ts +++ b/packages/misc-util/src/ecma/string/string-pointer.ts @@ -1,4 +1,4 @@ -import { clamp } from "../math/clamp.js"; +import { clamp } from "../../math/clamp.js"; /** * A C-like string pointer. diff --git a/packages/misc-util/src/ecma/timers/periodical-timer.ts b/packages/misc-util/src/ecma/timers/periodical-timer.ts index 543a0875..df58d9b5 100644 --- a/packages/misc-util/src/ecma/timers/periodical-timer.ts +++ b/packages/misc-util/src/ecma/timers/periodical-timer.ts @@ -1,5 +1,5 @@ import { noThrowAsync } from "../function/no-throw.js"; -import type { MaybeAsyncCallback } from "../function/types.js"; +import type { MaybeAsyncCallable } from "../function/types.js"; import { defaults } from "../object/defaults.js"; import { Timer } from "./timer.js"; @@ -32,7 +32,7 @@ export class PeriodicalTimer { * @param options Optional settings for the timer. */ constructor( - private readonly callback: MaybeAsyncCallback, + private readonly callback: MaybeAsyncCallable, readonly intervalMs: number, options?: PeriodicalTimerOptions, ) { diff --git a/packages/misc-util/src/ecma/timers/timer.ts b/packages/misc-util/src/ecma/timers/timer.ts index 1b21b4fc..56442e4e 100644 --- a/packages/misc-util/src/ecma/timers/timer.ts +++ b/packages/misc-util/src/ecma/timers/timer.ts @@ -1,5 +1,5 @@ import { noThrowAsync } from "../function/no-throw.js"; -import type { Callback } from "../function/types.js"; +import type { Callable } from "../function/types.js"; /** * A simple timer that calls a callback after a specified delay. @@ -14,7 +14,7 @@ export class Timer { * @param delayMs The delay in milliseconds before the timer fires. */ constructor( - private readonly callback: Callback, + private readonly callback: Callable, private readonly delayMs: number, ) {} diff --git a/packages/misc-util/src/index.ts b/packages/misc-util/src/index.ts index d9cce695..9060f140 100644 --- a/packages/misc-util/src/index.ts +++ b/packages/misc-util/src/index.ts @@ -2,42 +2,47 @@ export * from "./3rdparty/chardet/chardet-charset-to-buffer-encoding.js"; export * from "./3rdparty/editorconfig/editorconfig-charset-to-buffer-encoding.js"; export * from "./3rdparty/fs-ext/flock.js"; export * from "./3rdparty/mime-db/get-first-mime-type-file-extension.js"; -export * from "./async/counter.js"; -export * from "./async/file-lock.js"; -export * from "./async/ilockable.js"; -export * from "./async/isubscribable.js"; -export * from "./async/iwaitable.js"; -export * from "./async/lockable-base.js"; -export * from "./async/mutex.js"; -export * from "./async/semaphore.js"; -export * from "./async/signal.js"; -export * from "./async/subscribable.js"; -export * from "./async/subscribable-event.js"; -export * from "./async/udp-bind-lock.js"; -export * from "./async/wait-notifiable.js"; +export * from "./async/events/event.js"; +export * from "./async/events/helpers/event-dispatcher-to-async-iterator.js"; +export * from "./async/events/ievent-dispatcher.js"; +export * from "./async/events/ievent-dispatcher-map.js"; +export * from "./async/synchro/barrier.js"; +export * from "./async/synchro/broadcast.js"; +export * from "./async/synchro/channel.js"; +export * from "./async/synchro/condition.js"; +export * from "./async/synchro/counter.js"; +export * from "./async/synchro/file-lock.js"; +export * from "./async/synchro/helpers/lock-hold.js"; +export * from "./async/synchro/ilock.js"; +export * from "./async/synchro/latch.js"; +export * from "./async/synchro/mutex.js"; +export * from "./async/synchro/rw-lock.js"; +export * from "./async/synchro/semaphore.js"; +export * from "./async/synchro/signal.js"; +export * from "./async/synchro/udp-bind-lock.js"; export * from "./constants.js"; -export * from "./data/abstract-types/ibinary-tree.js"; -export * from "./data/abstract-types/icollection.js"; -export * from "./data/abstract-types/ideque.js"; -export * from "./data/abstract-types/iheap.js"; -export * from "./data/abstract-types/ilist.js"; -export * from "./data/abstract-types/ipriority-queue.js"; -export * from "./data/abstract-types/iqueue.js"; -export * from "./data/abstract-types/istack.js"; -export * from "./data/abstract-types/itree.js"; export * from "./data/binary-heap.js"; -export * from "./data/collection-base/binary-heap-collection.js"; -export * from "./data/collection-base/linked-list-collection.js"; -export * from "./data/collection-base/native-array-collection.js"; export * from "./data/deque.js"; export * from "./data/doubly-linked-list.js"; +export * from "./data/ibinary-tree.js"; +export * from "./data/icollection.js"; +export * from "./data/ideque.js"; +export * from "./data/iheap.js"; +export * from "./data/ilist.js"; +export * from "./data/ipriority-queue.js"; +export * from "./data/iqueue.js"; +export * from "./data/istack.js"; +export * from "./data/itree.js"; export * from "./data/linked-list.js"; export * from "./data/native-array.js"; export * from "./data/priority-queue.js"; export * from "./data/queue.js"; export * from "./data/stack.js"; +export * from "./ecma/array/compact.js"; +export * from "./ecma/array/remove-safe.js"; export * from "./ecma/array/uniq.js"; export * from "./ecma/array/uniq-by.js"; +export * from "./ecma/dispose/types.js"; export * from "./ecma/error/aggregate-error.js"; export * from "./ecma/error/capture-stack-trace.js"; export * from "./ecma/error/error.js"; @@ -47,25 +52,21 @@ export * from "./ecma/error/suppressed-error.js"; export * from "./ecma/error/traverse-error.js"; export * from "./ecma/error/unimplemented-error.js"; export * from "./ecma/error/unsupported-error.js"; -export * from "./ecma/function/abortable.js"; -export * from "./ecma/function/debounce-queue.js"; export * from "./ecma/function/no-throw.js"; +export * from "./ecma/function/serialize-queue-next.js"; export * from "./ecma/function/types.js"; export * from "./ecma/function/wait-for.js"; export * from "./ecma/is-object.js"; export * from "./ecma/is-pojo.js"; export * from "./ecma/iterator/types.js"; export * from "./ecma/json/json-serialize.js"; -export * from "./ecma/json/json-serialize-error.js"; -export * from "./ecma/json/json-stringify-safe.js"; -export * from "./ecma/json/make-json-replacer-function.js"; +export * from "./ecma/json/json-stringify.js"; +export * from "./ecma/json/make-replacer-function.js"; +export * from "./ecma/json/replacers/all-replacers.js"; +export * from "./ecma/json/replacers/big-int.js"; +export * from "./ecma/json/replacers/circular-reference.js"; +export * from "./ecma/json/replacers/error.js"; export * from "./ecma/map/case-insensitive-map.js"; -export * from "./ecma/math/average.js"; -export * from "./ecma/math/clamp.js"; -export * from "./ecma/math/minmax.js"; -export * from "./ecma/math/random.js"; -export * from "./ecma/math/round.js"; -export * from "./ecma/math/scale.js"; export * from "./ecma/number/to-fixed-length.js"; export * from "./ecma/object/clone.js"; export * from "./ecma/object/deep-clone.js"; @@ -75,7 +76,6 @@ export * from "./ecma/object/deep-merge-options.js"; export * from "./ecma/object/defaults.js"; export * from "./ecma/object/is-deep-equal.js"; export * from "./ecma/object/is-equal.js"; -export * from "./ecma/promise/abortable-promise.js"; export * from "./ecma/promise/types.js"; export * from "./ecma/regexp/pattern-trim.js"; export * from "./ecma/regexp/pattern-util.js"; @@ -111,10 +111,15 @@ export * from "./fs/text-files/toml-file.js"; export * from "./fs/text-files/yaml-file.js"; export * from "./globals.d/builtin-json.js"; export * from "./globals.d/builtin-regexp.js"; +export * from "./math/average.js"; +export * from "./math/clamp.js"; +export * from "./math/minmax.js"; +export * from "./math/random.js"; +export * from "./math/round.js"; +export * from "./math/scale.js"; export * from "./net/get-random-ephemeral-port.js"; -export * from "./net/http/fields/http-field-value-util.js"; -export * from "./net/http/fields/http-fields.js"; export * from "./net/http/http-headers.js"; +export * from "./net/http/http-trailers.js"; export * from "./net/http/is-http-available.js"; export * from "./net/http/is-redirect-status.js"; export * from "./net/http/types.js"; @@ -132,8 +137,11 @@ export * from "./node/module/import-module.js"; export * from "./node/module/resolve-module.js"; export * from "./node/net/dgram-socket.js"; export * from "./node/net/inet.js"; -export * from "./node/net/tcp-client.js"; +export * from "./node/net/ipc-socket.js"; +export * from "./node/net/stream-socket.js"; export * from "./node/net/tcp-server.js"; +export * from "./node/net/tcp-socket.js"; +export * from "./node/net/tls-socket.js"; export * from "./node/process/error-listeners.js"; export * from "./node/process/exit-manager.js"; export * from "./node/process/pid-file.js"; diff --git a/packages/misc-util/src/ecma/math/average.test.ts b/packages/misc-util/src/math/average.test.ts similarity index 100% rename from packages/misc-util/src/ecma/math/average.test.ts rename to packages/misc-util/src/math/average.test.ts diff --git a/packages/misc-util/src/ecma/math/average.ts b/packages/misc-util/src/math/average.ts similarity index 100% rename from packages/misc-util/src/ecma/math/average.ts rename to packages/misc-util/src/math/average.ts diff --git a/packages/misc-util/src/ecma/math/clamp.test.ts b/packages/misc-util/src/math/clamp.test.ts similarity index 100% rename from packages/misc-util/src/ecma/math/clamp.test.ts rename to packages/misc-util/src/math/clamp.test.ts diff --git a/packages/misc-util/src/ecma/math/clamp.ts b/packages/misc-util/src/math/clamp.ts similarity index 100% rename from packages/misc-util/src/ecma/math/clamp.ts rename to packages/misc-util/src/math/clamp.ts diff --git a/packages/misc-util/src/ecma/math/minmax.test.ts b/packages/misc-util/src/math/minmax.test.ts similarity index 100% rename from packages/misc-util/src/ecma/math/minmax.test.ts rename to packages/misc-util/src/math/minmax.test.ts diff --git a/packages/misc-util/src/ecma/math/minmax.ts b/packages/misc-util/src/math/minmax.ts similarity index 100% rename from packages/misc-util/src/ecma/math/minmax.ts rename to packages/misc-util/src/math/minmax.ts diff --git a/packages/misc-util/src/ecma/math/random.test.ts b/packages/misc-util/src/math/random.test.ts similarity index 100% rename from packages/misc-util/src/ecma/math/random.test.ts rename to packages/misc-util/src/math/random.test.ts diff --git a/packages/misc-util/src/ecma/math/random.ts b/packages/misc-util/src/math/random.ts similarity index 100% rename from packages/misc-util/src/ecma/math/random.ts rename to packages/misc-util/src/math/random.ts diff --git a/packages/misc-util/src/ecma/math/round.test.ts b/packages/misc-util/src/math/round.test.ts similarity index 100% rename from packages/misc-util/src/ecma/math/round.test.ts rename to packages/misc-util/src/math/round.test.ts diff --git a/packages/misc-util/src/ecma/math/round.ts b/packages/misc-util/src/math/round.ts similarity index 94% rename from packages/misc-util/src/ecma/math/round.ts rename to packages/misc-util/src/math/round.ts index cb9447db..d3a4cad5 100644 --- a/packages/misc-util/src/ecma/math/round.ts +++ b/packages/misc-util/src/math/round.ts @@ -1,4 +1,4 @@ -import { defaults } from "../object/defaults.js"; +import { defaults } from "../ecma/object/defaults.js"; /** * Supported rounding methods. diff --git a/packages/misc-util/src/ecma/math/scale.test.ts b/packages/misc-util/src/math/scale.test.ts similarity index 100% rename from packages/misc-util/src/ecma/math/scale.test.ts rename to packages/misc-util/src/math/scale.test.ts diff --git a/packages/misc-util/src/ecma/math/scale.ts b/packages/misc-util/src/math/scale.ts similarity index 100% rename from packages/misc-util/src/ecma/math/scale.ts rename to packages/misc-util/src/math/scale.ts diff --git a/packages/misc-util/src/net/http/fields/http-field-value-util.test.ts b/packages/misc-util/src/net/http/_http-field-value-util.test.ts similarity index 99% rename from packages/misc-util/src/net/http/fields/http-field-value-util.test.ts rename to packages/misc-util/src/net/http/_http-field-value-util.test.ts index 98dad5d3..276f4b9f 100644 --- a/packages/misc-util/src/net/http/fields/http-field-value-util.test.ts +++ b/packages/misc-util/src/net/http/_http-field-value-util.test.ts @@ -5,7 +5,7 @@ import { httpFieldParseQuotedString, httpFieldSplitValueByWs, httpFieldUnfoldValues, -} from "./http-field-value-util.js"; +} from "./_http-field-value-util.js"; suite("httpFieldSplitValueByWs", () => { test("should split by whitespace", () => { diff --git a/packages/misc-util/src/net/http/fields/http-field-value-util.ts b/packages/misc-util/src/net/http/_http-field-value-util.ts similarity index 95% rename from packages/misc-util/src/net/http/fields/http-field-value-util.ts rename to packages/misc-util/src/net/http/_http-field-value-util.ts index 86a80b86..63a0af05 100644 --- a/packages/misc-util/src/net/http/fields/http-field-value-util.ts +++ b/packages/misc-util/src/net/http/_http-field-value-util.ts @@ -1,5 +1,6 @@ -import { patternTrim } from "../../../ecma/regexp/pattern-trim.js"; -import { patternInOutCapture } from "../../../ecma/regexp/pattern-util.js"; +import { patternTrim } from "../../ecma/regexp/pattern-trim.js"; +import { patternInOutCapture } from "../../ecma/regexp/pattern-util.js"; +import type { Pattern } from "../../ecma/regexp/types.js"; // https://httpwg.org/specs/rfc9110.html#rfc.section.2.1 const httpVcharPattern = "[\\x21-\\x7E]"; @@ -72,7 +73,7 @@ const httpDayNameLPattern = "(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)"; const httpRfc850DatePattern = `(?:${httpDayNameLPattern}, ${httpDate2Pattern} ${httpTimeOfDayPattern} GMT)`; const httpDate3Pattern = `(?:${httpMonthPattern} (?:\\d{2}| \\d))`; -const httpAsctimeDatePattern = `(?:${httpDayNamePattern} ${httpDate3Pattern} ${httpTimeOfDayPattern} ${httpYearPattern})`; +export const httpAsctimeDatePattern: Pattern = `(?:${httpDayNamePattern} ${httpDate3Pattern} ${httpTimeOfDayPattern} ${httpYearPattern})`; const httpObsDatePattern = `(?:${httpRfc850DatePattern}|${httpAsctimeDatePattern})`; const httpDatePattern = `(?:${httpImfFixdatePattern}|${httpObsDatePattern})`; diff --git a/packages/misc-util/src/net/http/fields/http-fields.test.ts b/packages/misc-util/src/net/http/_http-fields.test.ts similarity index 98% rename from packages/misc-util/src/net/http/fields/http-fields.test.ts rename to packages/misc-util/src/net/http/_http-fields.test.ts index 0da0df54..7cc22855 100644 --- a/packages/misc-util/src/net/http/fields/http-fields.test.ts +++ b/packages/misc-util/src/net/http/_http-fields.test.ts @@ -1,6 +1,6 @@ import { inspect } from "node:util"; import { expect, suite, test } from "vitest"; -import { HttpFields } from "./http-fields.js"; +import { HttpFields } from "./_http-fields.js"; suite("HttpHeaders", () => { test("should create empty fields", () => { diff --git a/packages/misc-util/src/net/http/fields/http-fields.ts b/packages/misc-util/src/net/http/_http-fields.ts similarity index 95% rename from packages/misc-util/src/net/http/fields/http-fields.ts rename to packages/misc-util/src/net/http/_http-fields.ts index db50a252..60443d10 100644 --- a/packages/misc-util/src/net/http/fields/http-fields.ts +++ b/packages/misc-util/src/net/http/_http-fields.ts @@ -1,12 +1,12 @@ import { EOL } from "node:os"; import { type InspectOptionsStylized, inspect } from "node:util"; -import type { Predicate } from "../../../ecma/function/types.js"; -import { CaseInsensitiveMap } from "../../../ecma/map/case-insensitive-map.js"; -import { stringIsEqualCaseInsensitive } from "../../../ecma/string/string-is-equal.js"; +import type { Predicate } from "../../ecma/function/types.js"; +import { CaseInsensitiveMap } from "../../ecma/map/case-insensitive-map.js"; +import { stringIsEqualCaseInsensitive } from "../../ecma/string/string-is-equal.js"; import { httpFieldFoldValues, httpFieldUnfoldValues, -} from "./http-field-value-util.js"; +} from "./_http-field-value-util.js"; export type HttpFieldName = string; export type HttpFieldValue = string; diff --git a/packages/misc-util/src/net/http/http-headers.test.ts b/packages/misc-util/src/net/http/http-headers.test.ts index 4613e6e5..71ab0556 100644 --- a/packages/misc-util/src/net/http/http-headers.test.ts +++ b/packages/misc-util/src/net/http/http-headers.test.ts @@ -103,9 +103,7 @@ suite("HttpHeaders", () => { expect(lastModified?.toUTCString()).toBe("Sun, 06 Nov 1994 08:49:37 GMT"); }); - // TODO: Enable this test when asctime format is supported (and required...) - // TODO: for now it takes the local timezone into account which is not correct (?) - test.skip("should parse Last-Modified header with asctime format", () => { + test("should parse Last-Modified header with asctime format", () => { const headers = new HttpHeaders({ "last-modified": ["Sun Nov 6 08:49:37 1994"], }); diff --git a/packages/misc-util/src/net/http/http-headers.ts b/packages/misc-util/src/net/http/http-headers.ts index b75aa61c..f01a46f5 100644 --- a/packages/misc-util/src/net/http/http-headers.ts +++ b/packages/misc-util/src/net/http/http-headers.ts @@ -1,16 +1,22 @@ import contentDispositionLib from "content-disposition"; import contentTypeLib from "content-type"; -import { HttpFields } from "./fields/http-fields.js"; +import { httpAsctimeDatePattern } from "./_http-field-value-util.js"; +import { HttpFields } from "./_http-fields.js"; export type HttpContentType = contentTypeLib.ParsedMediaType; export type HttpContentDisposition = contentDispositionLib.ContentDisposition; export class HttpHeaders extends HttpFields { get lastModified(): Date | null { - const lastModifiedHeader = this.get("last-modified")?.[0]; + let lastModifiedHeader = this.get("last-modified")?.[0]; if (!lastModifiedHeader) { return null; } + + if (lastModifiedHeader.match(httpAsctimeDatePattern)) { + lastModifiedHeader += " GMT"; + } + const date = new Date(lastModifiedHeader); if (Number.isNaN(date.getTime())) { throw new Error("Invalid Last-Modified header"); diff --git a/packages/misc-util/src/net/http/http-trailers.test.ts b/packages/misc-util/src/net/http/http-trailers.test.ts new file mode 100644 index 00000000..24da72a3 --- /dev/null +++ b/packages/misc-util/src/net/http/http-trailers.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; +import { HttpTrailers } from "./http-trailers.js"; + +describe("HttpTrailers", () => { + describe("constructor", () => { + it("should create empty trailers", () => { + const trailers = new HttpTrailers(); + expect(trailers.has("any-field")).toBe(false); + }); + + it("should create trailers from object", () => { + const trailers = new HttpTrailers({ + "x-trailer-1": "value1", + "x-trailer-2": "value2", + }); + expect(trailers.get("x-trailer-1")).toEqual(["value1"]); + expect(trailers.get("x-trailer-2")).toEqual(["value2"]); + }); + + it("should create trailers from iterable with single values", () => { + const entries: [string, string][] = [ + ["x-trailer-1", "value1"], + ["x-trailer-2", "value2"], + ]; + const trailers = new HttpTrailers(entries); + expect(trailers.get("x-trailer-1")).toEqual(["value1"]); + expect(trailers.get("x-trailer-2")).toEqual(["value2"]); + }); + + it("should create trailers from iterable with multiple values", () => { + const entries: [string, string[]][] = [ + ["x-trailer-1", ["value1"]], + ["x-trailer-2", ["value2", "value3"]], + ]; + const trailers = new HttpTrailers(entries); + expect(trailers.get("x-trailer-1")).toEqual(["value1"]); + expect(trailers.get("x-trailer-2")).toEqual(["value2", "value3"]); + }); + }); + + describe("field operations", () => { + it("should set and get trailer values", () => { + const trailers = new HttpTrailers(); + trailers.set("x-custom-trailer", ["custom-value"]); + expect(trailers.get("x-custom-trailer")).toEqual(["custom-value"]); + }); + + it("should handle multiple values for same field", () => { + const trailers = new HttpTrailers(); + trailers.append("x-multi", "value1"); + trailers.append("x-multi", "value2"); + expect(trailers.get("x-multi")).toEqual(["value1", "value2"]); + }); + + it("should set multiple values at once", () => { + const trailers = new HttpTrailers(); + trailers.set("x-multi", ["value1", "value2", "value3"]); + expect(trailers.get("x-multi")).toEqual(["value1", "value2", "value3"]); + }); + + it("should delete trailer fields", () => { + const trailers = new HttpTrailers({ + "x-trailer-1": "value1", + "x-trailer-2": "value2", + }); + expect(trailers.has("x-trailer-1")).toBe(true); + trailers.delete("x-trailer-1"); + expect(trailers.has("x-trailer-1")).toBe(false); + expect(trailers.has("x-trailer-2")).toBe(true); + }); + + it("should handle case-insensitive field names", () => { + const trailers = new HttpTrailers(); + trailers.set("X-Custom-Trailer", ["value"]); + expect(trailers.get("x-custom-trailer")).toEqual(["value"]); + expect(trailers.get("X-CUSTOM-TRAILER")).toEqual(["value"]); + expect(trailers.has("x-CuStOm-TrAiLeR")).toBe(true); + }); + + it("should clear all fields", () => { + const trailers = new HttpTrailers({ + "x-trailer-1": "value1", + "x-trailer-2": "value2", + }); + expect(trailers.has("x-trailer-1")).toBe(true); + trailers.clear(); + expect(trailers.has("x-trailer-1")).toBe(false); + expect(trailers.has("x-trailer-2")).toBe(false); + }); + + it("should set null to delete field", () => { + const trailers = new HttpTrailers({ + "x-trailer": "value", + }); + expect(trailers.has("x-trailer")).toBe(true); + trailers.set("x-trailer", null); + expect(trailers.has("x-trailer")).toBe(false); + }); + }); + + describe("iteration", () => { + it("should iterate over trailer entries", () => { + const trailers = new HttpTrailers({ + "x-trailer-1": "value1", + "x-trailer-2": "value2", + }); + + const entries = Array.from(trailers); + expect(entries).toHaveLength(2); + expect(entries).toContainEqual(["x-trailer-1", ["value1"]]); + expect(entries).toContainEqual(["x-trailer-2", ["value2"]]); + }); + + it("should iterate over trailer keys", () => { + const trailers = new HttpTrailers({ + "x-trailer-1": "value1", + "x-trailer-2": "value2", + }); + + // Extract all entries using iteration + const allEntries = Array.from(trailers); + const keys = allEntries.map(([name]) => name); + expect(keys).toHaveLength(2); + expect(keys).toContain("x-trailer-1"); + expect(keys).toContain("x-trailer-2"); + }); + + it("should iterate over folded entries", () => { + const trailers = new HttpTrailers({ + "x-trailer-1": "value1", + "x-trailer-2": ["value2", "value3"], + }); + + const entries = Array.from(trailers.foldedEntries()); + expect(entries).toHaveLength(2); + expect(entries).toContainEqual(["x-trailer-1", "value1"]); + // Multiple values should be folded into one + expect(entries).toContainEqual(["x-trailer-2", "value2, value3"]); + }); + }); + + describe("inheritance from HttpFields", () => { + it("should support all HttpFields methods", () => { + const trailers = new HttpTrailers({ + "x-trailer": "value", + }); + + // Test that it has HttpFields properties/methods + expect(trailers.get("x-trailer")).toEqual(["value"]); + expect(trailers.has("x-trailer")).toBe(true); + }); + + it("should support filtering", () => { + const trailers = new HttpTrailers({ + "x-keep": "value1", + "x-remove": "value2", + }); + + const filtered = trailers.filter((name) => name.startsWith("x-keep")); + expect(filtered.has("x-keep")).toBe(true); + expect(filtered.has("x-remove")).toBe(false); + }); + }); +}); diff --git a/packages/misc-util/src/net/http/http-trailers.ts b/packages/misc-util/src/net/http/http-trailers.ts new file mode 100644 index 00000000..af64fef0 --- /dev/null +++ b/packages/misc-util/src/net/http/http-trailers.ts @@ -0,0 +1,9 @@ +import { HttpFields } from "./_http-fields.js"; + +/** + * HTTP trailer headers. + * + * Trailers are headers sent after the message body in chunked transfer encoding + * (HTTP/1.1) or as trailing HEADERS frames (HTTP/2/3). + */ +export class HttpTrailers extends HttpFields {} diff --git a/packages/misc-util/src/net/http/is-http-available.test.ts b/packages/misc-util/src/net/http/is-http-available.test.ts new file mode 100644 index 00000000..257068ae --- /dev/null +++ b/packages/misc-util/src/net/http/is-http-available.test.ts @@ -0,0 +1,181 @@ +import type { Server } from "node:http"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { isHttpAvailable } from "./is-http-available.js"; + +function listenAsync( + server: Server, + port: number, + host: string, +): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, () => { + server.removeListener("error", reject); + resolve(); + }); + }); +} + +function closeAsync(server: Server): Promise { + return new Promise((resolve, reject) => { + server.closeAllConnections(); + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +} + +describe("isHttpAvailable", () => { + let server: Server | null = null; + + afterEach(async () => { + if (server?.listening) { + await closeAsync(server); + server = null; + } + }); + + describe("available endpoints", () => { + it("should return true for an available HTTP endpoint", async () => { + server = createServer((_, res) => { + res.writeHead(200); + res.end(); + }); + await listenAsync(server, 0, "127.0.0.1"); + const address = server.address() as AddressInfo; + + const url = `http://127.0.0.1:${address.port}/`; + const result = await isHttpAvailable(url); + + expect(result).toBe(true); + }); + + it("should return true for an available endpoint (HEAD request)", async () => { + let requestMethod = ""; + server = createServer((req, res) => { + requestMethod = req.method ?? ""; + res.writeHead(200); + res.end(); + }); + await listenAsync(server, 0, "127.0.0.1"); + const address = server.address() as AddressInfo; + + const url = `http://127.0.0.1:${address.port}/`; + const result = await isHttpAvailable(url); + + expect(result).toBe(true); + expect(requestMethod).toBe("HEAD"); + }); + + it("should return true even for error responses", async () => { + server = createServer((_, res) => { + res.writeHead(404); + res.end(); + }); + await listenAsync(server, 0, "127.0.0.1"); + const address = server.address() as AddressInfo; + + const url = `http://127.0.0.1:${address.port}/not-found`; + const result = await isHttpAvailable(url); + + // The function returns true if the server responds, regardless of status code + expect(result).toBe(true); + }); + }); + + describe("unavailable endpoints", () => { + it("should return false for an unavailable endpoint", async () => { + // Use a port that's unlikely to be in use + const url = "http://127.0.0.1:59999/"; + const result = await isHttpAvailable(url); + + expect(result).toBe(false); + }); + + it("should return false for invalid URL", async () => { + const result = await isHttpAvailable("http://invalid.local.test:99999/"); + + expect(result).toBe(false); + }); + + it("should return false for network errors", async () => { + const result = await isHttpAvailable("http://0.0.0.0:1/"); + + expect(result).toBe(false); + }); + }); + + describe("URL parameter", () => { + it("should accept URL object", async () => { + server = createServer((_, res) => { + res.writeHead(200); + res.end(); + }); + await listenAsync(server, 0, "127.0.0.1"); + const address = server.address() as AddressInfo; + + const url = new URL(`http://127.0.0.1:${address.port}/`); + const result = await isHttpAvailable(url); + + expect(result).toBe(true); + }); + + it("should accept string URL", async () => { + server = createServer((_, res) => { + res.writeHead(200); + res.end(); + }); + await listenAsync(server, 0, "127.0.0.1"); + const address = server.address() as AddressInfo; + + const url = `http://127.0.0.1:${address.port}/test`; + const result = await isHttpAvailable(url); + + expect(result).toBe(true); + }); + }); + + describe("abort signal", () => { + it("should respect abort signal", async () => { + // Don't respond immediately to simulate a slow server + server = createServer((_, res) => { + setTimeout(() => { + res.writeHead(200); + res.end(); + }, 5000); + }); + await listenAsync(server, 0, "127.0.0.1"); + const address = server.address() as AddressInfo; + + const controller = new AbortController(); + const url = `http://127.0.0.1:${address.port}/`; + + // Abort after 100ms + setTimeout(() => controller.abort(), 100); + + const result = await isHttpAvailable(url, controller.signal); + + expect(result).toBe(false); + }); + + it("should work with null signal", async () => { + server = createServer((_, res) => { + res.writeHead(200); + res.end(); + }); + await listenAsync(server, 0, "127.0.0.1"); + const address = server.address() as AddressInfo; + + const url = `http://127.0.0.1:${address.port}/`; + const result = await isHttpAvailable(url, null); + + expect(result).toBe(true); + }); + }); +}); diff --git a/packages/misc-util/src/net/http/is-redirect-status.test.ts b/packages/misc-util/src/net/http/is-redirect-status.test.ts new file mode 100644 index 00000000..4b111df6 --- /dev/null +++ b/packages/misc-util/src/net/http/is-redirect-status.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { httpIsRedirectStatus } from "./is-redirect-status.js"; +import { HttpStatusCode } from "./types.js"; + +describe("httpIsRedirectStatus", () => { + describe("redirect status codes", () => { + it("should return true for 301 Moved Permanently", () => { + expect(httpIsRedirectStatus(HttpStatusCode.MOVED_PERMANENTLY)).toBe(true); + expect(httpIsRedirectStatus(301)).toBe(true); + }); + + it("should return true for 302 Found", () => { + expect(httpIsRedirectStatus(HttpStatusCode.FOUND)).toBe(true); + expect(httpIsRedirectStatus(302)).toBe(true); + }); + + it("should return true for 303 See Other", () => { + expect(httpIsRedirectStatus(HttpStatusCode.SEE_OTHER)).toBe(true); + expect(httpIsRedirectStatus(303)).toBe(true); + }); + + it("should return true for 307 Temporary Redirect", () => { + expect(httpIsRedirectStatus(HttpStatusCode.TEMPORARY_REDIRECT)).toBe( + true, + ); + expect(httpIsRedirectStatus(307)).toBe(true); + }); + + it("should return true for 308 Permanent Redirect", () => { + expect(httpIsRedirectStatus(HttpStatusCode.PERMANENT_REDIRECT)).toBe( + true, + ); + expect(httpIsRedirectStatus(308)).toBe(true); + }); + }); + + describe("non-redirect status codes", () => { + it("should return false for 2xx success codes", () => { + expect(httpIsRedirectStatus(HttpStatusCode.OK)).toBe(false); + expect(httpIsRedirectStatus(HttpStatusCode.CREATED)).toBe(false); + expect(httpIsRedirectStatus(HttpStatusCode.NO_CONTENT)).toBe(false); + }); + + it("should return false for 4xx client error codes", () => { + expect(httpIsRedirectStatus(HttpStatusCode.BAD_REQUEST)).toBe(false); + expect(httpIsRedirectStatus(HttpStatusCode.NOT_FOUND)).toBe(false); + expect(httpIsRedirectStatus(HttpStatusCode.UNAUTHORIZED)).toBe(false); + }); + + it("should return false for 5xx server error codes", () => { + expect(httpIsRedirectStatus(HttpStatusCode.INTERNAL_SERVER_ERROR)).toBe( + false, + ); + expect(httpIsRedirectStatus(502)).toBe(false); // Bad Gateway + expect(httpIsRedirectStatus(HttpStatusCode.SERVICE_UNAVAILABLE)).toBe( + false, + ); + }); + + it("should return false for 1xx informational codes", () => { + expect(httpIsRedirectStatus(100)).toBe(false); // Continue + expect(httpIsRedirectStatus(101)).toBe(false); // Switching Protocols + }); + + it("should return false for other 3xx codes not in the redirect list", () => { + expect(httpIsRedirectStatus(HttpStatusCode.NOT_MODIFIED)).toBe(false); // 304 + expect(httpIsRedirectStatus(300)).toBe(false); // Multiple Choices + expect(httpIsRedirectStatus(305)).toBe(false); // Use Proxy (deprecated) + expect(httpIsRedirectStatus(306)).toBe(false); // Switch Proxy (unused) + }); + + it("should return false for invalid status codes", () => { + expect(httpIsRedirectStatus(0)).toBe(false); + expect(httpIsRedirectStatus(999)).toBe(false); + expect(httpIsRedirectStatus(-1)).toBe(false); + }); + }); + + describe("edge cases", () => { + it("should handle numeric values", () => { + expect(httpIsRedirectStatus(301.5)).toBe(false); // Not an integer + expect(httpIsRedirectStatus(Number.NaN)).toBe(false); + expect(httpIsRedirectStatus(Number.POSITIVE_INFINITY)).toBe(false); + }); + }); +}); diff --git a/packages/misc-util/src/node/module/import-module.ts b/packages/misc-util/src/node/module/import-module.ts index 5b5b9450..15967bc7 100644 --- a/packages/misc-util/src/node/module/import-module.ts +++ b/packages/misc-util/src/node/module/import-module.ts @@ -13,7 +13,6 @@ export async function importModule( id: string, resolvePaths?: string[], ): Promise { - // biome-ignore lint/nursery/noUnnecessaryConditions: false positive if (resolvePaths && resolvePaths.length > 0) { id = require.resolve(id, { paths: resolvePaths }); } diff --git a/packages/misc-util/src/node/net/dgram-socket.test.ts b/packages/misc-util/src/node/net/dgram-socket.test.ts index 2dbb68ad..49f54573 100644 --- a/packages/misc-util/src/node/net/dgram-socket.test.ts +++ b/packages/misc-util/src/node/net/dgram-socket.test.ts @@ -132,7 +132,7 @@ describe("DgramSocket", () => { msg: Buffer; size: number; }>((resolve) => { - receiver.once("message", (msg, size) => { + receiver.subscribe("message", (msg, size) => { resolve({ msg, size }); }); }); @@ -163,7 +163,7 @@ describe("DgramSocket", () => { if (!receiverAddress) return; const messagePromise = new Promise((resolve) => { - receiver.once("message", (msg) => { + receiver.subscribe("message", (msg) => { resolve(msg); }); }); @@ -194,7 +194,7 @@ describe("DgramSocket", () => { if (!receiverAddress) return; const messagePromise = new Promise((resolve) => { - receiver.once("message", (msg) => { + receiver.subscribe("message", (msg) => { resolve(msg); }); }); @@ -221,7 +221,7 @@ describe("DgramSocket", () => { if (!receiverAddress) return; const messagePromise = new Promise((resolve) => { - receiver.once("message", (msg) => { + receiver.subscribe("message", (msg) => { resolve(msg); }); }); @@ -274,20 +274,114 @@ describe("DgramSocket", () => { socket.address(); expect(addressSpy).toHaveBeenCalledOnce(); }); + + it("should configure broadcast and buffers", async () => { + const mockSocket = dgram.createSocket({ type: "udp4" }); + socket = new DgramSocket(mockSocket); + await socket.bind(0, "127.0.0.1"); + + const setBroadcastSpy = vi.spyOn(mockSocket, "setBroadcast"); + const setRecvBufferSizeSpy = vi.spyOn(mockSocket, "setRecvBufferSize"); + const setSendBufferSizeSpy = vi.spyOn(mockSocket, "setSendBufferSize"); + const getRecvBufferSizeSpy = vi.spyOn(mockSocket, "getRecvBufferSize"); + const getSendBufferSizeSpy = vi.spyOn(mockSocket, "getSendBufferSize"); + + socket.setBroadcast(true); + socket.setRecvBufferSize(1024); + socket.setSendBufferSize(2048); + void socket.getRecvBufferSize(); + void socket.getSendBufferSize(); + + expect(setBroadcastSpy).toHaveBeenCalledWith(true); + expect(setRecvBufferSizeSpy).toHaveBeenCalledWith(1024); + expect(setSendBufferSizeSpy).toHaveBeenCalledWith(2048); + expect(getRecvBufferSizeSpy).toHaveBeenCalledOnce(); + expect(getSendBufferSizeSpy).toHaveBeenCalledOnce(); + }); + + it("should configure multicast and source membership", async () => { + const mockSocket = dgram.createSocket({ type: "udp4" }); + socket = new DgramSocket(mockSocket); + await socket.bind(0, "127.0.0.1"); + + const addMembershipSpy = vi.spyOn(mockSocket, "addMembership"); + const dropMembershipSpy = vi.spyOn(mockSocket, "dropMembership"); + const addSourceSpecificMembershipSpy = vi.spyOn( + mockSocket, + "addSourceSpecificMembership", + ); + const dropSourceSpecificMembershipSpy = vi.spyOn( + mockSocket, + "dropSourceSpecificMembership", + ); + const setMulticastInterfaceSpy = vi.spyOn( + mockSocket, + "setMulticastInterface", + ); + const setMulticastLoopbackSpy = vi.spyOn( + mockSocket, + "setMulticastLoopback", + ); + const setMulticastTTLSpy = vi.spyOn(mockSocket, "setMulticastTTL"); + const setTTLSpy = vi.spyOn(mockSocket, "setTTL"); + + socket.addMembership("239.0.0.1", "0.0.0.0"); + socket.dropMembership("239.0.0.1", "0.0.0.0"); + socket.addSourceMembership("192.0.2.1", "239.0.0.1", "0.0.0.0"); + socket.dropSourceMembership("192.0.2.1", "239.0.0.1", "0.0.0.0"); + socket.setMulticastInterface("0.0.0.0"); + socket.setMulticastLoop(true); + socket.setMulticastTtl(2); + socket.setTtl(64); + + expect(addMembershipSpy).toHaveBeenCalledWith("239.0.0.1", "0.0.0.0"); + expect(dropMembershipSpy).toHaveBeenCalledWith("239.0.0.1", "0.0.0.0"); + expect(addSourceSpecificMembershipSpy).toHaveBeenCalledWith( + "192.0.2.1", + "239.0.0.1", + "0.0.0.0", + ); + expect(dropSourceSpecificMembershipSpy).toHaveBeenCalledWith( + "192.0.2.1", + "239.0.0.1", + "0.0.0.0", + ); + expect(setMulticastInterfaceSpy).toHaveBeenCalledWith("0.0.0.0"); + expect(setMulticastLoopbackSpy).toHaveBeenCalledWith(true); + expect(setMulticastTTLSpy).toHaveBeenCalledWith(2); + expect(setTTLSpy).toHaveBeenCalledWith(64); + }); + + it("should expose send queue statistics", () => { + const mockSocket = dgram.createSocket({ type: "udp4" }); + socket = new DgramSocket(mockSocket); + + const getSendQueueCountSpy = vi + .spyOn(mockSocket, "getSendQueueCount") + .mockReturnValue(3); + const getSendQueueSizeSpy = vi + .spyOn(mockSocket, "getSendQueueSize") + .mockReturnValue(512); + + const stat = socket.getStat(); + + expect(getSendQueueCountSpy).toHaveBeenCalledOnce(); + expect(getSendQueueSizeSpy).toHaveBeenCalledOnce(); + expect(stat.sendQueueCount).toBe(3); + expect(stat.sendQueueSize).toBe(512); + }); }); describe("events", () => { - describe("on()", () => { + describe("subscribe()/unsubscribe()", () => { it("should register listening event listener", async () => { socket = DgramSocket.from(); - const listeningPromise = new Promise((resolve) => { expect(socket).toBeDefined(); - socket?.on("listening", () => { + socket?.subscribe("listening", () => { resolve(); }); }); - await socket.bind(0); await listeningPromise; }); @@ -295,34 +389,29 @@ describe("DgramSocket", () => { it("should register message event listener", async () => { socket = DgramSocket.from(); await socket.bind(0, "127.0.0.1"); - const messagePromise = new Promise((resolve) => { expect(socket).toBeDefined(); - socket?.on("message", () => { + socket?.subscribe("message", () => { resolve(); }); }); - const address = socket.address(); expect(address).not.toBeNull(); if (!address) return; const sender = DgramSocket.from(); await sender.send(address.port, address.address, "test"); - await messagePromise; await sender.close(); }); it("should register close event listener", async () => { socket = DgramSocket.from(); - const closePromise = new Promise((resolve) => { expect(socket).toBeDefined(); - socket?.on("close", () => { + socket?.subscribe("close", () => { resolve(); }); }); - await socket.close(); await closePromise; }); @@ -330,81 +419,50 @@ describe("DgramSocket", () => { it("should register error event listener", async () => { socket = DgramSocket.from(); await socket.bind(0); - const testError = new Error("Socket error"); - const errorPromise = new Promise((resolve) => { expect(socket).toBeDefined(); - socket?.on("error", (err) => { + socket?.subscribe("error", (err) => { resolve(err); }); }); - // Emit an error directly on the underlying socket to test error forwarding // (Note: bind errors are handled specially by bind() and won't be re-emitted) // biome-ignore lint/suspicious/noExplicitAny: accessing private property for testing (socket as any).sock.emit("error", testError); - const error = await errorPromise; expect(error).toBe(testError); }); - it("should return this for chaining", () => { - socket = DgramSocket.from(); - const result = socket.on("listening", () => {}); - expect(result).toBe(socket); - }); - }); - describe("once()", () => { it("should register one-time listening event listener", async () => { let testSocket = DgramSocket.from(); - let callCount = 0; - testSocket.once("listening", () => { - callCount++; - }); - + testSocket.subscribe( + "listening", + () => { + callCount++; + }, + { once: true }, + ); await testSocket.bind(0); await testSocket.close(); - testSocket = DgramSocket.from(); await testSocket.bind(0); - expect(callCount).toBe(1); - await testSocket.close(); }); - it("should return this for chaining", () => { - socket = DgramSocket.from(); - const result = socket.once("listening", () => {}); - expect(result).toBe(socket); - }); - }); - - describe("off()", () => { it("should remove listening event listener", async () => { socket = DgramSocket.from(); - let callCount = 0; const listener = () => { callCount++; }; - - socket.on("listening", listener); - socket.off("listening", listener); - + socket.subscribe("listening", listener); + socket.unsubscribe("listening", listener); await socket.bind(0); - expect(callCount).toBe(0); }); - - it("should return this for chaining", () => { - socket = DgramSocket.from(); - const listener = () => {}; - const result = socket.off("listening", listener); - expect(result).toBe(socket); - }); }); }); @@ -412,15 +470,16 @@ describe("DgramSocket", () => { it("should forward listening event", async () => { const mockSocket = dgram.createSocket({ type: "udp4" }); const testSocket = new DgramSocket(mockSocket); - const listeningPromise = new Promise((resolve) => { - testSocket.once("listening", () => { - resolve(); - }); + testSocket.subscribe( + "listening", + () => { + resolve(); + }, + { once: true }, + ); }); - mockSocket.emit("listening"); - await listeningPromise; mockSocket.close(); }); @@ -428,30 +487,32 @@ describe("DgramSocket", () => { it("should forward close event", async () => { const mockSocket = dgram.createSocket({ type: "udp4" }); const testSocket = new DgramSocket(mockSocket); - const closePromise = new Promise((resolve) => { - testSocket.once("close", () => { - resolve(); - }); + testSocket.subscribe( + "close", + () => { + resolve(); + }, + { once: true }, + ); }); - mockSocket.emit("close"); - await closePromise; }); it("should forward connect event", async () => { const mockSocket = dgram.createSocket({ type: "udp4" }); const testSocket = new DgramSocket(mockSocket); - const connectPromise = new Promise((resolve) => { - testSocket.once("connect", () => { - resolve(); - }); + testSocket.subscribe( + "connect", + () => { + resolve(); + }, + { once: true }, + ); }); - mockSocket.emit("connect"); - await connectPromise; mockSocket.close(); }); @@ -459,17 +520,17 @@ describe("DgramSocket", () => { it("should forward error event", async () => { const mockSocket = dgram.createSocket({ type: "udp4" }); const testSocket = new DgramSocket(mockSocket); - const testError = new Error("Test error"); - const errorPromise = new Promise((resolve) => { - testSocket.once("error", (error) => { - resolve(error); - }); + testSocket.subscribe( + "error", + (error) => { + resolve(error); + }, + { once: true }, + ); }); - mockSocket.emit("error", testError); - const receivedError = await errorPromise; expect(receivedError).toBe(testError); mockSocket.close(); @@ -478,7 +539,6 @@ describe("DgramSocket", () => { it("should forward message event with transformed data", async () => { const mockSocket = dgram.createSocket({ type: "udp4" }); const testSocket = new DgramSocket(mockSocket); - const messagePromise = new Promise<{ msg: Buffer; size: number; @@ -486,17 +546,20 @@ describe("DgramSocket", () => { port: number; family: number | null; }>((resolve) => { - testSocket.once("message", (msg, size, from) => { - resolve({ - msg, - size, - address: from.address, - port: from.port, - family: from.family, - }); - }); + testSocket.subscribe( + "message", + (msg, size, from) => { + resolve({ + msg, + size, + address: from.address, + port: from.port, + family: from.family, + }); + }, + { once: true }, + ); }); - const testMsg = Buffer.from("Hello"); const rinfo = { address: "192.168.1.100", @@ -504,9 +567,7 @@ describe("DgramSocket", () => { port: 54321, size: testMsg.length, }; - mockSocket.emit("message", testMsg, rinfo); - const result = await messagePromise; expect(result.msg).toBe(testMsg); expect(result.size).toBe(testMsg.length); @@ -521,38 +582,39 @@ describe("DgramSocket", () => { it("should handle bidirectional communication", async () => { const socket1 = DgramSocket.from(); const socket2 = DgramSocket.from(); - await socket1.bind(0, "127.0.0.1"); await socket2.bind(0, "127.0.0.1"); - const addr1 = socket1.address(); const addr2 = socket2.address(); expect(addr1).not.toBeNull(); expect(addr2).not.toBeNull(); if (!addr1 || !addr2) return; - // Socket1 sends to Socket2 const message1Promise = new Promise((resolve) => { - socket2.once("message", (msg) => { - resolve(msg.toString()); - }); + socket2.subscribe( + "message", + (msg) => { + resolve(msg.toString()); + }, + { once: true }, + ); }); - await socket1.send(addr2.port, addr2.address, "Hello from socket1"); const msg1 = await message1Promise; expect(msg1).toBe("Hello from socket1"); - // Socket2 sends to Socket1 const message2Promise = new Promise((resolve) => { - socket1.once("message", (msg) => { - resolve(msg.toString()); - }); + socket1.subscribe( + "message", + (msg) => { + resolve(msg.toString()); + }, + { once: true }, + ); }); - await socket2.send(addr1.port, addr1.address, "Hello from socket2"); const msg2 = await message2Promise; expect(msg2).toBe("Hello from socket2"); - await socket1.close(); await socket2.close(); }); @@ -560,47 +622,41 @@ describe("DgramSocket", () => { it("should handle multiple messages", async () => { const receiver = DgramSocket.from(); await receiver.bind(0, "127.0.0.1"); - const receiverAddress = receiver.address(); expect(receiverAddress).not.toBeNull(); if (!receiverAddress) return; - const messages: string[] = []; - receiver.on("message", (msg) => { + receiver.subscribe("message", (msg) => { messages.push(msg.toString()); }); - socket = DgramSocket.from(); - await socket.send(receiverAddress.port, receiverAddress.address, "msg1"); await socket.send(receiverAddress.port, receiverAddress.address, "msg2"); await socket.send(receiverAddress.port, receiverAddress.address, "msg3"); - // Wait a bit for messages to arrive await new Promise((resolve) => setTimeout(resolve, 100)); - expect(messages).toHaveLength(3); expect(messages).toContain("msg1"); expect(messages).toContain("msg2"); expect(messages).toContain("msg3"); - await receiver.close(); }); it("should handle binary data", async () => { const receiver = DgramSocket.from(); await receiver.bind(0, "127.0.0.1"); - const receiverAddress = receiver.address(); expect(receiverAddress).not.toBeNull(); if (!receiverAddress) return; - const messagePromise = new Promise((resolve) => { - receiver.once("message", (msg) => { - resolve(msg); - }); + receiver.subscribe( + "message", + (msg) => { + resolve(msg); + }, + { once: true }, + ); }); - socket = DgramSocket.from(); const binaryData = Buffer.from([0x01, 0x02, 0x03, 0x04, 0xff]); await socket.send( @@ -608,27 +664,26 @@ describe("DgramSocket", () => { receiverAddress.address, binaryData, ); - const msg = await messagePromise; expect(msg).toEqual(binaryData); - await receiver.close(); }); it("should send DataView messages", async () => { const receiver = DgramSocket.from(); await receiver.bind(0, "127.0.0.1"); - const receiverAddress = receiver.address(); expect(receiverAddress).not.toBeNull(); if (!receiverAddress) return; - const messagePromise = new Promise((resolve) => { - receiver.once("message", (msg) => { - resolve(msg); - }); + receiver.subscribe( + "message", + (msg) => { + resolve(msg); + }, + { once: true }, + ); }); - socket = DgramSocket.from(); const buffer = new ArrayBuffer(5); const view = new DataView(buffer); @@ -637,12 +692,9 @@ describe("DgramSocket", () => { view.setUint8(2, 108); // 'l' view.setUint8(3, 108); // 'l' view.setUint8(4, 111); // 'o' - await socket.send(receiverAddress.port, receiverAddress.address, view); - const msg = await messagePromise; expect(msg.toString()).toBe("Hello"); - await receiver.close(); }); diff --git a/packages/misc-util/src/node/net/dgram-socket.ts b/packages/misc-util/src/node/net/dgram-socket.ts index 7a9b306b..2c7f1c48 100644 --- a/packages/misc-util/src/node/net/dgram-socket.ts +++ b/packages/misc-util/src/node/net/dgram-socket.ts @@ -1,7 +1,8 @@ import * as dgram from "node:dgram"; -import { EventEmitter } from "node:events"; import type * as net from "node:net"; import type { Except, TypedArray } from "type-fest"; +import { EventDispatcherMapBase } from "../../async/events/_event-dispatcher-map-base.js"; +import type { IEventDispatcherMap } from "../../async/events/ievent-dispatcher-map.js"; import type { IError } from "../../ecma/error/error.js"; import { isNodeErrorWithCode } from "../error/node-error.js"; import { composeInetAddress, type InetEndpoint } from "./inet.js"; @@ -9,7 +10,7 @@ import { composeInetAddress, type InetEndpoint } from "./inet.js"; /** * Event map for DgramSocket socket-specific events. */ -export interface DgramSocketEvents { +export type DgramSocketEvents = { /** * Emitted when the socket is closed. */ @@ -37,10 +38,34 @@ export interface DgramSocketEvents { * @param from - The sender's endpoint information (address, port, and family) */ message: [msg: Buffer, msgSize: number, from: InetEndpoint]; +}; + +export interface DgramSocketStat { + /** + * The number of datagrams currently queued for sending. + */ + sendQueueCount: number; + + /** + * The total number of bytes currently queued for sending. + */ + sendQueueSize: number; } /** - * Datagram (UDP) socket wrapper. + * High-level wrapper around Node.js {@link dgram.Socket | UDP datagram sockets}. + * + * This class delegates almost all behavior to an underlying {@link dgram.Socket} + * instance while: + * + * - Providing strongly-typed events via {@link DgramSocketEvents}. + * - Using {@link InetEndpoint} instead of Node's `RemoteInfo`. + * - Exposing promise-based {@link DgramSocket.bind | bind} and + * {@link DgramSocket.close | close} helpers. + * - Aggregating send-queue statistics through {@link DgramSocket.getStat}. + * + * Unless explicitly documented otherwise, methods have the same semantics as + * their counterparts on {@link dgram.Socket}. * * Example usage: * ```ts @@ -53,7 +78,10 @@ export interface DgramSocketEvents { * await dgramSocket.close(); * ``` */ -export class DgramSocket extends EventEmitter { +export class DgramSocket + extends EventDispatcherMapBase + implements IEventDispatcherMap +{ /** * Creates a new `DgramSocket` instance with the specified options. */ @@ -85,6 +113,157 @@ export class DgramSocket extends EventEmitter { this.sock.unref(); } + /** + * Enables or disables sending of broadcast datagrams. + * + * This is a thin wrapper around {@link dgram.Socket.setBroadcast}, which + * toggles the `SO_BROADCAST` socket option. When enabled, UDP packets may be + * sent to broadcast addresses. + * + * @param flag Whether broadcast should be enabled. + */ + setBroadcast(flag: boolean): void { + this.sock.setBroadcast(flag); + } + + /** + * Adds this socket to the given multicast group. + * + * This is a thin wrapper around {@link dgram.Socket.addMembership}, which + * configures `IP_ADD_MEMBERSHIP` for the given multicast group. + */ + addMembership(multicastAddress: string, multicastInterface?: string): void { + this.sock.addMembership(multicastAddress, multicastInterface); + } + + /** + * Sets the size in bytes of the underlying receive buffer. + * + * This is a thin wrapper around {@link dgram.Socket.setRecvBufferSize}, which + * sets the `SO_RCVBUF` socket option. + */ + setRecvBufferSize(size: number): void { + this.sock.setRecvBufferSize(size); + } + + /** + * Returns the size in bytes of the underlying receive buffer. + * + * This is a thin wrapper around {@link dgram.Socket.getRecvBufferSize}, which + * reads the `SO_RCVBUF` socket option. + */ + getRecvBufferSize(): number { + return this.sock.getRecvBufferSize(); + } + + /** + * Sets the size in bytes of the underlying send buffer. + * + * This is a thin wrapper around {@link dgram.Socket.setSendBufferSize}, which + * sets the `SO_SNDBUF` socket option. + */ + setSendBufferSize(size: number): void { + this.sock.setSendBufferSize(size); + } + + /** + * Returns the size in bytes of the underlying send buffer. + * + * This is a thin wrapper around {@link dgram.Socket.getSendBufferSize}, which + * reads the `SO_SNDBUF` socket option. + */ + getSendBufferSize(): number { + return this.sock.getSendBufferSize(); + } + + /** + * Removes this socket from the given multicast group. + * + * This is a thin wrapper around {@link dgram.Socket.dropMembership}, which + * configures `IP_DROP_MEMBERSHIP` for the given multicast group. + */ + dropMembership(multicastAddress: string, multicastInterface?: string): void { + this.sock.dropMembership(multicastAddress, multicastInterface); + } + + /** + * Adds this socket to a source-specific multicast group. + * + * This is a thin wrapper around + * {@link dgram.Socket.addSourceSpecificMembership}, configuring + * source-specific multicast membership for the given source and group. + */ + addSourceMembership( + sourceAddress: string, + groupAddress: string, + multicastInterface?: string, + ): void { + this.sock.addSourceSpecificMembership( + sourceAddress, + groupAddress, + multicastInterface, + ); + } + + /** + * Removes this socket from a source-specific multicast group. + * + * This is a thin wrapper around + * {@link dgram.Socket.dropSourceSpecificMembership}, removing + * source-specific multicast membership for the given source and group. + */ + dropSourceMembership( + sourceAddress: string, + groupAddress: string, + multicastInterface?: string, + ): void { + this.sock.dropSourceSpecificMembership( + sourceAddress, + groupAddress, + multicastInterface, + ); + } + + /** + * Sets the outbound multicast interface. + * + * This is a thin wrapper around {@link dgram.Socket.setMulticastInterface}, + * which controls the default outgoing interface for multicast traffic. + */ + setMulticastInterface(multicastInterface: string): void { + this.sock.setMulticastInterface(multicastInterface); + } + + /** + * Enables or disables loopback for multicast packets sent from this socket. + * + * This is a thin wrapper around {@link dgram.Socket.setMulticastLoopback}, + * which toggles the `IP_MULTICAST_LOOP` socket option. + */ + setMulticastLoop(flag: boolean): void { + this.sock.setMulticastLoopback(flag); + } + + /** + * Sets the time-to-live (TTL) value for multicast packets. + * + * This is a thin wrapper around {@link dgram.Socket.setMulticastTTL}, which + * configures the `IP_MULTICAST_TTL` socket option. + */ + setMulticastTtl(ttl: number): void { + this.sock.setMulticastTTL(ttl); + } + + /** + * Sets the unicast time-to-live (TTL) value for outgoing packets. + * + * This is a thin wrapper around {@link dgram.Socket.setTTL}, which + * configures the `IP_TTL` socket option. + */ + setTtl(ttl: number): void { + this.sock.setTTL(ttl); + } + /** * Gets the address information of the bound socket. * @@ -172,6 +351,19 @@ export class DgramSocket extends EventEmitter { }); } + /** + * Returns statistics about the underlying send queue. + * + * This wraps {@link dgram.Socket.getSendQueueCount} and + * {@link dgram.Socket.getSendQueueSize} into a single object. + */ + getStat(): DgramSocketStat { + return { + sendQueueCount: this.sock.getSendQueueCount(), + sendQueueSize: this.sock.getSendQueueSize(), + }; + } + /** * Sends a message to the specified address and port. * @@ -219,16 +411,13 @@ export class DgramSocket extends EventEmitter { }); } - /** - * Sets up event forwarding from the underlying socket to this EventEmitter. - */ private setupEventForwarding(): void { this.sock.on("close", () => { - this.emit("close"); + this.dispatch("close"); }); this.sock.on("connect", () => { - this.emit("connect"); + this.dispatch("connect"); }); this.sock.on("error", (err) => { @@ -236,11 +425,11 @@ export class DgramSocket extends EventEmitter { this.handledErrorEvents.delete(err); return; } - this.emit("error", err); + this.dispatch("error", err); }); this.sock.on("listening", () => { - this.emit("listening"); + this.dispatch("listening"); }); this.sock.on("message", (msg, rinfo) => { @@ -248,7 +437,7 @@ export class DgramSocket extends EventEmitter { ...composeInetAddress(rinfo.family, rinfo.address), port: rinfo.port, }; - this.emit("message", msg, rinfo.size, from); + this.dispatch("message", msg, rinfo.size, from); }); } } diff --git a/packages/misc-util/src/node/net/ipc-socket.ts b/packages/misc-util/src/node/net/ipc-socket.ts new file mode 100644 index 00000000..2628e00e --- /dev/null +++ b/packages/misc-util/src/node/net/ipc-socket.ts @@ -0,0 +1,106 @@ +import * as net from "node:net"; +import type { Except } from "type-fest"; +import type { IError } from "../../ecma/error/error.js"; +import { composeInetAddress, type InetEndpoint } from "./inet.js"; +import { StreamSocket, type StreamSocketEvents } from "./stream-socket.js"; + +/** + * Event map for IpcSocket socket-specific events. + */ +export type IpcSocketEvents = StreamSocketEvents & { + /** + * Emitted when a new connection attempt is started. + * May be emitted multiple times if family autoselection is enabled. + */ + connectionAttempt: [endpoint: InetEndpoint]; + + /** + * Emitted when a connection attempt failed. + * May be emitted multiple times if family autoselection is enabled. + */ + connectionAttemptFailed: [endpoint: InetEndpoint, error: Error]; + + /** + * Emitted when a connection attempt timed out. + * May be emitted multiple times if family autoselection is enabled. + */ + connectionAttemptTimeout: [endpoint: InetEndpoint]; +}; + +/** + */ +export class IpcSocket< + TSock extends net.Socket = net.Socket, + TEvents extends IpcSocketEvents = IpcSocketEvents, +> extends StreamSocket { + /** + * Creates a new {@link IpcSocket} instance. + * + * @param options Options for creating the underlying Node.js socket. + * @returns A new `StreamSocket` instance. + */ + static override from(options?: net.SocketConstructorOpts): IpcSocket { + return new IpcSocket(new net.Socket(options)); + } + + constructor(sock: TSock) { + super(sock); + this.setupEventForwarding(); + } + + /** + * + */ + connect( + path: string, + options?: Except, + ): Promise { + return new Promise((resolve, reject) => { + const handleError = (error: IError) => { + this.handledErrorEvents.add(error); + reject(error); + }; + this.sock.prependOnceListener("error", handleError); + + this.sock.connect( + { + ...options, + path, + }, + () => { + this.sock.removeListener("error", handleError); + resolve(); + }, + ); + }); + } + + protected override setupEventForwarding(): void { + super.setupEventForwarding(); + + this.sock.on("connectionAttempt", (ip, port, family) => { + this.dispatch("connectionAttempt", { + ...composeInetAddress(family, ip), + port, + }); + }); + + this.sock.on("connectionAttemptFailed", (ip, port, family, error) => { + this.dispatch( + "connectionAttemptFailed", + { + ...composeInetAddress(family, ip), + port, + }, + error, + ); + }); + + this.sock.on("connectionAttemptTimeout", (ip, port, family) => { + this.dispatch("connectionAttemptTimeout", { + ...composeInetAddress(family, ip), + port, + }); + }); + } +} diff --git a/packages/misc-util/src/node/net/stream-socket.test.ts b/packages/misc-util/src/node/net/stream-socket.test.ts new file mode 100644 index 00000000..65a7456b --- /dev/null +++ b/packages/misc-util/src/node/net/stream-socket.test.ts @@ -0,0 +1,208 @@ +import * as net from "node:net"; +import { describe, expect, it, vi } from "vitest"; +import type { IError } from "../../ecma/error/error.js"; +import { StreamSocket } from "./stream-socket.js"; + +describe("StreamSocket", () => { + it("should expose basic properties from underlying socket", () => { + const underlying = new net.Socket(); + const socket = new StreamSocket(underlying); + + Object.defineProperty(underlying, "closed", { value: false }); + Object.defineProperty(underlying, "destroyed", { value: false }); + Object.defineProperty(underlying, "bytesRead", { value: 10 }); + Object.defineProperty(underlying, "bytesWritten", { value: 20 }); + Object.defineProperty(underlying, "connecting", { value: true }); + Object.defineProperty(underlying, "timeout", { value: 1234 }); + + expect(socket.stream).toBe(underlying); + expect(socket.closed).toBe(false); + expect(socket.destroyed).toBe(false); + expect(socket.bytesRead).toBe(10); + expect(socket.bytesWritten).toBe(20); + expect(socket.connecting).toBe(true); + expect(socket.timeout).toBe(1234); + }); + + it("should set timeout via underlying setTimeout", () => { + const underlying = new net.Socket(); + const socket = new StreamSocket(underlying); + const setTimeoutSpy = vi.spyOn(underlying, "setTimeout"); + + socket.timeout = 5000; + expect(setTimeoutSpy).toHaveBeenCalledWith(5000); + + socket.timeout = null; + expect(setTimeoutSpy).toHaveBeenCalledWith(0); + }); + + it("should delegate ref and unref to underlying socket", () => { + const underlying = new net.Socket(); + const socket = new StreamSocket(underlying); + const refSpy = vi.spyOn(underlying, "ref"); + const unrefSpy = vi.spyOn(underlying, "unref"); + + socket.ref(); + socket.unref(); + + expect(refSpy).toHaveBeenCalledTimes(1); + expect(unrefSpy).toHaveBeenCalledTimes(1); + }); + + it("should resolve end when socket ends without close wait", async () => { + const underlying = new net.Socket(); + const socket = new StreamSocket(underlying); + const endSpy = vi.spyOn(underlying, "end"); + + Object.defineProperty(underlying, "closed", { + value: false, + configurable: true, + }); + + const promise = socket.end(); + const endCall = endSpy.mock.calls[0]; + if (!endCall) { + throw new Error("end call not captured"); + } + const endCallback = endCall[0] as unknown as (() => void) | undefined; + if (endCallback) { + endCallback(); + } + + await expect(promise).resolves.toBeUndefined(); + }); + + it("should resolve end when waitForClose is true and close event fires", async () => { + const underlying = new net.Socket(); + const socket = new StreamSocket(underlying); + const endSpy = vi.spyOn(underlying, "end"); + + Object.defineProperty(underlying, "closed", { + value: false, + configurable: true, + }); + + const promise = socket.end({ waitForClose: true }); + const endCall = endSpy.mock.calls[0]; + if (!endCall) { + throw new Error("end call not captured"); + } + const endCallback = endCall[0] as unknown as (() => void) | undefined; + if (endCallback) { + endCallback(); + } + + underlying.emit("close", false); + + await expect(promise).resolves.toBeUndefined(); + }); + + it("should reject end on error and mark error as handled", async () => { + const underlying = new net.Socket(); + const socket = new StreamSocket(underlying); + + Object.defineProperty(underlying, "closed", { + value: false, + configurable: true, + }); + + const promise = socket.end(); + const error: IError = new Error("end-failure") as IError; + underlying.emit("error", error); + + await expect(promise).rejects.toBe(error); + }); + + it("should write data and resolve when underlying write succeeds", async () => { + const underlying = new net.Socket(); + const socket = new StreamSocket(underlying); + const writeSpy = vi.spyOn(underlying, "write"); + + const promise = socket.write(Buffer.from("abc")); + const writeCall = writeSpy.mock.calls[0]; + if (!writeCall) { + throw new Error("write call not captured"); + } + const callback = writeCall[1] as unknown as + | ((err?: IError | null) => void) + | undefined; + if (!callback) { + throw new Error("write callback not captured"); + } + callback(null); + + await expect(promise).resolves.toBeUndefined(); + }); + + it("should reject write when underlying write callback receives error", async () => { + const underlying = new net.Socket(); + const socket = new StreamSocket(underlying); + const writeSpy = vi.spyOn(underlying, "write"); + const error: IError = new Error("write-failure") as IError; + + const promise = socket.write(Buffer.from("abc")); + const writeCall = writeSpy.mock.calls[0]; + if (!writeCall) { + throw new Error("write call not captured"); + } + const callback = writeCall[1] as unknown as + | ((err?: IError | null) => void) + | undefined; + if (!callback) { + throw new Error("write callback not captured"); + } + callback(error); + + await expect(promise).rejects.toBe(error); + }); + + it("should forward destroy to underlying socket", () => { + const underlying = new net.Socket(); + const socket = new StreamSocket(underlying); + const destroySpy = vi.spyOn(underlying, "destroy"); + + socket.destroy(); + + expect(destroySpy).toHaveBeenCalledTimes(1); + }); + + it("should dispatch forwarded events from underlying socket", async () => { + const underlying = new net.Socket(); + const socket = new StreamSocket(underlying); + + const events: { type: string; payload?: unknown }[] = []; + + await Promise.resolve(); + + socket.subscribe("error", (err) => { + events.push({ type: "error", payload: err }); + }); + socket.subscribe("close", (hadError) => { + events.push({ type: "close", payload: hadError }); + }); + socket.subscribe("connect", () => { + events.push({ type: "connect" }); + }); + socket.subscribe("ready", () => { + events.push({ type: "ready" }); + }); + socket.subscribe("timeout", () => { + events.push({ type: "timeout" }); + }); + + const forwardedError: IError = new Error("boom") as IError; + underlying.emit("error", forwardedError); + underlying.emit("close", true); + underlying.emit("connect"); + underlying.emit("ready"); + underlying.emit("timeout"); + + expect(events).toContainEqual({ type: "close", payload: true }); + expect(events).toContainEqual({ type: "connect" }); + expect(events).toContainEqual({ type: "ready" }); + expect(events).toContainEqual({ type: "timeout" }); + expect(events.find((e) => e.type === "error")?.payload).toBe( + forwardedError, + ); + }); +}); diff --git a/packages/misc-util/src/node/net/stream-socket.ts b/packages/misc-util/src/node/net/stream-socket.ts new file mode 100644 index 00000000..0ab807d5 --- /dev/null +++ b/packages/misc-util/src/node/net/stream-socket.ts @@ -0,0 +1,279 @@ +import * as net from "node:net"; +import type * as stream from "node:stream"; +import { EventDispatcherMapBase } from "../../async/events/_event-dispatcher-map-base.js"; +import type { IEventDispatcherMap } from "../../async/events/ievent-dispatcher-map.js"; +import type { IError } from "../../ecma/error/error.js"; + +/** + * Event map for {@link StreamSocket} lifecycle events. + * + * These correspond to the core {@link net.Socket} events that are + * meaningful for any stream-oriented connection, regardless of the + * underlying transport (TCP, Unix domain socket, etc.). + */ +export type StreamSocketEvents = { + /** + * Emitted when an error occurs on the underlying socket. + * + * After this event the implementation will typically attempt to + * close the socket and then emit {@link StreamSocketEvents.close}. + */ + error: [error: IError]; + + /** + * Emitted once the socket has been fully closed. + * + * After this event the socket is no longer usable. + */ + close: [hadError: boolean]; + + /** + * Emitted when the socket connection is successfully established. + * + * This maps to the underlying {@link net.Socket} "connect" event. + */ + connect: []; + + /** + * Emitted when the socket is ready for I/O. + * + * On Node.js this is typically fired immediately after "connect", + * once the internal initialization is complete. + */ + ready: []; + + /** + * Emitted if the socket times out from inactivity. + * + * The underlying connection is not automatically closed; it is up to + * the caller to decide whether to end or destroy the socket. + */ + timeout: []; +}; + +/** + * Base class for stream-oriented client sockets. + * + * This abstraction wraps a {@link net.Socket} instance and exposes: + * + * - A stable, duplex {@link stream} for reading and writing data. + * - Common lifecycle properties such as {@link closed}, + * {@link destroyed}, {@link bytesRead}, {@link bytesWritten} and + * {@link connecting}. + * - Timeout management via the {@link timeout} property. + * - Promise-based {@link end} and {@link write} helpers. + * - Strongly-typed lifecycle events via {@link StreamSocketEvents}. + * + * It is intentionally transport-agnostic: it does not assume a + * particular address family or protocol. Concrete subclasses such as + * {@link TcpClient} add protocol-specific concerns (for example + * `InetEndpoint` accessors, TCP keep-alive configuration, or DNS + * resolution events) on top of this base. + * + * Unless explicitly documented otherwise, methods mirror the semantics + * of their underlying {@link net.Socket} counterparts. + */ +export class StreamSocket< + TSock extends net.Socket = net.Socket, + TEvents extends StreamSocketEvents = StreamSocketEvents, + > + extends EventDispatcherMapBase + implements IEventDispatcherMap +{ + protected readonly handledErrorEvents: Set = new Set(); + + /** + * Creates a new {@link StreamSocket} instance. + * + * @param options Options for creating the underlying Node.js socket. + * @returns A new `StreamSocket` instance. + */ + static from(options?: net.SocketConstructorOpts): StreamSocket { + return new StreamSocket(new net.Socket(options)); + } + + /** + * Creates a new {@link StreamSocket} instance. + * + * @param sock The underlying Node.js socket. + */ + constructor(protected readonly sock: TSock) { + super(); + this.setupEventForwarding(); + } + + /** + * Underlying duplex stream for reading and writing data. + * + * This is the wrapped {@link net.Socket} instance and can be passed + * directly to APIs that expect a Node.js stream. + */ + get stream(): stream.Duplex { + return this.sock; + } + + /** + * Indicates whether the socket has been fully closed. + */ + get closed(): boolean { + return this.sock.closed; + } + + /** + * Indicates whether the underlying socket has been destroyed. + */ + get destroyed(): boolean { + return this.sock.destroyed; + } + + /** + * Total number of bytes read from the socket so far. + */ + get bytesRead(): number { + return this.sock.bytesRead; + } + + /** + * Total number of bytes written to the socket so far. + */ + get bytesWritten(): number { + return this.sock.bytesWritten; + } + + /** + * Whether the socket is currently in the process of connecting. + */ + get connecting(): boolean { + return this.sock.connecting; + } + + /** + * Current inactivity timeout in milliseconds, or `null` if disabled. + */ + get timeout(): number | null { + return this.sock.timeout ?? null; + } + + /** + * Updates the inactivity timeout for the socket. + * + * When set to a positive number, the socket emits a "timeout" event + * if no I/O activity occurs within the given number of milliseconds. + * A value of `0` or `null` disables the timeout entirely. + */ + set timeout(timeout: number | null) { + this.sock.setTimeout(timeout ?? 0); + } + + /** + * Marks the socket as referenced, preventing the Node.js process from + * exiting while the socket is active. + */ + ref(): void { + this.sock.ref(); + } + + /** + * Marks the socket as unreferenced, allowing the Node.js process to + * exit even if the socket is still active. + */ + unref(): void { + this.sock.unref(); + } + + /** + * Half-closes the socket, optionally waiting for a full close. + * + * If `waitForClose` is omitted or `false`, the promise resolves once + * the local side has finished sending data and the FIN has been + * queued. When `waitForClose` is `true`, the promise resolves only + * after the remote side has also closed and the "close" event has + * fired. + * + * @param options Optional settings for ending the socket. + * @returns A promise that resolves once the socket has ended. + */ + end(options?: { waitForClose?: boolean }): Promise { + return new Promise((resolve, reject) => { + const handleError = (error: IError) => { + this.handledErrorEvents.add(error); + reject(error); + }; + const handleEnd = () => { + this.sock.removeListener("error", handleError); + resolve(); + }; + + this.sock.prependOnceListener("error", handleError); + + if (options?.waitForClose && !this.sock.closed) { + this.sock.prependOnceListener("close", () => { + this.sock.removeListener("error", handleError); + resolve(); + }); + } + + this.sock.end( + options?.waitForClose && !this.sock.closed ? undefined : handleEnd, + ); + }); + } + + /** + * Writes data to the socket and resolves once the write completes. + * + * @param data The data to write + * @param options Optional encoding if `data` is a string. + */ + write(data: Uint8Array): Promise { + return new Promise((resolve, reject) => { + const callback = (error?: IError | null) => { + if (error) { + reject(error); + return; + } + resolve(); + }; + + // TODO: Handle backpressure + this.sock.write(data, callback); + }); + } + + /** + * Immediately destroys the underlying socket. + * + * Pending I/O is discarded, the connection is closed and the socket + * transitions to a terminal state. Any registered "close" listeners + * will still be invoked. + */ + destroy(): void { + this.sock.destroy(); + } + + protected setupEventForwarding(): void { + this.sock.on("error", (err) => { + if (this.handledErrorEvents.has(err)) { + this.handledErrorEvents.delete(err); + return; + } + this.dispatch("error", err as IError); + }); + + this.sock.on("close", (hadError) => { + this.dispatch("close", Boolean(hadError)); + }); + + this.sock.on("connect", () => { + this.dispatch("connect"); + }); + + this.sock.on("ready", () => { + this.dispatch("ready"); + }); + + this.sock.on("timeout", () => { + this.dispatch("timeout"); + }); + } +} diff --git a/packages/misc-util/src/node/net/tcp-client.test.ts b/packages/misc-util/src/node/net/tcp-client.test.ts deleted file mode 100644 index 9396c0cf..00000000 --- a/packages/misc-util/src/node/net/tcp-client.test.ts +++ /dev/null @@ -1,928 +0,0 @@ -import * as net from "node:net"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { TcpClient } from "./tcp-client.js"; - -describe("TcpClient", () => { - let server: net.Server; - let serverPort: number; - let serverHost: string; - let receivedData: Buffer[]; - - beforeEach( - () => - new Promise((resolve) => { - receivedData = []; - serverHost = "127.0.0.1"; - - server = net.createServer((socket) => { - socket.on("data", (data) => { - receivedData.push(data); - // Echo back the data - socket.write(data); - }); - - socket.on("end", () => { - socket.end(); - }); - }); - - server.listen(0, serverHost, () => { - const address = server.address() as net.AddressInfo; - serverPort = address.port; - resolve(); - }); - }), - ); - - afterEach( - () => - new Promise((resolve) => { - server.close(() => { - resolve(); - }); - }), - ); - - describe("from", () => { - it("should create a new TcpClient instance", () => { - const client = TcpClient.from(); - expect(client).toBeInstanceOf(TcpClient); - }); - - it("should create a TcpClient with custom socket options", () => { - const mockSocket = new net.Socket({ allowHalfOpen: true }); - const client = new TcpClient(mockSocket); - expect(client).toBeInstanceOf(TcpClient); - // Stream is a net.Socket which has allowHalfOpen - expect((client.stream as { allowHalfOpen?: boolean }).allowHalfOpen).toBe( - true, - ); - }); - }); - - describe("stream", () => { - it("should expose the underlying socket stream", () => { - const mockSocket = new net.Socket(); - const client = new TcpClient(mockSocket); - expect(client.stream).toBeDefined(); - expect(client.stream).toHaveProperty("on"); - expect(client.stream).toHaveProperty("write"); - expect(client.stream).toHaveProperty("read"); - }); - - it("should return a Duplex stream", () => { - const mockSocket = new net.Socket(); - const client = new TcpClient(mockSocket); - expect(client.stream.readable).toBeDefined(); - expect(client.stream.writable).toBeDefined(); - }); - }); - - describe("properties", () => { - describe("connected client", () => { - let client: TcpClient; - - beforeEach(async () => { - client = TcpClient.from(); - await client.connect(serverPort, serverHost); - }); - - afterEach(async () => { - if (!client.closed) { - await client.end({ waitForClose: true }); - } - }); - it("should report closed status during lifecycle", async () => { - expect(client.closed).toBe(false); - await client.connect(serverPort, serverHost); - expect(client.closed).toBe(false); - await client.end({ waitForClose: true }); - expect(client.closed).toBe(true); - }); - - it("should report connecting status during connection", async () => { - expect(client.connecting).toBe(false); - const connectPromise = client.connect(serverPort, serverHost); - expect(client.connecting).toBe(true); - await connectPromise; - expect(client.connecting).toBe(false); - }); - - it("should track bytesRead and bytesWritten during communication", async () => { - await client.connect(serverPort, serverHost); - - expect(client.bytesRead).toBe(0); - expect(client.bytesWritten).toBe(0); - - await client.write("test data"); - expect(client.bytesWritten).toBeGreaterThan(0); - - // Wait for echo response - await new Promise((resolve) => { - client.stream.once("data", resolve); - }); - expect(client.bytesRead).toBeGreaterThan(0); - }); - - it("should provide remoteEndpoint", async () => { - const endpoint = client.remoteEndpoint; - expect(endpoint).not.toBeNull(); - expect(endpoint?.address).toBe(serverHost); - expect(endpoint?.port).toBe(serverPort); - expect(endpoint?.family).toBeOneOf([4, 6]); - }); - - it("should provide localEndpoint", async () => { - const endpoint = client.localEndpoint; - expect(endpoint).not.toBeNull(); - expect(endpoint?.address).toBeDefined(); - expect(endpoint?.port).toBeGreaterThan(0); - expect(endpoint?.family).toBeOneOf([4, 6]); - }); - - it("should get and set timeout on connected socket", async () => { - expect(client.timeout).toBeNull(); - client.timeout = 5000; - expect(client.timeout).toBe(5000); - client.timeout = 0; - expect(client.timeout).toBe(0); - }); - }); - - describe("property getters and setters", () => { - it("should get and set timeout", () => { - const mockSocket = new net.Socket(); - const mockedClient = new TcpClient(mockSocket); - - const setTimeoutSpy = vi.spyOn(mockSocket, "setTimeout"); - - // Initially null (no timeout set) - expect(mockedClient.timeout).toBeNull(); - - // Set timeout - mockedClient.timeout = 5000; - expect(setTimeoutSpy).toHaveBeenCalledWith(5000); - - // Set to 0 (disable timeout) - mockedClient.timeout = 0; - expect(setTimeoutSpy).toHaveBeenCalledWith(0); - }); - - it("should report closed status", () => { - const mockSocket = new net.Socket(); - const mockedClient = new TcpClient(mockSocket); - - // Mock closed property - Object.defineProperty(mockSocket, "closed", { - get: vi.fn(() => false), - configurable: true, - }); - - expect(mockedClient.closed).toBe(false); - - // Change closed to true - Object.defineProperty(mockSocket, "closed", { - get: vi.fn(() => true), - configurable: true, - }); - - expect(mockedClient.closed).toBe(true); - }); - - it("should report connecting status", () => { - const mockSocket = new net.Socket(); - const mockedClient = new TcpClient(mockSocket); - - // Mock connecting property - Object.defineProperty(mockSocket, "connecting", { - get: vi.fn(() => true), - configurable: true, - }); - - expect(mockedClient.connecting).toBe(true); - - // Change connecting to false - Object.defineProperty(mockSocket, "connecting", { - get: vi.fn(() => false), - configurable: true, - }); - - expect(mockedClient.connecting).toBe(false); - }); - - it("should track bytesRead and bytesWritten", () => { - const mockSocket = new net.Socket(); - const mockedClient = new TcpClient(mockSocket); - - // Mock bytes properties - Object.defineProperty(mockSocket, "bytesRead", { - get: vi.fn(() => 1234), - configurable: true, - }); - Object.defineProperty(mockSocket, "bytesWritten", { - get: vi.fn(() => 5678), - configurable: true, - }); - - expect(mockedClient.bytesRead).toBe(1234); - expect(mockedClient.bytesWritten).toBe(5678); - }); - - it("should provide null remoteEndpoint when not connected", () => { - const mockSocket = new net.Socket(); - const mockedClient = new TcpClient(mockSocket); - - // Mock properties to return undefined - Object.defineProperty(mockSocket, "remoteAddress", { - get: vi.fn(), - configurable: true, - }); - Object.defineProperty(mockSocket, "remotePort", { - get: vi.fn(), - configurable: true, - }); - - expect(mockedClient.remoteEndpoint).toBeNull(); - }); - - it("should provide remoteEndpoint", () => { - const mockSocket = new net.Socket(); - const mockedClient = new TcpClient(mockSocket); - - // Mock remote endpoint properties - Object.defineProperty(mockSocket, "remoteAddress", { - get: vi.fn(() => "192.168.1.100"), - configurable: true, - }); - Object.defineProperty(mockSocket, "remotePort", { - get: vi.fn(() => 8080), - configurable: true, - }); - Object.defineProperty(mockSocket, "remoteFamily", { - get: vi.fn(() => "IPv4"), - configurable: true, - }); - - const endpoint = mockedClient.remoteEndpoint; - expect(endpoint).not.toBeNull(); - expect(endpoint?.address).toBe("192.168.1.100"); - expect(endpoint?.port).toBe(8080); - expect(endpoint?.family).toBe(4); - }); - - it("should provide localEndpoint", () => { - const mockSocket = new net.Socket(); - const mockedClient = new TcpClient(mockSocket); - - // Mock local endpoint properties - Object.defineProperty(mockSocket, "localAddress", { - get: vi.fn(() => "127.0.0.1"), - configurable: true, - }); - Object.defineProperty(mockSocket, "localPort", { - get: vi.fn(() => 12345), - configurable: true, - }); - Object.defineProperty(mockSocket, "localFamily", { - get: vi.fn(() => "IPv4"), - configurable: true, - }); - - const endpoint = mockedClient.localEndpoint; - expect(endpoint).not.toBeNull(); - expect(endpoint?.address).toBe("127.0.0.1"); - expect(endpoint?.port).toBe(12345); - expect(endpoint?.family).toBe(4); - }); - }); - }); - - describe("methods", () => { - it("should set keep-alive", () => { - const mockSocket = new net.Socket(); - const client = new TcpClient(mockSocket); - - const setKeepAliveSpy = vi.spyOn(mockSocket, "setKeepAlive"); - - client.setKeepAlive(true, 1000); - expect(setKeepAliveSpy).toHaveBeenNthCalledWith(1, true, 1000); - - client.setKeepAlive(false); - expect(setKeepAliveSpy).toHaveBeenNthCalledWith(2, false, undefined); - }); - - it("should set no-delay", () => { - const mockSocket = new net.Socket(); - const client = new TcpClient(mockSocket); - - const setNoDelaySpy = vi.spyOn(mockSocket, "setNoDelay"); - - client.setNoDelay(true); - expect(setNoDelaySpy).toHaveBeenCalledWith(true); - - client.setNoDelay(false); - expect(setNoDelaySpy).toHaveBeenCalledWith(false); - }); - - it("should ref and unref", () => { - const mockSocket = new net.Socket(); - const client = new TcpClient(mockSocket); - - const refSpy = vi.spyOn(mockSocket, "ref"); - const unrefSpy = vi.spyOn(mockSocket, "unref"); - - client.ref(); - expect(refSpy).toHaveBeenCalled(); - - client.unref(); - expect(unrefSpy).toHaveBeenCalled(); - - client.ref(); - expect(refSpy).toHaveBeenCalledTimes(2); - }); - }); - - describe("connect", () => { - it("should connect to a TCP server", async () => { - const client = TcpClient.from(); - await client.connect(serverPort, serverHost); - expect(client.connecting).toBe(false); - expect(client.closed).toBe(false); - await client.end(); - }); - - it("should connect with only port parameter", async () => { - const client = TcpClient.from(); - await client.connect(serverPort); - expect(client.connecting).toBe(false); - expect(client.closed).toBe(false); - await client.end(); - }); - - it("should reject on connection error", async () => { - const client = TcpClient.from(); - // Try to connect to an invalid port - await expect(client.connect(1, "0.0.0.0")).rejects.toThrow(); - }); - - it("should reject on connection to non-existent host", async () => { - const client = TcpClient.from(); - await expect( - client.connect(serverPort, "invalid.host.example.com"), - ).rejects.toThrow(); - }); - - it("should pass additional connection options", async () => { - const client = TcpClient.from(); - await client.connect(serverPort, serverHost, { - localPort: undefined, // Let system assign - }); - expect(client.connecting).toBe(false); - expect(client.closed).toBe(false); - await client.end(); - }); - }); - - describe("write", () => { - let client: TcpClient; - - beforeEach(async () => { - client = TcpClient.from(); - await client.connect(serverPort, serverHost); - }); - - afterEach(async () => { - try { - await client.end(); - } catch { - // Ignore errors during cleanup - } - }); - - it("should write string data to the socket", async () => { - const testData = "Hello, World!"; - await client.write(testData); - - // Give server time to receive data - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(receivedData.length).toBeGreaterThan(0); - expect(receivedData[0]?.toString()).toBe(testData); - }); - - it("should write Uint8Array data to the socket", async () => { - const testData = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello" - await client.write(testData); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(receivedData.length).toBeGreaterThan(0); - expect(receivedData[0]?.toString()).toBe("Hello"); - }); - - it("should write string data with encoding option", async () => { - const testData = "Hello, UTF-8!"; - await client.write(testData, { encoding: "utf8" }); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(receivedData.length).toBeGreaterThan(0); - expect(receivedData[0]?.toString("utf8")).toBe(testData); - }); - - it("should write string data with base64 encoding", async () => { - const testData = "SGVsbG8="; // "Hello" in base64 - await client.write(testData, { encoding: "base64" }); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(receivedData.length).toBeGreaterThan(0); - expect(receivedData[0]?.toString()).toBe("Hello"); - }); - - it("should reject when writing to a closed socket", async () => { - await client.end(); - - // Wait for socket to fully close - await new Promise((resolve) => setTimeout(resolve, 50)); - - await expect(client.write("test")).rejects.toThrow(); - }); - - it("should handle multiple sequential writes", async () => { - await client.write("First "); - await client.write("Second "); - await client.write("Third"); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(receivedData.length).toBeGreaterThanOrEqual(1); - const allData = Buffer.concat(receivedData).toString(); - expect(allData).toContain("First"); - expect(allData).toContain("Second"); - expect(allData).toContain("Third"); - }); - }); - - describe("end", () => { - let client: TcpClient; - - beforeEach(async () => { - client = TcpClient.from(); - await client.connect(serverPort, serverHost); - }); - - it("should end the connection without waiting for close", async () => { - await client.end(); - // Should resolve immediately after end is called - }); - - it("should end the connection and wait for close event", async () => { - const startTime = Date.now(); - await client.end({ waitForClose: true }); - const duration = Date.now() - startTime; - - // Should have waited for close event - expect(duration).toBeGreaterThanOrEqual(0); - expect(client.closed).toBe(true); - }); - - it("should handle calling end multiple times", async () => { - // First end call - await client.end(); - - // Second end call should also succeed without error - await expect(client.end()).resolves.toBeUndefined(); - }); - - it("should resolve quickly when not waiting for close", async () => { - const startTime = Date.now(); - await client.end({ waitForClose: false }); - const duration = Date.now() - startTime; - - // Should resolve quickly without waiting for full close - expect(duration).toBeLessThan(200); - }); - }); - - describe("error handling", () => { - it("should handle connection refused error", async () => { - // Use a port that is very unlikely to be in use - const unusedPort = 54321; - const client = TcpClient.from(); - - await expect(client.connect(unusedPort, serverHost)).rejects.toThrow(); - }); - - it("should propagate errors during write", async () => { - const mockSocket = new net.Socket(); - - // Create client with mock socket - const testClient = new TcpClient(mockSocket); - - // Mock write to trigger error - vi.spyOn(mockSocket, "write").mockImplementation( - // biome-ignore lint/suspicious/noExplicitAny: test mock - ((_data: any, _encodingOrCallback?: any, callback?: any) => { - const cb = - typeof _encodingOrCallback === "function" - ? _encodingOrCallback - : callback; - if (cb) { - setImmediate(() => cb(new Error("Write failed"))); - } - return false; - }) as typeof mockSocket.write, - ); - - await expect(testClient.write("test")).rejects.toThrow("Write failed"); - }); - - it("should handle calling end multiple times on same connection", async () => { - const client = TcpClient.from(); - await client.connect(serverPort, serverHost); - - // End the connection normally - await client.end({ waitForClose: true }); - - // Calling end again should not throw even though socket is closed - await expect(client.end()).resolves.toBeUndefined(); - }); - - it("should handle end with immediate close", async () => { - const mockSocket = new net.Socket(); - const testClient = new TcpClient(mockSocket); - - // Mock end to trigger close immediately - vi.spyOn(mockSocket, "end").mockImplementation(function ( - this: net.Socket, - ) { - setImmediate(() => this.emit("close", false)); - return this; - }); - - await expect( - testClient.end({ waitForClose: true }), - ).resolves.toBeUndefined(); - }); - }); - - describe("integration scenarios", () => { - it("should handle full request-response cycle", async () => { - const client = TcpClient.from(); - await client.connect(serverPort, serverHost); - - // Write data - const testData = "Test message"; - await client.write(testData); - - // Read response - const response = await new Promise((resolve) => { - client.stream.once("data", (data: Buffer) => { - resolve(data.toString()); - }); - }); - - expect(response).toBe(testData); - - await client.end({ waitForClose: true }); - }); - - it("should handle multiple connections sequentially", async () => { - // First connection - const client1 = TcpClient.from(); - await client1.connect(serverPort, serverHost); - await client1.write("Connection 1"); - await client1.end(); - - // Second connection - const client2 = TcpClient.from(); - await client2.connect(serverPort, serverHost); - await client2.write("Connection 2"); - await client2.end(); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(receivedData.length).toBe(2); - }); - - it("should handle write after partial end (half-close)", async () => { - const client = TcpClient.from({ allowHalfOpen: true }); - await client.connect(serverPort, serverHost); - - await client.write("Before end"); - await client.end(); - - // After end(), client has sent FIN but can still receive - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(receivedData.length).toBeGreaterThan(0); - }); - - it("should handle connection with data listener", async () => { - const client = TcpClient.from(); - const dataChunks: Buffer[] = []; - - client.stream.on("data", (chunk: Buffer) => { - dataChunks.push(chunk); - }); - - await client.connect(serverPort, serverHost); - await client.write("Test data"); - - // Wait for echo - await new Promise((resolve) => setTimeout(resolve, 100)); - - expect(dataChunks.length).toBeGreaterThan(0); - expect(Buffer.concat(dataChunks).toString()).toBe("Test data"); - - await client.end({ waitForClose: true }); - }); - }); - - describe("events", () => { - it("should emit connect event", async () => { - const client = TcpClient.from(); - const connectPromise = new Promise((resolve) => { - client.once("connect", resolve); - }); - - await client.connect(serverPort, serverHost); - await connectPromise; - await client.end(); - }); - - it("should emit ready event", async () => { - const client = TcpClient.from(); - const readyPromise = new Promise((resolve) => { - client.once("ready", resolve); - }); - - await client.connect(serverPort, serverHost); - await readyPromise; - await client.end(); - }); - - it("should emit close event", async () => { - const client = TcpClient.from(); - await client.connect(serverPort, serverHost); - - const closePromise = new Promise((resolve) => { - client.once("close", (arg) => { - resolve(arg); - }); - }); - - await client.end(); - const hadError = await closePromise; - expect(hadError).toBe(false); - }); - - it("should emit timeout event", async () => { - const client = TcpClient.from(); - await client.connect(serverPort, serverHost); - - const timeoutPromise = new Promise((resolve) => { - client.once("timeout", resolve); - }); - - client.timeout = 10; // Very short timeout - - await timeoutPromise; - await client.end(); - }); - - it("should emit lookup event when connecting to hostname", async () => { - const client = TcpClient.from(); - const lookupPromise = new Promise((resolve) => { - client.once("lookup", (err, address, host) => { - expect(err).toBeNull(); - expect(address).toBeDefined(); - expect(address.address).toBeDefined(); - expect(address.family).toBeOneOf([4, 6, null]); - expect(host).toBe("localhost"); - resolve(); - }); - }); - - // Connect to 'localhost' to trigger DNS lookup - await client.connect(serverPort, "localhost"); - await lookupPromise; - await client.end(); - }); - - it("should forward error events", async () => { - const mockSocket = new net.Socket(); - const client = new TcpClient(mockSocket); - - const testError = new Error("Socket error"); - - const errorPromise = new Promise((resolve) => { - client.once("error", (error) => { - resolve(error); - }); - }); - - mockSocket.emit("error", testError); - - const receivedError = await errorPromise; - expect(receivedError).toBe(testError); - }); - - it("should forward close events with hadError flag", async () => { - const mockSocket = new net.Socket(); - const client = new TcpClient(mockSocket); - - const closePromise = new Promise((resolve) => { - client.once("close", (error) => { - resolve(error); - }); - }); - - mockSocket.emit("close", true); - - const hadError = await closePromise; - expect(hadError).toBe(true); - }); - - it("should forward connect event", async () => { - const mockSocket = new net.Socket(); - const client = new TcpClient(mockSocket); - - const connectPromise = new Promise((resolve) => { - client.once("connect", () => { - resolve(); - }); - }); - - mockSocket.emit("connect"); - - await connectPromise; - }); - - it("should forward ready event", async () => { - const mockSocket = new net.Socket(); - const client = new TcpClient(mockSocket); - - const readyPromise = new Promise((resolve) => { - client.once("ready", () => { - resolve(); - }); - }); - - mockSocket.emit("ready"); - - await readyPromise; - }); - - it("should forward timeout event", async () => { - const mockSocket = new net.Socket(); - const client = new TcpClient(mockSocket); - - const timeoutPromise = new Promise((resolve) => { - client.once("timeout", () => { - resolve(); - }); - }); - - mockSocket.emit("timeout"); - - await timeoutPromise; - }); - - it("should forward connectionAttempt event", async () => { - const mockSocket = new net.Socket(); - const client = new TcpClient(mockSocket); - - const testEndpoint = { - address: "127.0.0.1", - port: 8080, - family: 4 as const, - }; - - const eventPromise = new Promise<{ - address: string; - port: number; - family: 4 | 6 | null; - }>((resolve) => { - client.once("connectionAttempt", (ep) => { - resolve(ep); - }); - }); - - mockSocket.emit("connectionAttempt", "127.0.0.1", 8080, 4); - - const endpoint = await eventPromise; - expect(endpoint).toEqual(testEndpoint); - }); - - it("should forward connectionAttemptFailed event", async () => { - const mockSocket = new net.Socket(); - const client = new TcpClient(mockSocket); - - const testError = new Error("Connection failed"); - const testEndpoint = { - address: "127.0.0.1", - port: 8080, - family: 4 as const, - }; - - const eventPromise = new Promise<{ - endpoint: { address: string; port: number; family: 4 | 6 | null }; - error: Error; - }>((resolve) => { - client.once("connectionAttemptFailed", (endpoint, error) => { - resolve({ endpoint, error }); - }); - }); - - mockSocket.emit( - "connectionAttemptFailed", - "127.0.0.1", - 8080, - 4, - testError, - ); - - const result = await eventPromise; - expect(result.endpoint).toEqual(testEndpoint); - expect(result.error).toBe(testError); - }); - - it("should forward connectionAttemptTimeout event", async () => { - const mockSocket = new net.Socket(); - const client = new TcpClient(mockSocket); - - const testEndpoint = { - address: "127.0.0.1", - port: 8080, - family: 4 as const, - }; - - const eventPromise = new Promise<{ - address: string; - port: number; - family: 4 | 6 | null; - }>((resolve) => { - client.once("connectionAttemptTimeout", (ep) => { - resolve(ep); - }); - }); - - mockSocket.emit("connectionAttemptTimeout", "127.0.0.1", 8080, 4); - - const endpoint = await eventPromise; - expect(endpoint).toEqual(testEndpoint); - }); - - it("should forward lookup event with IPv4", async () => { - const mockSocket = new net.Socket(); - const client = new TcpClient(mockSocket); - - const eventPromise = new Promise<{ - err: Error | null; - address: unknown; - host: string; - }>((resolve) => { - client.once("lookup", (err, address, host) => { - resolve({ err, address, host }); - }); - }); - - mockSocket.emit("lookup", null, "127.0.0.1", 4, "localhost"); - - const result = await eventPromise; - expect(result.err).toBeNull(); - expect(result.address).toEqual({ - address: "127.0.0.1", - family: 4, - }); - expect(result.host).toBe("localhost"); - }); - - it("should forward lookup event with IPv6", async () => { - const mockSocket = new net.Socket(); - const client = new TcpClient(mockSocket); - - const eventPromise = new Promise<{ - err: Error | null; - address: unknown; - host: string; - }>((resolve) => { - client.once("lookup", (err, address, host) => { - resolve({ err, address, host }); - }); - }); - - mockSocket.emit("lookup", null, "::1", 6, "localhost"); - - const result = await eventPromise; - expect(result.err).toBeNull(); - expect(result.address).toEqual({ - address: "::1", - family: 6, - }); - expect(result.host).toBe("localhost"); - }); - }); -}); diff --git a/packages/misc-util/src/node/net/tcp-client.ts b/packages/misc-util/src/node/net/tcp-client.ts deleted file mode 100644 index cd98f705..00000000 --- a/packages/misc-util/src/node/net/tcp-client.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { EventEmitter } from "node:events"; -import * as net from "node:net"; -import type { Duplex } from "node:stream"; -import type { Except } from "type-fest"; -import type { IError } from "../../ecma/error/error.js"; -import { - composeInetAddress, - type InetAddress, - type InetEndpoint, -} from "./inet.js"; - -/** - * Event map for TcpClient socket-specific events. - */ -export interface TcpClientEvents { - /** - * Emitted when an error occurs on the socket. - */ - error: [error: IError]; - - /** - * Emitted once the socket is fully closed. - */ - close: [hadError: boolean]; - - /** - * Emitted when a socket connection is successfully established. - */ - connect: []; - - /** - * Emitted when a new connection attempt is started. - * May be emitted multiple times if family autoselection is enabled. - */ - connectionAttempt: [endpoint: InetEndpoint]; - - /** - * Emitted when a connection attempt failed. - * May be emitted multiple times if family autoselection is enabled. - */ - connectionAttemptFailed: [endpoint: InetEndpoint, error: Error]; - - /** - * Emitted when a connection attempt timed out. - * May be emitted multiple times if family autoselection is enabled. - */ - connectionAttemptTimeout: [endpoint: InetEndpoint]; - - /** - * Emitted after resolving the host name but before connecting. - */ - lookup: [err: Error | null, address: InetAddress, host: string]; - - /** - * Emitted when a socket is ready to be used. - * Triggered immediately after 'connect'. - */ - ready: []; - - /** - * Emitted if the socket times out from inactivity. - * The connection is not automatically severed. - */ - timeout: []; -} - -/** - * TCP client for establishing connections to TCP servers. - * - * Example usage: - * ```ts - * const tcpClient = new TcpClient.from(); - * await tcpClient.connect(80, "example.com"); - * await tcpClient.write("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"); - * await tcpClient.end({ waitForClose: true }); - * ``` - */ -export class TcpClient extends EventEmitter { - /** - * Creates a new `TcpClient` instance with the specified options. - * - * @param options Options for creating the TCP connection. Defaults to connecting to port 80. - * @returns A new `TcpClient` instance. - */ - static from(options?: net.SocketConstructorOpts): TcpClient { - return new TcpClient(new net.Socket(options)); - } - - private readonly handledErrorEvents: Set = new Set(); - - constructor(private readonly sock: net.Socket) { - super(); - this.setupEventForwarding(); - } - - /** - * Returns the underlying socket stream for reading/writing data. - * This provides access to the socket as a Duplex stream. - */ - get stream(): Duplex { - return this.sock; - } - - /** - * Returns whether the socket is closed. - */ - get closed(): boolean { - return this.sock.closed; - } - - /** - * Returns the remote endpoint information of the socket. - * @throws {UnsupportedError} If the socket family is not IPv4 or IPv6. - */ - get remoteEndpoint(): InetEndpoint | null { - const address = this.sock.remoteAddress; - const port = this.sock.remotePort; - const familyStr = this.sock.remoteFamily; - - if ( - address === undefined || - port === undefined || - familyStr === undefined - ) { - return null; - } - - return { - ...composeInetAddress(familyStr, address), - port, - }; - } - - /** - * Returns the local endpoint information of the socket. - * @throws {UnsupportedError} If the socket family is not IPv4 or IPv6. - */ - get localEndpoint(): InetEndpoint | null { - const address = this.sock.localAddress; - const port = this.sock.localPort; - const familyStr = this.sock.localFamily; - - if ( - address === undefined || - port === undefined || - familyStr === undefined - ) { - return null; - } - - return { - ...composeInetAddress(familyStr, address), - port, - }; - } - - /** - * Returns the number of bytes read from the socket. - */ - get bytesRead(): number { - return this.sock.bytesRead; - } - - /** - * Returns the number of bytes written to the socket. - */ - get bytesWritten(): number { - return this.sock.bytesWritten; - } - - /** - * Returns whether the socket is connecting. - */ - get connecting(): boolean { - return this.sock.connecting; - } - - /** - * Gets or sets the timeout value for the socket. - */ - get timeout(): number | null { - return this.sock.timeout ?? null; - } - - /** - * Sets the timeout value for the socket. - * - * @param timeout The timeout value in milliseconds. If 0, the timeout is disabled. - */ - set timeout(timeout: number) { - this.sock.setTimeout(timeout); - } - - /** - * Sets the keep-alive option for the socket. - * - * @param enable Whether to enable keep-alive. - * @param initialDelay The initial delay in milliseconds before the first keep-alive probe. - */ - setKeepAlive(enable: boolean, initialDelay?: number): void { - this.sock.setKeepAlive(enable, initialDelay); - } - - /** - * Disables the Nagle algorithm for the socket. - * - * @param noDelay Whether to disable the Nagle algorithm. - */ - setNoDelay(noDelay: boolean): void { - this.sock.setNoDelay(noDelay); - } - - /** - * References the socket, preventing the process from exiting while the socket is active. - */ - ref(): void { - this.sock.ref(); - } - - /** - * Unreferences the socket, allowing the process to exit even if the socket is active. - */ - unref(): void { - this.sock.unref(); - } - - /** - * Establishes a TCP connection to the specified port and host. - * - * @param port The port to connect to. - * @param host The host to connect to. - * @param options Connection options. - * @returns A promise that resolves when the connection is successfully established. - */ - connect( - port: number, - host?: string, - options?: Except, - ): Promise { - return new Promise((resolve, reject) => { - const handleError = (error: IError) => { - this.handledErrorEvents.add(error); - reject(error); - }; - this.sock.prependOnceListener("error", handleError); - - this.sock.connect( - { - ...options, - port, - host, - }, - () => { - this.sock.removeListener("error", handleError); - resolve(); - }, - ); - }); - } - - /** - * Half-closes the TCP connection. It sends a FIN packet to the server, indicating - * that no more data will be sent. The server can still send data back until it - * also half-closes the connection. - * - * @param options Options for disconnecting. If `waitForClose` is true, the promise - * will resolve only after the 'close' event is emitted. - * @returns A promise that resolves when the connection is half-closed or fully closed - * based on the provided options. - */ - end(options?: { waitForClose?: boolean }): Promise { - return new Promise((resolve, reject) => { - const handleError = (error: IError) => { - this.handledErrorEvents.add(error); - reject(error); - }; - const handleEnd = () => { - this.sock.removeListener("error", handleError); - resolve(); - }; - - this.sock.prependOnceListener("error", handleError); - - if (options?.waitForClose && !this.sock.closed) { - this.sock.prependOnceListener("close", () => { - this.sock.removeListener("error", handleError); - resolve(); - }); - } - - this.sock.end( - options?.waitForClose && !this.sock.closed ? undefined : handleEnd, - ); - }); - } - - /** - * Writes data to the TCP socket. - * - * @param data The data to write. Can be a string or a Uint8Array. - * @param options Optional write options, such as encoding. - * @returns A promise that resolves when the data is successfully written. - */ - write( - data: string | Uint8Array, - options?: { encoding?: BufferEncoding }, - ): Promise { - return new Promise((resolve, reject) => { - const callback = (error?: IError | null) => { - if (error) { - reject(error); - } else { - resolve(); - } - }; - - if (options?.encoding) { - return this.sock.write(data, options.encoding, callback); - } - - this.sock.write(data, callback); - }); - } - - /** - * Sets up event forwarding from the underlying socket to this EventEmitter. - */ - private setupEventForwarding(): void { - this.sock.on("error", (err) => { - if (this.handledErrorEvents.has(err)) { - this.handledErrorEvents.delete(err); - return; - } - this.emit("error", err); - }); - - this.sock.on("close", (hadError) => { - this.emit("close", hadError); - }); - - this.sock.on("connect", () => { - this.emit("connect"); - }); - - this.sock.on("connectionAttempt", (ip, port, family) => { - this.emit("connectionAttempt", { - ...composeInetAddress(family, ip), - port, - }); - }); - - this.sock.on("connectionAttemptFailed", (ip, port, family, error) => { - this.emit( - "connectionAttemptFailed", - { - ...composeInetAddress(family, ip), - port, - }, - error, - ); - }); - - this.sock.on("connectionAttemptTimeout", (ip, port, family) => { - this.emit("connectionAttemptTimeout", { - ...composeInetAddress(family, ip), - port, - }); - }); - - this.sock.on("lookup", (err, address, family, host) => { - this.emit("lookup", err, composeInetAddress(family, address), host); - }); - - this.sock.on("ready", () => { - this.emit("ready"); - }); - - this.sock.on("timeout", () => { - this.emit("timeout"); - }); - } -} diff --git a/packages/misc-util/src/node/net/tcp-server.test.ts b/packages/misc-util/src/node/net/tcp-server.test.ts index cf525666..e66c5755 100644 --- a/packages/misc-util/src/node/net/tcp-server.test.ts +++ b/packages/misc-util/src/node/net/tcp-server.test.ts @@ -1,8 +1,8 @@ import * as net from "node:net"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { InetEndpoint } from "./inet.js"; -import { TcpClient } from "./tcp-client.js"; import { TcpServer } from "./tcp-server.js"; +import { TcpSocket } from "./tcp-socket.js"; describe("TcpServer", () => { let server: TcpServer; @@ -137,7 +137,7 @@ describe("TcpServer", () => { // Try to bind another server to the same port const server2 = TcpServer.from(); - server2.on("error", () => { + server2.subscribe("error", () => { // Prevent unhandled error }); @@ -152,15 +152,6 @@ describe("TcpServer", () => { expect(server.listening).toBe(true); }); - - it("should remove error listener after successful listen", async () => { - server = TcpServer.from(); - await server.listen(0, serverHost); - - // Check that error listeners have been cleaned up - const errorListenerCount = server.listenerCount("error"); - expect(errorListenerCount).toBe(0); - }); }); describe("close", () => { @@ -186,7 +177,7 @@ describe("TcpServer", () => { serverPort = address.port; // Connect a client - const client = TcpClient.from(); + const client = TcpSocket.from(); await client.connect(serverPort, serverHost); // Close the client @@ -233,7 +224,7 @@ describe("TcpServer", () => { serverPort = address.port; // Connect a client - const client1 = TcpClient.from(); + const client1 = TcpSocket.from(); await client1.connect(serverPort, serverHost); // Wait for connection to be established @@ -243,7 +234,7 @@ describe("TcpServer", () => { expect(count1).toBe(1); // Connect another client - const client2 = TcpClient.from(); + const client2 = TcpSocket.from(); await client2.connect(serverPort, serverHost); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -263,7 +254,7 @@ describe("TcpServer", () => { const address = server.address() as InetEndpoint; serverPort = address.port; - const client = TcpClient.from(); + const client = TcpSocket.from(); await client.connect(serverPort, serverHost); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -358,233 +349,160 @@ describe("TcpServer", () => { }); describe("event listeners", () => { - describe("on", () => { + describe("subscribe()/unsubscribe()", () => { it("should register connection event listener", async () => { server = TcpServer.from(); - const connections: TcpClient[] = []; - - server.on("connection", (connectedClient) => { + const connections: TcpSocket[] = []; + server.subscribe("connection", (connectedClient) => { connections.push(connectedClient); }); - await server.listen(0, serverHost); - const address = server.address() as InetEndpoint; serverPort = address.port; - - const client = TcpClient.from(); + const client = TcpSocket.from(); await client.connect(serverPort, serverHost); - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(connections.length).toBe(1); - expect(connections[0]).toBeInstanceOf(TcpClient); - + expect(connections[0]).toBeInstanceOf(TcpSocket); await client.end({ waitForClose: true }); }); it("should register listening event listener", async () => { server = TcpServer.from(); let listeningCalled = false; - - server.on("listening", () => { + server.subscribe("listening", () => { listeningCalled = true; }); - await server.listen(0, serverHost); - expect(listeningCalled).toBe(true); }); it("should register close event listener", async () => { server = TcpServer.from(); let closeCalled = false; - - server.on("close", () => { + server.subscribe("close", () => { closeCalled = true; }); - await server.listen(0, serverHost); await server.close(); - expect(closeCalled).toBe(true); }); it("should register error event listener", async () => { server = TcpServer.from(); let errorReceived = false; - - server.on("error", () => { + server.subscribe("error", () => { errorReceived = true; }); - await server.listen(0, serverHost); - // Error event test - simply verify the listener can be registered expect(errorReceived).toBe(false); }); - it("should return this for chaining", () => { - server = TcpServer.from(); - const result = server.on("connection", () => {}); - expect(result).toBe(server); - }); - }); - describe("once", () => { it("should register one-time connection event listener", async () => { server = TcpServer.from(); let connectionCount = 0; - - server.once("connection", () => { - connectionCount++; - }); - + server.subscribe( + "connection", + () => { + connectionCount++; + }, + { once: true }, + ); await server.listen(0, serverHost); - const address = server.address() as InetEndpoint; serverPort = address.port; - - const client1 = TcpClient.from(); + const client1 = TcpSocket.from(); await client1.connect(serverPort, serverHost); - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(connectionCount).toBe(1); - - const client2 = TcpClient.from(); + const client2 = TcpSocket.from(); await client2.connect(serverPort, serverHost); - await new Promise((resolve) => setTimeout(resolve, 50)); - // Should still be 1 because it's a one-time listener expect(connectionCount).toBe(1); - await client1.end({ waitForClose: true }); await client2.end({ waitForClose: true }); }); - it("should return this for chaining", () => { - server = TcpServer.from(); - const result = server.once("connection", () => {}); - expect(result).toBe(server); - }); - }); - - describe("off", () => { it("should remove connection event listener", async () => { server = TcpServer.from(); let connectionCount = 0; - const listener = () => { connectionCount++; }; - - server.on("connection", listener); + server.subscribe("connection", listener); await server.listen(0, serverHost); - const address = server.address() as InetEndpoint; serverPort = address.port; - - const client1 = TcpClient.from(); + const client1 = TcpSocket.from(); await client1.connect(serverPort, serverHost); - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(connectionCount).toBe(1); - // Remove the listener - server.off("connection", listener); - - const client2 = TcpClient.from(); + server.unsubscribe("connection", listener); + const client2 = TcpSocket.from(); await client2.connect(serverPort, serverHost); - await new Promise((resolve) => setTimeout(resolve, 50)); - // Should still be 1 because listener was removed expect(connectionCount).toBe(1); - await client1.end({ waitForClose: true }); await client2.end({ waitForClose: true }); }); - - it("should return this for chaining", () => { - server = TcpServer.from(); - const listener = () => {}; - const result = server.off("connection", listener); - expect(result).toBe(server); - }); }); }); describe("integration scenarios", () => { it("should handle echo server pattern", async () => { server = TcpServer.from(); - - server.on("connection", (connectedClient) => { + server.subscribe("connection", (connectedClient: TcpSocket) => { connectedClient.stream.on("data", async (data: Buffer) => { await connectedClient.write(data); }); }); - await server.listen(0, serverHost); - const address = server.address() as InetEndpoint; serverPort = address.port; - - const client = TcpClient.from(); + const client = TcpSocket.from(); await client.connect(serverPort, serverHost); - const testData = "Hello, World!"; - await client.write(testData); - + await client.write(Buffer.from(testData)); const response = await new Promise((resolve) => { client.stream.once("data", (data: Buffer) => { resolve(data.toString()); }); }); - expect(response).toBe(testData); - await client.end({ waitForClose: true }); }); it("should handle multiple concurrent connections", async () => { server = TcpServer.from(); const receivedData: string[] = []; - - server.on("connection", (connectedClient) => { + server.subscribe("connection", (connectedClient: TcpSocket) => { connectedClient.stream.on("data", (data: Buffer) => { receivedData.push(data.toString()); }); }); - await server.listen(0, serverHost); - const address = server.address() as InetEndpoint; serverPort = address.port; - // Connect multiple clients - const client1 = TcpClient.from(); + const client1 = TcpSocket.from(); await client1.connect(serverPort, serverHost); - - const client2 = TcpClient.from(); + const client2 = TcpSocket.from(); await client2.connect(serverPort, serverHost); - - const client3 = TcpClient.from(); + const client3 = TcpSocket.from(); await client3.connect(serverPort, serverHost); - // Send data from each client - await client1.write("Client 1"); - await client2.write("Client 2"); - await client3.write("Client 3"); - + await client1.write(Buffer.from("Client 1")); + await client2.write(Buffer.from("Client 2")); + await client3.write(Buffer.from("Client 3")); await new Promise((resolve) => setTimeout(resolve, 100)); - expect(receivedData.length).toBe(3); expect(receivedData).toContain("Client 1"); expect(receivedData).toContain("Client 2"); expect(receivedData).toContain("Client 3"); - // Clean up await client1.end({ waitForClose: true }); await client2.end({ waitForClose: true }); @@ -594,91 +512,68 @@ describe("TcpServer", () => { it("should handle client disconnect gracefully", async () => { server = TcpServer.from(); let disconnectCount = 0; - const disconnectPromise = new Promise((resolve) => { - server.on("connection", (connectedClient) => { + server.subscribe("connection", (connectedClient: TcpSocket) => { connectedClient.stream.on("end", () => { disconnectCount++; resolve(); }); }); }); - await server.listen(0, serverHost); - const address = server.address() as InetEndpoint; serverPort = address.port; - - const client = TcpClient.from(); + const client = TcpSocket.from(); await client.connect(serverPort, serverHost); - await client.end({ waitForClose: true }); - // Wait for the disconnect event await disconnectPromise; - expect(disconnectCount).toBe(1); }); it("should handle binary data", async () => { server = TcpServer.from(); const receivedBuffers: Buffer[] = []; - - server.on("connection", (connectedClient) => { + server.subscribe("connection", (connectedClient: TcpSocket) => { connectedClient.stream.on("data", (data: Buffer) => { receivedBuffers.push(data); }); }); - await server.listen(0, serverHost); - const address = server.address() as InetEndpoint; serverPort = address.port; - - const client = TcpClient.from(); + const client = TcpSocket.from(); await client.connect(serverPort, serverHost); - const testData = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello" await client.write(testData); - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(receivedBuffers.length).toBeGreaterThan(0); expect(receivedBuffers[0]?.toString()).toBe("Hello"); - await client.end({ waitForClose: true }); }); it("should handle request-response pattern", async () => { server = TcpServer.from(); - - server.on("connection", (connectedClient) => { + server.subscribe("connection", (connectedClient: TcpSocket) => { connectedClient.stream.on("data", async (data: Buffer) => { const request = data.toString(); if (request === "PING") { - await connectedClient.write("PONG"); + await connectedClient.write(Buffer.from("PONG")); } }); }); - await server.listen(0, serverHost); - const address = server.address() as InetEndpoint; serverPort = address.port; - - const client = TcpClient.from(); + const client = TcpSocket.from(); await client.connect(serverPort, serverHost); - - await client.write("PING"); - + await client.write(Buffer.from("PING")); const response = await new Promise((resolve) => { client.stream.once("data", (data: Buffer) => { resolve(data.toString()); }); }); - expect(response).toBe("PONG"); - await client.end({ waitForClose: true }); }); }); @@ -686,10 +581,14 @@ describe("TcpServer", () => { describe("events", () => { it("should emit connection event with TcpClient instance", async () => { server = TcpServer.from(); - const connectionPromise = new Promise((resolve) => { - server.once("connection", (arg) => { - resolve(arg); - }); + const connectionPromise = new Promise((resolve) => { + server.subscribe( + "connection", + (arg) => { + resolve(arg); + }, + { once: true }, + ); }); await server.listen(0, serverHost); @@ -697,11 +596,11 @@ describe("TcpServer", () => { const address = server.address() as InetEndpoint; serverPort = address.port; - const client = TcpClient.from(); + const client = TcpSocket.from(); await client.connect(serverPort, serverHost); const connectedClient = await connectionPromise; - expect(connectedClient).toBeInstanceOf(TcpClient); + expect(connectedClient).toBeInstanceOf(TcpSocket); await client.end({ waitForClose: true }); }); @@ -709,7 +608,7 @@ describe("TcpServer", () => { it("should emit listening event", async () => { server = TcpServer.from(); const listeningPromise = new Promise((resolve) => { - server.once("listening", resolve); + server.subscribe("listening", resolve, { once: true }); }); await server.listen(0, serverHost); @@ -721,7 +620,7 @@ describe("TcpServer", () => { await server.listen(0, serverHost); const closePromise = new Promise((resolve) => { - server.once("close", resolve); + server.subscribe("close", resolve, { once: true }); }); await server.close(); @@ -735,9 +634,13 @@ describe("TcpServer", () => { const testError = new Error("Connection error"); const errorPromise = new Promise((resolve) => { - server.once("error", (err) => { - resolve(err); - }); + server.subscribe( + "error", + (err) => { + resolve(err); + }, + { once: true }, + ); }); // Emit an error directly on the underlying server to test error forwarding @@ -758,9 +661,13 @@ describe("TcpServer", () => { local: InetEndpoint | null; remote: InetEndpoint | null; }>((resolve) => { - server.once("drop", (localEndpoint, remoteEndpoint) => { - resolve({ local: localEndpoint, remote: remoteEndpoint }); - }); + server.subscribe( + "drop", + (localEndpoint, remoteEndpoint) => { + resolve({ local: localEndpoint, remote: remoteEndpoint }); + }, + { once: true }, + ); }); await server.listen(0, serverHost); @@ -769,7 +676,7 @@ describe("TcpServer", () => { serverPort = address.port; // Try to connect (should be dropped due to maxConnections = 0) - const client = TcpClient.from(); + const client = TcpSocket.from(); client.connect(serverPort, serverHost).catch(() => { // Expected to be dropped or fail }); @@ -802,17 +709,21 @@ describe("TcpServer", () => { const mockServer = net.createServer(); const testServer = new TcpServer(mockServer); - const connectionPromise = new Promise((resolve) => { - testServer.once("connection", (client) => { - resolve(client); - }); + const connectionPromise = new Promise((resolve) => { + testServer.subscribe( + "connection", + (client) => { + resolve(client); + }, + { once: true }, + ); }); const mockSocket = new net.Socket(); mockServer.emit("connection", mockSocket); const client = await connectionPromise; - expect(client).toBeInstanceOf(TcpClient); + expect(client).toBeInstanceOf(TcpSocket); }); it("should forward listening event", async () => { @@ -820,9 +731,13 @@ describe("TcpServer", () => { const testServer = new TcpServer(mockServer); const listeningPromise = new Promise((resolve) => { - testServer.once("listening", () => { - resolve(); - }); + testServer.subscribe( + "listening", + () => { + resolve(); + }, + { once: true }, + ); }); mockServer.emit("listening"); @@ -835,9 +750,13 @@ describe("TcpServer", () => { const testServer = new TcpServer(mockServer); const closePromise = new Promise((resolve) => { - testServer.once("close", () => { - resolve(); - }); + testServer.subscribe( + "close", + () => { + resolve(); + }, + { once: true }, + ); }); mockServer.emit("close"); @@ -852,9 +771,13 @@ describe("TcpServer", () => { const testError = new Error("Server error"); const errorPromise = new Promise((resolve) => { - testServer.once("error", (error) => { - resolve(error); - }); + testServer.subscribe( + "error", + (error) => { + resolve(error); + }, + { once: true }, + ); }); mockServer.emit("error", testError); @@ -871,9 +794,13 @@ describe("TcpServer", () => { local: InetEndpoint | null; remote: InetEndpoint | null; }>((resolve) => { - testServer.once("drop", (local, remote) => { - resolve({ local, remote }); - }); + testServer.subscribe( + "drop", + (local, remote) => { + resolve({ local, remote }); + }, + { once: true }, + ); }); const mockData = { diff --git a/packages/misc-util/src/node/net/tcp-server.ts b/packages/misc-util/src/node/net/tcp-server.ts index 84e085f8..69e148bf 100644 --- a/packages/misc-util/src/node/net/tcp-server.ts +++ b/packages/misc-util/src/node/net/tcp-server.ts @@ -1,15 +1,16 @@ -import { EventEmitter } from "node:events"; import * as net from "node:net"; import type { Except } from "type-fest"; +import { EventDispatcherMapBase } from "../../async/events/_event-dispatcher-map-base.js"; +import type { IEventDispatcherMap } from "../../async/events/ievent-dispatcher-map.js"; import type { IError } from "../../ecma/error/error.js"; import { UnsupportedError } from "../../ecma/error/unsupported-error.js"; import { composeInetAddress, type InetEndpoint } from "./inet.js"; -import { TcpClient } from "./tcp-client.js"; +import { TcpSocket } from "./tcp-socket.js"; /** * Event map for TcpServer server-specific events. */ -export interface TcpServerEvents { +export type TcpServerEvents = { /** * Emitted when the server closes. */ @@ -19,7 +20,7 @@ export interface TcpServerEvents { * Emitted when a new connection is made. * The connection is wrapped in a TcpClient instance. */ - connection: [client: TcpClient]; + connection: [client: TcpSocket]; /** * Emitted when a new connection is dropped. @@ -39,7 +40,7 @@ export interface TcpServerEvents { * Emitted when the server has been bound after calling server.listen(). */ listening: []; -} +}; /** * TCP server to accept incoming connections. @@ -58,7 +59,10 @@ export interface TcpServerEvents { * await server.close(); * ``` */ -export class TcpServer extends EventEmitter { +export class TcpServer + extends EventDispatcherMapBase + implements IEventDispatcherMap +{ /** * Creates a new `TcpServer` instance with the specified options. * @@ -206,17 +210,14 @@ export class TcpServer extends EventEmitter { }; } - /** - * Sets up event forwarding from the underlying server to this EventEmitter. - */ private setupEventForwarding(): void { this.srv.on("connection", (socket) => { - const client = new TcpClient(socket); - this.emit("connection", client); + const client = new TcpSocket(socket); + this.dispatch("connection", client); }); this.srv.on("close", () => { - this.emit("close"); + this.dispatch("close"); }); this.srv.on("error", (err) => { @@ -224,11 +225,11 @@ export class TcpServer extends EventEmitter { this.handledErrorEvents.delete(err); return; } - this.emit("error", err); + this.dispatch("error", err); }); this.srv.on("listening", () => { - this.emit("listening"); + this.dispatch("listening"); }); this.srv.on("drop", (data?) => { @@ -260,7 +261,7 @@ export class TcpServer extends EventEmitter { remoteEndpoint = null; } - this.emit("drop", localEndpoint, remoteEndpoint); + this.dispatch("drop", localEndpoint, remoteEndpoint); }); } } diff --git a/packages/misc-util/src/node/net/tcp-socket.test.ts b/packages/misc-util/src/node/net/tcp-socket.test.ts new file mode 100644 index 00000000..9653d8be --- /dev/null +++ b/packages/misc-util/src/node/net/tcp-socket.test.ts @@ -0,0 +1,496 @@ +import * as net from "node:net"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { InetAddress, InetEndpoint } from "./inet.js"; +import { TcpSocket } from "./tcp-socket.js"; + +describe("TcpSocket", () => { + let server: net.Server; + let serverPort: number; + let serverHost: string; + let receivedData: Buffer[]; + + beforeEach( + () => + new Promise((resolve) => { + receivedData = []; + serverHost = "127.0.0.1"; + + server = net.createServer((socket) => { + socket.on("data", (data) => { + receivedData.push(data); + // Echo back the data + socket.write(data); + }); + + socket.on("end", () => { + socket.end(); + }); + }); + + server.listen(0, serverHost, () => { + const address = server.address() as net.AddressInfo; + serverPort = address.port; + resolve(); + }); + }), + ); + + afterEach( + () => + new Promise((resolve) => { + server.close(() => { + resolve(); + }); + }), + ); + + describe("properties", () => { + describe("connected socket", () => { + let socket: TcpSocket; + + beforeEach(async () => { + socket = TcpSocket.from(); + await socket.connect(serverPort, serverHost); + }); + + afterEach(async () => { + if (!socket.closed) { + await socket.end({ waitForClose: true }); + } + }); + it("should report closed status during lifecycle", async () => { + expect(socket.closed).toBe(false); + await socket.connect(serverPort, serverHost); + expect(socket.closed).toBe(false); + await socket.end({ waitForClose: true }); + expect(socket.closed).toBe(true); + }); + + it("should report connecting status during connection", async () => { + expect(socket.connecting).toBe(false); + const connectPromise = socket.connect(serverPort, serverHost); + expect(socket.connecting).toBe(true); + await connectPromise; + expect(socket.connecting).toBe(false); + }); + + it("should track bytesRead and bytesWritten during communication", async () => { + await socket.connect(serverPort, serverHost); + + expect(socket.bytesRead).toBe(0); + expect(socket.bytesWritten).toBe(0); + + await socket.write(Buffer.from("test data")); + expect(socket.bytesWritten).toBeGreaterThan(0); + + // Wait for echo response + await new Promise((resolve) => { + socket.stream.once("data", resolve); + }); + expect(socket.bytesRead).toBeGreaterThan(0); + }); + + it("should provide remoteEndpoint", async () => { + const endpoint = socket.remoteEndpoint; + expect(endpoint).not.toBeNull(); + expect(endpoint?.address).toBe(serverHost); + expect(endpoint?.port).toBe(serverPort); + expect(endpoint?.family).toBeOneOf([4, 6]); + }); + + it("should provide localEndpoint", async () => { + const endpoint = socket.localEndpoint; + expect(endpoint).not.toBeNull(); + expect(endpoint?.address).toBeDefined(); + expect(endpoint?.port).toBeGreaterThan(0); + expect(endpoint?.family).toBeOneOf([4, 6]); + }); + + it("should get and set timeout on connected socket", async () => { + expect(socket.timeout).toBeNull(); + socket.timeout = 5000; + expect(socket.timeout).toBe(5000); + socket.timeout = 0; + expect(socket.timeout).toBe(0); + }); + }); + + describe("property getters and setters", () => { + it("should provide null remoteEndpoint when not connected", () => { + const mockSocket = new net.Socket(); + const mockedClient = new TcpSocket(mockSocket); + + // Mock properties to return undefined + Object.defineProperty(mockSocket, "remoteAddress", { + get: vi.fn(), + configurable: true, + }); + Object.defineProperty(mockSocket, "remotePort", { + get: vi.fn(), + configurable: true, + }); + + expect(mockedClient.remoteEndpoint).toBeNull(); + }); + + it("should provide remoteEndpoint", () => { + const mockSocket = new net.Socket(); + const mockedClient = new TcpSocket(mockSocket); + + // Mock remote endpoint properties + Object.defineProperty(mockSocket, "remoteAddress", { + get: vi.fn(() => "192.168.1.100"), + configurable: true, + }); + Object.defineProperty(mockSocket, "remotePort", { + get: vi.fn(() => 8080), + configurable: true, + }); + Object.defineProperty(mockSocket, "remoteFamily", { + get: vi.fn(() => "IPv4"), + configurable: true, + }); + + const endpoint = mockedClient.remoteEndpoint; + expect(endpoint).not.toBeNull(); + expect(endpoint?.address).toBe("192.168.1.100"); + expect(endpoint?.port).toBe(8080); + expect(endpoint?.family).toBe(4); + }); + + it("should provide localEndpoint", () => { + const mockSocket = new net.Socket(); + const mockedClient = new TcpSocket(mockSocket); + + // Mock local endpoint properties + Object.defineProperty(mockSocket, "localAddress", { + get: vi.fn(() => "127.0.0.1"), + configurable: true, + }); + Object.defineProperty(mockSocket, "localPort", { + get: vi.fn(() => 12345), + configurable: true, + }); + Object.defineProperty(mockSocket, "localFamily", { + get: vi.fn(() => "IPv4"), + configurable: true, + }); + + const endpoint = mockedClient.localEndpoint; + expect(endpoint).not.toBeNull(); + expect(endpoint?.address).toBe("127.0.0.1"); + expect(endpoint?.port).toBe(12345); + expect(endpoint?.family).toBe(4); + }); + }); + }); + + describe("methods", () => { + it("should set keep-alive", () => { + const mockSocket = new net.Socket(); + const socket = new TcpSocket(mockSocket); + + const setKeepAliveSpy = vi.spyOn(mockSocket, "setKeepAlive"); + + socket.setKeepAlive(true, 1000); + expect(setKeepAliveSpy).toHaveBeenNthCalledWith(1, true, 1000); + + socket.setKeepAlive(false); + expect(setKeepAliveSpy).toHaveBeenNthCalledWith(2, false, undefined); + }); + + it("should set no-delay", () => { + const mockSocket = new net.Socket(); + const socket = new TcpSocket(mockSocket); + + const setNoDelaySpy = vi.spyOn(mockSocket, "setNoDelay"); + + socket.setNoDelay(true); + expect(setNoDelaySpy).toHaveBeenCalledWith(true); + + socket.setNoDelay(false); + expect(setNoDelaySpy).toHaveBeenCalledWith(false); + }); + + describe("connect", () => { + it("should connect to a TCP server", async () => { + const socket = TcpSocket.from(); + await socket.connect(serverPort, serverHost); + expect(socket.connecting).toBe(false); + expect(socket.closed).toBe(false); + await socket.end(); + }); + + it("should connect with only port parameter", async () => { + const socket = TcpSocket.from(); + await socket.connect(serverPort); + expect(socket.connecting).toBe(false); + expect(socket.closed).toBe(false); + await socket.end(); + }); + + it("should reject on connection error", async () => { + const socket = TcpSocket.from(); + // Try to connect to an invalid port + await expect(socket.connect(1, "0.0.0.0")).rejects.toThrow(); + }); + + it("should reject on connection to non-existent host", async () => { + const socket = TcpSocket.from(); + await expect( + socket.connect(serverPort, "invalid.host.example.com"), + ).rejects.toThrow(); + }); + + it("should pass additional connection options", async () => { + const socket = TcpSocket.from(); + await socket.connect(serverPort, serverHost, { + localPort: undefined, // Let system assign + }); + expect(socket.connecting).toBe(false); + expect(socket.closed).toBe(false); + await socket.end(); + }); + + it("should handle connection refused error", async () => { + // Use a port that is very unlikely to be in use + const unusedPort = 54321; + const socket = TcpSocket.from(); + + await expect(socket.connect(unusedPort, serverHost)).rejects.toThrow(); + }); + }); + }); + + describe("events forwarding", () => { + it("should forward connect event", async () => { + const mockSocket = new net.Socket(); + const socket = new TcpSocket(mockSocket); + const connectPromise = new Promise((resolve) => { + socket.subscribe( + "connect", + () => { + resolve(); + }, + { once: true }, + ); + }); + mockSocket.emit("connect"); + await connectPromise; + }); + + it("should forward connectionAttempt event", async () => { + const mockSocket = new net.Socket(); + const socket = new TcpSocket(mockSocket); + const testEndpoint: InetEndpoint = { + address: "127.0.0.1", + port: 8080, + family: 4, + }; + const eventPromise = new Promise<{ endpoint: typeof testEndpoint }>( + (resolve) => { + socket.subscribe( + "connectionAttempt", + (endpoint) => { + resolve({ endpoint }); + }, + { once: true }, + ); + }, + ); + mockSocket.emit( + "connectionAttempt", + testEndpoint.address, + testEndpoint.port, + testEndpoint.family, + ); + const { endpoint } = await eventPromise; + expect(endpoint).toEqual(testEndpoint); + }); + + it("should forward connectionAttemptFailed event", async () => { + const mockSocket = new net.Socket(); + const socket = new TcpSocket(mockSocket); + const testError = new Error("Connection failed"); + const testEndpoint: InetEndpoint = { + address: "127.0.0.1", + port: 8080, + family: 4 as const, + }; + const eventPromise = new Promise<{ + endpoint: typeof testEndpoint; + error: Error; + }>((resolve) => { + socket.subscribe( + "connectionAttemptFailed", + (endpoint, error) => { + resolve({ endpoint, error }); + }, + { once: true }, + ); + }); + mockSocket.emit( + "connectionAttemptFailed", + testEndpoint.address, + testEndpoint.port, + testEndpoint.family, + testError, + ); + const result = await eventPromise; + expect(result.endpoint).toEqual(testEndpoint); + expect(result.error).toBe(testError); + }); + + it("should forward connectionAttemptTimeout event", async () => { + const mockSocket = new net.Socket(); + const socket = new TcpSocket(mockSocket); + const testEndpoint: InetEndpoint = { + address: "127.0.0.1", + port: 8080, + family: 4 as const, + }; + const eventPromise = new Promise<{ endpoint: typeof testEndpoint }>( + (resolve) => { + socket.subscribe( + "connectionAttemptTimeout", + (endpoint) => { + resolve({ endpoint }); + }, + { once: true }, + ); + }, + ); + mockSocket.emit( + "connectionAttemptTimeout", + testEndpoint.address, + testEndpoint.port, + testEndpoint.family, + ); + const { endpoint } = await eventPromise; + expect(endpoint).toEqual(testEndpoint); + }); + + it("should forward lookup event with IPv4", async () => { + const mockSocket = new net.Socket(); + const socket = new TcpSocket(mockSocket); + const eventPromise = new Promise<{ + err: Error | null; + address: InetAddress; + host: string; + }>((resolve) => { + socket.subscribe( + "lookup", + (err, address, host) => { + resolve({ err, address, host }); + }, + { once: true }, + ); + }); + mockSocket.emit("lookup", null, "127.0.0.1", 4, "localhost"); + const result = await eventPromise; + expect(result.err).toBeNull(); + expect(result.address).toEqual({ + address: "127.0.0.1", + family: 4, + }); + expect(result.host).toBe("localhost"); + }); + + it("should forward lookup event with IPv6", async () => { + const mockSocket = new net.Socket(); + const socket = new TcpSocket(mockSocket); + const eventPromise = new Promise<{ + err: Error | null; + address: InetAddress; + host: string; + }>((resolve) => { + socket.subscribe( + "lookup", + (err, address, host) => { + resolve({ err, address, host }); + }, + { once: true }, + ); + }); + mockSocket.emit("lookup", null, "::1", 6, "localhost"); + const result = await eventPromise; + expect(result.err).toBeNull(); + expect(result.address).toEqual({ + address: "::1", + family: 6, + }); + expect(result.host).toBe("localhost"); + }); + }); + + describe("integration scenarios", () => { + it("should handle full request-response cycle", async () => { + const socket = TcpSocket.from(); + await socket.connect(serverPort, serverHost); + + // Write data + const testData = "Test message"; + await socket.write(Buffer.from(testData)); + + // Read response + const response = await new Promise((resolve) => { + socket.stream.once("data", (data: Buffer) => { + resolve(data.toString()); + }); + }); + + expect(response).toBe(testData); + + await socket.end({ waitForClose: true }); + }); + + it("should handle connection with data listener", async () => { + const socket = TcpSocket.from(); + const dataChunks: Buffer[] = []; + + socket.stream.on("data", (chunk: Buffer) => { + dataChunks.push(chunk); + }); + + await socket.connect(serverPort, serverHost); + await socket.write(Buffer.from("Test data")); + + // Wait for echo + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(dataChunks.length).toBeGreaterThan(0); + expect(Buffer.concat(dataChunks).toString()).toBe("Test data"); + + await socket.end({ waitForClose: true }); + }); + + describe("events", () => { + it("should emit connect event", async () => { + const socket = TcpSocket.from(); + const connectPromise = new Promise((resolve) => { + socket.subscribe("connect", resolve, { once: true }); + }); + await socket.connect(serverPort, serverHost); + await connectPromise; + await socket.end(); + }); + + it("should emit lookup event when connecting to hostname", async () => { + const socket = TcpSocket.from(); + const lookupPromise = new Promise((resolve) => { + socket.subscribe( + "lookup", + (_err, _address, _host) => { + resolve(); + }, + { once: true }, + ); + }); + // Connect to 'localhost' to trigger DNS lookup + await socket.connect(serverPort, "localhost"); + await lookupPromise; + await socket.end(); + }); + }); + }); +}); diff --git a/packages/misc-util/src/node/net/tcp-socket.ts b/packages/misc-util/src/node/net/tcp-socket.ts new file mode 100644 index 00000000..0db6c7f4 --- /dev/null +++ b/packages/misc-util/src/node/net/tcp-socket.ts @@ -0,0 +1,202 @@ +import * as net from "node:net"; +import type { Except } from "type-fest"; +import type { IError } from "../../ecma/error/error.js"; +import { + composeInetAddress, + type InetAddress, + type InetEndpoint, +} from "./inet.js"; +import { StreamSocket, type StreamSocketEvents } from "./stream-socket.js"; + +/** + * Event map for TcpSocket socket-specific events. + */ +export type TcpSocketEvents = StreamSocketEvents & { + /** + * Emitted when a new connection attempt is started. + * May be emitted multiple times if family autoselection is enabled. + */ + connectionAttempt: [endpoint: InetEndpoint]; + + /** + * Emitted when a connection attempt failed. + * May be emitted multiple times if family autoselection is enabled. + */ + connectionAttemptFailed: [endpoint: InetEndpoint, error: Error]; + + /** + * Emitted when a connection attempt timed out. + * May be emitted multiple times if family autoselection is enabled. + */ + connectionAttemptTimeout: [endpoint: InetEndpoint]; + + /** + * Emitted after resolving the host name but before connecting. + */ + lookup: [err: Error | null, address: InetAddress, host: string]; +}; + +/** + * TCP socket for establishing connections to TCP servers. + * + * Example usage: + * ```ts + * const tcpSocket = new TcpSocket.from(); + * await tcpSocket.connect(80, "example.com"); + * await tcpSocket.write("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"); + * await tcpSocket.end({ waitForClose: true }); + * ``` + */ +export class TcpSocket< + TSock extends net.Socket = net.Socket, + TEvents extends TcpSocketEvents = TcpSocketEvents, +> extends StreamSocket { + /** + * Creates a new {@link TcpSocket} instance. + * + * @param options Options for creating the underlying Node.js socket. + * @returns A new `TcpSocket` instance. + */ + static override from( + options?: Except, + ): TcpSocket { + return new TcpSocket(new net.Socket(options)); + } + + constructor(sock: TSock) { + super(sock); + this.setupEventForwarding(); + } + + /** + * Returns the remote endpoint information of the socket. + * @throws {UnsupportedError} If the socket family is not IPv4 or IPv6. + */ + get remoteEndpoint(): InetEndpoint | null { + const address = this.sock.remoteAddress; + const port = this.sock.remotePort; + const familyStr = this.sock.remoteFamily; + + if ( + address === undefined || + port === undefined || + familyStr === undefined + ) { + return null; + } + + return { + ...composeInetAddress(familyStr, address), + port, + }; + } + + /** + * Returns the local endpoint information of the socket. + * @throws {UnsupportedError} If the socket family is not IPv4 or IPv6. + */ + get localEndpoint(): InetEndpoint | null { + const address = this.sock.localAddress; + const port = this.sock.localPort; + const familyStr = this.sock.localFamily; + + if ( + address === undefined || + port === undefined || + familyStr === undefined + ) { + return null; + } + + return { + ...composeInetAddress(familyStr, address), + port, + }; + } + + /** + * Sets the keep-alive option for the socket. + * + * @param enable Whether to enable keep-alive. + * @param initialDelay The initial delay in milliseconds before the first keep-alive probe. + */ + setKeepAlive(enable: boolean, initialDelay?: number): void { + this.sock.setKeepAlive(enable, initialDelay); + } + + /** + * Disables the Nagle algorithm for the socket. + * + * @param noDelay Whether to disable the Nagle algorithm. + */ + setNoDelay(noDelay: boolean): void { + this.sock.setNoDelay(noDelay); + } + + /** + * Establishes a TCP connection to the specified port and host. + * + * @param port The port to connect to. + * @param host The host to connect to (defaults to `localhost`). + * @param options Connection options. + * @returns A promise that resolves when the connection is successfully established. + */ + connect( + port: number, + host?: string, + options?: Except, + ): Promise { + return new Promise((resolve, reject) => { + const handleError = (error: IError) => { + this.handledErrorEvents.add(error); + reject(error); + }; + this.sock.prependOnceListener("error", handleError); + + this.sock.connect( + { + ...options, + port, + host, + }, + () => { + this.sock.removeListener("error", handleError); + resolve(); + }, + ); + }); + } + + protected override setupEventForwarding(): void { + super.setupEventForwarding(); + + this.sock.on("connectionAttempt", (ip, port, family) => { + this.dispatch("connectionAttempt", { + ...composeInetAddress(family, ip), + port, + }); + }); + + this.sock.on("connectionAttemptFailed", (ip, port, family, error) => { + this.dispatch( + "connectionAttemptFailed", + { + ...composeInetAddress(family, ip), + port, + }, + error, + ); + }); + + this.sock.on("connectionAttemptTimeout", (ip, port, family) => { + this.dispatch("connectionAttemptTimeout", { + ...composeInetAddress(family, ip), + port, + }); + }); + + this.sock.on("lookup", (err, address, family, host) => { + this.dispatch("lookup", err, composeInetAddress(family, address), host); + }); + } +} diff --git a/packages/misc-util/src/node/net/tls-socket.ts b/packages/misc-util/src/node/net/tls-socket.ts new file mode 100644 index 00000000..c167be19 --- /dev/null +++ b/packages/misc-util/src/node/net/tls-socket.ts @@ -0,0 +1,175 @@ +import type { X509Certificate } from "node:crypto"; +import type { Duplex } from "node:stream"; +import * as tls from "node:tls"; +import type { Except } from "type-fest"; +import { isObject } from "../../ecma/is-object.js"; +import type { StreamSocketEvents } from "./stream-socket.js"; +import { StreamSocket } from "./stream-socket.js"; + +export type TlsSocketEvents = StreamSocketEvents & { + secureConnect: []; + ocspResponse: [response: Buffer | null]; + keylog: [line: Buffer]; + session: [session: Buffer]; +}; + +export type TlsSocketOptions = Except< + tls.TLSSocketOptions, + "isServer" | "server" | keyof tls.SecureContextOptions +>; + +export class TlsSocket extends StreamSocket { + static override from(stream: Duplex, options?: TlsSocketOptions): TlsSocket { + return new TlsSocket(new tls.TLSSocket(stream, options)); + } + + get encrypted(): boolean { + return this.sock.encrypted; + } + + get authorized(): boolean { + return this.sock.authorized; + } + + get authorizationError(): Error | null { + return this.sock.authorizationError ?? null; + } + + get alpnProtocol(): string | null { + const value = this.sock.alpnProtocol; + return value === false ? null : value; + } + + getCertificate(): tls.PeerCertificate | null { + const cert = this.sock.getCertificate(); + + // Connection established but no certificate presented + if (isObject(cert) && Object.keys(cert).length === 0) { + return null; + } + + if (cert === null) { + return null; + } + + return cert as tls.PeerCertificate; + } + + getCipher(): tls.CipherNameAndProtocol { + return this.sock.getCipher(); + } + + getEphemeralKeyInfo(): tls.EphemeralKeyInfo | object | null { + return this.sock.getEphemeralKeyInfo(); + } + + getFinished(): Buffer | null { + const value = this.sock.getFinished(); + return value ?? null; + } + + getPeerCertificate< + T extends boolean | undefined, + R = T extends true ? tls.DetailedPeerCertificate : tls.PeerCertificate, + >(detailed?: T): R { + return this.sock.getPeerCertificate(detailed) as R; + } + + getPeerFinished(): Buffer | null { + const value = this.sock.getPeerFinished(); + return value ?? null; + } + + getProtocol(): string | null { + return this.sock.getProtocol(); + } + + getSession(): Buffer | null { + const value = this.sock.getSession(); + return value ?? null; + } + + getSharedSigalgs(): string[] { + return this.sock.getSharedSigalgs(); + } + + getTicket(): Buffer | null { + const value = this.sock.getTLSTicket(); + return value ?? null; + } + + isSessionReused(): boolean { + return this.sock.isSessionReused(); + } + + async renegotiate( + options: Parameters[0], + ): Promise { + // renegotiate callback is never called if socket is destroyed + if (this.sock.destroyed) { + throw new Error("Socket is destroyed"); + } + + return new Promise((resolve, reject) => { + const callback = (err: Error | null | undefined) => { + if (err) { + reject(err); + } else { + resolve(); + } + }; + + this.sock.renegotiate(options, callback); + }); + } + + setKeyCert(context: tls.SecureContext): void { + this.sock.setKeyCert(context); + } + + setMaxSendFragment(size: number): boolean { + return this.sock.setMaxSendFragment(size); + } + + disableRenegotiation(): void { + this.sock.disableRenegotiation(); + } + + enableTrace(): void { + this.sock.enableTrace(); + } + + getPeerX509Certificate(): X509Certificate | null { + const cert = this.sock.getPeerX509Certificate(); + return cert ?? null; + } + + getX509Certificate(): X509Certificate | null { + const cert = this.sock.getX509Certificate(); + return cert ?? null; + } + + exportKeyingMaterial(length: number, label: string, context: Buffer): Buffer { + return this.sock.exportKeyingMaterial(length, label, context); + } + + protected override setupEventForwarding(): void { + super.setupEventForwarding(); + + this.sock.on("secureConnect", () => { + this.dispatch("secureConnect"); + }); + + this.sock.on("OCSPResponse", (response) => { + this.dispatch("ocspResponse", response); + }); + + this.sock.on("keylog", (line) => { + this.dispatch("keylog", line); + }); + + this.sock.on("session", (session) => { + this.dispatch("session", session); + }); + } +} diff --git a/packages/misc-util/src/types.d/json.ts b/packages/misc-util/src/types.d/json.ts new file mode 100644 index 00000000..da168841 --- /dev/null +++ b/packages/misc-util/src/types.d/json.ts @@ -0,0 +1,17 @@ +import type { JsonObject, JsonValue } from "type-fest"; + +export type JsonError = JsonObject & { + name: string; + message: string; + stack?: string; + cause?: JsonError | JsonValue; +}; + +export type JsonAggregateError = JsonError & { + errors: (JsonError | JsonValue)[]; +}; + +export type JsonSuppressedError = JsonError & { + error: JsonError | JsonValue; + suppressed: JsonError | JsonValue; +}; diff --git a/scripts/yarn-workspaces-exec-if-dep.sh b/scripts/yarn-workspaces-exec-if-dep.sh old mode 100755 new mode 100644 diff --git a/vitest.config.ts b/vitest.config.mjs similarity index 100% rename from vitest.config.ts rename to vitest.config.mjs diff --git a/yarn.lock b/yarn.lock index dceff0a4..d4e1f419 100644 --- a/yarn.lock +++ b/yarn.lock @@ -158,18 +158,18 @@ __metadata: languageName: node linkType: hard -"@biomejs/biome@npm:2.3.13": - version: 2.3.13 - resolution: "@biomejs/biome@npm:2.3.13" - dependencies: - "@biomejs/cli-darwin-arm64": "npm:2.3.13" - "@biomejs/cli-darwin-x64": "npm:2.3.13" - "@biomejs/cli-linux-arm64": "npm:2.3.13" - "@biomejs/cli-linux-arm64-musl": "npm:2.3.13" - "@biomejs/cli-linux-x64": "npm:2.3.13" - "@biomejs/cli-linux-x64-musl": "npm:2.3.13" - "@biomejs/cli-win32-arm64": "npm:2.3.13" - "@biomejs/cli-win32-x64": "npm:2.3.13" +"@biomejs/biome@npm:2.4.4": + version: 2.4.4 + resolution: "@biomejs/biome@npm:2.4.4" + dependencies: + "@biomejs/cli-darwin-arm64": "npm:2.4.4" + "@biomejs/cli-darwin-x64": "npm:2.4.4" + "@biomejs/cli-linux-arm64": "npm:2.4.4" + "@biomejs/cli-linux-arm64-musl": "npm:2.4.4" + "@biomejs/cli-linux-x64": "npm:2.4.4" + "@biomejs/cli-linux-x64-musl": "npm:2.4.4" + "@biomejs/cli-win32-arm64": "npm:2.4.4" + "@biomejs/cli-win32-x64": "npm:2.4.4" dependenciesMeta: "@biomejs/cli-darwin-arm64": optional: true @@ -189,62 +189,62 @@ __metadata: optional: true bin: biome: bin/biome - checksum: 10c0/920c44c755ab8efa2114b441baf74b8f5fae8e6ad98a301ce02228f546d20ef6ab7c33b990b0a4acd1ffc8f5985ae839cf1fec59a6f169732a0f86c14308728b + checksum: 10c0/f2b7620d39caeeb9e0ed070dd1065cc2378626c7fa6ecda3db84b64df4c94fb4909407726044e28c83e8a95121ad389498cc3f0e5be600a421d906ca705b821c languageName: node linkType: hard -"@biomejs/cli-darwin-arm64@npm:2.3.13": - version: 2.3.13 - resolution: "@biomejs/cli-darwin-arm64@npm:2.3.13" +"@biomejs/cli-darwin-arm64@npm:2.4.4": + version: 2.4.4 + resolution: "@biomejs/cli-darwin-arm64@npm:2.4.4" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@biomejs/cli-darwin-x64@npm:2.3.13": - version: 2.3.13 - resolution: "@biomejs/cli-darwin-x64@npm:2.3.13" +"@biomejs/cli-darwin-x64@npm:2.4.4": + version: 2.4.4 + resolution: "@biomejs/cli-darwin-x64@npm:2.4.4" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@biomejs/cli-linux-arm64-musl@npm:2.3.13": - version: 2.3.13 - resolution: "@biomejs/cli-linux-arm64-musl@npm:2.3.13" +"@biomejs/cli-linux-arm64-musl@npm:2.4.4": + version: 2.4.4 + resolution: "@biomejs/cli-linux-arm64-musl@npm:2.4.4" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@biomejs/cli-linux-arm64@npm:2.3.13": - version: 2.3.13 - resolution: "@biomejs/cli-linux-arm64@npm:2.3.13" +"@biomejs/cli-linux-arm64@npm:2.4.4": + version: 2.4.4 + resolution: "@biomejs/cli-linux-arm64@npm:2.4.4" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@biomejs/cli-linux-x64-musl@npm:2.3.13": - version: 2.3.13 - resolution: "@biomejs/cli-linux-x64-musl@npm:2.3.13" +"@biomejs/cli-linux-x64-musl@npm:2.4.4": + version: 2.4.4 + resolution: "@biomejs/cli-linux-x64-musl@npm:2.4.4" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@biomejs/cli-linux-x64@npm:2.3.13": - version: 2.3.13 - resolution: "@biomejs/cli-linux-x64@npm:2.3.13" +"@biomejs/cli-linux-x64@npm:2.4.4": + version: 2.4.4 + resolution: "@biomejs/cli-linux-x64@npm:2.4.4" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@biomejs/cli-win32-arm64@npm:2.3.13": - version: 2.3.13 - resolution: "@biomejs/cli-win32-arm64@npm:2.3.13" +"@biomejs/cli-win32-arm64@npm:2.4.4": + version: 2.4.4 + resolution: "@biomejs/cli-win32-arm64@npm:2.4.4" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@biomejs/cli-win32-x64@npm:2.3.13": - version: 2.3.13 - resolution: "@biomejs/cli-win32-x64@npm:2.3.13" +"@biomejs/cli-win32-x64@npm:2.4.4": + version: 2.4.4 + resolution: "@biomejs/cli-win32-x64@npm:2.4.4" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -3004,7 +3004,7 @@ __metadata: dependencies: "@ac-essentials/biome-config": "workspace:*" "@ac-essentials/markdownlint-cli2-config": "workspace:*" - "@biomejs/biome": "npm:2.3.13" + "@biomejs/biome": "npm:2.4.4" "@vitest/coverage-v8": "npm:4.0.18" is-ci: "npm:4.1.0" lefthook: "npm:2.0.16"