Node streaming response helpers drop all but the last Set-Cookie header
Description
pipeTextStreamToResponse and pipeUIMessageStreamToResponse drop all but the last Set-Cookie header. For example, a streaming endpoint setting two preference cookies sends only the second one.
This happens with both a Headers instance and an array of header pairs. The response body and unrelated headers arrive normally. createTextStreamResponse, createUIMessageStreamResponse, and native Node responses preserve both cookies in the same tests.
Both pipe helpers convert the prepared headers with Object.fromEntries(headers.entries()). Each Set-Cookie has a separate entry, so later entries overwrite earlier ones. The Node writer already accepts string[] header values; preserving headers.getSetCookie() as an array should avoid the loss. Cookies should not be split on commas because an Expires attribute can contain one.
The conversion is still present in the text helper and the UI helper on current main. This differs from #11229, which fixed the writeHead arguments when statusText was undefined.
Reproduction
In an empty directory:
npm init -y
npm install [email protected] [email protected]Save as repro.mjs and run node repro.mjs. It uses a local HTTP server; no model or credentials are needed.
import { createServer } from 'node:http';
import { once } from 'node:events';
import { pipeTextStreamToResponse, pipeUIMessageStreamToResponse } from 'ai';
const cookies = ['theme=light; Path=/', 'locale=en; Path=/'];
const server = createServer((request, response) => {
const headers = new Headers();
for (const cookie of cookies) headers.append('set-cookie', cookie);
const ui = request.url === '/ui';
const stream = new ReadableStream({ start(controller) {
if (ui) {
controller.enqueue({ type: 'start', messageId: 'synthetic-message' });
controller.enqueue({ type: 'finish' });
} else controller.enqueue('Synthetic text.');
controller.close();
} });
const pipe = ui ? pipeUIMessageStreamToResponse : pipeTextStreamToResponse;
void pipe({ response, headers, stream }).catch(error => response.destroy(error));
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
try {
const { port } = server.address();
for (const path of ['/text', '/ui']) {
const response = await fetch(`http://127.0.0.1:${port}${path}`);
await response.text();
console.log(path, response.headers.getSetCookie());
}
console.log('Expected for each:', cookies);
} finally {
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
}Actual output:
/text [ 'locale=en; Path=/' ]
/ui [ 'locale=en; Path=/' ]
Expected for each: [ 'theme=light; Path=/', 'locale=en; Path=/' ]Expected: both cookies reach the client on both routes.
I also ran 17 test cases on each Node version below: 13 controls pass; the four cases with two cookies through a Node pipe helper fail (two helpers × two header input forms). These include an Expires attribute containing a comma.
I used AI assistance to investigate and prepare the reproduction and tests.
AI SDK Version
ai:7.0.101- Node.js:
22.20.0and24.13.0 - macOS
Code of Conduct
- I agree to follow this project's Code of Conduct
Source: vercel/ai