feat(ai-client,ai-react): add fetcher option to ChatClient/useChat#512
feat(ai-client,ai-react): add fetcher option to ChatClient/useChat#512tombeckenham wants to merge 7 commits intoTanStack:mainfrom
fetcher option to ChatClient/useChat#512Conversation
Mirrors the `fetcher` option on the multimedia hooks (useGenerateSpeech / useSummarize / useTranscription / useGenerateImage). Pass either `connection` (a ConnectionAdapter) or `fetcher` (a direct async function — typically a TanStack Start server function) — runtime XOR validation. The fetcher may return either a Response (parsed as SSE) or an AsyncIterable<StreamChunk> (yielded directly). Internally, fetcher is wrapped via `fetcherToConnectionAdapter` and reuses the same subscribe/ send queue plumbing as every other connection adapter — no new code paths in ChatClient itself. Purely additive: stream(), rpcStream(), fetchServerSentEvents(), and fetchHttpStream() are unchanged. Other framework wrappers (ai-solid, ai-vue, ai-svelte) untouched in this branch — same shape can be added to each in a follow-up if this design is preferred. Sketch alternative to TanStack#508 (the stream() connection-adapter approach) for design comparison. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughThis change introduces a Changes
Sequence Diagram(s)sequenceDiagram
participant App as App/Hook
participant Client as ChatClient
participant Adapter as fetcherToConnectionAdapter
participant Fetcher as ChatFetcher
participant Parser as responseToSSEChunks
App->>Client: new ChatClient({ fetcher })
activate Client
Client->>Client: resolveTransport(fetcher)
Client->>Adapter: fetcherToConnectionAdapter(fetcher)
deactivate Client
App->>Client: sendMessage()
activate Client
Client->>Adapter: send(request)
activate Adapter
Adapter->>Fetcher: fetcher({ messages, data, signal })
activate Fetcher
Fetcher-->>Adapter: Response | AsyncIterable<StreamChunk>
deactivate Fetcher
alt Response Path
Adapter->>Parser: responseToSSEChunks(response)
activate Parser
Parser-->>Adapter: AsyncIterable<StreamChunk>
deactivate Parser
else AsyncIterable Path
Adapter->>Adapter: use iterable directly
end
loop For each chunk
Adapter-->>Client: StreamChunk
Client-->>App: onMessage/message update
end
Adapter-->>Client: RUN_FINISHED (synthesized)
deactivate Adapter
Client-->>App: ready state
deactivate Client
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes The changes introduce significant new control flow and type logic across multiple layers. The connection adapters refactor ( Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
View your CI Pipeline Execution ↗ for commit ebec591
☁️ Nx Cloud last updated this comment at |
@tanstack/ai
@tanstack/ai-anthropic
@tanstack/ai-client
@tanstack/ai-code-mode
@tanstack/ai-code-mode-skills
@tanstack/ai-devtools-core
@tanstack/ai-elevenlabs
@tanstack/ai-event-client
@tanstack/ai-fal
@tanstack/ai-gemini
@tanstack/ai-grok
@tanstack/ai-groq
@tanstack/ai-isolate-cloudflare
@tanstack/ai-isolate-node
@tanstack/ai-isolate-quickjs
@tanstack/ai-ollama
@tanstack/ai-openai
@tanstack/ai-openrouter
@tanstack/ai-preact
@tanstack/ai-react
@tanstack/ai-react-ui
@tanstack/ai-solid
@tanstack/ai-solid-ui
@tanstack/ai-svelte
@tanstack/ai-vue
@tanstack/ai-vue-ui
@tanstack/preact-ai-devtools
@tanstack/react-ai-devtools
@tanstack/solid-ai-devtools
commit: |
…ranch The fetcher path uses the same SSE parsing and connect-wrapper plumbing as the stream() path on TanStack#508, so the polish that landed during TanStack#508's review applies directly here. Carry it over so this branch has the same robustness. - Skip SSE control lines (`:` comments, `event:` / `id:` / `retry:`) in responseToSSEChunks. Proxies and CDNs inject these as keepalives; letting them through would feed JSON.parse a non-payload line. - Drop unterminated trailing buffer in readStreamLines. A non-empty buffer at stream end means the connection was cut mid-line, so the data is partial — yielding it would surface a misleading RUN_ERROR for what is really a transport-layer issue. - Surface JSON.parse failures in responseToSSEChunks and fetchHttpStream. Stop swallowing them behind console.warn; let SyntaxError propagate so the connect-wrapper turns it into a visible RUN_ERROR. - Drop unsafe `as unknown as StreamChunk` casts in normalizeConnectionAdapter's synthesized RUN_FINISHED / RUN_ERROR events. Use EventType + RunFinishedEvent / RunErrorEvent so missing required fields are caught by the compiler. Track upstream threadId/runId from chunks and reuse them in the synthesis instead of fabricating both ids unconditionally. - Forward optional abortSignal third arg through stream() and rpcStream() factory signatures. Backwards-compatible for existing callers; lets long-running factories cancel when useChat aborts. Mirrors what fetcherToConnectionAdapter already does. Tests: - Update the two `should handle malformed JSON gracefully` tests to assert SyntaxError throws instead of silent drop. - Update stream() / rpcStream() factory mock assertions to expect the new third arg. - Add chat-fetcher test asserting a fetcher returning a malformed-SSE Response surfaces as a RUN_ERROR via onError. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… type Promote `ChatClientOptions` to a discriminated union so exactly one of `connection` or `fetcher` is required at the type level, surface stream truncation as a `StreamTruncatedError` instead of a silent warn, synthesize RUN_FINISHED on legacy `[DONE]` sentinels, and abort fetcher-returned async iterables that ignore their signal. Update framework wrappers (react/preact/solid/svelte/vue) and the e2e route to match. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fetcher option on useChat (mirrors useGenerateSpeech)fetcher option to ChatClient/useChat
…preading a partial transport Mirrors the pattern in `useGeneration` (multimedia hooks): build a `baseOptions` literal once, then call `new ChatClient(...)` in two narrow branches with the matching transport. Drops the `optionsRef.current.fetcher!` non-null assertion and the awkward discriminated-union spread, and provides a clear hook-level error when neither `connection` nor `fetcher` is provided. Applied to all five chat hooks: ai-react, ai-preact, ai-solid, ai-vue, ai-svelte. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add `@tanstack/ai-preact`, `ai-solid`, `ai-svelte`, and `ai-vue` to the fetcher changeset as minor bumps — they all expose the new `fetcher` option transitively via `ChatClientOptions`. Also simplify the hooks to pick the transport into a single object before constructing `ChatClient`, instead of duplicating the option bag in if/else branches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`ai-preact`, `ai-solid`, `ai-svelte`, and `ai-vue` don't add any new public exports — they only adjust internal plumbing to handle the new connection/fetcher XOR shape from `ai-client`. `ai-react` stays minor because it genuinely re-exports new symbols (`rpcStream`, `ChatFetcher`, `ChatFetcherInput`, `ChatFetcherOptions`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 7
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
testing/e2e/src/routes/$provider/$feature.tsx (1)
17-26:⚠️ Potential issue | 🟡 MinorWhitelist
modeinstead of casting arbitrary strings.
search.mode as Modeaccepts any string from the URL, sovalidateSearchno longer guarantees a valid transport mode. That can leak invalid state into downstream components and defeat the purpose of the search validation.Suggested fix
- mode: typeof search.mode === 'string' ? (search.mode as Mode) : undefined, + mode: + search.mode === 'sse' || search.mode === 'fetcher' + ? search.mode + : undefined,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@testing/e2e/src/routes/`$provider/$feature.tsx around lines 17 - 26, validateSearch currently casts any string from search.mode to Mode using (search.mode as Mode), allowing invalid modes; instead implement a whitelist: define the allowed Mode values (the Mode union/enum) and check that typeof search.mode === 'string' and allowedModes.includes(search.mode as Mode) before returning it, otherwise return undefined. Update the validateSearch function to perform this include-check (referencing validateSearch, Mode, and search.mode) so only known/allowed transport modes are propagated.
🧹 Nitpick comments (1)
packages/typescript/ai-client/src/chat-client.ts (1)
33-51: Prefer Zod for transport XOR runtime validation.
resolveTransportdoes manual shape validation; this should use a Zod schema to stay consistent with validation patterns inpackages/typescript/**/src/**/*.ts.As per coding guidelines:
packages/typescript/**/src/**/*.ts: "Use Zod for schema validation and tool definition across the library".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/typescript/ai-client/src/chat-client.ts` around lines 33 - 51, The resolveTransport function currently does manual runtime checks for connection vs fetcher; replace that logic with a Zod schema that enforces an exclusive-or (XOR) between ConnectionAdapter and ChatFetcher and parses the input, then use the parsed result to return either the ConnectionAdapter or the result of fetcherToConnectionAdapter(fetcher). Specifically, create a Zod object schema (using z.object and z.union or z.discriminatedUnion / refine to enforce XOR) that accepts { connection?: ConnectionAdapter } or { fetcher?: ChatFetcher }, validate the incoming transport with that schema inside resolveTransport, and throw the same descriptive errors if validation fails; keep using the same symbols resolveTransport, ConnectionAdapter, ChatFetcher, and fetcherToConnectionAdapter so callers and behavior remain unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@examples/ts-react-chat/src/lib/server-fns.ts`:
- Around line 380-394: Replace the no-op inputValidator on chatFn with a real
runtime schema that validates the overall payload shape (e.g., an object with
messages: Array<UIMessage> and optional data), validate each message fields used
by chat (role, content, id, etc.), and remove the unsafe cast "data.messages as
any" so the handler passes the typed/validated messages into chat; update the
inputValidator call to use that schema (or a Zod schema) and ensure
chatFn.handler expects the validated type before calling
chat/openaiText/toServerSentEventsResponse.
In `@examples/ts-react-chat/src/routes/server-fn-chat.tsx`:
- Around line 31-33: The fetcher example passed to useChat uses the wrong call
shape for chatFn; update the fetcher so it forwards the messages correctly by
calling chatFn with data containing messages and the signal (i.e., call chatFn
with an object whose data property wraps messages and includes signal) — locate
the useChat fetcher and replace the current chatFn({ data, signal }) invocation
with the correct shape chatFn({ data: { messages }, signal }) so copy/pasted
examples work as intended.
- Line 18: The handler signature uses React.FormEvent but the React namespace
isn't available under strict TS settings; update imports to include the type and
switch the parameter type: import type { FormEvent } from 'react' and change the
handleSubmit signature from (e: React.FormEvent) to (e: FormEvent) in the
handleSubmit function so the type resolves under moduleResolution: "bundler" and
allowUmdGlobalAccess: false.
In `@packages/typescript/ai-client/src/connection-adapters.ts`:
- Around line 139-150: The code is JSON.parse'ing untrusted stream data and
casting to StreamChunk without validation; update the parsing to validate
against a Zod schema (define a StreamChunkSchema using zod matching StreamChunk
fields) and use StreamChunkSchema.parse or safeParse on the parsed object before
assigning values or yielding; replace direct casts in the block that sets
lastThreadId, lastRunId, lastModel and yields chunk (and the similar occurrences
around the other mentioned locations) with the validated result, handling
validation failures (log/skip/break as appropriate) instead of trusting the
cast.
- Around line 78-83: The trailing-buffer check is misclassifying an intentional
abort as a StreamTruncatedError; update the validation so it only throws
StreamTruncatedError when the stream ended unexpectedly, not when the operation
was aborted. Specifically, in the block that inspects buffer (the code that
currently does if (buffer.trim()) { throw new StreamTruncatedError() }), add a
guard that detects an abort (e.g., check controller.signal?.aborted or a
local/instance flag set by stop()) and skip throwing if aborted—only throw
StreamTruncatedError when buffer.trim() is true AND the abort signal/stop flag
is false. Ensure you reference and use the existing stop() abort mechanism and
the StreamTruncatedError symbol so behavior remains unchanged for genuine
truncation.
In `@packages/typescript/ai-client/tests/chat-client.test.ts`:
- Around line 91-95: Add a regression test that exercises the "both provided"
branch of the ChatClient constructor: construct a new ChatClient passing both a
`connection` and a `fetcher` (e.g., stub/mocked objects) and assert it throws
the same constructor error (the message about requiring either `connection` or
`fetcher`). Target the ChatClient constructor in the existing test file and
mirror the existing pattern used in the empty-transport test so both branches
(none provided and both provided) are covered.
In `@testing/e2e/tests/chat.spec.ts`:
- Around line 26-40: The test 'fetcher mode — streams an SSE Response through
useChat({ fetcher })' currently mirrors the SSE flow and needs a
fetcher-specific assertion; update the test (the async test block using
featureUrl, sendMessage, waitForResponse, getLastAssistantMessage) to
additionally assert a fetcher-only signal—for example, check the UI mode
indicator element text/value that shows "fetcher" is selected after page.goto,
or intercept and inspect the outgoing request body to confirm it used the
fetcher payload shape (e.g., contains a fetcher flag or specific header) before
sending the message; use existing helpers on the page to query the mode element
or route/intercept the request and add an expect that fails if the fetcher
marker is absent.
---
Outside diff comments:
In `@testing/e2e/src/routes/`$provider/$feature.tsx:
- Around line 17-26: validateSearch currently casts any string from search.mode
to Mode using (search.mode as Mode), allowing invalid modes; instead implement a
whitelist: define the allowed Mode values (the Mode union/enum) and check that
typeof search.mode === 'string' and allowedModes.includes(search.mode as Mode)
before returning it, otherwise return undefined. Update the validateSearch
function to perform this include-check (referencing validateSearch, Mode, and
search.mode) so only known/allowed transport modes are propagated.
---
Nitpick comments:
In `@packages/typescript/ai-client/src/chat-client.ts`:
- Around line 33-51: The resolveTransport function currently does manual runtime
checks for connection vs fetcher; replace that logic with a Zod schema that
enforces an exclusive-or (XOR) between ConnectionAdapter and ChatFetcher and
parses the input, then use the parsed result to return either the
ConnectionAdapter or the result of fetcherToConnectionAdapter(fetcher).
Specifically, create a Zod object schema (using z.object and z.union or
z.discriminatedUnion / refine to enforce XOR) that accepts { connection?:
ConnectionAdapter } or { fetcher?: ChatFetcher }, validate the incoming
transport with that schema inside resolveTransport, and throw the same
descriptive errors if validation fails; keep using the same symbols
resolveTransport, ConnectionAdapter, ChatFetcher, and fetcherToConnectionAdapter
so callers and behavior remain unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 325740fd-64b9-4f1b-8045-6509c9bb580f
📒 Files selected for processing (22)
.changeset/usechat-fetcher-server-functions.mdexamples/ts-react-chat/src/components/Header.tsxexamples/ts-react-chat/src/lib/server-fns.tsexamples/ts-react-chat/src/routeTree.gen.tsexamples/ts-react-chat/src/routes/server-fn-chat.tsxpackages/typescript/ai-client/src/chat-client.tspackages/typescript/ai-client/src/connection-adapters.tspackages/typescript/ai-client/src/index.tspackages/typescript/ai-client/src/types.tspackages/typescript/ai-client/tests/chat-client.test.tspackages/typescript/ai-client/tests/chat-fetcher.test.tspackages/typescript/ai-client/tests/connection-adapters.test.tspackages/typescript/ai-preact/src/use-chat.tspackages/typescript/ai-react/src/index.tspackages/typescript/ai-react/src/types.tspackages/typescript/ai-react/src/use-chat.tspackages/typescript/ai-react/tests/use-chat-fetcher.test.tspackages/typescript/ai-solid/src/use-chat.tspackages/typescript/ai-svelte/src/create-chat.svelte.tspackages/typescript/ai-vue/src/use-chat.tstesting/e2e/src/routes/$provider/$feature.tsxtesting/e2e/tests/chat.spec.ts
| export const chatFn = createServerFn({ method: 'POST' }) | ||
| .inputValidator( | ||
| (data: { messages: Array<UIMessage>; data?: Record<string, any> }) => data, | ||
| ) | ||
| .handler(({ data }) => | ||
| toServerSentEventsResponse( | ||
| chat({ | ||
| adapter: openaiText('gpt-5.2'), | ||
| messages: data.messages as any, | ||
| systemPrompts: [ | ||
| 'You are a helpful assistant. Keep replies short and friendly.', | ||
| ], | ||
| }), | ||
| ), | ||
| ) |
There was a problem hiding this comment.
Restore runtime validation for chatFn.
This handler currently accepts unchecked input: the identity inputValidator lets malformed payloads through, and data.messages as any hides that at the call site. Please switch this to a real schema so bad requests fail before reaching chat().
Suggested fix
export const chatFn = createServerFn({ method: 'POST' })
.inputValidator(
- (data: { messages: Array<UIMessage>; data?: Record<string, any> }) => data,
+ z.object({
+ messages: z.array(z.any()),
+ data: z.record(z.unknown()).optional(),
+ }),
)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@examples/ts-react-chat/src/lib/server-fns.ts` around lines 380 - 394, Replace
the no-op inputValidator on chatFn with a real runtime schema that validates the
overall payload shape (e.g., an object with messages: Array<UIMessage> and
optional data), validate each message fields used by chat (role, content, id,
etc.), and remove the unsafe cast "data.messages as any" so the handler passes
the typed/validated messages into chat; update the inputValidator call to use
that schema (or a Zod schema) and ensure chatFn.handler expects the validated
type before calling chat/openaiText/toServerSentEventsResponse.
| }) | ||
| const [input, setInput] = useState('') | ||
|
|
||
| const handleSubmit = (e: React.FormEvent) => { |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Checking TS config for UMD global access + JSX mode..."
fd -i 'tsconfig*.json' | while read -r f; do
echo "### $f"
cat "$f" | jq '.compilerOptions | {jsx, allowUmdGlobalAccess, types}'
done
echo
echo "Searching for React namespace type usage in TSX files..."
rg -nP --type=tsx '\bReact\.[A-Za-z_]+' -C1Repository: TanStack/ai
Length of output: 1771
🏁 Script executed:
# Attempt to examine the file and TypeScript config
# First, let's check if the repository is available and explore the file structure
# Check for the file in question
git ls-files | grep -E "(server-fn-chat|tsconfig)" | head -20Repository: TanStack/ai
Length of output: 931
🏁 Script executed:
# If the file exists, read its contents
if git ls-files | grep -q "examples/ts-react-chat/src/routes/server-fn-chat.tsx"; then
echo "=== File: server-fn-chat.tsx (lines 1-50) ==="
head -50 "examples/ts-react-chat/src/routes/server-fn-chat.tsx"
echo ""
echo "=== Checking imports and line 18 context ==="
sed -n '1,30p' "examples/ts-react-chat/src/routes/server-fn-chat.tsx" | cat -n
fiRepository: TanStack/ai
Length of output: 3156
🏁 Script executed:
# Check tsconfig files
echo "=== Looking for tsconfig files ==="
find . -name "tsconfig*.json" -type f | head -10 | while read f; do
echo "### $f"
head -30 "$f"
echo ""
doneRepository: TanStack/ai
Length of output: 5905
React.FormEvent type reference will fail in strict TypeScript mode without a React import.
Line 18 uses React.FormEvent but only imports useState from React. With moduleResolution: "bundler" and allowUmdGlobalAccess: false (default), the React namespace is unavailable. Fix by importing the type directly: import type { FormEvent } from 'react' and use FormEvent instead of React.FormEvent.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@examples/ts-react-chat/src/routes/server-fn-chat.tsx` at line 18, The handler
signature uses React.FormEvent but the React namespace isn't available under
strict TS settings; update imports to include the type and switch the parameter
type: import type { FormEvent } from 'react' and change the handleSubmit
signature from (e: React.FormEvent) to (e: FormEvent) in the handleSubmit
function so the type resolves under moduleResolution: "bundler" and
allowUmdGlobalAccess: false.
| useChat({ fetcher: ({'{'}messages{'}'}, {'{'}signal{'}'}) => | ||
| chatFn({'{'} data, signal {'}'}) }) | ||
| </code>{' '} |
There was a problem hiding this comment.
Displayed fetcher snippet uses the wrong chatFn call shape.
The rendered example shows chatFn({ data, signal }), but the real call is chatFn({ data: { messages }, signal }). This can cause copy/paste confusion.
✏️ Suggested text fix
- chatFn({'{'} data, signal {'}'}) })
+ chatFn({'{'} data: {'{'} messages {'}'}, signal {'}'}) })📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| useChat({ fetcher: ({'{'}messages{'}'}, {'{'}signal{'}'}) => | |
| chatFn({'{'} data, signal {'}'}) }) | |
| </code>{' '} | |
| useChat({ fetcher: ({'{'}messages{'}'}, {'{'}signal{'}'}) => | |
| chatFn({'{'} data: {'{'} messages {'}'}, signal {'}'}) }) | |
| </code>{' '} |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@examples/ts-react-chat/src/routes/server-fn-chat.tsx` around lines 31 - 33,
The fetcher example passed to useChat uses the wrong call shape for chatFn;
update the fetcher so it forwards the messages correctly by calling chatFn with
data containing messages and the signal (i.e., call chatFn with an object whose
data property wraps messages and includes signal) — locate the useChat fetcher
and replace the current chatFn({ data, signal }) invocation with the correct
shape chatFn({ data: { messages }, signal }) so copy/pasted examples work as
intended.
| // A non-empty trailing buffer means the connection was cut mid-line. | ||
| // Surface this as an error so the chat client transitions to 'error' | ||
| // state instead of silently presenting a partial stream as success. | ||
| if (buffer.trim()) { | ||
| yield buffer | ||
| throw new StreamTruncatedError() | ||
| } |
There was a problem hiding this comment.
Abort can be misclassified as StreamTruncatedError when a partial line is buffered.
After stop(), breaking the loop still runs trailing-buffer validation; if abort happens mid-line, this can wrongly surface an error instead of a clean abort.
✅ Minimal fix
- if (buffer.trim()) {
+ if (!abortSignal?.aborted && buffer.trim()) {
throw new StreamTruncatedError()
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/typescript/ai-client/src/connection-adapters.ts` around lines 78 -
83, The trailing-buffer check is misclassifying an intentional abort as a
StreamTruncatedError; update the validation so it only throws
StreamTruncatedError when the stream ended unexpectedly, not when the operation
was aborted. Specifically, in the block that inspects buffer (the code that
currently does if (buffer.trim()) { throw new StreamTruncatedError() }), add a
guard that detects an abort (e.g., check controller.signal?.aborted or a
local/instance flag set by stop()) and skip throwing if aborted—only throw
StreamTruncatedError when buffer.trim() is true AND the abort signal/stop flag
is false. Ensure you reference and use the existing stop() abort mechanism and
the StreamTruncatedError symbol so behavior remains unchanged for genuine
truncation.
| const chunk = JSON.parse(data) as StreamChunk | ||
| if ('threadId' in chunk && typeof chunk.threadId === 'string') { | ||
| lastThreadId = chunk.threadId | ||
| } | ||
| if ('runId' in chunk && typeof chunk.runId === 'string') { | ||
| lastRunId = chunk.runId | ||
| } | ||
| if ('model' in chunk && typeof chunk.model === 'string') { | ||
| lastModel = chunk.model | ||
| } | ||
| yield chunk | ||
| } |
There was a problem hiding this comment.
Validate parsed stream payloads before casting to StreamChunk.
Both SSE and HTTP-stream paths currently do JSON.parse(...) as StreamChunk on untrusted network input. This should be schema-validated before use.
As per coding guidelines: packages/typescript/**/src/**/*.ts: "Use Zod for schema validation and tool definition across the library".
Also applies to: 479-481
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/typescript/ai-client/src/connection-adapters.ts` around lines 139 -
150, The code is JSON.parse'ing untrusted stream data and casting to StreamChunk
without validation; update the parsing to validate against a Zod schema (define
a StreamChunkSchema using zod matching StreamChunk fields) and use
StreamChunkSchema.parse or safeParse on the parsed object before assigning
values or yielding; replace direct casts in the block that sets lastThreadId,
lastRunId, lastModel and yields chunk (and the similar occurrences around the
other mentioned locations) with the validated result, handling validation
failures (log/skip/break as appropriate) instead of trusting the cast.
| it('should throw if neither connection nor fetcher is provided', () => { | ||
| expect(() => new ChatClient({} as any)).toThrow( | ||
| 'Connection adapter is required', | ||
| 'either `connection` or `fetcher` is required', | ||
| ) | ||
| }) |
There was a problem hiding this comment.
Cover the both provided branch as well.
This assertion only exercises the empty-transport path. The new constructor contract also rejects configurations that include both connection and fetcher, so add a regression check for that branch too.
Suggested test addition
it('should throw if neither connection nor fetcher is provided', () => {
expect(() => new ChatClient({} as any)).toThrow(
'either `connection` or `fetcher` is required',
)
})
+
+ it('should throw if both connection and fetcher are provided', () => {
+ expect(() =>
+ new ChatClient({
+ connection: createMockConnectionAdapter(),
+ fetcher: vi.fn(),
+ } as any),
+ ).toThrow('pass either `connection` or `fetcher`, not both.')
+ })📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| it('should throw if neither connection nor fetcher is provided', () => { | |
| expect(() => new ChatClient({} as any)).toThrow( | |
| 'Connection adapter is required', | |
| 'either `connection` or `fetcher` is required', | |
| ) | |
| }) | |
| it('should throw if neither connection nor fetcher is provided', () => { | |
| expect(() => new ChatClient({} as any)).toThrow( | |
| 'either `connection` or `fetcher` is required', | |
| ) | |
| }) | |
| it('should throw if both connection and fetcher are provided', () => { | |
| expect(() => | |
| new ChatClient({ | |
| connection: createMockConnectionAdapter(), | |
| fetcher: vi.fn(), | |
| } as any), | |
| ).toThrow('pass either `connection` or `fetcher`, not both.') | |
| }) |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/typescript/ai-client/tests/chat-client.test.ts` around lines 91 -
95, Add a regression test that exercises the "both provided" branch of the
ChatClient constructor: construct a new ChatClient passing both a `connection`
and a `fetcher` (e.g., stub/mocked objects) and assert it throws the same
constructor error (the message about requiring either `connection` or
`fetcher`). Target the ChatClient constructor in the existing test file and
mirror the existing pattern used in the empty-transport test so both branches
(none provided and both provided) are covered.
| test('fetcher mode — streams an SSE Response through useChat({ fetcher })', async ({ | ||
| page, | ||
| testId, | ||
| aimockPort, | ||
| }) => { | ||
| await page.goto( | ||
| featureUrl(provider, 'chat', testId, aimockPort, 'fetcher'), | ||
| ) | ||
|
|
||
| await sendMessage(page, '[chat] recommend a guitar') | ||
| await waitForResponse(page) | ||
|
|
||
| const response = await getLastAssistantMessage(page) | ||
| expect(response).toContain('Fender Stratocaster') | ||
| }) |
There was a problem hiding this comment.
Add a fetcher-specific assertion.
This case still mirrors the SSE flow closely enough that it would pass even if the route silently fell back to the old transport. Please assert something that only happens in fetcher mode, such as the selected mode in the UI or the request payload.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@testing/e2e/tests/chat.spec.ts` around lines 26 - 40, The test 'fetcher mode
— streams an SSE Response through useChat({ fetcher })' currently mirrors the
SSE flow and needs a fetcher-specific assertion; update the test (the async test
block using featureUrl, sendMessage, waitForResponse, getLastAssistantMessage)
to additionally assert a fetcher-only signal—for example, check the UI mode
indicator element text/value that shows "fetcher" is selected after page.goto,
or intercept and inspect the outgoing request body to confirm it used the
fetcher payload shape (e.g., contains a fetcher flag or specific header) before
sending the message; use existing helpers on the page to query the mode element
or route/intercept the request and add an expect that fails if the fetcher
marker is absent.
Closes #509.
🎯 Changes
Adds a
fetcheroption toChatClientanduseChat, mirroring thefetcheroption that already exists on the generation hooks (useGenerateSpeech,useSummarize,useTranscription,useGenerateImage). This givesuseChata first-class way to accept a TanStack Start server function (or any other async request function) without going through a connection adapter.Why
Issue #509 reports that wiring a server function into
useChatvia the existingstream()connection adapter fails to typecheck:stream()'s factory is typed as() => AsyncIterable<StreamChunk>, but a server function call is async and returns eitherPromise<Response>(when the handler returnstoServerSentEventsResponse(...)) orPromise<AsyncIterable<StreamChunk>>(when it returns the stream directly). Neither shape is currently assignable withoutas any.Rather than widening
stream()'s factory signature, this PR introducesfetcher— a sibling option toconnectionwhose type is built around exactly this async/Response/AsyncIterable shape, and which matches the mental model users already have from the multimedia hooks.Shape
ChatClientOptionsnow extends aChatTransportdiscriminated union — exactly one ofconnectionorfetcheris required, enforced at the type level (no runtime XOR check needed).Internals
fetcherToConnectionAdapter(fetcher)wraps aChatFetcherinto a regularConnectionAdapter. The chat client doesn't gain any new code paths — the fetcher rides the existingnormalizeConnectionAdapterqueue plumbing that already powers SSE / HTTP-stream /stream()/rpcStream(). Aborts, retries, andlive: trueall work unchanged.Responseparsing is factored out offetchServerSentEventsinto a sharedresponseToSSEChunkshelper, reused by the fetcher path when a fetcher resolves to aResponse.stream(),rpcStream(),fetchServerSentEvents, andfetchHttpStreamare unchanged.What's in the diff
packages/typescript/ai-client/src/types.ts—ChatFetcher/ChatFetcherInput/ChatFetcherOptions/ChatTransporttypespackages/typescript/ai-client/src/chat-client.ts— accept eitherconnectionorfetcherin the constructor andupdateOptionspackages/typescript/ai-client/src/connection-adapters.ts—fetcherToConnectionAdapterhelper + extractedresponseToSSEChunkspackages/typescript/ai-client/src/index.ts— re-export new typespackages/typescript/ai-react/src/{types.ts,use-chat.ts,index.ts}— passfetcherthroughpackages/typescript/ai-{solid,vue,svelte,preact}/...— same one-linefetcherpass-through in each framework hookpackages/typescript/ai-client/tests/chat-fetcher.test.ts— 7 cases covering Response / AsyncIterable / abort / error / body mergingpackages/typescript/ai-react/tests/use-chat-fetcher.test.ts— React hook coverageexamples/ts-react-chat/{src/lib/server-fns.ts, src/routes/server-fn-chat.tsx, src/components/Header.tsx}— example wiring a TanStack Start server function via the newfetcheroption, demonstrating the pattern from the issuetesting/e2e/...— e2e coverage for the fetcher path.changeset/usechat-fetcher-server-functions.md✅ Checklist
pnpm run test:pr.🚀 Release Impact
🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes
New Features
fetcheroption to chat client and hooks as an alternative to connection-based transport. Supports returning either an SSE Response or AsyncIterable stream.Bug Fixes & Improvements