-
-
Notifications
You must be signed in to change notification settings - Fork 209
feat: dual ESM+CJS builds + toJSONResponse/fetchJSON for non-streaming runtimes #478
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
e118295
1bbc932
ee3f393
3fa270d
3222f82
3ab6871
43e59bb
d347c85
feee9ab
1a26a98
ee6613c
9e42324
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<FetchConnectionOptions>) = {}, | ||||||||||||||||||||||||||||||||
| ): 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<string, string> = { | ||||||||||||||||||||||||||||||||
| '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) { | ||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. servers usually put the actual diagnostic information in the body. e.g. OpenAI/Anthropic upstream rate limit: {"error":{"type":"rate_limit_error","message":"Rate limit exceeded for...","retryAfter":42}}
Suggested change
|
||||||||||||||||||||||||||||||||
| throw new Error( | ||||||||||||||||||||||||||||||||
| `HTTP error! status: ${response.status} ${response.statusText}`, | ||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const payload = (await response.json()) as unknown | ||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You need to wrap this in try catch otherwise the user will get an annoying "Unexpected token < in JSON at position 0" error instead of what probably happened e.g. a gateway error or something that returned html
Suggested change
|
||||||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You never check for the abort signal in this yield loop despite adding it in toJSONResponse |
||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||
| * Create a direct stream connection adapter (for server functions or direct streams) | ||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,6 +35,6 @@ export default mergeConfig( | |
| './src/middlewares/index.ts', | ||
| ], | ||
| srcDir: './src', | ||
| cjs: false, | ||
| cjs: true, | ||
| }), | ||
| ) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You should add an e2e test for the roundtrip toJSONResponse → fetchJSON