From e11829586a3103cc114246507c275915e92ffd18 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 20 Apr 2026 15:00:56 +0200 Subject: [PATCH 1/9] feat: dual ESM+CJS builds and toJSONResponse / fetchJSON utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #308 and #309. - @tanstack/ai, @tanstack/ai-client, @tanstack/ai-event-client now emit both dist/esm/*.js and dist/cjs/*.cjs with matching .d.cts files. package.json exports gained nested import/require conditions plus a `main` field so Metro / Expo / other CJS-only resolvers can find the subpath exports (`./adapters`, `./middlewares`, etc.). - New toJSONResponse(stream, init?) on @tanstack/ai: drains the stream and returns a JSON-array Response. For runtimes that can't stream ReadableStream bodies (Expo's @expo/server, edge proxies). - New fetchJSON(url, options?) connection adapter on @tanstack/ai-client: the client-side counterpart — fetches the JSON array and replays each chunk into the normal ChatClient pipeline. - Trade-off documented in both: you lose incremental rendering; use SSE / HTTP-stream responses when the runtime supports them. --- .changeset/cjs-output-and-json-response.md | 9 +++ packages/typescript/ai-client/package.json | 11 ++- .../ai-client/src/connection-adapters.ts | 75 +++++++++++++++++++ packages/typescript/ai-client/src/index.ts | 1 + packages/typescript/ai-client/vite.config.ts | 2 +- .../typescript/ai-event-client/package.json | 11 ++- .../typescript/ai-event-client/vite.config.ts | 2 +- packages/typescript/ai/package.json | 31 ++++++-- packages/typescript/ai/src/index.ts | 1 + .../typescript/ai/src/stream-to-response.ts | 48 ++++++++++++ .../ai/tests/stream-to-response.test.ts | 73 ++++++++++++++++++ packages/typescript/ai/vite.config.ts | 2 +- 12 files changed, 253 insertions(+), 13 deletions(-) create mode 100644 .changeset/cjs-output-and-json-response.md diff --git a/.changeset/cjs-output-and-json-response.md b/.changeset/cjs-output-and-json-response.md new file mode 100644 index 000000000..389033ca4 --- /dev/null +++ b/.changeset/cjs-output-and-json-response.md @@ -0,0 +1,9 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-client': minor +'@tanstack/ai-event-client': patch +--- + +**Dual ESM + CJS output.** `@tanstack/ai`, `@tanstack/ai-client`, and `@tanstack/ai-event-client` now ship both ESM and CJS builds with type-aware dual `exports` maps (`import` → `./dist/esm/*.js`, `require` → `./dist/cjs/*.cjs`), plus a `main` field pointing at CJS. Fixes Metro / Expo / CJS-only resolvers that previously couldn't find `@tanstack/ai/adapters` or `@tanstack/ai-client` because the packages were ESM-only (#308). + +**New `toJSONResponse(stream, init?)` on `@tanstack/ai`.** Drains the chat stream fully and returns a JSON-array `Response` with `Content-Type: application/json`. Use on server runtimes that can't emit `ReadableStream` responses (Expo's `@expo/server`, some edge proxies). Pair with the new `fetchJSON(url, options?)` connection adapter on `@tanstack/ai-client` — it fetches the array and replays each chunk into the normal `ChatClient` pipeline. Trade-off: no incremental rendering (every chunk arrives at once when the request resolves). Closes #309. diff --git a/packages/typescript/ai-client/package.json b/packages/typescript/ai-client/package.json index 38330616e..939d6e453 100644 --- a/packages/typescript/ai-client/package.json +++ b/packages/typescript/ai-client/package.json @@ -18,12 +18,19 @@ "streaming" ], "type": "module", + "main": "./dist/cjs/index.cjs", "module": "./dist/esm/index.js", "types": "./dist/esm/index.d.ts", "exports": { ".": { - "types": "./dist/esm/index.d.ts", - "import": "./dist/esm/index.js" + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } } }, "files": [ diff --git a/packages/typescript/ai-client/src/connection-adapters.ts b/packages/typescript/ai-client/src/connection-adapters.ts index 91d63a146..a7bd32673 100644 --- a/packages/typescript/ai-client/src/connection-adapters.ts +++ b/packages/typescript/ai-client/src/connection-adapters.ts @@ -424,6 +424,81 @@ export function fetchHttpStream( } } +/** + * Create a JSON-array connection adapter for server runtimes that cannot + * stream `ReadableStream` responses (e.g. Expo's `@expo/server`, certain + * edge proxies). Pair with `toJSONResponse(stream)` on the server: the + * server drains the chat stream fully, JSON-serialises the collected + * chunks into an array, and this adapter fetches the array and replays + * each chunk one-by-one into the normal client pipeline. + * + * Trade-off: you lose incremental rendering — the UI sees every chunk + * only after the request resolves. Use SSE/HTTP-stream adapters when the + * runtime supports them. + * + * @param url - The API endpoint URL (or a function that returns the URL) + * @param options - Fetch options (headers, credentials, body, etc.) or a function that returns options (can be async) + * @returns A connection adapter for JSON-array responses + * + * @example + * ```typescript + * // Expo / RN client that hits an Expo API route returning toJSONResponse(stream) + * const connection = fetchJSON('/api/chat') + * + * const client = new ChatClient({ connection }) + * ``` + */ +export function fetchJSON( + url: string | (() => string), + options: + | FetchConnectionOptions + | (() => FetchConnectionOptions | Promise) = {}, +): ConnectConnectionAdapter { + return { + async *connect(messages, data, abortSignal) { + const resolvedUrl = typeof url === 'function' ? url() : url + const resolvedOptions = + typeof options === 'function' ? await options() : options + + const requestHeaders: Record = { + 'Content-Type': 'application/json', + ...mergeHeaders(resolvedOptions.headers), + } + + const requestBody = { + messages, + data, + ...resolvedOptions.body, + } + + const fetchClient = resolvedOptions.fetchClient ?? fetch + const response = await fetchClient(resolvedUrl, { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify(requestBody), + credentials: resolvedOptions.credentials || 'same-origin', + signal: abortSignal || resolvedOptions.signal, + }) + + if (!response.ok) { + throw new Error( + `HTTP error! status: ${response.status} ${response.statusText}`, + ) + } + + const payload = (await response.json()) as unknown + if (!Array.isArray(payload)) { + throw new Error( + 'fetchJSON: expected response body to be a JSON array of StreamChunks. Did you forget to use `toJSONResponse(stream)` on the server?', + ) + } + for (const chunk of payload) { + yield chunk as StreamChunk + } + }, + } +} + /** * Create a direct stream connection adapter (for server functions or direct streams) * diff --git a/packages/typescript/ai-client/src/index.ts b/packages/typescript/ai-client/src/index.ts index 93654ba66..6f3e90028 100644 --- a/packages/typescript/ai-client/src/index.ts +++ b/packages/typescript/ai-client/src/index.ts @@ -54,6 +54,7 @@ export type { export { fetchServerSentEvents, fetchHttpStream, + fetchJSON, stream, rpcStream, type ConnectConnectionAdapter, diff --git a/packages/typescript/ai-client/vite.config.ts b/packages/typescript/ai-client/vite.config.ts index 77bcc2e60..c87275f3e 100644 --- a/packages/typescript/ai-client/vite.config.ts +++ b/packages/typescript/ai-client/vite.config.ts @@ -31,6 +31,6 @@ export default mergeConfig( tanstackViteConfig({ entry: ['./src/index.ts'], srcDir: './src', - cjs: false, + cjs: true, }), ) diff --git a/packages/typescript/ai-event-client/package.json b/packages/typescript/ai-event-client/package.json index ca860bfb3..48711de78 100644 --- a/packages/typescript/ai-event-client/package.json +++ b/packages/typescript/ai-event-client/package.json @@ -10,12 +10,19 @@ "directory": "packages/typescript/ai-event-client" }, "type": "module", + "main": "./dist/cjs/index.cjs", "module": "./dist/esm/index.js", "types": "./dist/esm/index.d.ts", "exports": { ".": { - "types": "./dist/esm/index.d.ts", - "import": "./dist/esm/index.js" + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } } }, "sideEffects": false, diff --git a/packages/typescript/ai-event-client/vite.config.ts b/packages/typescript/ai-event-client/vite.config.ts index 77bcc2e60..c87275f3e 100644 --- a/packages/typescript/ai-event-client/vite.config.ts +++ b/packages/typescript/ai-event-client/vite.config.ts @@ -31,6 +31,6 @@ export default mergeConfig( tanstackViteConfig({ entry: ['./src/index.ts'], srcDir: './src', - cjs: false, + cjs: true, }), ) diff --git a/packages/typescript/ai/package.json b/packages/typescript/ai/package.json index 1eea2b8b7..f7495342c 100644 --- a/packages/typescript/ai/package.json +++ b/packages/typescript/ai/package.json @@ -10,20 +10,39 @@ "directory": "packages/typescript/ai" }, "type": "module", + "main": "./dist/cjs/index.cjs", "module": "./dist/esm/index.js", "types": "./dist/esm/index.d.ts", "exports": { ".": { - "types": "./dist/esm/index.d.ts", - "import": "./dist/esm/index.js" + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } }, "./adapters": { - "types": "./dist/esm/activities/index.d.ts", - "import": "./dist/esm/activities/index.js" + "import": { + "types": "./dist/esm/activities/index.d.ts", + "default": "./dist/esm/activities/index.js" + }, + "require": { + "types": "./dist/cjs/activities/index.d.cts", + "default": "./dist/cjs/activities/index.cjs" + } }, "./middlewares": { - "types": "./dist/esm/middlewares/index.d.ts", - "import": "./dist/esm/middlewares/index.js" + "import": { + "types": "./dist/esm/middlewares/index.d.ts", + "default": "./dist/esm/middlewares/index.js" + }, + "require": { + "types": "./dist/cjs/middlewares/index.d.cts", + "default": "./dist/cjs/middlewares/index.cjs" + } } }, "sideEffects": false, diff --git a/packages/typescript/ai/src/index.ts b/packages/typescript/ai/src/index.ts index 34e1b5922..3f40b1422 100644 --- a/packages/typescript/ai/src/index.ts +++ b/packages/typescript/ai/src/index.ts @@ -58,6 +58,7 @@ export { toServerSentEventsResponse, toHttpStream, toHttpResponse, + toJSONResponse, } from './stream-to-response' // Tool call management diff --git a/packages/typescript/ai/src/stream-to-response.ts b/packages/typescript/ai/src/stream-to-response.ts index 2f83bc017..8da077d00 100644 --- a/packages/typescript/ai/src/stream-to-response.ts +++ b/packages/typescript/ai/src/stream-to-response.ts @@ -250,3 +250,51 @@ export function toHttpResponse( ...init, }) } + +/** + * Drain a StreamChunk async iterable fully, then return the collected chunks + * as a single JSON-array `Response`. + * + * Use this when the target runtime does not support streaming + * `ReadableStream` responses — for example Expo's `@expo/server` runtime, + * Vercel Edge/Node hybrids behind certain proxies, or Cloudflare setups + * without streaming enabled. The consumer pairs with + * `fetchJSON` on the client, which decodes the array and yields each + * chunk back into the normal streaming pipeline — so the on-screen UX + * becomes "render everything at once when the request resolves" instead + * of incremental streaming, but the rest of the chat pipeline is unchanged. + * + * Trade-off: you lose the incremental rendering. Use only when you can't + * ship SSE / HTTP-stream responses. + * + * @param stream - AsyncIterable of StreamChunks from chat() + * @param init - Optional Response initialization options (including `abortController`) + * @returns Response with `Content-Type: application/json` containing an array of StreamChunks + * + * @example + * ```typescript + * // Expo API route where streaming responses aren't supported + * export async function POST(request: Request) { + * const stream = chat({ adapter: openaiText(), messages: [...] }) + * return toJSONResponse(stream) + * } + * ``` + */ +export async function toJSONResponse( + stream: AsyncIterable, + init?: ResponseInit & { abortController?: AbortController }, +): Promise { + const { abortController, headers, ...rest } = init ?? {} + const chunks: Array = [] + try { + for await (const chunk of stream) { + chunks.push(chunk) + } + } catch (error) { + abortController?.abort() + throw error + } + const merged = new Headers(headers) + if (!merged.has('Content-Type')) merged.set('Content-Type', 'application/json') + return new Response(JSON.stringify(chunks), { ...rest, headers: merged }) +} diff --git a/packages/typescript/ai/tests/stream-to-response.test.ts b/packages/typescript/ai/tests/stream-to-response.test.ts index f57425fb0..d454ddbbc 100644 --- a/packages/typescript/ai/tests/stream-to-response.test.ts +++ b/packages/typescript/ai/tests/stream-to-response.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest' import { toServerSentEventsStream, toServerSentEventsResponse, + toJSONResponse, } from '../src/stream-to-response' import type { StreamChunk } from '../src/types' @@ -870,3 +871,75 @@ describe('SSE Round-Trip (Encode → Decode)', () => { ) }) }) + +describe('toJSONResponse', () => { + it('drains the stream and returns a JSON-array Response', async () => { + const chunks: Array> = [ + { + type: 'RUN_STARTED', + runId: 'r1', + model: 'test', + timestamp: 1, + }, + { + type: 'TEXT_MESSAGE_CONTENT', + messageId: 'm1', + model: 'test', + timestamp: 2, + delta: 'Hello', + content: 'Hello', + }, + { + type: 'RUN_FINISHED', + runId: 'r1', + model: 'test', + timestamp: 3, + }, + ] + const response = await toJSONResponse(createMockStream(chunks)) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('application/json') + expect(await response.json()).toEqual(chunks) + }) + + it('defers to caller-provided headers and preserves extra init', async () => { + const response = await toJSONResponse(createMockStream([]), { + status: 201, + headers: { 'X-Custom': '1' }, + }) + + expect(response.status).toBe(201) + expect(response.headers.get('X-Custom')).toBe('1') + expect(response.headers.get('Content-Type')).toBe('application/json') + }) + + it('does not override an explicit Content-Type', async () => { + const response = await toJSONResponse(createMockStream([]), { + headers: { 'Content-Type': 'application/vnd.tanstack-ai+json' }, + }) + + expect(response.headers.get('Content-Type')).toBe( + 'application/vnd.tanstack-ai+json', + ) + }) + + it('aborts the supplied controller and rethrows if the upstream errors', async () => { + const abortController = new AbortController() + const abortSpy = vi.spyOn(abortController, 'abort') + async function* failing(): AsyncGenerator { + yield { + type: 'RUN_STARTED', + runId: 'r1', + model: 'test', + timestamp: 1, + } as StreamChunk + throw new Error('upstream failure') + } + + await expect(toJSONResponse(failing(), { abortController })).rejects.toThrow( + 'upstream failure', + ) + expect(abortSpy).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/typescript/ai/vite.config.ts b/packages/typescript/ai/vite.config.ts index 2a28101da..3ea666614 100644 --- a/packages/typescript/ai/vite.config.ts +++ b/packages/typescript/ai/vite.config.ts @@ -35,6 +35,6 @@ export default mergeConfig( './src/middlewares/index.ts', ], srcDir: './src', - cjs: false, + cjs: true, }), ) From 1bbc932081ab497eecc95ae9f86063d89d4fc56f Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:02:14 +0000 Subject: [PATCH 2/9] ci: apply automated fixes --- packages/typescript/ai/src/stream-to-response.ts | 3 ++- packages/typescript/ai/tests/stream-to-response.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/typescript/ai/src/stream-to-response.ts b/packages/typescript/ai/src/stream-to-response.ts index 8da077d00..38a280324 100644 --- a/packages/typescript/ai/src/stream-to-response.ts +++ b/packages/typescript/ai/src/stream-to-response.ts @@ -295,6 +295,7 @@ export async function toJSONResponse( throw error } const merged = new Headers(headers) - if (!merged.has('Content-Type')) merged.set('Content-Type', 'application/json') + if (!merged.has('Content-Type')) + merged.set('Content-Type', 'application/json') return new Response(JSON.stringify(chunks), { ...rest, headers: merged }) } diff --git a/packages/typescript/ai/tests/stream-to-response.test.ts b/packages/typescript/ai/tests/stream-to-response.test.ts index d454ddbbc..d08c677ea 100644 --- a/packages/typescript/ai/tests/stream-to-response.test.ts +++ b/packages/typescript/ai/tests/stream-to-response.test.ts @@ -937,9 +937,9 @@ describe('toJSONResponse', () => { throw new Error('upstream failure') } - await expect(toJSONResponse(failing(), { abortController })).rejects.toThrow( - 'upstream failure', - ) + await expect( + toJSONResponse(failing(), { abortController }), + ).rejects.toThrow('upstream failure') expect(abortSpy).toHaveBeenCalledOnce() }) }) From ee3f393cdd95756f5e7bc889e8adf49fb601cff0 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 20 Apr 2026 16:12:36 +0200 Subject: [PATCH 3/9] docs: add React Native & Expo guide + toJSONResponse / fetchJSON references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serves three personas: Expo/RN builders hitting streaming-response crashes, builders on other non-streaming runtimes (edge proxies, legacy serverless), and evaluators checking whether TanStack AI supports RN/Expo. - New journey page at docs/chat/non-streaming-runtimes.md titled 'React Native & Expo'. A → B: Expo API route crashing on streaming response → working chat via toJSONResponse + fetchJSON. - Cross-linked from chat/streaming.md (callout near toServerSentEventsResponse) and chat/connection-adapters.md (new 'JSON Array (non-streaming runtimes)' subsection). - Added the new entries to the API references: toJSONResponse in docs/api/ai.md and fetchJSON in docs/api/ai-client.md, each pointing back to the walkthrough. - Registered the new page in docs/config.json under 'Chat & Streaming', sequenced right after Connection Adapters. --- docs/api/ai-client.md | 16 +++++ docs/api/ai.md | 26 ++++++++ docs/chat/connection-adapters.md | 15 +++++ docs/chat/non-streaming-runtimes.md | 95 +++++++++++++++++++++++++++++ docs/chat/streaming.md | 2 + docs/config.json | 4 ++ 6 files changed, 158 insertions(+) create mode 100644 docs/chat/non-streaming-runtimes.md diff --git a/docs/api/ai-client.md b/docs/api/ai-client.md index 379e58589..87ed954ae 100644 --- a/docs/api/ai-client.md +++ b/docs/api/ai-client.md @@ -166,6 +166,22 @@ import { fetchHttpStream } from "@tanstack/ai-client"; const adapter = fetchHttpStream("/api/chat"); ``` +### `fetchJSON(url, options?)` + +Creates a connection adapter for non-streaming runtimes — pair with [`toJSONResponse`](./ai#tojsonresponsestream-init) on the server. The adapter POSTs `{ messages, data }`, expects a `StreamChunk[]` JSON body, and replays each chunk into the normal `ChatClient` pipeline. + +```typescript +import { fetchJSON } from "@tanstack/ai-client"; + +const adapter = fetchJSON("/api/chat", { + headers: { + Authorization: "Bearer token", + }, +}); +``` + +Use this on Expo / React Native / edge proxies that can't emit `ReadableStream` responses. Trade-off: no incremental rendering — the UI sees every chunk at once when the request resolves. Full walkthrough: [React Native & Expo](../chat/non-streaming-runtimes). + ### `stream(connectFn)` Creates a custom connection adapter. diff --git a/docs/api/ai.md b/docs/api/ai.md index da0970d14..b76184107 100644 --- a/docs/api/ai.md +++ b/docs/api/ai.md @@ -191,6 +191,32 @@ return toServerSentEventsResponse(stream); A `Response` object suitable for HTTP endpoints with SSE headers (`Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`). +## `toJSONResponse(stream, init?)` + +Drains the whole stream, then returns a JSON-array `Response` containing every `StreamChunk`. For runtimes that can't emit `ReadableStream` bodies (Expo's `@expo/server`, some edge proxies). Pair with [`fetchJSON`](./ai-client#fetchjsonurl-options) on the client. + +```typescript +import { chat, toJSONResponse } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; + +const stream = chat({ + adapter: openaiText("gpt-5.2"), + messages: [...], +}); +return toJSONResponse(stream); +``` + +### Parameters + +- `stream` - Async iterable of `StreamChunk` +- `init?` - Optional ResponseInit options (including `abortController`). Caller-provided headers are preserved; `Content-Type` defaults to `application/json`. + +### Returns + +A `Promise` with the stringified `StreamChunk[]` as the body. If the upstream stream throws mid-drain, a provided `abortController` is aborted and the error propagates. + +> **Trade-off:** no incremental rendering — the UI sees every chunk at once when the request resolves. Use SSE / HTTP-stream responses when the runtime supports them. See [React Native & Expo](../chat/non-streaming-runtimes) for the full walkthrough. + ## `maxIterations(count)` Creates an agent loop strategy that limits iterations. diff --git a/docs/chat/connection-adapters.md b/docs/chat/connection-adapters.md index 0c4460b2b..cfdf22a32 100644 --- a/docs/chat/connection-adapters.md +++ b/docs/chat/connection-adapters.md @@ -81,6 +81,21 @@ const { messages } = useChat({ }); ``` +### JSON Array (non-streaming runtimes) + +For runtimes that can't emit `ReadableStream` responses — Expo / React Native, some edge proxies, certain legacy serverless runtimes — pair `fetchJSON` on the client with [`toJSONResponse`](../api/ai#tojsonresponsestream-init) on the server: + +```typescript +import { useChat } from "@tanstack/ai-react"; +import { fetchJSON } from "@tanstack/ai-client"; + +const { messages } = useChat({ + connection: fetchJSON("/api/chat"), +}); +``` + +The server drains the whole chat stream before responding, and this adapter replays each chunk into the normal `ChatClient` pipeline. Trade-off: no incremental rendering — the UI sees every chunk at once when the request resolves. See [React Native & Expo](./non-streaming-runtimes) for the full walkthrough. + ## Custom Adapters For specialized use cases, you can create custom adapters to meet specific protocols or requirements: diff --git a/docs/chat/non-streaming-runtimes.md b/docs/chat/non-streaming-runtimes.md new file mode 100644 index 000000000..32bb5c7f7 --- /dev/null +++ b/docs/chat/non-streaming-runtimes.md @@ -0,0 +1,95 @@ +--- +title: React Native & Expo +id: non-streaming-runtimes +order: 4 +description: "Run TanStack AI on React Native, Expo, and other runtimes that can't emit ReadableStream responses — using toJSONResponse on the server and fetchJSON on the client." +keywords: + - tanstack ai + - react native + - expo + - expo router + - metro bundler + - non-streaming + - toJSONResponse + - fetchJSON + - edge runtime +--- + +You have a React Native or Expo app and you want to add AI chat, but the usual `toServerSentEventsResponse()` helper crashes on Expo's server runtime with: + +``` +TypeError: Cannot read properties of undefined (reading 'statusText') +``` + +…and Metro refuses to resolve `@tanstack/ai/adapters` at all. By the end of this guide, you'll have a working chat flow on Expo/React Native using a JSON-array fallback path. The same approach works for any deployment target that can't stream `ReadableStream` responses (some edge proxies, legacy serverless runtimes, etc.). + +## What's actually going wrong + +Two separate problems show up on React Native / Expo: + +1. **Module resolution.** `@tanstack/ai` and `@tanstack/ai-client` ship dual ESM + CJS builds with `main`/`module`/`exports` all wired up. If your version is new enough, Metro resolves them out of the box. If you're stuck on an older version, upgrade — older releases were ESM-only and Metro can't consume them. + +2. **Response shape.** Expo's `@expo/server` runtime (and a few edge proxies) can't emit a `ReadableStream` body, which is what `toServerSentEventsResponse` and `toHttpResponse` return. The request silently fails on the client side and `isLoading` flips back to `false` immediately. + +The fix for (2) is to drain the chat stream on the server, send the collected chunks as a single JSON array, and replay them on the client. You lose incremental rendering — the UI sees every chunk at once when the request resolves — but every other piece of the chat pipeline keeps working as-is. + +## Step 1: Return a JSON-array response on the server + +Swap `toServerSentEventsResponse` for `toJSONResponse` in your API route. On Expo Router: + +```typescript +// app/api/chat+api.ts +import { chat, toJSONResponse } from "@tanstack/ai"; +import { openaiText } from "@tanstack/ai-openai"; + +export async function POST(request: Request) { + const { messages } = await request.json(); + + const stream = chat({ + adapter: openaiText("gpt-5.2"), + messages, + }); + + return toJSONResponse(stream); +} +``` + +`toJSONResponse` iterates the whole stream, collects each `StreamChunk` into an array, and returns a plain `Response` with `Content-Type: application/json`. It accepts the same `init` options as `toServerSentEventsResponse` (including `abortController`) and honours any `Content-Type` you pass in `headers`. + +## Step 2: Use `fetchJSON` as the connection adapter on the client + +Swap `fetchServerSentEvents` for `fetchJSON` in your `useChat` call: + +```typescript +import { useChat } from "@tanstack/ai-react"; +import { fetchJSON } from "@tanstack/ai-client"; + +export function ChatScreen() { + const { messages, sendMessage, isLoading } = useChat({ + connection: fetchJSON("/api/chat"), + }); + + // messages and isLoading behave identically to the streaming path — + // they just update all at once when the request resolves. + return ; +} +``` + +`fetchJSON` accepts the same `url` + `options` signature as the other connection adapters (static string or function, headers, credentials, custom `fetchClient`, extra body, abort signal). It POSTs the usual `{ messages, data }` body, decodes the response as a `StreamChunk[]`, and replays each chunk into the normal `ChatClient` pipeline — tool calls, approvals, thinking content, errors all behave the same way they do with SSE. + +## Step 3: Expect no incremental rendering + +The one thing you give up: the UI won't update character-by-character. The request hangs until the server finishes the whole run, then the full message — including tool calls, results, and the final assistant turn — appears at once. + +If this becomes a problem, the answer is to move to a runtime that supports streaming responses (Hono on Node, Next.js, TanStack Start, a real SSE endpoint proxied through a CDN that doesn't buffer) rather than to work around the limitation further. The JSON-array path is a pragmatic escape hatch, not the intended happy path. + +## Going back to streaming when you can + +If you later deploy your server code to a runtime that *does* support streaming, you only need to change two call sites — `toJSONResponse` → `toServerSentEventsResponse` and `fetchJSON` → `fetchServerSentEvents`. Everything downstream (messages, tool calls, approvals, `useChat` state, error handling) is identical between the two paths, so there's no cleanup to chase through the app. + +## Next Steps + +- [Streaming](./streaming) — the normal incremental-rendering path +- [Connection Adapters](./connection-adapters) — full list of client-side adapters, including `fetchJSON` +- [API Reference: `toJSONResponse`](../api/ai#tojsonresponsestream-init) — server-side helper reference +- [API Reference: `fetchJSON`](../api/ai-client#fetchjsonurl-options) — client-side adapter reference diff --git a/docs/chat/streaming.md b/docs/chat/streaming.md index a11bd2ca2..5a9a2afad 100644 --- a/docs/chat/streaming.md +++ b/docs/chat/streaming.md @@ -55,6 +55,8 @@ export async function POST(request: Request) { } ``` +> **Running on Expo, React Native, or another runtime that can't emit `ReadableStream` responses?** See [React Native & Expo](./non-streaming-runtimes) for the `toJSONResponse` + `fetchJSON` fallback pair. + ## Client-Side Streaming The `useChat` hook automatically handles streaming: diff --git a/docs/config.json b/docs/config.json index 698d18545..fb1357da6 100644 --- a/docs/config.json +++ b/docs/config.json @@ -92,6 +92,10 @@ "label": "Connection Adapters", "to": "chat/connection-adapters" }, + { + "label": "React Native & Expo", + "to": "chat/non-streaming-runtimes" + }, { "label": "Structured Outputs", "to": "chat/structured-outputs" From 3ab68713a6f9c33602f65737cd9d4a9c70b92054 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 24 Apr 2026 11:49:33 +0200 Subject: [PATCH 4/9] fix(ai, ai-client): honor abort signal in toJSONResponse + test fetchJSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CR findings: - toJSONResponse now checks `abortController.signal.aborted` on entry (throws the signal's reason without draining the upstream) and inside the drain loop (breaks early if aborted mid-stream), matching the semantics of toServerSentEventsStream and toHttpStream. Previously the signal was only consulted from the error-path catch handler, so a pre-aborted controller drained the full stream anyway and a mid-drain abort was silently ignored. - Add two new tests covering pre-abort (infinite stream never pulled) and mid-drain abort (bounded pulls after abort fires). - Add 8 fetchJSON tests covering happy path, non-2xx, non-array body with descriptive error, url-as-function, options-as-async-function, options.body merging, custom fetchClient override, and AbortSignal propagation — the adapter previously had zero direct test coverage. --- .../tests/connection-adapters.test.ts | 172 ++++++++++++++++++ .../typescript/ai/src/stream-to-response.ts | 19 ++ .../ai/tests/stream-to-response.test.ts | 48 +++++ 3 files changed, 239 insertions(+) diff --git a/packages/typescript/ai-client/tests/connection-adapters.test.ts b/packages/typescript/ai-client/tests/connection-adapters.test.ts index 60c36763a..7817299ed 100644 --- a/packages/typescript/ai-client/tests/connection-adapters.test.ts +++ b/packages/typescript/ai-client/tests/connection-adapters.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { fetchHttpStream, + fetchJSON, fetchServerSentEvents, normalizeConnectionAdapter, rpcStream, @@ -1026,4 +1027,175 @@ describe('connection-adapters', () => { ) }) }) + + describe('fetchJSON', () => { + const jsonOk = (payload: unknown, init: ResponseInit = { status: 200 }) => + ({ + ok: (init.status ?? 200) >= 200 && (init.status ?? 200) < 300, + status: init.status ?? 200, + statusText: init.statusText ?? 'OK', + json: async () => payload, + }) as unknown as Response + + it('drains a JSON array body into chunks', async () => { + const payload = [ + asChunk({ + type: 'RUN_STARTED', + runId: 'r1', + model: 'test', + timestamp: 1, + }), + asChunk({ + type: 'TEXT_MESSAGE_CONTENT', + messageId: 'm1', + model: 'test', + timestamp: 2, + delta: 'Hi', + content: 'Hi', + }), + asChunk({ + type: 'RUN_FINISHED', + runId: 'r1', + model: 'test', + timestamp: 3, + }), + ] + fetchMock.mockResolvedValue(jsonOk(payload)) + + const adapter = fetchJSON('/api/chat') + const chunks: Array = [] + for await (const chunk of adapter.connect([ + { role: 'user', content: 'Hi' }, + ])) { + chunks.push(chunk) + } + + expect(chunks).toEqual(payload) + }) + + it('throws on a non-2xx response', async () => { + fetchMock.mockResolvedValue( + jsonOk(null, { status: 500, statusText: 'Internal Server Error' }), + ) + + const adapter = fetchJSON('/api/chat') + + await expect(async () => { + for await (const _ of adapter.connect([ + { role: 'user', content: 'x' }, + ])) { + // drain + } + }).rejects.toThrow(/500/) + }) + + it('throws a descriptive error when response body is not an array', async () => { + fetchMock.mockResolvedValue(jsonOk({ message: 'not an array' })) + + const adapter = fetchJSON('/api/chat') + + await expect(async () => { + for await (const _ of adapter.connect([ + { role: 'user', content: 'x' }, + ])) { + // drain + } + }).rejects.toThrow(/toJSONResponse/) + }) + + it('resolves url-as-function at call time', async () => { + fetchMock.mockResolvedValue(jsonOk([])) + + const getUrl = vi.fn(() => '/api/dynamic') + const adapter = fetchJSON(getUrl) + for await (const _ of adapter.connect([ + { role: 'user', content: 'x' }, + ])) { + // drain + } + + expect(getUrl).toHaveBeenCalledOnce() + expect(fetchMock).toHaveBeenCalledWith( + '/api/dynamic', + expect.any(Object), + ) + }) + + it('resolves options-as-async-function at call time', async () => { + fetchMock.mockResolvedValue(jsonOk([])) + + const getOptions = vi.fn( + async () => + ({ + headers: { 'X-Custom': 'yes' }, + body: { runId: 'abc' }, + }) as const, + ) + const adapter = fetchJSON('/api/chat', getOptions) + for await (const _ of adapter.connect([ + { role: 'user', content: 'x' }, + ])) { + // drain + } + + expect(getOptions).toHaveBeenCalledOnce() + const [, init] = fetchMock.mock.calls[0]! + expect(init.headers).toMatchObject({ 'X-Custom': 'yes' }) + const parsed = JSON.parse(init.body as string) as { + runId?: string + } + expect(parsed.runId).toBe('abc') + }) + + it('merges options.body into the POST body', async () => { + fetchMock.mockResolvedValue(jsonOk([])) + + const adapter = fetchJSON('/api/chat', { body: { extra: 42 } }) + for await (const _ of adapter.connect( + [{ role: 'user', content: 'x' }], + { sessionId: 'sess' }, + )) { + // drain + } + + const [, init] = fetchMock.mock.calls[0]! + const body = JSON.parse(init.body as string) as Record + expect(body).toMatchObject({ + messages: expect.any(Array), + data: { sessionId: 'sess' }, + extra: 42, + }) + }) + + it('honors a custom fetchClient override', async () => { + const customFetch = vi.fn().mockResolvedValue(jsonOk([])) + + const adapter = fetchJSON('/api/chat', { fetchClient: customFetch }) + for await (const _ of adapter.connect([ + { role: 'user', content: 'x' }, + ])) { + // drain + } + + expect(customFetch).toHaveBeenCalledOnce() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('propagates the abortSignal to fetch', async () => { + fetchMock.mockResolvedValue(jsonOk([])) + const controller = new AbortController() + + const adapter = fetchJSON('/api/chat') + for await (const _ of adapter.connect( + [{ role: 'user', content: 'x' }], + undefined, + controller.signal, + )) { + // drain + } + + const [, init] = fetchMock.mock.calls[0]! + expect(init.signal).toBe(controller.signal) + }) + }) }) diff --git a/packages/typescript/ai/src/stream-to-response.ts b/packages/typescript/ai/src/stream-to-response.ts index 38a280324..7076a59ee 100644 --- a/packages/typescript/ai/src/stream-to-response.ts +++ b/packages/typescript/ai/src/stream-to-response.ts @@ -285,9 +285,28 @@ export async function toJSONResponse( init?: ResponseInit & { abortController?: AbortController }, ): Promise { const { abortController, headers, ...rest } = init ?? {} + + // Honor a pre-aborted signal: don't drain the stream at all, throw the + // caller's abort reason (or a synthesized AbortError) so behavior matches + // the SSE / HTTP-stream variants, which both short-circuit on aborted. + if (abortController?.signal.aborted) { + throw ( + abortController.signal.reason ?? + new DOMException('The operation was aborted', 'AbortError') + ) + } + const chunks: Array = [] try { for await (const chunk of stream) { + // Honor mid-drain abort: break out early rather than over-draining an + // upstream that may not itself honor the signal. + if (abortController?.signal.aborted) { + throw ( + abortController.signal.reason ?? + new DOMException('The operation was aborted', 'AbortError') + ) + } chunks.push(chunk) } } catch (error) { diff --git a/packages/typescript/ai/tests/stream-to-response.test.ts b/packages/typescript/ai/tests/stream-to-response.test.ts index d08c677ea..a6fe0995c 100644 --- a/packages/typescript/ai/tests/stream-to-response.test.ts +++ b/packages/typescript/ai/tests/stream-to-response.test.ts @@ -942,4 +942,52 @@ describe('toJSONResponse', () => { ).rejects.toThrow('upstream failure') expect(abortSpy).toHaveBeenCalledOnce() }) + + it('throws immediately without draining when abortController is pre-aborted', async () => { + const abortController = new AbortController() + abortController.abort() + + let pulled = 0 + async function* infinite(): AsyncGenerator { + while (true) { + pulled++ + yield { + type: 'RUN_STARTED', + runId: 'r1', + model: 'test', + timestamp: 1, + } as StreamChunk + } + } + + await expect( + toJSONResponse(infinite(), { abortController }), + ).rejects.toThrow() + expect(pulled).toBe(0) + }) + + it('stops draining and throws when aborted mid-stream', async () => { + const abortController = new AbortController() + let pulled = 0 + async function* slow(): AsyncGenerator { + while (true) { + pulled++ + yield { + type: 'RUN_STARTED', + runId: `r${pulled}`, + model: 'test', + timestamp: pulled, + } as StreamChunk + if (pulled === 2) abortController.abort() + // Let the microtask queue flush so the signal is observed next iter. + await Promise.resolve() + } + } + + await expect( + toJSONResponse(slow(), { abortController }), + ).rejects.toThrow() + // Bounded: should not have pulled an unbounded number of items after abort. + expect(pulled).toBeLessThan(10) + }) }) From 43e59bba49803bb85c211edaecc96cf5fe98afe4 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:50:38 +0000 Subject: [PATCH 5/9] ci: apply automated fixes --- .../tests/connection-adapters.test.ts | 24 ++++++------------- .../ai/tests/stream-to-response.test.ts | 4 +--- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/packages/typescript/ai-client/tests/connection-adapters.test.ts b/packages/typescript/ai-client/tests/connection-adapters.test.ts index 7817299ed..6ee9edb18 100644 --- a/packages/typescript/ai-client/tests/connection-adapters.test.ts +++ b/packages/typescript/ai-client/tests/connection-adapters.test.ts @@ -1108,17 +1108,12 @@ describe('connection-adapters', () => { const getUrl = vi.fn(() => '/api/dynamic') const adapter = fetchJSON(getUrl) - for await (const _ of adapter.connect([ - { role: 'user', content: 'x' }, - ])) { + for await (const _ of adapter.connect([{ role: 'user', content: 'x' }])) { // drain } expect(getUrl).toHaveBeenCalledOnce() - expect(fetchMock).toHaveBeenCalledWith( - '/api/dynamic', - expect.any(Object), - ) + expect(fetchMock).toHaveBeenCalledWith('/api/dynamic', expect.any(Object)) }) it('resolves options-as-async-function at call time', async () => { @@ -1132,9 +1127,7 @@ describe('connection-adapters', () => { }) as const, ) const adapter = fetchJSON('/api/chat', getOptions) - for await (const _ of adapter.connect([ - { role: 'user', content: 'x' }, - ])) { + for await (const _ of adapter.connect([{ role: 'user', content: 'x' }])) { // drain } @@ -1151,10 +1144,9 @@ describe('connection-adapters', () => { fetchMock.mockResolvedValue(jsonOk([])) const adapter = fetchJSON('/api/chat', { body: { extra: 42 } }) - for await (const _ of adapter.connect( - [{ role: 'user', content: 'x' }], - { sessionId: 'sess' }, - )) { + for await (const _ of adapter.connect([{ role: 'user', content: 'x' }], { + sessionId: 'sess', + })) { // drain } @@ -1171,9 +1163,7 @@ describe('connection-adapters', () => { const customFetch = vi.fn().mockResolvedValue(jsonOk([])) const adapter = fetchJSON('/api/chat', { fetchClient: customFetch }) - for await (const _ of adapter.connect([ - { role: 'user', content: 'x' }, - ])) { + for await (const _ of adapter.connect([{ role: 'user', content: 'x' }])) { // drain } diff --git a/packages/typescript/ai/tests/stream-to-response.test.ts b/packages/typescript/ai/tests/stream-to-response.test.ts index a6fe0995c..8c304aebf 100644 --- a/packages/typescript/ai/tests/stream-to-response.test.ts +++ b/packages/typescript/ai/tests/stream-to-response.test.ts @@ -984,9 +984,7 @@ describe('toJSONResponse', () => { } } - await expect( - toJSONResponse(slow(), { abortController }), - ).rejects.toThrow() + await expect(toJSONResponse(slow(), { abortController })).rejects.toThrow() // Bounded: should not have pulled an unbounded number of items after abort. expect(pulled).toBeLessThan(10) }) From feee9ab91f5d50cda5e98e7ae63948f36e3e5390 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 8 May 2026 11:41:19 +0200 Subject: [PATCH 6/9] =?UTF-8?q?fix(ai-client):=20address=20PR=20#478=20rev?= =?UTF-8?q?iew=20=E2=80=94=20fetchJSON=20error=20handling,=20abort,=20e2e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on the toJSONResponse / fetchJSON pair: - fetchJSON now bails out of the chunk replay loop when abortSignal fires mid-replay so a late consumer abort stops the buffered payload from being pushed into an abandoned pipeline. - Wrap response.json() in try/catch so non-JSON bodies (HTML gateway error pages, etc.) produce an actionable error citing URL, status, and cause instead of "Unexpected token < in JSON at position 0". - On non-2xx responses, read the body (truncated to 500 chars) and include it in the thrown error so upstream diagnostic info (OpenAI/Anthropic rate-limit JSON, gateway snippets) survives. - Add Playwright e2e spec covering the toJSONResponse → fetchJSON roundtrip via a new mode=json on the chat feature backed by /api/chat-json. - Re-export fetchJSON from ai-react / ai-preact / ai-solid / ai-vue / ai-svelte for parity with fetchHttpStream / fetchServerSentEvents. - Expose ./package.json in exports for ai, ai-client, ai-event-client so tooling that probes the manifest isn't blocked by a closed export map. - Lock in toJSONResponse not aborting the controller on a successful drain. --- .changeset/cjs-output-and-json-response.md | 5 + packages/typescript/ai-client/package.json | 3 +- .../ai-client/src/connection-adapters.ts | 31 +++- .../tests/connection-adapters.test.ts | 159 ++++++++++++++++++ .../typescript/ai-event-client/package.json | 3 +- packages/typescript/ai-preact/src/index.ts | 1 + packages/typescript/ai-react/src/index.ts | 1 + packages/typescript/ai-solid/src/index.ts | 1 + packages/typescript/ai-svelte/src/index.ts | 1 + packages/typescript/ai-vue/src/index.ts | 1 + packages/typescript/ai/package.json | 3 +- .../ai/tests/stream-to-response.test.ts | 26 +++ testing/e2e/src/lib/types.ts | 2 +- testing/e2e/src/routeTree.gen.ts | 21 +++ testing/e2e/src/routes/$provider/$feature.tsx | 13 +- testing/e2e/src/routes/$provider/index.tsx | 6 +- testing/e2e/src/routes/api.chat-json.ts | 67 ++++++++ testing/e2e/tests/chat-json.spec.ts | 32 ++++ 18 files changed, 366 insertions(+), 10 deletions(-) create mode 100644 testing/e2e/src/routes/api.chat-json.ts create mode 100644 testing/e2e/tests/chat-json.spec.ts diff --git a/.changeset/cjs-output-and-json-response.md b/.changeset/cjs-output-and-json-response.md index 389033ca4..b6af3a79f 100644 --- a/.changeset/cjs-output-and-json-response.md +++ b/.changeset/cjs-output-and-json-response.md @@ -2,6 +2,11 @@ '@tanstack/ai': minor '@tanstack/ai-client': minor '@tanstack/ai-event-client': patch +'@tanstack/ai-react': patch +'@tanstack/ai-preact': patch +'@tanstack/ai-solid': patch +'@tanstack/ai-vue': patch +'@tanstack/ai-svelte': patch --- **Dual ESM + CJS output.** `@tanstack/ai`, `@tanstack/ai-client`, and `@tanstack/ai-event-client` now ship both ESM and CJS builds with type-aware dual `exports` maps (`import` → `./dist/esm/*.js`, `require` → `./dist/cjs/*.cjs`), plus a `main` field pointing at CJS. Fixes Metro / Expo / CJS-only resolvers that previously couldn't find `@tanstack/ai/adapters` or `@tanstack/ai-client` because the packages were ESM-only (#308). diff --git a/packages/typescript/ai-client/package.json b/packages/typescript/ai-client/package.json index 4a4795850..e8b4c7f9f 100644 --- a/packages/typescript/ai-client/package.json +++ b/packages/typescript/ai-client/package.json @@ -31,7 +31,8 @@ "types": "./dist/cjs/index.d.cts", "default": "./dist/cjs/index.cjs" } - } + }, + "./package.json": "./package.json" }, "files": [ "dist", diff --git a/packages/typescript/ai-client/src/connection-adapters.ts b/packages/typescript/ai-client/src/connection-adapters.ts index a7bd32673..6fb57bdcb 100644 --- a/packages/typescript/ai-client/src/connection-adapters.ts +++ b/packages/typescript/ai-client/src/connection-adapters.ts @@ -481,18 +481,45 @@ export function fetchJSON( }) if (!response.ok) { + // Surface the response body so upstream diagnostic info (e.g. an + // OpenAI/Anthropic rate-limit JSON, a gateway HTML error page) isn't + // hidden behind a generic "status 500" message. + let bodySnippet = '' + try { + const text = await response.text() + bodySnippet = text.length > 500 ? `${text.slice(0, 500)}…` : text + } catch { + // body unreadable, fall through with status only + } throw new Error( - `HTTP error! status: ${response.status} ${response.statusText}`, + `HTTP error! status: ${response.status} ${response.statusText}${ + bodySnippet ? ` — ${bodySnippet}` : '' + }`, ) } - const payload = (await response.json()) as unknown + // Wrap JSON parsing so a non-JSON body (e.g. HTML gateway error page) + // produces an actionable error instead of "Unexpected token < in JSON". + let payload: unknown + try { + payload = await response.json() + } catch (err) { + const cause = err instanceof Error ? err.message : String(err) + throw new Error( + `fetchJSON: failed to parse response body as JSON from ${resolvedUrl} (status ${response.status}): ${cause}`, + { cause: err }, + ) + } if (!Array.isArray(payload)) { throw new Error( 'fetchJSON: expected response body to be a JSON array of StreamChunks. Did you forget to use `toJSONResponse(stream)` on the server?', ) } for (const chunk of payload) { + // Honor late aborts: the payload is fully buffered, so the consumer + // may have torn down before we drained — bail rather than push more + // chunks into an abandoned pipeline. + if (abortSignal?.aborted) return yield chunk as StreamChunk } }, diff --git a/packages/typescript/ai-client/tests/connection-adapters.test.ts b/packages/typescript/ai-client/tests/connection-adapters.test.ts index 6ee9edb18..0bb14bfa2 100644 --- a/packages/typescript/ai-client/tests/connection-adapters.test.ts +++ b/packages/typescript/ai-client/tests/connection-adapters.test.ts @@ -1035,6 +1035,22 @@ describe('connection-adapters', () => { status: init.status ?? 200, statusText: init.statusText ?? 'OK', json: async () => payload, + text: async () => + typeof payload === 'string' ? payload : JSON.stringify(payload), + }) as unknown as Response + + const errorResponse = ( + body: string, + init: ResponseInit = { status: 500, statusText: 'Internal Server Error' }, + ) => + ({ + ok: false, + status: init.status ?? 500, + statusText: init.statusText ?? 'Internal Server Error', + text: async () => body, + json: async () => { + throw new SyntaxError('Unexpected token < in JSON at position 0') + }, }) as unknown as Response it('drains a JSON array body into chunks', async () => { @@ -1187,5 +1203,148 @@ describe('connection-adapters', () => { const [, init] = fetchMock.mock.calls[0]! expect(init.signal).toBe(controller.signal) }) + + it('stops yielding chunks once the abortSignal fires mid-replay', async () => { + const payload = [ + asChunk({ type: 'RUN_STARTED', runId: 'r1', model: 't', timestamp: 1 }), + asChunk({ + type: 'TEXT_MESSAGE_CONTENT', + messageId: 'm1', + model: 't', + timestamp: 2, + delta: 'A', + content: 'A', + }), + asChunk({ + type: 'TEXT_MESSAGE_CONTENT', + messageId: 'm1', + model: 't', + timestamp: 3, + delta: 'B', + content: 'B', + }), + asChunk({ + type: 'RUN_FINISHED', + runId: 'r1', + model: 't', + timestamp: 4, + }), + ] + fetchMock.mockResolvedValue(jsonOk(payload)) + + const controller = new AbortController() + const adapter = fetchJSON('/api/chat') + const seen: Array = [] + for await (const chunk of adapter.connect( + [{ role: 'user', content: 'x' }], + undefined, + controller.signal, + )) { + seen.push(chunk) + if (seen.length === 2) controller.abort() + } + + // Two chunks consumed before abort, then loop bails — last two never + // surface to the consumer. + expect(seen).toHaveLength(2) + expect(seen[0]).toMatchObject({ type: 'RUN_STARTED' }) + expect(seen[1]).toMatchObject({ type: 'TEXT_MESSAGE_CONTENT' }) + }) + + it('throws a descriptive error when the response body is not JSON', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => { + throw new SyntaxError('Unexpected token < in JSON at position 0') + }, + } as unknown as Response) + + const adapter = fetchJSON('/api/chat') + + await expect(async () => { + for await (const _ of adapter.connect([ + { role: 'user', content: 'x' }, + ])) { + // drain + } + }).rejects.toThrow(/failed to parse response body as JSON/) + }) + + it('includes the response body snippet in the HTTP error message', async () => { + const errorBody = JSON.stringify({ + error: { + type: 'rate_limit_error', + message: 'Rate limit exceeded for upstream', + }, + }) + fetchMock.mockResolvedValue( + errorResponse(errorBody, { + status: 429, + statusText: 'Too Many Requests', + }), + ) + + const adapter = fetchJSON('/api/chat') + + await expect(async () => { + for await (const _ of adapter.connect([ + { role: 'user', content: 'x' }, + ])) { + // drain + } + }).rejects.toThrow(/429.*rate_limit_error/) + }) + + it('truncates oversized response bodies in the HTTP error message', async () => { + const long = 'x'.repeat(2000) + fetchMock.mockResolvedValue( + errorResponse(long, { status: 502, statusText: 'Bad Gateway' }), + ) + + const adapter = fetchJSON('/api/chat') + + let captured: Error | undefined + try { + for await (const _ of adapter.connect([ + { role: 'user', content: 'x' }, + ])) { + // drain + } + } catch (err) { + captured = err as Error + } + + expect(captured).toBeDefined() + // 500-char snippet plus a single ellipsis character — keeps logs sane. + expect(captured!.message).toMatch(/502/) + expect(captured!.message).toContain('…') + expect(captured!.message.length).toBeLessThan(700) + }) + + it('falls back to status-only when the error body is unreadable', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + text: async () => { + throw new Error('body stream already read') + }, + json: async () => { + throw new Error('not reached') + }, + } as unknown as Response) + + const adapter = fetchJSON('/api/chat') + + await expect(async () => { + for await (const _ of adapter.connect([ + { role: 'user', content: 'x' }, + ])) { + // drain + } + }).rejects.toThrow(/503 Service Unavailable/) + }) }) }) diff --git a/packages/typescript/ai-event-client/package.json b/packages/typescript/ai-event-client/package.json index 61505432d..3ccb0f3b0 100644 --- a/packages/typescript/ai-event-client/package.json +++ b/packages/typescript/ai-event-client/package.json @@ -23,7 +23,8 @@ "types": "./dist/cjs/index.d.cts", "default": "./dist/cjs/index.cjs" } - } + }, + "./package.json": "./package.json" }, "sideEffects": false, "files": [ diff --git a/packages/typescript/ai-preact/src/index.ts b/packages/typescript/ai-preact/src/index.ts index 763df52ef..1503d2494 100644 --- a/packages/typescript/ai-preact/src/index.ts +++ b/packages/typescript/ai-preact/src/index.ts @@ -9,6 +9,7 @@ export type { export { fetchServerSentEvents, fetchHttpStream, + fetchJSON, stream, createChatClientOptions, type ConnectionAdapter, diff --git a/packages/typescript/ai-react/src/index.ts b/packages/typescript/ai-react/src/index.ts index 5ce8c9911..4e95952c1 100644 --- a/packages/typescript/ai-react/src/index.ts +++ b/packages/typescript/ai-react/src/index.ts @@ -55,6 +55,7 @@ export type { export { fetchServerSentEvents, fetchHttpStream, + fetchJSON, stream, createChatClientOptions, type ConnectionAdapter, diff --git a/packages/typescript/ai-solid/src/index.ts b/packages/typescript/ai-solid/src/index.ts index 6c60da993..c91a39170 100644 --- a/packages/typescript/ai-solid/src/index.ts +++ b/packages/typescript/ai-solid/src/index.ts @@ -50,6 +50,7 @@ export type { export { fetchServerSentEvents, fetchHttpStream, + fetchJSON, stream, createChatClientOptions, type ConnectionAdapter, diff --git a/packages/typescript/ai-svelte/src/index.ts b/packages/typescript/ai-svelte/src/index.ts index b16482739..3658cae62 100644 --- a/packages/typescript/ai-svelte/src/index.ts +++ b/packages/typescript/ai-svelte/src/index.ts @@ -53,6 +53,7 @@ export type { export { fetchServerSentEvents, fetchHttpStream, + fetchJSON, stream, createChatClientOptions, clientTools, diff --git a/packages/typescript/ai-vue/src/index.ts b/packages/typescript/ai-vue/src/index.ts index 6c60da993..c91a39170 100644 --- a/packages/typescript/ai-vue/src/index.ts +++ b/packages/typescript/ai-vue/src/index.ts @@ -50,6 +50,7 @@ export type { export { fetchServerSentEvents, fetchHttpStream, + fetchJSON, stream, createChatClientOptions, type ConnectionAdapter, diff --git a/packages/typescript/ai/package.json b/packages/typescript/ai/package.json index 1511f594c..2746a971f 100644 --- a/packages/typescript/ai/package.json +++ b/packages/typescript/ai/package.json @@ -57,7 +57,8 @@ "types": "./dist/cjs/adapter-internals.d.cts", "default": "./dist/cjs/adapter-internals.cjs" } - } + }, + "./package.json": "./package.json" }, "sideEffects": false, "engines": { diff --git a/packages/typescript/ai/tests/stream-to-response.test.ts b/packages/typescript/ai/tests/stream-to-response.test.ts index 8c304aebf..fb45bb858 100644 --- a/packages/typescript/ai/tests/stream-to-response.test.ts +++ b/packages/typescript/ai/tests/stream-to-response.test.ts @@ -924,6 +924,32 @@ describe('toJSONResponse', () => { ) }) + it('does not abort the supplied controller when the stream drains successfully', async () => { + const abortController = new AbortController() + const abortSpy = vi.spyOn(abortController, 'abort') + + const chunks: Array> = [ + { type: 'RUN_STARTED', runId: 'r1', model: 'test', timestamp: 1 }, + { + type: 'TEXT_MESSAGE_CONTENT', + messageId: 'm1', + model: 'test', + timestamp: 2, + delta: 'ok', + content: 'ok', + }, + { type: 'RUN_FINISHED', runId: 'r1', model: 'test', timestamp: 3 }, + ] + + const response = await toJSONResponse(createMockStream(chunks), { + abortController, + }) + + expect(await response.json()).toEqual(chunks) + expect(abortSpy).not.toHaveBeenCalled() + expect(abortController.signal.aborted).toBe(false) + }) + it('aborts the supplied controller and rethrows if the upstream errors', async () => { const abortController = new AbortController() const abortSpy = vi.spyOn(abortController, 'abort') diff --git a/testing/e2e/src/lib/types.ts b/testing/e2e/src/lib/types.ts index eafe588fc..4785b5562 100644 --- a/testing/e2e/src/lib/types.ts +++ b/testing/e2e/src/lib/types.ts @@ -1,4 +1,4 @@ -export type Mode = 'sse' | 'http-stream' | 'fetcher' +export type Mode = 'sse' | 'http-stream' | 'fetcher' | 'json' export type Provider = | 'openai' diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 0563de17f..4a34e9afc 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as ApiToolsTestRouteImport } from './routes/api.tools-test' import { Route as ApiSummarizeRouteImport } from './routes/api.summarize' import { Route as ApiMiddlewareTestRouteImport } from './routes/api.middleware-test' import { Route as ApiImageRouteImport } from './routes/api.image' +import { Route as ApiChatJsonRouteImport } from './routes/api.chat-json' import { Route as ApiChatRouteImport } from './routes/api.chat' import { Route as ApiAudioRouteImport } from './routes/api.audio' import { Route as ProviderFeatureRouteImport } from './routes/$provider/$feature' @@ -84,6 +85,11 @@ const ApiImageRoute = ApiImageRouteImport.update({ path: '/api/image', getParentRoute: () => rootRouteImport, } as any) +const ApiChatJsonRoute = ApiChatJsonRouteImport.update({ + id: '/api/chat-json', + path: '/api/chat-json', + getParentRoute: () => rootRouteImport, +} as any) const ApiChatRoute = ApiChatRouteImport.update({ id: '/api/chat', path: '/api/chat', @@ -132,6 +138,7 @@ export interface FileRoutesByFullPath { '/$provider/$feature': typeof ProviderFeatureRoute '/api/audio': typeof ApiAudioRouteWithChildren '/api/chat': typeof ApiChatRoute + '/api/chat-json': typeof ApiChatJsonRoute '/api/image': typeof ApiImageRouteWithChildren '/api/middleware-test': typeof ApiMiddlewareTestRoute '/api/summarize': typeof ApiSummarizeRoute @@ -153,6 +160,7 @@ export interface FileRoutesByTo { '/$provider/$feature': typeof ProviderFeatureRoute '/api/audio': typeof ApiAudioRouteWithChildren '/api/chat': typeof ApiChatRoute + '/api/chat-json': typeof ApiChatJsonRoute '/api/image': typeof ApiImageRouteWithChildren '/api/middleware-test': typeof ApiMiddlewareTestRoute '/api/summarize': typeof ApiSummarizeRoute @@ -175,6 +183,7 @@ export interface FileRoutesById { '/$provider/$feature': typeof ProviderFeatureRoute '/api/audio': typeof ApiAudioRouteWithChildren '/api/chat': typeof ApiChatRoute + '/api/chat-json': typeof ApiChatJsonRoute '/api/image': typeof ApiImageRouteWithChildren '/api/middleware-test': typeof ApiMiddlewareTestRoute '/api/summarize': typeof ApiSummarizeRoute @@ -198,6 +207,7 @@ export interface FileRouteTypes { | '/$provider/$feature' | '/api/audio' | '/api/chat' + | '/api/chat-json' | '/api/image' | '/api/middleware-test' | '/api/summarize' @@ -219,6 +229,7 @@ export interface FileRouteTypes { | '/$provider/$feature' | '/api/audio' | '/api/chat' + | '/api/chat-json' | '/api/image' | '/api/middleware-test' | '/api/summarize' @@ -240,6 +251,7 @@ export interface FileRouteTypes { | '/$provider/$feature' | '/api/audio' | '/api/chat' + | '/api/chat-json' | '/api/image' | '/api/middleware-test' | '/api/summarize' @@ -262,6 +274,7 @@ export interface RootRouteChildren { ProviderFeatureRoute: typeof ProviderFeatureRoute ApiAudioRoute: typeof ApiAudioRouteWithChildren ApiChatRoute: typeof ApiChatRoute + ApiChatJsonRoute: typeof ApiChatJsonRoute ApiImageRoute: typeof ApiImageRouteWithChildren ApiMiddlewareTestRoute: typeof ApiMiddlewareTestRoute ApiSummarizeRoute: typeof ApiSummarizeRoute @@ -351,6 +364,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiImageRouteImport parentRoute: typeof rootRouteImport } + '/api/chat-json': { + id: '/api/chat-json' + path: '/api/chat-json' + fullPath: '/api/chat-json' + preLoaderRoute: typeof ApiChatJsonRouteImport + parentRoute: typeof rootRouteImport + } '/api/chat': { id: '/api/chat' path: '/api/chat' @@ -475,6 +495,7 @@ const rootRouteChildren: RootRouteChildren = { ProviderFeatureRoute: ProviderFeatureRoute, ApiAudioRoute: ApiAudioRouteWithChildren, ApiChatRoute: ApiChatRoute, + ApiChatJsonRoute: ApiChatJsonRoute, ApiImageRoute: ApiImageRouteWithChildren, ApiMiddlewareTestRoute: ApiMiddlewareTestRoute, ApiSummarizeRoute: ApiSummarizeRoute, diff --git a/testing/e2e/src/routes/$provider/$feature.tsx b/testing/e2e/src/routes/$provider/$feature.tsx index bf7d508dd..9c943e7a6 100644 --- a/testing/e2e/src/routes/$provider/$feature.tsx +++ b/testing/e2e/src/routes/$provider/$feature.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' -import { clientTools } from '@tanstack/ai-client' +import { clientTools, fetchJSON } from '@tanstack/ai-client' import type { Feature, Mode, Provider } from '@/lib/types' import { ALL_PROVIDERS } from '@/lib/types' import { isSupported } from '@/lib/feature-support' @@ -67,7 +67,7 @@ function FeaturePage() { ) } - return + return } function MediaFeature({ @@ -139,9 +139,11 @@ function MediaFeature({ function ChatFeature({ provider, feature, + mode, }: { provider: Provider feature: Feature + mode?: Mode }) { const needsApproval = feature === 'tool-approval' const showImageInput = @@ -151,9 +153,14 @@ function ChatFeature({ const { testId, aimockPort } = Route.useSearch() + // `mode=json` exercises the toJSONResponse → fetchJSON roundtrip used by + // non-streaming runtimes (Expo, certain edge proxies). Default stays SSE. + const connection = + mode === 'json' ? fetchJSON('/api/chat-json') : fetchServerSentEvents('/api/chat') + const { messages, sendMessage, isLoading, addToolApprovalResponse, stop } = useChat({ - connection: fetchServerSentEvents('/api/chat'), + connection, tools, body: { provider, feature, testId, aimockPort }, }) diff --git a/testing/e2e/src/routes/$provider/index.tsx b/testing/e2e/src/routes/$provider/index.tsx index e6376148e..fb1bdaa15 100644 --- a/testing/e2e/src/routes/$provider/index.tsx +++ b/testing/e2e/src/routes/$provider/index.tsx @@ -24,7 +24,11 @@ function ProviderPage() { key={feature} to="/$provider/$feature" params={{ provider, feature }} - search={{ testId: undefined, aimockPort: undefined }} + search={{ + testId: undefined, + aimockPort: undefined, + mode: undefined, + }} className="p-3 bg-gray-800/50 border border-gray-700 rounded-lg hover:border-orange-500/40 transition-colors text-center text-sm" > {feature} diff --git a/testing/e2e/src/routes/api.chat-json.ts b/testing/e2e/src/routes/api.chat-json.ts new file mode 100644 index 000000000..19880be45 --- /dev/null +++ b/testing/e2e/src/routes/api.chat-json.ts @@ -0,0 +1,67 @@ +import { createFileRoute } from '@tanstack/react-router' +import { chat, maxIterations, toJSONResponse } from '@tanstack/ai' +import type { Feature, Provider } from '@/lib/types' +import { createTextAdapter } from '@/lib/providers' +import { featureConfigs } from '@/lib/features' + +// Companion route for the SSE-based /api/chat that drains the chat stream +// fully and returns it as a single JSON array. Pairs with the `fetchJSON` +// connection adapter on the client to exercise the toJSONResponse → fetchJSON +// roundtrip used by non-streaming runtimes (Expo, certain edge proxies). +export const Route = createFileRoute('/api/chat-json')({ + server: { + handlers: { + POST: async ({ request }) => { + await import('@/lib/llmock-server').then((m) => m.ensureLLMock()) + if (request.signal.aborted) { + return new Response(null, { status: 499 }) + } + + const abortController = new AbortController() + const body = await request.json() + const { messages, data } = body + const provider: Provider = data?.provider || 'openai' + const feature: Feature = data?.feature || 'chat' + const testId: string | undefined = + typeof data?.testId === 'string' ? data.testId : undefined + const aimockPort: number | undefined = + data?.aimockPort != null ? Number(data.aimockPort) : undefined + + const config = featureConfigs[feature] + const modelOverride = config.modelOverrides?.[provider] + const adapterOptions = createTextAdapter( + provider, + modelOverride, + aimockPort, + testId, + ) + + try { + const stream = chat({ + ...adapterOptions, + tools: config.tools, + modelOptions: config.modelOptions, + systemPrompts: ['You are a helpful assistant for a guitar store.'], + agentLoopStrategy: maxIterations(5), + messages, + abortController, + }) + + return toJSONResponse(stream, { abortController }) + } catch (error: any) { + console.error(`[api.chat-json] Error:`, error.message) + if (error.name === 'AbortError' || abortController.signal.aborted) { + return new Response(null, { status: 499 }) + } + return new Response( + JSON.stringify({ error: error.message || 'An error occurred' }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + }, + }, + }, +}) diff --git a/testing/e2e/tests/chat-json.spec.ts b/testing/e2e/tests/chat-json.spec.ts new file mode 100644 index 000000000..eea6a0dcb --- /dev/null +++ b/testing/e2e/tests/chat-json.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from './fixtures' +import { + sendMessage, + waitForResponse, + getLastAssistantMessage, + featureUrl, +} from './helpers' +import { providersFor } from './test-matrix' + +// Roundtrip coverage for toJSONResponse (server) ↔ fetchJSON (client). +// We only need one provider to confirm the buffered transport delivers the +// same chunks as the streaming path — the unit tests already cover edge +// cases on each side independently. +const provider = providersFor('chat')[0]! + +test.describe(`${provider} — chat (toJSONResponse → fetchJSON roundtrip)`, () => { + test('drains the chat stream into JSON and replays it on the client', async ({ + page, + testId, + aimockPort, + }) => { + await page.goto(featureUrl(provider, 'chat', testId, aimockPort, 'json')) + + await sendMessage(page, '[chat] recommend a guitar') + await waitForResponse(page) + + const response = await getLastAssistantMessage(page) + // Same fixture content as the SSE chat spec — the transport is the only + // thing under test, so the assistant text should match exactly. + expect(response).toContain('Fender Stratocaster') + }) +}) From 1a26a98e356792078bfe352c0d09d90bff7bad88 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 09:47:55 +0000 Subject: [PATCH 7/9] ci: apply automated fixes --- testing/e2e/src/routes/$provider/$feature.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/testing/e2e/src/routes/$provider/$feature.tsx b/testing/e2e/src/routes/$provider/$feature.tsx index 9c943e7a6..7bf001b83 100644 --- a/testing/e2e/src/routes/$provider/$feature.tsx +++ b/testing/e2e/src/routes/$provider/$feature.tsx @@ -156,7 +156,9 @@ function ChatFeature({ // `mode=json` exercises the toJSONResponse → fetchJSON roundtrip used by // non-streaming runtimes (Expo, certain edge proxies). Default stays SSE. const connection = - mode === 'json' ? fetchJSON('/api/chat-json') : fetchServerSentEvents('/api/chat') + mode === 'json' + ? fetchJSON('/api/chat-json') + : fetchServerSentEvents('/api/chat') const { messages, sendMessage, isLoading, addToolApprovalResponse, stop } = useChat({ From ee6613cfe86cff406e28f069180c75ba6d069849 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 8 May 2026 12:05:35 +0200 Subject: [PATCH 8/9] feat(examples/ts-react-chat): showcase toJSONResponse + fetchJSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a sibling /tanchat-json route demonstrating the non-streaming chat transport for runtimes that can't emit ReadableStream responses (Expo's @expo/server, certain edge proxies). The server route mirrors api.tanchat.ts exactly except it returns toJSONResponse(stream) instead of toServerSentEventsResponse(stream); the page uses fetchJSON instead of fetchServerSentEvents and is otherwise a slim chat UI with a banner calling out the trade-off (no incremental rendering — the UI sees every chunk only after the request resolves). A "JSON mode" link in the existing chat header makes the showcase discoverable from the main demo. --- examples/ts-react-chat/src/routeTree.gen.ts | 42 +++++ .../src/routes/api.tanchat-json.ts | 163 ++++++++++++++++ examples/ts-react-chat/src/routes/index.tsx | 7 + .../ts-react-chat/src/routes/tanchat-json.tsx | 176 ++++++++++++++++++ 4 files changed, 388 insertions(+) create mode 100644 examples/ts-react-chat/src/routes/api.tanchat-json.ts create mode 100644 examples/ts-react-chat/src/routes/tanchat-json.tsx diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts index f9b2ac825..15623f631 100644 --- a/examples/ts-react-chat/src/routeTree.gen.ts +++ b/examples/ts-react-chat/src/routeTree.gen.ts @@ -9,6 +9,7 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as TanchatJsonRouteImport } from './routes/tanchat-json' import { Route as RealtimeRouteImport } from './routes/realtime' import { Route as ImageGenRouteImport } from './routes/image-gen' import { Route as IndexRouteImport } from './routes/index' @@ -20,6 +21,7 @@ import { Route as GenerationsSpeechRouteImport } from './routes/generations.spee import { Route as GenerationsImageRouteImport } from './routes/generations.image' import { Route as GenerationsAudioRouteImport } from './routes/generations.audio' import { Route as ApiTranscribeRouteImport } from './routes/api.transcribe' +import { Route as ApiTanchatJsonRouteImport } from './routes/api.tanchat-json' import { Route as ApiTanchatRouteImport } from './routes/api.tanchat' import { Route as ApiSummarizeRouteImport } from './routes/api.summarize' import { Route as ApiStructuredOutputRouteImport } from './routes/api.structured-output' @@ -31,6 +33,11 @@ import { Route as ApiGenerateSpeechRouteImport } from './routes/api.generate.spe import { Route as ApiGenerateImageRouteImport } from './routes/api.generate.image' import { Route as ApiGenerateAudioRouteImport } from './routes/api.generate.audio' +const TanchatJsonRoute = TanchatJsonRouteImport.update({ + id: '/tanchat-json', + path: '/tanchat-json', + getParentRoute: () => rootRouteImport, +} as any) const RealtimeRoute = RealtimeRouteImport.update({ id: '/realtime', path: '/realtime', @@ -88,6 +95,11 @@ const ApiTranscribeRoute = ApiTranscribeRouteImport.update({ path: '/api/transcribe', getParentRoute: () => rootRouteImport, } as any) +const ApiTanchatJsonRoute = ApiTanchatJsonRouteImport.update({ + id: '/api/tanchat-json', + path: '/api/tanchat-json', + getParentRoute: () => rootRouteImport, +} as any) const ApiTanchatRoute = ApiTanchatRouteImport.update({ id: '/api/tanchat', path: '/api/tanchat', @@ -143,10 +155,12 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/image-gen': typeof ImageGenRoute '/realtime': typeof RealtimeRoute + '/tanchat-json': typeof TanchatJsonRoute '/api/image-gen': typeof ApiImageGenRoute '/api/structured-output': typeof ApiStructuredOutputRoute '/api/summarize': typeof ApiSummarizeRoute '/api/tanchat': typeof ApiTanchatRoute + '/api/tanchat-json': typeof ApiTanchatJsonRoute '/api/transcribe': typeof ApiTranscribeRoute '/generations/audio': typeof GenerationsAudioRoute '/generations/image': typeof GenerationsImageRoute @@ -166,10 +180,12 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/image-gen': typeof ImageGenRoute '/realtime': typeof RealtimeRoute + '/tanchat-json': typeof TanchatJsonRoute '/api/image-gen': typeof ApiImageGenRoute '/api/structured-output': typeof ApiStructuredOutputRoute '/api/summarize': typeof ApiSummarizeRoute '/api/tanchat': typeof ApiTanchatRoute + '/api/tanchat-json': typeof ApiTanchatJsonRoute '/api/transcribe': typeof ApiTranscribeRoute '/generations/audio': typeof GenerationsAudioRoute '/generations/image': typeof GenerationsImageRoute @@ -190,10 +206,12 @@ export interface FileRoutesById { '/': typeof IndexRoute '/image-gen': typeof ImageGenRoute '/realtime': typeof RealtimeRoute + '/tanchat-json': typeof TanchatJsonRoute '/api/image-gen': typeof ApiImageGenRoute '/api/structured-output': typeof ApiStructuredOutputRoute '/api/summarize': typeof ApiSummarizeRoute '/api/tanchat': typeof ApiTanchatRoute + '/api/tanchat-json': typeof ApiTanchatJsonRoute '/api/transcribe': typeof ApiTranscribeRoute '/generations/audio': typeof GenerationsAudioRoute '/generations/image': typeof GenerationsImageRoute @@ -215,10 +233,12 @@ export interface FileRouteTypes { | '/' | '/image-gen' | '/realtime' + | '/tanchat-json' | '/api/image-gen' | '/api/structured-output' | '/api/summarize' | '/api/tanchat' + | '/api/tanchat-json' | '/api/transcribe' | '/generations/audio' | '/generations/image' @@ -238,10 +258,12 @@ export interface FileRouteTypes { | '/' | '/image-gen' | '/realtime' + | '/tanchat-json' | '/api/image-gen' | '/api/structured-output' | '/api/summarize' | '/api/tanchat' + | '/api/tanchat-json' | '/api/transcribe' | '/generations/audio' | '/generations/image' @@ -261,10 +283,12 @@ export interface FileRouteTypes { | '/' | '/image-gen' | '/realtime' + | '/tanchat-json' | '/api/image-gen' | '/api/structured-output' | '/api/summarize' | '/api/tanchat' + | '/api/tanchat-json' | '/api/transcribe' | '/generations/audio' | '/generations/image' @@ -285,10 +309,12 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute ImageGenRoute: typeof ImageGenRoute RealtimeRoute: typeof RealtimeRoute + TanchatJsonRoute: typeof TanchatJsonRoute ApiImageGenRoute: typeof ApiImageGenRoute ApiStructuredOutputRoute: typeof ApiStructuredOutputRoute ApiSummarizeRoute: typeof ApiSummarizeRoute ApiTanchatRoute: typeof ApiTanchatRoute + ApiTanchatJsonRoute: typeof ApiTanchatJsonRoute ApiTranscribeRoute: typeof ApiTranscribeRoute GenerationsAudioRoute: typeof GenerationsAudioRoute GenerationsImageRoute: typeof GenerationsImageRoute @@ -307,6 +333,13 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/tanchat-json': { + id: '/tanchat-json' + path: '/tanchat-json' + fullPath: '/tanchat-json' + preLoaderRoute: typeof TanchatJsonRouteImport + parentRoute: typeof rootRouteImport + } '/realtime': { id: '/realtime' path: '/realtime' @@ -384,6 +417,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiTranscribeRouteImport parentRoute: typeof rootRouteImport } + '/api/tanchat-json': { + id: '/api/tanchat-json' + path: '/api/tanchat-json' + fullPath: '/api/tanchat-json' + preLoaderRoute: typeof ApiTanchatJsonRouteImport + parentRoute: typeof rootRouteImport + } '/api/tanchat': { id: '/api/tanchat' path: '/api/tanchat' @@ -461,10 +501,12 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ImageGenRoute: ImageGenRoute, RealtimeRoute: RealtimeRoute, + TanchatJsonRoute: TanchatJsonRoute, ApiImageGenRoute: ApiImageGenRoute, ApiStructuredOutputRoute: ApiStructuredOutputRoute, ApiSummarizeRoute: ApiSummarizeRoute, ApiTanchatRoute: ApiTanchatRoute, + ApiTanchatJsonRoute: ApiTanchatJsonRoute, ApiTranscribeRoute: ApiTranscribeRoute, GenerationsAudioRoute: GenerationsAudioRoute, GenerationsImageRoute: GenerationsImageRoute, diff --git a/examples/ts-react-chat/src/routes/api.tanchat-json.ts b/examples/ts-react-chat/src/routes/api.tanchat-json.ts new file mode 100644 index 000000000..0a569fed3 --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.tanchat-json.ts @@ -0,0 +1,163 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + chat, + createChatOptions, + maxIterations, + toJSONResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { ollamaText } from '@tanstack/ai-ollama' +import { anthropicText } from '@tanstack/ai-anthropic' +import { geminiText } from '@tanstack/ai-gemini' +import { openRouterText } from '@tanstack/ai-openrouter' +import { grokText } from '@tanstack/ai-grok' +import { groqText } from '@tanstack/ai-groq' +import type { AnyTextAdapter } from '@tanstack/ai' +import { + addToCartToolDef, + addToWishListToolDef, + calculateFinancing, + compareGuitars, + getGuitars, + getPersonalGuitarPreferenceToolDef, + recommendGuitarToolDef, + searchGuitars, +} from '@/lib/guitar-tools' + +// Companion to /api/tanchat that returns the full chat as a single JSON +// array via toJSONResponse(stream). Use this when the target runtime can't +// emit a streaming Response — e.g. Expo's @expo/server, certain Cloudflare +// or edge proxy setups. Pair with fetchJSON('/api/tanchat-json') on the +// client. Trade-off: the UI sees nothing until the request resolves. + +type Provider = + | 'openai' + | 'anthropic' + | 'gemini' + | 'ollama' + | 'grok' + | 'groq' + | 'openrouter' + +const SYSTEM_PROMPT = `You are a helpful assistant for a guitar store. + +When a user asks for a guitar recommendation: +1. FIRST: Use the getGuitars tool (no parameters needed) +2. SECOND: Use the recommendGuitar tool with the ID of the guitar you want to recommend +3. NEVER write a recommendation directly — ALWAYS use the recommendGuitar tool +` + +const addToCartToolServer = addToCartToolDef.server((args) => ({ + success: true, + cartId: 'CART_' + Date.now(), + guitarId: args.guitarId, + quantity: args.quantity, + totalItems: args.quantity, +})) + +export const Route = createFileRoute('/api/tanchat-json')({ + server: { + handlers: { + POST: async ({ request }) => { + if (request.signal.aborted) { + return new Response(null, { status: 499 }) + } + + const abortController = new AbortController() + const body = await request.json() + const { messages, data } = body + + const provider: Provider = data?.provider || 'openai' + const model: string = data?.model || 'gpt-4o' + const conversationId: string | undefined = data?.conversationId + + const adapterConfig: Record< + Provider, + () => { adapter: AnyTextAdapter } + > = { + anthropic: () => + createChatOptions({ + adapter: anthropicText( + (model || 'claude-sonnet-4-5') as 'claude-sonnet-4-5', + ), + }), + openrouter: () => + createChatOptions({ + adapter: openRouterText('openai/gpt-5.1'), + }), + gemini: () => + createChatOptions({ + adapter: geminiText( + (model || 'gemini-2.5-flash') as 'gemini-2.5-flash', + ), + }), + grok: () => + createChatOptions({ + adapter: grokText((model || 'grok-3') as 'grok-3'), + }), + groq: () => + createChatOptions({ + adapter: groqText( + (model || + 'llama-3.3-70b-versatile') as 'llama-3.3-70b-versatile', + ), + }), + ollama: () => + createChatOptions({ + adapter: ollamaText((model || 'gpt-oss:120b') as 'gpt-oss:120b'), + }), + openai: () => + createChatOptions({ + adapter: openaiText((model || 'gpt-4o') as 'gpt-4o'), + }), + } + + try { + const options = adapterConfig[provider]() + + const stream = chat({ + ...options, + tools: [ + getGuitars, + recommendGuitarToolDef, + addToCartToolServer, + addToWishListToolDef, + getPersonalGuitarPreferenceToolDef, + compareGuitars, + calculateFinancing, + searchGuitars, + ], + systemPrompts: [SYSTEM_PROMPT], + agentLoopStrategy: maxIterations(20), + messages, + abortController, + conversationId, + }) + + // The only difference from /api/tanchat: the entire stream is + // drained and serialised as a JSON array instead of an SSE stream. + return toJSONResponse(stream, { abortController }) + } catch (error: any) { + console.error('[api.tanchat-json] Error in chat request:', { + message: error?.message, + name: error?.name, + status: error?.status, + stack: error?.stack, + }) + if (error.name === 'AbortError' || abortController.signal.aborted) { + return new Response(null, { status: 499 }) + } + return new Response( + JSON.stringify({ + error: error.message || 'An error occurred', + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + }, + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/index.tsx b/examples/ts-react-chat/src/routes/index.tsx index c91dbd746..789510b92 100644 --- a/examples/ts-react-chat/src/routes/index.tsx +++ b/examples/ts-react-chat/src/routes/index.tsx @@ -512,6 +512,13 @@ function ChatPage() { ))} + + JSON mode + }) { + if (messages.length === 0) { + return ( +
+
+

