diff --git a/.changeset/chilled-rockets-flash.md b/.changeset/chilled-rockets-flash.md new file mode 100644 index 000000000000..7c1ba400bf9f --- /dev/null +++ b/.changeset/chilled-rockets-flash.md @@ -0,0 +1,5 @@ +--- +'@astrojs/node': minor +--- + +Fixes NodeJS adapter for multiple set-cookie headers and combining AstroCookies and Response.headers cookies diff --git a/packages/integrations/node/package.json b/packages/integrations/node/package.json index 74338af4c3e2..439a2c0619ae 100644 --- a/packages/integrations/node/package.json +++ b/packages/integrations/node/package.json @@ -41,6 +41,7 @@ "astro": "workspace:^2.5.7" }, "devDependencies": { + "@types/node": "^18.7.21", "@types/send": "^0.17.1", "@types/server-destroy": "^1.0.1", "astro": "workspace:*", diff --git a/packages/integrations/node/src/createOutgoingHttpHeaders.ts b/packages/integrations/node/src/createOutgoingHttpHeaders.ts new file mode 100644 index 000000000000..5b99cfa4fbde --- /dev/null +++ b/packages/integrations/node/src/createOutgoingHttpHeaders.ts @@ -0,0 +1,40 @@ +import type { OutgoingHttpHeaders } from 'http'; + +/** + * Takes in a nullable WebAPI Headers object and produces a NodeJS OutgoingHttpHeaders object suitable for usage + * with ServerResponse.writeHead(..) or ServerResponse.setHeader(..) + * + * @param webHeaders WebAPI Headers object + * @returns NodeJS OutgoingHttpHeaders object with multiple set-cookie handled as an array of values + */ +export const createOutgoingHttpHeaders = (webHeaders: Headers | undefined | null): OutgoingHttpHeaders | undefined => { + if (!webHeaders) { + return undefined; + } + + // re-type to access Header.getSetCookie() + const headers = webHeaders as HeadersWithGetSetCookie; + + // at this point, a multi-value'd set-cookie header is invalid (it was concatenated as a single CSV, which is not valid for set-cookie) + const nodeHeaders: OutgoingHttpHeaders = Object.fromEntries(headers.entries()); + + if (Object.keys(nodeHeaders).length === 0) { + return undefined; + } + + // if there is > 1 set-cookie header, we have to fix it to be an array of values + if (headers.has('set-cookie')) { + const cookieHeaders = headers.getSetCookie(); + if (cookieHeaders.length > 1) { + // the Headers.entries() API already normalized all header names to lower case so we can safely index this as 'set-cookie' + nodeHeaders['set-cookie'] = cookieHeaders; + } + } + + return nodeHeaders; +}; + +interface HeadersWithGetSetCookie extends Headers { + // the @astrojs/webapi polyfill makes this available (as of undici@5.19.0), but tsc doesn't pick it up on the built-in Headers type from DOM lib + getSetCookie(): string[]; +} diff --git a/packages/integrations/node/src/nodeMiddleware.ts b/packages/integrations/node/src/nodeMiddleware.ts index c23cdb89ca4f..492130daa40a 100644 --- a/packages/integrations/node/src/nodeMiddleware.ts +++ b/packages/integrations/node/src/nodeMiddleware.ts @@ -3,6 +3,7 @@ import type { IncomingMessage, ServerResponse } from 'http'; import type { Readable } from 'stream'; import { responseIterator } from './response-iterator'; import type { Options } from './types'; +import { createOutgoingHttpHeaders } from './createOutgoingHttpHeaders'; export default function (app: NodeApp, mode: Options['mode']) { return async function ( @@ -44,12 +45,16 @@ async function writeWebResponse(app: NodeApp, res: ServerResponse, webResponse: if (app.setCookieHeaders) { const setCookieHeaders: Array = Array.from(app.setCookieHeaders(webResponse)); + if (setCookieHeaders.length) { - res.setHeader('Set-Cookie', setCookieHeaders); + for (const setCookieHeader of setCookieHeaders) { + webResponse.headers.append('set-cookie', setCookieHeader); + } } } - res.writeHead(status, Object.fromEntries(headers.entries())); + const nodeHeaders = createOutgoingHttpHeaders(headers); + res.writeHead(status, nodeHeaders); if (webResponse.body) { try { for await (const chunk of responseIterator(webResponse) as unknown as Readable) { diff --git a/packages/integrations/node/test/createOutgoingHttpHeaders.test.js b/packages/integrations/node/test/createOutgoingHttpHeaders.test.js new file mode 100644 index 000000000000..1876c9a00f07 --- /dev/null +++ b/packages/integrations/node/test/createOutgoingHttpHeaders.test.js @@ -0,0 +1,78 @@ +import { expect } from 'chai'; + +import { createOutgoingHttpHeaders } from '../dist/createOutgoingHttpHeaders.js'; + +describe('createOutgoingHttpHeaders', () => { + + it('undefined input headers', async () => { + const result = createOutgoingHttpHeaders(undefined); + expect(result).to.equal(undefined); + }); + + it('null input headers', async () => { + const result = createOutgoingHttpHeaders(undefined); + expect(result).to.equal(undefined); + }); + + it('Empty Headers', async () => { + const headers = new Headers(); + const result = createOutgoingHttpHeaders(headers); + expect(result).to.equal(undefined); + }); + + it('Headers with single key', async () => { + const headers = new Headers(); + headers.append('x-test', 'hello world'); + const result = createOutgoingHttpHeaders(headers); + expect(result).to.deep.equal({ 'x-test': 'hello world' }); + }); + + it('Headers with multiple keys', async () => { + const headers = new Headers(); + headers.append('x-test1', 'hello'); + headers.append('x-test2', 'world'); + const result = createOutgoingHttpHeaders(headers); + expect(result).to.deep.equal({ 'x-test1': 'hello', 'x-test2': 'world' }); + }); + + it('Headers with multiple values (not set-cookie)', async () => { + const headers = new Headers(); + headers.append('x-test', 'hello'); + headers.append('x-test', 'world'); + const result = createOutgoingHttpHeaders(headers); + expect(result).to.deep.equal({ 'x-test': 'hello, world' }); + }); + + it('Headers with multiple values (set-cookie special case)', async () => { + const headers = new Headers(); + headers.append('set-cookie', 'hello'); + headers.append('set-cookie', 'world'); + const result = createOutgoingHttpHeaders(headers); + expect(result).to.deep.equal({ 'set-cookie': ['hello', 'world'] }); + }); + + it('Headers with multiple values (set-cookie case handling)', async () => { + const headers = new Headers(); + headers.append('Set-cookie', 'hello'); + headers.append('Set-Cookie', 'world'); + const result = createOutgoingHttpHeaders(headers); + expect(result).to.deep.equal({ 'set-cookie': ['hello', 'world'] }); + }); + + it('Headers with all use cases', async () => { + const headers = new Headers(); + headers.append('x-single', 'single'); + headers.append('x-triple', 'one'); + headers.append('x-triple', 'two'); + headers.append('x-triple', 'three'); + headers.append('Set-cookie', 'hello'); + headers.append('Set-Cookie', 'world'); + const result = createOutgoingHttpHeaders(headers); + expect(result).to.deep.equal({ + 'x-single': 'single', + 'x-triple': 'one, two, three', + 'set-cookie': ['hello', 'world'], + }); + }); + +}); \ No newline at end of file diff --git a/packages/integrations/node/test/fixtures/headers/package.json b/packages/integrations/node/test/fixtures/headers/package.json new file mode 100644 index 000000000000..6a0858521083 --- /dev/null +++ b/packages/integrations/node/test/fixtures/headers/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/nodejs-headers", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*", + "@astrojs/node": "workspace:*" + } +} diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-cookies-multi.astro b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-cookies-multi.astro new file mode 100644 index 000000000000..a9ff193df848 --- /dev/null +++ b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-cookies-multi.astro @@ -0,0 +1,5 @@ +--- +Astro.cookies.set('from1', 'astro1'); +Astro.cookies.set('from2', 'astro2'); +--- +

hello world

\ No newline at end of file diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-cookies-single.astro b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-cookies-single.astro new file mode 100644 index 000000000000..c469fd66f5a5 --- /dev/null +++ b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-cookies-single.astro @@ -0,0 +1,4 @@ +--- +Astro.cookies.set('from1', 'astro1'); +--- +

hello world

\ No newline at end of file diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-response-cookie-multi.astro b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-response-cookie-multi.astro new file mode 100644 index 000000000000..91244e838bf9 --- /dev/null +++ b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-response-cookie-multi.astro @@ -0,0 +1,7 @@ +--- +Astro.response.headers.append('set-cookie', 'from1=response1'); +Astro.response.headers.append('set-cookie', 'from2=response2'); +Astro.cookies.set('from3', 'astro1'); +Astro.cookies.set('from4', 'astro2'); +--- +

hello world

\ No newline at end of file diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-response-cookie-single.astro b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-response-cookie-single.astro new file mode 100644 index 000000000000..97719dfa9eb3 --- /dev/null +++ b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-response-cookie-single.astro @@ -0,0 +1,5 @@ +--- +Astro.response.headers.append('set-cookie', 'from1=response1'); +Astro.cookies.set('from1', 'astro1'); +--- +

hello world

\ No newline at end of file diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-response-cookies-multi.astro b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-response-cookies-multi.astro new file mode 100644 index 000000000000..133cbd423172 --- /dev/null +++ b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-response-cookies-multi.astro @@ -0,0 +1,5 @@ +--- +Astro.response.headers.append('set-cookie', 'from1=value1'); +Astro.response.headers.append('set-cookie', 'from2=value2'); +--- +

hello world

\ No newline at end of file diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-response-cookies-single.astro b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-response-cookies-single.astro new file mode 100644 index 000000000000..dc76082db36c --- /dev/null +++ b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-response-cookies-single.astro @@ -0,0 +1,4 @@ +--- +Astro.response.headers.append('set-cookie', 'from1=value1'); +--- +

hello world

\ No newline at end of file diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-cookies-multi.ts b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-cookies-multi.ts new file mode 100644 index 000000000000..53d0c214c4ef --- /dev/null +++ b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-cookies-multi.ts @@ -0,0 +1,9 @@ +import type { APIContext } from 'astro'; + +export async function get({ request, cookies }: APIContext) { + const headers = new Headers(); + headers.append('content-type', 'text/plain;charset=utf-8'); + cookies.set('from1', 'astro1'); + cookies.set('from2', 'astro2'); + return new Response('hello world', { headers }); +} diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-cookies-single.ts b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-cookies-single.ts new file mode 100644 index 000000000000..5150f399059c --- /dev/null +++ b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-cookies-single.ts @@ -0,0 +1,8 @@ +import type { APIContext } from 'astro'; + +export async function get({ request, cookies }: APIContext) { + const headers = new Headers(); + headers.append('content-type', 'text/plain;charset=utf-8'); + cookies.set('from1', 'astro1'); + return new Response('hello world', { headers }); +} diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-response-cookie-multi.ts b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-response-cookie-multi.ts new file mode 100644 index 000000000000..215b1a5ba4c6 --- /dev/null +++ b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-response-cookie-multi.ts @@ -0,0 +1,11 @@ +import type { APIContext } from 'astro'; + +export async function get({ request, cookies }: APIContext) { + const headers = new Headers(); + headers.append('content-type', 'text/plain;charset=utf-8'); + headers.append('set-cookie', 'from1=response1'); + headers.append('set-cookie', 'from2=response2'); + cookies.set('from3', 'astro1'); + cookies.set('from4', 'astro2'); + return new Response('hello world', { headers }); +} diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-response-cookie-single.ts b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-response-cookie-single.ts new file mode 100644 index 000000000000..7202941da82f --- /dev/null +++ b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-response-cookie-single.ts @@ -0,0 +1,9 @@ +import type { APIContext } from 'astro'; + +export async function get({ request, cookies }: APIContext) { + const headers = new Headers(); + headers.append('content-type', 'text/plain;charset=utf-8'); + headers.append('set-cookie', 'from1=response1'); + cookies.set('from1', 'astro1'); + return new Response('hello world', { headers }); +} diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/kitchen-sink.ts b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/kitchen-sink.ts new file mode 100644 index 000000000000..2b3074a853b2 --- /dev/null +++ b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/kitchen-sink.ts @@ -0,0 +1,11 @@ +export async function get({ request }: { request: Request }) { + const headers = new Headers(); + headers.append('content-type', 'text/plain;charset=utf-8'); + headers.append('x-SINGLE', 'single'); + headers.append('X-triple', 'one'); + headers.append('x-Triple', 'two'); + headers.append('x-TRIPLE', 'three'); + headers.append('SET-cookie', 'hello1=world1'); + headers.append('Set-Cookie', 'hello2=world2'); + return new Response('hello world', { headers }); +} diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-cookies-multi.ts b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-cookies-multi.ts new file mode 100644 index 000000000000..ed3f867f4e01 --- /dev/null +++ b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-cookies-multi.ts @@ -0,0 +1,7 @@ +export async function get({ request }: { request: Request }) { + const headers = new Headers(); + headers.append('content-type', 'text/plain;charset=utf-8'); + headers.append('Set-Cookie', 'hello1=world1'); + headers.append('SET-COOKIE', 'hello2=world2'); + return new Response('hello world', { headers }); +} diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-cookies-single.ts b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-cookies-single.ts new file mode 100644 index 000000000000..25de307ef5e8 --- /dev/null +++ b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-cookies-single.ts @@ -0,0 +1,6 @@ +export async function get({ request }: { request: Request }) { + const headers = new Headers(); + headers.append('content-type', 'text/plain;charset=utf-8'); + headers.append('Set-Cookie', 'hello1=world1'); + return new Response('hello world', { headers }); +} diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-empty-headers-object.ts b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-empty-headers-object.ts new file mode 100644 index 000000000000..0c79cb537200 --- /dev/null +++ b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-empty-headers-object.ts @@ -0,0 +1,4 @@ +export async function get({ request }: { request: Request }) { + const headers = new Headers(); + return new Response('hello world', { headers }); +} diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-undefined-headers-object.ts b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-undefined-headers-object.ts new file mode 100644 index 000000000000..1afc6d843d72 --- /dev/null +++ b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-undefined-headers-object.ts @@ -0,0 +1,3 @@ +export async function get({ request }: { request: Request }) { + return new Response('hello world', { headers: undefined }); +} diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/simple.ts b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/simple.ts new file mode 100644 index 000000000000..3b728f6fa7ae --- /dev/null +++ b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/simple.ts @@ -0,0 +1,6 @@ +export async function get({ request }: { request: Request }) { + const headers = new Headers(); + headers.append('content-type', 'text/plain;charset=utf-8'); + headers.append('X-HELLO', 'world'); + return new Response('hello world', { headers }); +} diff --git a/packages/integrations/node/test/headers.test.js.js b/packages/integrations/node/test/headers.test.js.js new file mode 100644 index 000000000000..5b453dd56e84 --- /dev/null +++ b/packages/integrations/node/test/headers.test.js.js @@ -0,0 +1,148 @@ +import nodejs from '../dist/index.js'; +import { loadFixture, createRequestAndResponse } from './test-utils.js'; +import { expect } from 'chai'; + +describe('Node Adapter Headers', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/headers/', + output: 'server', + adapter: nodejs({ mode: 'middleware' }), + }); + await fixture.build(); + }); + + it('Endpoint Simple Headers', async () => { + await runTest('/endpoints/simple', { + 'content-type': 'text/plain;charset=utf-8', + 'x-hello': 'world', + }); + }); + + it('Endpoint Astro Single Cookie Header', async () => { + await runTest('/endpoints/astro-cookies-single', { + 'content-type': 'text/plain;charset=utf-8', + 'set-cookie': 'from1=astro1', + }); + }); + + it('Endpoint Astro Multi Cookie Header', async () => { + await runTest('/endpoints/astro-cookies-multi', { + 'content-type': 'text/plain;charset=utf-8', + 'set-cookie': ['from1=astro1', 'from2=astro2'], + }); + }); + + it('Endpoint Response Single Cookie Header', async () => { + await runTest('/endpoints/response-cookies-single', { + 'content-type': 'text/plain;charset=utf-8', + 'set-cookie': 'hello1=world1', + }); + }); + + it('Endpoint Response Multi Cookie Header', async () => { + await runTest('/endpoints/response-cookies-multi', { + 'content-type': 'text/plain;charset=utf-8', + 'set-cookie': ['hello1=world1', 'hello2=world2'], + }); + }); + + it('Endpoint Complex Headers Kitchen Sink', async () => { + await runTest('/endpoints/kitchen-sink', { + 'content-type': 'text/plain;charset=utf-8', + 'x-single': 'single', + 'x-triple': 'one, two, three', + 'set-cookie': ['hello1=world1', 'hello2=world2'], + }); + }); + + it('Endpoint Astro and Response Single Cookie Header', async () => { + await runTest('/endpoints/astro-response-cookie-single', { + 'content-type': 'text/plain;charset=utf-8', + 'set-cookie': ['from1=response1', 'from1=astro1'], + }); + }); + + it('Endpoint Astro and Response Multi Cookie Header', async () => { + await runTest('/endpoints/astro-response-cookie-multi', { + 'content-type': 'text/plain;charset=utf-8', + 'set-cookie': ['from1=response1', 'from2=response2', 'from3=astro1', 'from4=astro2'], + }); + }); + + it('Endpoint Response Empty Headers Object', async () => { + await runTest('/endpoints/response-empty-headers-object', { + 'content-type': 'text/plain;charset=UTF-8', + }); + }); + + it('Endpoint Response undefined Headers Object', async () => { + await runTest('/endpoints/response-undefined-headers-object', { + 'content-type': 'text/plain;charset=UTF-8', + }); + }); + + it('Component Astro Single Cookie Header', async () => { + await runTest('/astro/component-astro-cookies-single', { + 'content-type': 'text/html', + 'set-cookie': 'from1=astro1', + }); + }); + + it('Component Astro Multi Cookie Header', async () => { + await runTest('/astro/component-astro-cookies-multi', { + 'content-type': 'text/html', + 'set-cookie': ['from1=astro1', 'from2=astro2'], + }); + }); + + it('Component Response Single Cookie Header', async () => { + await runTest('/astro/component-response-cookies-single', { + 'content-type': 'text/html', + 'set-cookie': 'from1=value1', + }); + }); + + it('Component Response Multi Cookie Header', async () => { + await runTest('/astro/component-response-cookies-multi', { + 'content-type': 'text/html', + 'set-cookie': ['from1=value1', 'from2=value2'], + }); + }); + + it('Component Astro and Response Single Cookie Header', async () => { + await runTest('/astro/component-astro-response-cookie-single', { + 'content-type': 'text/html', + 'set-cookie': ['from1=response1', 'from1=astro1'], + }); + }); + + it('Component Astro and Response Multi Cookie Header', async () => { + await runTest('/astro/component-astro-response-cookie-multi', { + 'content-type': 'text/html', + 'set-cookie': ['from1=response1', 'from2=response2', 'from3=astro1', 'from4=astro2'], + }); + }); + +}); + +async function runTest(url, expectedHeaders) { + const { handler } = await import('./fixtures/headers/dist/server/entry.mjs'); + + let { req, res, done } = createRequestAndResponse({ + method: 'GET', + url, + }); + + handler(req, res); + + req.send(); + + await done; + const headers = res.getHeaders(); + + expect(headers).to.deep.equal(expectedHeaders); +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 107b9e2c0e24..29d4f497a347 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4497,6 +4497,9 @@ importers: specifier: ^1.0.1 version: 1.0.1 devDependencies: + '@types/node': + specifier: ^18.7.21 + version: 18.16.3 '@types/send': specifier: ^0.17.1 version: 0.17.1 @@ -4561,6 +4564,15 @@ importers: specifier: workspace:* version: link:../../../../../astro + packages/integrations/node/test/fixtures/headers: + dependencies: + '@astrojs/node': + specifier: workspace:* + version: link:../../.. + astro: + specifier: workspace:* + version: link:../../../../../astro + packages/integrations/node/test/fixtures/node-middleware: dependencies: '@astrojs/node':