Skip to content

Commit e4288b2

Browse files
feat(ai-client): pass abort signal to fetcher in generation clients (#354)
* feat(ai-client): pass abort signal to fetcher in generation clients The fetcher function in GenerationClient and VideoGenerationClient now receives the AbortSignal as an optional second parameter, allowing long-running fetcher calls to be cancelled mid-flight when stop() is called. Updated fetcher type signature across all framework integrations (React, Solid, Vue, Svelte) — backwards-compatible since the options parameter is optional. * ci: apply automated fixes * refactor: extract GenerationFetcher utility type to centralize fetcher signature Replace inline fetcher type definitions across all framework hooks (React, Solid, Vue, Svelte) with a shared GenerationFetcher<TInput, TResult> type from ai-client. Future changes to the fetcher signature only need to update generation-types.ts instead of ~28 files. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 127353e commit e4288b2

30 files changed

Lines changed: 131 additions & 31 deletions

packages/typescript/ai-client/src/generation-client.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ConnectionAdapter } from './connection-adapters'
44
import type {
55
GenerationClientOptions,
66
GenerationClientState,
7+
GenerationFetcher,
78
} from './generation-types'
89

910
/**
@@ -61,7 +62,7 @@ export class GenerationClient<
6162
TOutput = TResult,
6263
> {
6364
private connection: ConnectionAdapter | undefined
64-
private fetcher: ((input: TInput) => Promise<TResult>) | undefined
65+
private fetcher: GenerationFetcher<TInput, TResult> | undefined
6566
private body: Record<string, any>
6667
private result: TOutput | null = null
6768
private isLoading = false
@@ -74,7 +75,10 @@ export class GenerationClient<
7475
options: GenerationClientOptions<TInput, TResult, TOutput> &
7576
(
7677
| { connection: ConnectionAdapter; fetcher?: never }
77-
| { fetcher: (input: TInput) => Promise<TResult>; connection?: never }
78+
| {
79+
fetcher: GenerationFetcher<TInput, TResult>
80+
connection?: never
81+
}
7882
),
7983
) {
8084
this.connection = options.connection
@@ -112,7 +116,7 @@ export class GenerationClient<
112116
try {
113117
if (this.fetcher) {
114118
// Direct fetch path
115-
const result = await this.fetcher(input)
119+
const result = await this.fetcher(input, { signal })
116120
if (signal.aborted) return
117121
this.setResult(result)
118122
this.setStatus('success')

packages/typescript/ai-client/src/generation-types.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,32 @@ export const GENERATION_EVENTS = {
5656
// Transport Types
5757
// ===========================
5858

59+
/**
60+
* Options passed to a fetcher function by the generation client.
61+
*/
62+
export interface GenerationFetcherOptions {
63+
/** AbortSignal that is triggered when the user calls `stop()` */
64+
signal: AbortSignal
65+
}
66+
67+
/**
68+
* A direct async function that performs a generation request.
69+
*
70+
* @template TInput - The input type for the generation request
71+
* @template TResult - The result type returned by the generation
72+
*/
73+
export type GenerationFetcher<TInput, TResult> = (
74+
input: TInput,
75+
options?: GenerationFetcherOptions,
76+
) => Promise<TResult>
77+
5978
/**
6079
* Transport configuration for generation clients.
6180
* Supports either a ConnectionAdapter (streaming) or a direct fetcher function.
6281
*/
6382
export type GenerationTransport<TInput, TResult> =
6483
| { connection: ConnectionAdapter; fetcher?: never }
65-
| { fetcher: (input: TInput) => Promise<TResult>; connection?: never }
84+
| { fetcher: GenerationFetcher<TInput, TResult>; connection?: never }
6685

6786
// ===========================
6887
// Client Options

