diff --git a/src/api/providers/__tests__/request-config-builder.spec.ts b/src/api/providers/__tests__/request-config-builder.spec.ts new file mode 100644 index 0000000000..868254fd80 --- /dev/null +++ b/src/api/providers/__tests__/request-config-builder.spec.ts @@ -0,0 +1,609 @@ +import { describe, expect, test, vi } from "vitest" + +import type { ApiHandlerCreateMessageMetadata } from "../../index" +import { RequestConfigBuilder } from "../config-builder/request-config-builder" + +describe("RequestConfigBuilder", () => { + describe("constructor", () => { + test("should initialize with empty options by default", () => { + const builder = new RequestConfigBuilder() + expect(builder.build()).toBeUndefined() + }) + + test("should initialize with provided defaultOptions", () => { + const defaults = { modelId: "test-model" } + const builder = new RequestConfigBuilder(defaults) + const result = builder.build() + expect(result).toEqual({ modelId: "test-model" }) + }) + + test("should create a shallow copy of defaultOptions", () => { + const defaults = { modelId: "test-model" } + const builder = new RequestConfigBuilder(defaults) + defaults.modelId = "modified-model" + const result = builder.build() + expect(result?.modelId).toBe("test-model") + }) + + test("should initialize cleanup as a no-op", () => { + const builder = new RequestConfigBuilder() + + expect(builder.getCleanup()).toBeTypeOf("function") + expect(() => builder.getCleanup()()).not.toThrow() + }) + + test("should ignore undefined values from defaultOptions", () => { + const builder = new RequestConfigBuilder({ modelId: undefined }) + + expect(builder.build()).toBeUndefined() + }) + }) + + describe("addAbortSignal", () => { + test("should set signal when metadata contains abortSignal", () => { + const controller = new AbortController() + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + abortSignal: controller.signal, + } + + const builder = new RequestConfigBuilder() + const result = builder.addAbortSignal(metadata) + + expect(result).toBe(builder) // chainable + const config = builder.build() as { signal?: AbortSignal } + expect(config?.signal).toBe(controller.signal) + }) + + test("should do nothing when metadata is undefined", () => { + const builder = new RequestConfigBuilder({ initial: "value" }) + builder.addAbortSignal(undefined) + + const config = builder.build() as { signal?: AbortSignal } + expect(config.signal).toBeUndefined() + }) + + test("should do nothing when metadata.abortSignal is undefined", () => { + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + } + + const builder = new RequestConfigBuilder({ initial: "value" }) + builder.addAbortSignal(metadata) + + const config = builder.build() as { signal?: AbortSignal } + expect(config.signal).toBeUndefined() + }) + + test("should replace existing signal if metadata contains abortSignal", () => { + const controller1 = new AbortController() + const controller2 = new AbortController() + + const builder = new RequestConfigBuilder({ signal: controller1.signal }) + builder.addAbortSignal({ + taskId: "test-task", + abortSignal: controller2.signal, + } as ApiHandlerCreateMessageMetadata) + + const config = builder.build() as { signal?: AbortSignal } + expect(config?.signal).toBe(controller2.signal) + }) + + test("should support chaining with other methods", () => { + const controller = new AbortController() + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + abortSignal: controller.signal, + } + + const builder = new RequestConfigBuilder() + const result = builder.addAbortSignal(metadata).setOption("customKey", "customValue") + + expect(result).toBe(builder) + const config = builder.build() as { signal?: AbortSignal; customKey?: string } + expect(config?.signal).toBe(controller.signal) + expect(config?.customKey).toBe("customValue") + }) + }) + + describe("addHeaders", () => { + test("should merge headers when provided", () => { + const builder = new RequestConfigBuilder() + const result = builder.addHeaders({ "X-Custom": "value1" }) + + expect(result).toBe(builder) // chainable + const config = builder.build() as { headers?: Record } + expect(config?.headers).toEqual({ "X-Custom": "value1" }) + }) + + test("should do nothing when headers are undefined", () => { + const builder = new RequestConfigBuilder({ initial: "value" }) + const result = builder.addHeaders() + + expect(result).toBe(builder) // chainable + const config = builder.build() as { headers?: Record } + expect(config.headers).toBeUndefined() + }) + + test("should do nothing when headers object is empty", () => { + const builder = new RequestConfigBuilder({ initial: "value" }) + const result = builder.addHeaders({}) + + expect(result).toBe(builder) // chainable + const config = builder.build() as { headers?: Record } + expect(config.headers).toBeUndefined() + }) + + test("should override existing header values", () => { + const builder = new RequestConfigBuilder({ headers: { "X-Existing": "old" } }) + builder.addHeaders({ "X-Existing": "new" }) + + const config = builder.build() as { headers?: Record } + expect(config?.headers?.["X-Existing"]).toBe("new") + }) + + test("should merge with existing headers without overwriting unrelated keys", () => { + const builder = new RequestConfigBuilder({ headers: { "X-Existing": "value" } }) + builder.addHeaders({ "X-New": "newValue" }) + + const config = builder.build() as { headers?: Record } + expect(config?.headers).toEqual({ "X-Existing": "value", "X-New": "newValue" }) + }) + + test("should create headers object if none exists", () => { + const builder = new RequestConfigBuilder() + builder.addHeaders({ "X-Custom": "value" }) + + const config = builder.build() as { headers?: Record } + expect(config?.headers).toEqual({ "X-Custom": "value" }) + }) + + test("should support chaining with other methods", () => { + const builder = new RequestConfigBuilder() + builder.addHeaders({ "X-First": "1" }).addHeaders({ "X-Second": "2" }) + + const config = builder.build() as { headers?: Record } + expect(config?.headers).toEqual({ "X-First": "1", "X-Second": "2" }) + }) + }) + + describe("setOption", () => { + test("should set option when value is defined", () => { + const builder = new RequestConfigBuilder() + const result = builder.setOption("modelId", "test-model") + + expect(result).toBe(builder) // chainable + const config = builder.build() as { modelId?: string } + expect(config?.modelId).toBe("test-model") + }) + + test("should do nothing when value is undefined", () => { + const builder = new RequestConfigBuilder({ initial: "value" }) + builder.setOption("initial", undefined as unknown as string) + + const config = builder.build() as { initial?: string } + // When setOption receives undefined, it should NOT modify the existing value + expect(config.initial).toBe("value") + }) + + test("should replace existing option value", () => { + const builder = new RequestConfigBuilder({ modelId: "old-model" }) + builder.setOption("modelId", "new-model") + + const config = builder.build() as { modelId?: string } + expect(config?.modelId).toBe("new-model") + }) + + test("should support different value types", () => { + const builder = new RequestConfigBuilder() + + builder.setOption("stringKey", "stringValue") + builder.setOption("numberKey", 42) + builder.setOption("booleanKey", true) + builder.setOption("objectKey", { nested: true }) + + const config = builder.build() as { + stringKey?: string + numberKey?: number + booleanKey?: boolean + objectKey?: { nested: boolean } + } + expect(config.stringKey).toBe("stringValue") + expect(config.numberKey).toBe(42) + expect(config.booleanKey).toBe(true) + expect(config.objectKey).toEqual({ nested: true }) + }) + + test("should support chaining", () => { + const builder = new RequestConfigBuilder() + const result = builder.setOption("key1", "value1").setOption("key2", "value2") + + expect(result).toBe(builder) + const config = builder.build() as { key1?: string; key2?: string } + expect(config.key1).toBe("value1") + expect(config.key2).toBe("value2") + }) + }) + + describe("getOption", () => { + test("should return existing option value", () => { + const builder = new RequestConfigBuilder({ modelId: "test-model" }) + expect(builder.getOption("modelId")).toBe("test-model") + }) + + test("should return undefined for non-existent key", () => { + const builder = new RequestConfigBuilder() + expect(builder.getOption("nonExistent")).toBeUndefined() + }) + }) + + describe("build", () => { + test("should return shallow copy of options", () => { + const builder = new RequestConfigBuilder({ key: "value" }) + const result1 = builder.build() + const result2 = builder.build() + + expect(result1).toEqual(result2) + expect(result1).not.toBe(result2) // different references + }) + + test("should return undefined when options are empty", () => { + const builder = new RequestConfigBuilder() + expect(builder.build()).toBeUndefined() + }) + + test("modifying build result should not affect internal state", () => { + const builder = new RequestConfigBuilder({ key: "value" }) + const result = builder.build() as { key: string } + + result.key = "modified" + expect(builder.getOption("key")).toBe("value") + }) + + test("should return all set options", () => { + const controller = new AbortController() + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + abortSignal: controller.signal, + } + + const builder = new RequestConfigBuilder() + builder.addAbortSignal(metadata).addHeaders({ "X-Custom": "value" }).setOption("modelId", "test-model") + + const config = builder.build() as { + signal?: AbortSignal + headers?: Record + modelId?: string + } + expect(config.signal).toBe(controller.signal) + expect(config.headers).toEqual({ "X-Custom": "value" }) + expect(config.modelId).toBe("test-model") + }) + }) + + describe("static fromMetadata", () => { + test("should return undefined when both metadata and extraOptions are undefined", () => { + const result = RequestConfigBuilder.fromMetadata() + expect(result).toBeUndefined() + }) + + test("should set signal from metadata.abortSignal", () => { + const controller = new AbortController() + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + abortSignal: controller.signal, + } + + const result = RequestConfigBuilder.fromMetadata(metadata) as { signal?: AbortSignal } + expect(result.signal).toBe(controller.signal) + }) + + test("should merge extraOptions with metadata signal", () => { + const controller = new AbortController() + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + abortSignal: controller.signal, + } + const extraOptions = { modelId: "test-model", customKey: "customValue" } + + const result = RequestConfigBuilder.fromMetadata(metadata, extraOptions) as { + signal?: AbortSignal + modelId?: string + customKey?: string + } + expect(result.signal).toBe(controller.signal) + expect(result.modelId).toBe("test-model") + expect(result.customKey).toBe("customValue") + }) + + test("should return only extraOptions when metadata is undefined", () => { + const extraOptions = { modelId: "test-model" } + const result = RequestConfigBuilder.fromMetadata(undefined, extraOptions) as { modelId?: string } + expect(result.modelId).toBe("test-model") + }) + + test("should treat undefined extraOptions values as absent", () => { + const result = RequestConfigBuilder.fromMetadata(undefined, { signal: undefined }) + + expect(result).toBeUndefined() + }) + + test("should not set signal when metadata.abortSignal is undefined", () => { + const metadata: ApiHandlerCreateMessageMetadata = { taskId: "test-task" } + const extraOptions = { modelId: "test-model" } + + const result = RequestConfigBuilder.fromMetadata(metadata, extraOptions) as { + signal?: AbortSignal + modelId?: string + } + expect(result.signal).toBeUndefined() + expect(result.modelId).toBe("test-model") + }) + }) + + describe("addMergedSignal", () => { + test("should add internal controller signal when metadata and timeout are absent", () => { + const internalController = new AbortController() + const builder = new RequestConfigBuilder() + + const result = builder.addMergedSignal(internalController) + + expect(result).toBe(builder) + const config = builder.build() as { signal?: AbortSignal; _cleanup?: () => void } + expect(config.signal).toBe(internalController.signal) + expect(config).not.toHaveProperty("_cleanup") + expect(builder.getCleanup()).toBeTypeOf("function") + }) + + test("should merge internal controller signal with metadata abort signal", () => { + const internalController = new AbortController() + const externalController = new AbortController() + const builder = new RequestConfigBuilder() + + builder.addMergedSignal(internalController, { + taskId: "test-task", + abortSignal: externalController.signal, + }) + + const config = builder.build() as { signal?: AbortSignal } + expect(config.signal).not.toBe(internalController.signal) + expect(config.signal).not.toBe(externalController.signal) + + externalController.abort() + expect(config.signal?.aborted).toBe(true) + }) + + test("should clear timeout when cleanup runs before timeout fires", async () => { + vi.useFakeTimers() + try { + const internalController = new AbortController() + const builder = new RequestConfigBuilder() + + builder.addMergedSignal(internalController, undefined, 100) + + const config = builder.build() as { signal?: AbortSignal; _cleanup?: () => void } + expect(config.signal).not.toBe(internalController.signal) + expect(config).not.toHaveProperty("_cleanup") + expect(config.signal?.aborted).toBe(false) + expect(vi.getTimerCount()).toBe(1) + + builder.getCleanup()() + + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(100) + expect(config.signal?.aborted).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + test("should cleanup timeouts from repeated addMergedSignal calls", () => { + vi.useFakeTimers() + try { + const internalController = new AbortController() + const builder = new RequestConfigBuilder() + + builder.addMergedSignal(internalController, undefined, 100) + builder.addMergedSignal(internalController, undefined, 200) + + expect(vi.getTimerCount()).toBe(2) + builder.getCleanup()() + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + test("should immediately abort when metadata signal is already aborted", () => { + const internalController = new AbortController() + const externalController = new AbortController() + externalController.abort() + const builder = new RequestConfigBuilder() + + builder.addMergedSignal(internalController, { + taskId: "test-task", + abortSignal: externalController.signal, + }) + + const config = builder.build() as { signal?: AbortSignal } + expect(config.signal?.aborted).toBe(true) + expect(() => builder.getCleanup()()).not.toThrow() + }) + }) + + describe("static mergeAbortSignals", () => { + test("should return primary signal directly when secondarySignal is undefined", () => { + const controller = new AbortController() + const result = RequestConfigBuilder.mergeAbortSignals(controller.signal) + + expect(result).toBe(controller.signal) + expect(result.aborted).toBe(false) + }) + + test("should return merged signal when secondarySignal is already aborted", () => { + const primaryController = new AbortController() + const secondaryController = new AbortController() + secondaryController.abort() + + const result = RequestConfigBuilder.mergeAbortSignals(primaryController.signal, secondaryController.signal) + expect(result.aborted).toBe(true) + }) + + test("should return merged signal when both signals are active", () => { + const primaryController = new AbortController() + const secondaryController = new AbortController() + + const result = RequestConfigBuilder.mergeAbortSignals(primaryController.signal, secondaryController.signal) + expect(result).not.toBe(primaryController.signal) + expect(result).not.toBe(secondaryController.signal) + expect(result.aborted).toBe(false) + }) + + test("should abort merged signal when primarySignal is aborted", () => { + const primaryController = new AbortController() + const secondaryController = new AbortController() + + const mergedSignal = RequestConfigBuilder.mergeAbortSignals( + primaryController.signal, + secondaryController.signal, + ) + + let aborted = false + mergedSignal.addEventListener( + "abort", + () => { + aborted = true + }, + { once: true }, + ) + + primaryController.abort() + + expect(aborted).toBe(true) + }) + + test("should abort merged signal when secondarySignal is aborted", () => { + const primaryController = new AbortController() + const secondaryController = new AbortController() + + const mergedSignal = RequestConfigBuilder.mergeAbortSignals( + primaryController.signal, + secondaryController.signal, + ) + + let aborted = false + mergedSignal.addEventListener( + "abort", + () => { + aborted = true + }, + { once: true }, + ) + + secondaryController.abort() + + expect(aborted).toBe(true) + }) + + test("should not abort merged signal when neither signal is aborted", () => { + const primaryController = new AbortController() + const secondaryController = new AbortController() + + const mergedSignal = RequestConfigBuilder.mergeAbortSignals( + primaryController.signal, + secondaryController.signal, + ) + + let aborted = false + mergedSignal.addEventListener( + "abort", + () => { + aborted = true + }, + { once: true }, + ) + + expect(aborted).toBe(false) + }) + + test("should handle primary already aborted before merge", () => { + const primaryController = new AbortController() + const secondaryController = new AbortController() + + primaryController.abort() + + const mergedSignal = RequestConfigBuilder.mergeAbortSignals( + primaryController.signal, + secondaryController.signal, + ) + expect(mergedSignal.aborted).toBe(true) + }) + }) + + describe("integration tests", () => { + test("should support full chain of operations", () => { + const controller = new AbortController() + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + abortSignal: controller.signal, + } + + type TestOptions = { + modelId?: string + signal?: AbortSignal + headers?: Record + maxTokens?: number + } + + const builder = new RequestConfigBuilder({ modelId: "default-model" }) + builder.addAbortSignal(metadata) + builder.addHeaders({ "X-API-Key": "secret" }) + builder.setOption("maxTokens", 2000) + + const config = builder.build() as TestOptions + expect(config.modelId).toBe("default-model") + expect(config.signal).toBe(controller.signal) + expect(config.headers).toEqual({ "X-API-Key": "secret" }) + expect(config.maxTokens).toBe(2000) + }) + + test("should handle empty builder through full lifecycle", () => { + const builder = new RequestConfigBuilder() + expect(builder.build()).toBeUndefined() + expect(builder.getOption("anyKey")).toBeUndefined() + }) + + test("should work with custom default options type", () => { + type CustomOptions = { apiUrl: string; timeout: number; retryCount?: number } + + const defaults: Partial = { + apiUrl: "https://api.example.com", + timeout: 30000, + } + + const builder = new RequestConfigBuilder(defaults) + builder.setOption("retryCount", 3) + + const config = builder.build() as CustomOptions + expect(config.apiUrl).toBe("https://api.example.com") + expect(config.timeout).toBe(30000) + expect(config.retryCount).toBe(3) + }) + + test("should accept interface-based options without an index signature", () => { + interface SdkOptions { + modelId?: string + signal?: AbortSignal + headers?: Record + maxTokens?: number + } + + const builder = new RequestConfigBuilder({ modelId: "default-model" }) + builder.setOption("maxTokens", 2000) + + const config = builder.build() as SdkOptions + expect(config.modelId).toBe("default-model") + expect(config.maxTokens).toBe(2000) + }) + }) +}) diff --git a/src/api/providers/config-builder/request-config-builder.ts b/src/api/providers/config-builder/request-config-builder.ts new file mode 100644 index 0000000000..8eeb9e99c7 --- /dev/null +++ b/src/api/providers/config-builder/request-config-builder.ts @@ -0,0 +1,173 @@ +import type { ApiHandlerCreateMessageMetadata } from "../../index" +import { mergeAbortSignalAndTimeout, mergeAbortSignals } from "../utils/abort-signal" + +/** + * A generic, SDK-agnostic request configuration builder. + * + * Provides a fluent API for building request configurations with: + * - Chainable method calls + * - Generic type support (TOptions) + * - Abort signal handling + * - Header merging + * - Static factory methods + */ +type RequestConfigOptionsBase = object & { + headers?: Record + signal?: AbortSignal +} + +type RequestConfigOptions = RequestConfigOptionsBase & Record + +export class RequestConfigBuilder { + protected options: Partial + private cleanupFn: () => void = () => {} + + constructor(defaultOptions?: Partial) { + this.options = defaultOptions + ? (Object.fromEntries( + Object.entries(defaultOptions).filter(([, value]) => value !== undefined), + ) as Partial) + : {} + } + + /** + * Add an abort signal from metadata. + * + * @param metadata - Optional metadata containing an abortSignal + * @returns this for chainable calls + */ + addAbortSignal(metadata?: ApiHandlerCreateMessageMetadata): this { + if (!metadata?.abortSignal) { + return this + } + + this.options = { ...this.options, signal: metadata.abortSignal } + return this + } + + /** + * Add or merge custom headers. + * + * @param headers - Key-value pairs of header names and values + * @returns this for chainable calls + */ + addHeaders(headers?: Record): this { + if (!headers || Object.keys(headers).length === 0) { + return this + } + + const existingHeaders = this.options.headers ?? {} + this.options = { ...this.options, headers: { ...existingHeaders, ...headers } } + return this + } + + /** + * Merge an internal controller signal with an external metadata signal and optional timeout. + * + * Use this for providers that already maintain their own AbortController but also need + * to honor the request-level abort signal from metadata and/or a timeout. + * + * @param internalController - Provider-owned AbortController for the current request + * @param metadata - Optional metadata containing an external abortSignal + * @param timeoutMs - Optional positive timeout in milliseconds; <= 0 disables timeout + * @returns this for chainable calls + */ + addMergedSignal( + internalController: AbortController, + metadata?: ApiHandlerCreateMessageMetadata, + timeoutMs?: number, + ): this { + const merged = mergeAbortSignalAndTimeout(metadata?.abortSignal, timeoutMs) + const signal = mergeAbortSignals(internalController.signal, merged.signal) + + this.options = { ...this.options, signal } + const previousCleanup = this.cleanupFn + this.cleanupFn = () => { + previousCleanup() + merged.cleanup() + } + return this + } + + /** + * Get the cleanup function for resources created by addMergedSignal. + * + * @returns Cleanup function, or a no-op when no merged signal was added + */ + getCleanup(): () => void { + return this.cleanupFn + } + + /** + * Set a single option by key (type-safe). + * + * @param key - Option key + * @param value - Option value + * @returns this for chainable calls + */ + setOption(key: K, value: TOptions[K]): this { + if (value === undefined) { + return this + } + + this.options = { ...this.options, [key]: value } + return this + } + + /** + * Get an option by key. + * + * @param key - Option key + * @returns The option value or undefined if not set + */ + getOption(key: K): TOptions[K] | undefined { + return this.options[key] + } + + /** + * Build the final configuration object. + * + * Returns a shallow copy of the internal options to ensure immutability. + * Returns undefined if no options have been set. + * + * @returns The built configuration or undefined if empty + */ + build(): TOptions | undefined { + const keys = Object.keys(this.options as object) + if (keys.length === 0) { + return undefined + } + + return { ...this.options } as TOptions + } + + /** + * Factory method to quickly create and configure a builder from metadata. + * + * @param metadata - Optional metadata containing an abortSignal + * @param extraOptions - Additional options to merge + * @returns The built configuration or undefined if empty + */ + static fromMetadata( + metadata?: ApiHandlerCreateMessageMetadata, + extraOptions?: Partial, + ): TOptions | undefined { + const builder = new RequestConfigBuilder(extraOptions) + builder.addAbortSignal(metadata) + return builder.build() + } + + /** + * Merge multiple abort signals using the standard API. + * + * Uses `AbortSignal.any()` which correctly handles the case where + * any signal is already aborted. + * + * @param primarySignal - The primary abort signal + * @param secondarySignal - Optional secondary abort signal + * @returns A merged AbortSignal that aborts when any input signal aborts + */ + static mergeAbortSignals(primarySignal: AbortSignal, secondarySignal?: AbortSignal): AbortSignal { + return mergeAbortSignals(primarySignal, secondarySignal) + } +} diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index 5bdd7c8deb..7b63b2e656 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -1,3 +1,4 @@ +export { RequestConfigBuilder } from "./config-builder/request-config-builder" export { AnthropicVertexHandler } from "./anthropic-vertex" export { AnthropicHandler } from "./anthropic" export { AwsBedrockHandler } from "./bedrock" diff --git a/src/api/providers/utils/__tests__/abort-signal.spec.ts b/src/api/providers/utils/__tests__/abort-signal.spec.ts new file mode 100644 index 0000000000..f3ebb3aebc --- /dev/null +++ b/src/api/providers/utils/__tests__/abort-signal.spec.ts @@ -0,0 +1,93 @@ +import { mergeAbortSignalAndTimeout, mergeAbortSignals } from "../abort-signal" + +describe("abort-signal utilities", () => { + describe("mergeAbortSignalAndTimeout", () => { + afterEach(() => { + vi.useRealTimers() + }) + + it("returns no signal and noop cleanup when no signal or positive timeout is provided", () => { + const result = mergeAbortSignalAndTimeout(undefined, 0) + + expect(result.signal).toBeUndefined() + expect(() => result.cleanup()).not.toThrow() + }) + + it("forwards external signal directly when timeout is disabled", () => { + const controller = new AbortController() + + const result = mergeAbortSignalAndTimeout(controller.signal, -1) + + expect(result.signal).toBe(controller.signal) + expect(() => result.cleanup()).not.toThrow() + }) + + it("creates a timeout signal when only positive timeout is provided", async () => { + vi.useFakeTimers() + + const result = mergeAbortSignalAndTimeout(undefined, 100) + + expect(result.signal).toBeInstanceOf(AbortSignal) + expect(result.signal?.aborted).toBe(false) + + await vi.advanceTimersByTimeAsync(100) + + expect(result.signal?.aborted).toBe(true) + }) + + it("merges external signal and timeout signal", async () => { + vi.useFakeTimers() + const controller = new AbortController() + + const result = mergeAbortSignalAndTimeout(controller.signal, 100) + + expect(result.signal).toBeInstanceOf(AbortSignal) + expect(result.signal).not.toBe(controller.signal) + expect(result.signal?.aborted).toBe(false) + + controller.abort() + + expect(result.signal?.aborted).toBe(true) + + await vi.advanceTimersByTimeAsync(100) + expect(result.signal?.aborted).toBe(true) + }) + + it("clears timeout during cleanup", async () => { + vi.useFakeTimers() + + const result = mergeAbortSignalAndTimeout(undefined, 100) + result.cleanup() + + await vi.advanceTimersByTimeAsync(100) + + expect(result.signal?.aborted).toBe(false) + expect(vi.getTimerCount()).toBe(0) + }) + }) + + describe("mergeAbortSignals", () => { + it("returns primary signal directly when secondary signal is absent", () => { + const controller = new AbortController() + + const result = mergeAbortSignals(controller.signal) + + expect(result).toBe(controller.signal) + }) + + it("returns a merged signal when secondary signal is present", () => { + const primaryController = new AbortController() + const secondaryController = new AbortController() + + const result = mergeAbortSignals(primaryController.signal, secondaryController.signal) + + expect(result).not.toBe(primaryController.signal) + expect(result).not.toBe(secondaryController.signal) + expect(result.aborted).toBe(false) + + secondaryController.abort() + + expect(result.aborted).toBe(true) + }) + }) +}) diff --git a/src/api/providers/utils/abort-signal.ts b/src/api/providers/utils/abort-signal.ts new file mode 100644 index 0000000000..4de6445515 --- /dev/null +++ b/src/api/providers/utils/abort-signal.ts @@ -0,0 +1,49 @@ +export type MergedAbortSignal = { + signal?: AbortSignal + cleanup: () => void +} + +const noop = () => {} + +/** + * Merge an optional external abort signal with an optional timeout. + * + * Timeout values <= 0 are treated as disabled. Call cleanup() from a finally + * block to clear any pending timeout created by this helper. + */ +export function mergeAbortSignalAndTimeout(externalSignal?: AbortSignal, timeoutMs?: number): MergedAbortSignal { + const hasTimeout = typeof timeoutMs === "number" && timeoutMs > 0 + + if (!externalSignal && !hasTimeout) { + return { cleanup: noop } + } + + if (externalSignal && !hasTimeout) { + return { signal: externalSignal, cleanup: noop } + } + + const timeoutController = new AbortController() + const timeoutId = setTimeout(() => timeoutController.abort(), timeoutMs) + const cleanup = () => clearTimeout(timeoutId) + + if (!externalSignal) { + return { signal: timeoutController.signal, cleanup } + } + + return { signal: mergeAbortSignals(externalSignal, timeoutController.signal), cleanup } +} + +/** + * Merge two abort signals using the standard AbortSignal.any() API. + * + * Returns the primary signal directly when no secondary signal is provided to + * avoid creating unnecessary controllers/listeners for the common single-signal + * path. + */ +export function mergeAbortSignals(primarySignal: AbortSignal, secondarySignal?: AbortSignal): AbortSignal { + if (!secondarySignal) { + return primarySignal + } + + return AbortSignal.any([primarySignal, secondarySignal]) +}