Skip to content

Commit 1349476

Browse files
committed
feat(api): add abort signal builder utilities
1 parent 2a4b81f commit 1349476

4 files changed

Lines changed: 231 additions & 10 deletions

File tree

src/api/providers/__tests__/request-config-builder.spec.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,15 @@ describe("RequestConfigBuilder", () => {
103103
expect(config?.headers).toEqual({ "X-Custom": "value1" })
104104
})
105105

106+
test("should do nothing when headers are undefined", () => {
107+
const builder = new RequestConfigBuilder({ initial: "value" })
108+
const result = builder.addHeaders()
109+
110+
expect(result).toBe(builder) // chainable
111+
const config = builder.build() as Record<string, any>
112+
expect(config.headers).toBeUndefined()
113+
})
114+
106115
test("should do nothing when headers object is empty", () => {
107116
const builder = new RequestConfigBuilder({ initial: "value" })
108117
const result = builder.addHeaders({})
@@ -297,12 +306,62 @@ describe("RequestConfigBuilder", () => {
297306
})
298307
})
299308

309+
describe("addMergedSignal", () => {
310+
test("should add internal controller signal when metadata and timeout are absent", () => {
311+
const internalController = new AbortController()
312+
const builder = new RequestConfigBuilder()
313+
314+
const result = builder.addMergedSignal(internalController)
315+
316+
expect(result).toBe(builder)
317+
const config = builder.build() as { signal?: AbortSignal; _cleanup?: () => void }
318+
expect(config.signal).toBe(internalController.signal)
319+
expect(config._cleanup).toBeTypeOf("function")
320+
})
321+
322+
test("should merge internal controller signal with metadata abort signal", () => {
323+
const internalController = new AbortController()
324+
const externalController = new AbortController()
325+
const builder = new RequestConfigBuilder()
326+
327+
builder.addMergedSignal(internalController, {
328+
taskId: "test-task",
329+
abortSignal: externalController.signal,
330+
})
331+
332+
const config = builder.build() as { signal?: AbortSignal }
333+
expect(config.signal).not.toBe(internalController.signal)
334+
expect(config.signal).not.toBe(externalController.signal)
335+
336+
externalController.abort()
337+
expect(config.signal?.aborted).toBe(true)
338+
})
339+
340+
test("should merge internal controller signal with timeout", async () => {
341+
vi.useFakeTimers()
342+
const internalController = new AbortController()
343+
const builder = new RequestConfigBuilder()
344+
345+
builder.addMergedSignal(internalController, undefined, 100)
346+
347+
const config = builder.build() as { signal?: AbortSignal; _cleanup?: () => void }
348+
expect(config.signal).not.toBe(internalController.signal)
349+
expect(config.signal?.aborted).toBe(false)
350+
351+
await vi.advanceTimersByTimeAsync(100)
352+
353+
expect(config.signal?.aborted).toBe(true)
354+
config._cleanup?.()
355+
vi.useRealTimers()
356+
})
357+
})
358+
300359
describe("static mergeAbortSignals", () => {
301-
test("should return merged signal when secondarySignal is undefined", () => {
360+
test("should return primary signal directly when secondarySignal is undefined", () => {
302361
const controller = new AbortController()
303362
const result = RequestConfigBuilder.mergeAbortSignals(controller.signal)
304-
// AbortSignal.any() always returns a new signal
305-
expect(result).not.toBe(controller.signal)
363+
364+
expect(result).toBe(controller.signal)
306365
expect(result.aborted).toBe(false)
307366
})
308367

src/api/providers/config-builder/request-config-builder.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ApiHandlerCreateMessageMetadata } from "../../index"
2+
import { mergeAbortSignalAndTimeout, mergeAbortSignals } from "../utils/abort-signal"
23

34
/**
45
* A generic, SDK-agnostic request configuration builder.
@@ -38,8 +39,8 @@ export class RequestConfigBuilder<TOptions extends Record<string, any> = Record<
3839
* @param headers - Key-value pairs of header names and values
3940
* @returns this for chainable calls
4041
*/
41-
addHeaders(headers: Record<string, string> = {}): this {
42-
if (Object.keys(headers).length === 0) {
42+
addHeaders(headers?: Record<string, string>): this {
43+
if (!headers || Object.keys(headers).length === 0) {
4344
return this
4445
}
4546

@@ -48,6 +49,29 @@ export class RequestConfigBuilder<TOptions extends Record<string, any> = Record<
4849
return this
4950
}
5051

52+
/**
53+
* Merge an internal controller signal with an external metadata signal and optional timeout.
54+
*
55+
* Use this for providers that already maintain their own AbortController but also need
56+
* to honor the request-level abort signal from metadata and/or a timeout.
57+
*
58+
* @param internalController - Provider-owned AbortController for the current request
59+
* @param metadata - Optional metadata containing an external abortSignal
60+
* @param timeoutMs - Optional positive timeout in milliseconds; <= 0 disables timeout
61+
* @returns this for chainable calls
62+
*/
63+
addMergedSignal(
64+
internalController: AbortController,
65+
metadata?: ApiHandlerCreateMessageMetadata,
66+
timeoutMs?: number,
67+
): this {
68+
const merged = mergeAbortSignalAndTimeout(metadata?.abortSignal, timeoutMs)
69+
const signal = mergeAbortSignals(internalController.signal, merged.signal)
70+
71+
this.options = { ...this.options, signal, _cleanup: merged.cleanup } as TOptions
72+
return this
73+
}
74+
5175
/**
5276
* Set a single option by key (type-safe).
5377
*
@@ -118,10 +142,6 @@ export class RequestConfigBuilder<TOptions extends Record<string, any> = Record<
118142
* @returns A merged AbortSignal that aborts when any input signal aborts
119143
*/
120144
static mergeAbortSignals(primarySignal: AbortSignal, secondarySignal?: AbortSignal): AbortSignal {
121-
if (!secondarySignal) {
122-
return AbortSignal.any([primarySignal])
123-
}
124-
125-
return AbortSignal.any([primarySignal, secondarySignal])
145+
return mergeAbortSignals(primarySignal, secondarySignal)
126146
}
127147
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { mergeAbortSignalAndTimeout, mergeAbortSignals } from "../abort-signal"
2+
3+
describe("abort-signal utilities", () => {
4+
describe("mergeAbortSignalAndTimeout", () => {
5+
afterEach(() => {
6+
vi.useRealTimers()
7+
})
8+
9+
it("returns no signal and noop cleanup when no signal or positive timeout is provided", () => {
10+
const result = mergeAbortSignalAndTimeout(undefined, 0)
11+
12+
expect(result.signal).toBeUndefined()
13+
expect(() => result.cleanup()).not.toThrow()
14+
})
15+
16+
it("forwards external signal directly when timeout is disabled", () => {
17+
const controller = new AbortController()
18+
19+
const result = mergeAbortSignalAndTimeout(controller.signal, -1)
20+
21+
expect(result.signal).toBe(controller.signal)
22+
expect(() => result.cleanup()).not.toThrow()
23+
})
24+
25+
it("creates a timeout signal when only positive timeout is provided", async () => {
26+
vi.useFakeTimers()
27+
28+
const result = mergeAbortSignalAndTimeout(undefined, 100)
29+
30+
expect(result.signal).toBeInstanceOf(AbortSignal)
31+
expect(result.signal?.aborted).toBe(false)
32+
33+
await vi.advanceTimersByTimeAsync(100)
34+
35+
expect(result.signal?.aborted).toBe(true)
36+
})
37+
38+
it("merges external signal and timeout signal", async () => {
39+
vi.useFakeTimers()
40+
const controller = new AbortController()
41+
42+
const result = mergeAbortSignalAndTimeout(controller.signal, 100)
43+
44+
expect(result.signal).toBeInstanceOf(AbortSignal)
45+
expect(result.signal).not.toBe(controller.signal)
46+
expect(result.signal?.aborted).toBe(false)
47+
48+
controller.abort()
49+
50+
expect(result.signal?.aborted).toBe(true)
51+
52+
await vi.advanceTimersByTimeAsync(100)
53+
expect(result.signal?.aborted).toBe(true)
54+
})
55+
56+
it("clears timeout during cleanup", async () => {
57+
vi.useFakeTimers()
58+
59+
const result = mergeAbortSignalAndTimeout(undefined, 100)
60+
result.cleanup()
61+
62+
await vi.advanceTimersByTimeAsync(100)
63+
64+
expect(result.signal?.aborted).toBe(false)
65+
expect(vi.getTimerCount()).toBe(0)
66+
})
67+
})
68+
69+
describe("mergeAbortSignals", () => {
70+
it("returns primary signal directly when secondary signal is absent", () => {
71+
const controller = new AbortController()
72+
73+
const result = mergeAbortSignals(controller.signal)
74+
75+
expect(result).toBe(controller.signal)
76+
})
77+
78+
it("returns a merged signal when secondary signal is present", () => {
79+
const primaryController = new AbortController()
80+
const secondaryController = new AbortController()
81+
82+
const result = mergeAbortSignals(primaryController.signal, secondaryController.signal)
83+
84+
expect(result).not.toBe(primaryController.signal)
85+
expect(result).not.toBe(secondaryController.signal)
86+
expect(result.aborted).toBe(false)
87+
88+
secondaryController.abort()
89+
90+
expect(result.aborted).toBe(true)
91+
})
92+
})
93+
})
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
export type MergedAbortSignal = {
2+
signal?: AbortSignal
3+
cleanup: () => void
4+
}
5+
6+
const noop = () => {}
7+
8+
/**
9+
* Merge an optional external abort signal with an optional timeout.
10+
*
11+
* Timeout values <= 0 are treated as disabled. Call cleanup() from a finally
12+
* block to clear any pending timeout created by this helper.
13+
*/
14+
export function mergeAbortSignalAndTimeout(externalSignal?: AbortSignal, timeoutMs?: number): MergedAbortSignal {
15+
const hasTimeout = typeof timeoutMs === "number" && timeoutMs > 0
16+
17+
if (!externalSignal && !hasTimeout) {
18+
return { cleanup: noop }
19+
}
20+
21+
if (externalSignal && !hasTimeout) {
22+
return { signal: externalSignal, cleanup: noop }
23+
}
24+
25+
const timeoutController = new AbortController()
26+
const timeoutId = setTimeout(() => timeoutController.abort(), timeoutMs)
27+
const cleanup = () => clearTimeout(timeoutId)
28+
29+
if (!externalSignal) {
30+
return { signal: timeoutController.signal, cleanup }
31+
}
32+
33+
return { signal: mergeAbortSignals(externalSignal, timeoutController.signal), cleanup }
34+
}
35+
36+
/**
37+
* Merge two abort signals using the standard AbortSignal.any() API.
38+
*
39+
* Returns the primary signal directly when no secondary signal is provided to
40+
* avoid creating unnecessary controllers/listeners for the common single-signal
41+
* path.
42+
*/
43+
export function mergeAbortSignals(primarySignal: AbortSignal, secondarySignal?: AbortSignal): AbortSignal {
44+
if (!secondarySignal) {
45+
return primarySignal
46+
}
47+
48+
return AbortSignal.any([primarySignal, secondarySignal])
49+
}

0 commit comments

Comments
 (0)