packages/typescript/ai-client/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export type {
2222
InferGenerationOutput,
2323
GenerationClientState,
2424
GenerationClientOptions,
25+
GenerationFetcher,
26+
GenerationFetcherOptions,
2527
GenerationTransport,
2628
VideoGenerationClientOptions,
2729
VideoStatusInfo,

packages/typescript/ai-client/src/video-generation-client.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { StreamChunk } from '@tanstack/ai'
33
import type { ConnectionAdapter } from './connection-adapters'
44
import type {
55
GenerationClientState,
6+
GenerationFetcher,
67
VideoGenerateInput,
78
VideoGenerateResult,
89
VideoGenerationClientOptions,
@@ -65,7 +66,7 @@ interface VideoCallbacks<TOutput> {
6566
export class VideoGenerationClient<TOutput = VideoGenerateResult> {
6667
private connection: ConnectionAdapter | undefined
6768
private fetcher:
68-
| ((input: VideoGenerateInput) => Promise<VideoGenerateResult>)
69+
| GenerationFetcher<VideoGenerateInput, VideoGenerateResult>
6970
| undefined
7071
private body: Record<string, any>
7172

@@ -83,7 +84,7 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
8384
(
8485
| { connection: ConnectionAdapter; fetcher?: never }
8586
| {
86-
fetcher: (input: VideoGenerateInput) => Promise<VideoGenerateResult>
87+
fetcher: GenerationFetcher<VideoGenerateInput, VideoGenerateResult>
8788
connection?: never
8889
}
8990
),
@@ -159,7 +160,7 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
159160
if (!this.fetcher) return
160161

161162
// Fetcher returns a completed result directly
162-
const result = await this.fetcher(input)
163+
const result = await this.fetcher(input, { signal })
163164
if (signal.aborted) return
164165

165166
this.setResult(result)

packages/typescript/ai-client/tests/generation-client.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,29 @@ describe('GenerationClient', () => {
7171
expect(states).toEqual([true, false])
7272
})
7373

74+
it('should pass abort signal to fetcher', async () => {
75+
const fetcherSpy = vi.fn(
76+
async (_input: any, options?: { signal: AbortSignal }) => {
77+
expect(options).toBeDefined()
78+
expect(options!.signal).toBeInstanceOf(AbortSignal)
79+
expect(options!.signal.aborted).toBe(false)
80+
return { id: '1' }
81+
},
82+
)
83+
84+
const client = new GenerationClient({
85+
fetcher: fetcherSpy,
86+
})
87+
88+
await client.generate({ prompt: 'test' })
89+
90+
expect(fetcherSpy).toHaveBeenCalledTimes(1)
91+
expect(fetcherSpy).toHaveBeenCalledWith(
92+
{ prompt: 'test' },
93+
{ signal: expect.any(AbortSignal) },
94+
)
95+
})
96+
7497
it('should not allow concurrent requests', async () => {
7598
let resolveFirst: (value: any) => void
7699
let callCount = 0

packages/typescript/ai-client/tests/video-generation-client.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,33 @@ describe('VideoGenerationClient', () => {
7777
expect(states).toEqual([true, false])
7878
})
7979

80+
it('should pass abort signal to fetcher', async () => {
81+
const fetcherSpy = vi.fn(
82+
async (_input: any, options?: { signal: AbortSignal }) => {
83+
expect(options).toBeDefined()
84+
expect(options!.signal).toBeInstanceOf(AbortSignal)
85+
expect(options!.signal.aborted).toBe(false)
86+
return {
87+
jobId: 'job-1',
88+
status: 'completed' as const,
89+
url: 'https://example.com/video.mp4',
90+
}
91+
},
92+
)
93+
94+
const client = new VideoGenerationClient({
95+
fetcher: fetcherSpy,
96+
})
97+
98+
await client.generate({ prompt: 'test video' })
99+
100+
expect(fetcherSpy).toHaveBeenCalledTimes(1)
101+
expect(fetcherSpy).toHaveBeenCalledWith(
102+
{ prompt: 'test video' },
103+
{ signal: expect.any(AbortSignal) },
104+
)
105+
})
106+
80107
it('should not allow concurrent requests', async () => {
81108
let resolveFirst: (value: any) => void
82109
let callCount = 0

packages/typescript/ai-react/src/use-generate-image.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { ImageGenerationResult, StreamChunk } from '@tanstack/ai'
33
import type {
44
ConnectionAdapter,
55
GenerationClientState,
6+
GenerationFetcher,
67
ImageGenerateInput,
78
InferGenerationOutput,
89
} from '@tanstack/ai-client'
@@ -16,7 +17,7 @@ export interface UseGenerateImageOptions<TOutput = ImageGenerationResult> {
1617
/** Connection adapter for streaming transport (SSE, HTTP stream, custom) */
1718
connection?: ConnectionAdapter
1819
/** Direct async function for image generation */
19-
fetcher?: (input: ImageGenerateInput) => Promise<ImageGenerationResult>
20+
fetcher?: GenerationFetcher<ImageGenerateInput, ImageGenerationResult>
2021
/** Unique identifier for this generation instance */
2122
id?: string
2223
/** Additional body parameters to send with ConnectionAdapter requests */

packages/typescript/ai-react/src/use-generate-speech.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { StreamChunk, TTSResult } from '@tanstack/ai'
33
import type {
44
ConnectionAdapter,
55
GenerationClientState,
6+
GenerationFetcher,
67
InferGenerationOutput,
78
SpeechGenerateInput,
89
} from '@tanstack/ai-client'
@@ -16,7 +17,7 @@ export interface UseGenerateSpeechOptions<TOutput = TTSResult> {
1617
/** Connection adapter for streaming transport (SSE, HTTP stream, custom) */
1718
connection?: ConnectionAdapter
1819
/** Direct async function for speech generation */
19-
fetcher?: (input: SpeechGenerateInput) => Promise<TTSResult>
20+
fetcher?: GenerationFetcher<SpeechGenerateInput, TTSResult>
2021
/** Unique identifier for this generation instance */
2122
id?: string
2223
/** Additional body parameters to send with ConnectionAdapter requests */

packages/typescript/ai-react/src/use-generate-video.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { StreamChunk } from '@tanstack/ai'
44
import type {
55
ConnectionAdapter,
66
GenerationClientState,
7+
GenerationFetcher,
78
InferGenerationOutput,
89
VideoGenerateInput,
910
VideoGenerateResult,
@@ -17,7 +18,7 @@ export interface UseGenerateVideoOptions<TOutput = VideoGenerateResult> {
1718
/** Connection adapter for streaming transport (server handles polling) */
1819
connection?: ConnectionAdapter
1920
/** Direct async function that returns a completed video result */
20-
fetcher?: (input: VideoGenerateInput) => Promise<VideoGenerateResult>
21+
fetcher?: GenerationFetcher<VideoGenerateInput, VideoGenerateResult>
2122
/** Unique identifier for this generation instance */
2223
id?: string
2324
/** Additional body parameters to send with ConnectionAdapter requests */

packages/typescript/ai-react/src/use-generation.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
ConnectionAdapter,
66
GenerationClientOptions,
77
GenerationClientState,
8+
GenerationFetcher,
89
InferGenerationOutput,
910
} from '@tanstack/ai-client'
1011

@@ -21,7 +22,7 @@ export interface UseGenerationOptions<TInput, TResult, TOutput = TResult> {
2122
/** Connection adapter for streaming transport (SSE, HTTP stream, custom) */
2223
connection?: ConnectionAdapter
2324
/** Direct async function for one-shot generation (no streaming protocol needed) */
24-
fetcher?: (input: TInput) => Promise<TResult>
25+
fetcher?: GenerationFetcher<TInput, TResult>
2526
/** Unique identifier for this generation instance */
2627
id?: string
2728
/** Additional body parameters to send with ConnectionAdapter requests */

0 commit comments

Comments
 (0)