+ Send a message — the response will arrive in one shot when the + server finishes draining the stream. +

+
+
+ ) + } + + return ( +
+ {messages.map((message) => ( +
+
+
+ {message.role === 'assistant' ? 'AI' : 'U'} +
+
+ {message.parts.map((part, index) => { + if (part.type === 'text' && part.content) { + return ( + {part.content} + ) + } + return null + })} +
+
+
+ ))} +
+ ) +} + +function TanChatJsonPage() { + const [input, setInput] = useState('') + const textareaRef = useRef(null) + + const { messages, sendMessage, isLoading, error, stop } = useChat({ + connection: fetchJSON('/api/tanchat-json'), + body: { provider: 'openai', model: 'gpt-4o' }, + }) + + const handleSend = () => { + if (!input.trim()) return + sendMessage(input.trim()) + setInput('') + if (textareaRef.current) textareaRef.current.style.height = 'auto' + } + + return ( +
+
+
+
+

+ Non-streaming chat (toJSONResponse / fetchJSON) +

+

+ Server drains the chat stream and returns it as a single JSON + array. Use this on runtimes that can't emit ReadableStream + responses (e.g. Expo). UI sees everything at once. +

+
+ + Streaming demo → + +
+ + + + {error && ( +
+ {error.message} +
+ )} + +
+
+ {isLoading && ( +
+ +
+ )} + +
+
+