diff --git a/packages/standard-server-fastify/src/response.test.ts b/packages/standard-server-fastify/src/response.test.ts index bd1c6ed8a..19fae60e5 100644 --- a/packages/standard-server-fastify/src/response.test.ts +++ b/packages/standard-server-fastify/src/response.test.ts @@ -6,6 +6,7 @@ import request from 'supertest' import { sendStandardResponse } from './response' const toNodeHttpBodySpy = vi.spyOn(StandardServerNode, 'toNodeHttpBody') +const toNodeHttpHeadersSpy = vi.spyOn(StandardServerNode, 'toNodeHttpHeaders') beforeEach(() => { vi.clearAllMocks() @@ -39,11 +40,16 @@ describe('sendStandardResponse', () => { 'x-custom-header': 'custom-value', }, options) + expect(toNodeHttpHeadersSpy).toBeCalledTimes(1) + expect(toNodeHttpHeadersSpy).toBeCalledWith({ + 'x-custom-header': 'custom-value', + }) + expect(sendSpy).toBeCalledTimes(1) expect(sendSpy).toBeCalledWith(undefined) expect(res.status).toBe(207) - expect(res.headers['content-type']).toEqual(undefined) + expect(res.headers).not.toHaveProperty('content-type') expect(res.headers['x-custom-header']).toEqual('custom-value') expect(res.text).toEqual('') @@ -77,6 +83,12 @@ describe('sendStandardResponse', () => { 'x-custom-header': 'custom-value', }, options) + expect(toNodeHttpHeadersSpy).toBeCalledTimes(1) + expect(toNodeHttpHeadersSpy).toBeCalledWith({ + 'content-type': 'application/json', + 'x-custom-header': 'custom-value', + }) + expect(sendSpy).toBeCalledTimes(1) expect(sendSpy).toBeCalledWith(toNodeHttpBodySpy.mock.results[0]!.value) @@ -120,6 +132,14 @@ describe('sendStandardResponse', () => { 'x-custom-header': 'custom-value', }, options) + expect(toNodeHttpHeadersSpy).toBeCalledTimes(1) + expect(toNodeHttpHeadersSpy).toBeCalledWith({ + 'content-disposition': 'inline; filename="blob"; filename*=utf-8\'\'blob', + 'content-length': '3', + 'content-type': 'text/plain', + 'x-custom-header': 'custom-value', + }) + expect(sendSpy).toBeCalledTimes(1) expect(sendSpy).toBeCalledWith(toNodeHttpBodySpy.mock.results[0]!.value) @@ -170,6 +190,12 @@ describe('sendStandardResponse', () => { 'x-custom-header': 'custom-value', }, options) + expect(toNodeHttpHeadersSpy).toBeCalledTimes(1) + expect(toNodeHttpHeadersSpy).toBeCalledWith({ + 'content-type': 'text/event-stream', + 'x-custom-header': 'custom-value', + }) + expect(sendSpy).toBeCalledTimes(1) expect(sendSpy).toBeCalledWith(toNodeHttpBodySpy.mock.results[0]!.value) diff --git a/packages/standard-server-fastify/src/response.ts b/packages/standard-server-fastify/src/response.ts index eaeaea8d1..d088e2471 100644 --- a/packages/standard-server-fastify/src/response.ts +++ b/packages/standard-server-fastify/src/response.ts @@ -1,7 +1,7 @@ import type { StandardHeaders, StandardResponse } from '@orpc/standard-server' import type { ToNodeHttpBodyOptions } from '@orpc/standard-server-node' import type { FastifyReply } from 'fastify' -import { toNodeHttpBody } from '@orpc/standard-server-node' +import { toNodeHttpBody, toNodeHttpHeaders } from '@orpc/standard-server-node' export interface SendStandardResponseOptions extends ToNodeHttpBodyOptions { } @@ -19,7 +19,9 @@ export function sendStandardResponse( const resBody = toNodeHttpBody(standardResponse.body, resHeaders, options) reply.status(standardResponse.status) - reply.headers(resHeaders) + // Fastify treats undefined headers as empty string, so remember to use toNodeHttpHeaders + // to filter out undefined headers + reply.headers(toNodeHttpHeaders(resHeaders)) reply.send(resBody) }) } diff --git a/packages/standard-server-node/src/headers.test.ts b/packages/standard-server-node/src/headers.test.ts new file mode 100644 index 000000000..968987ae2 --- /dev/null +++ b/packages/standard-server-node/src/headers.test.ts @@ -0,0 +1,17 @@ +import { toNodeHttpHeaders } from './headers' + +describe('toNodeHttpHeaders', () => { + it('filters out undefined values', () => { + const headers = toNodeHttpHeaders({ + 'x-custom': 'value', + 'x-undefined': undefined, + 'set-cookie': ['cookie1=value1', 'cookie2=value2'], + }) + + expect(headers).toEqual({ + 'x-custom': 'value', + 'set-cookie': ['cookie1=value1', 'cookie2=value2'], + }) + expect(headers).not.toHaveProperty('x-undefined') + }) +}) diff --git a/packages/standard-server-node/src/headers.ts b/packages/standard-server-node/src/headers.ts new file mode 100644 index 000000000..c33459001 --- /dev/null +++ b/packages/standard-server-node/src/headers.ts @@ -0,0 +1,15 @@ +import type { StandardHeaders } from '@orpc/standard-server' +import type { OutgoingHttpHeaders } from 'node:http' + +export function toNodeHttpHeaders(headers: StandardHeaders): OutgoingHttpHeaders { + const nodeHttpHeaders: OutgoingHttpHeaders = {} + + for (const [key, value] of Object.entries(headers)) { + // Node.js does not allow headers to be undefined + if (value !== undefined) { + nodeHttpHeaders[key] = value + } + } + + return nodeHttpHeaders +} diff --git a/packages/standard-server-node/src/index.ts b/packages/standard-server-node/src/index.ts index 005d1b859..2cab78bcf 100644 --- a/packages/standard-server-node/src/index.ts +++ b/packages/standard-server-node/src/index.ts @@ -1,5 +1,6 @@ export * from './body' export * from './event-iterator' +export * from './headers' export * from './method' export * from './request' export * from './response' diff --git a/packages/standard-server-node/src/response.test.ts b/packages/standard-server-node/src/response.test.ts index 3dab22d47..414b3b2e1 100644 --- a/packages/standard-server-node/src/response.test.ts +++ b/packages/standard-server-node/src/response.test.ts @@ -4,9 +4,11 @@ import { Buffer } from 'node:buffer' import Stream from 'node:stream' import request from 'supertest' import * as Body from './body' +import * as Headers from './headers' import { sendStandardResponse } from './response' const toNodeHttpBodySpy = vi.spyOn(Body, 'toNodeHttpBody') +const toNodeHttpHeadersSpy = vi.spyOn(Headers, 'toNodeHttpHeaders') beforeEach(() => { vi.clearAllMocks() @@ -34,11 +36,16 @@ describe('sendStandardResponse', () => { 'x-custom-header': 'custom-value', }, options) + expect(toNodeHttpHeadersSpy).toBeCalledTimes(1) + expect(toNodeHttpHeadersSpy).toBeCalledWith({ + 'x-custom-header': 'custom-value', + }) + expect(endSpy).toBeCalledTimes(1) expect(endSpy).toBeCalledWith() expect(res.status).toBe(207) - expect(res.headers['content-type']).toEqual(undefined) + expect(res.headers).not.toHaveProperty('content-type') expect(res.headers['x-custom-header']).toEqual('custom-value') expect(res.text).toEqual('') @@ -66,6 +73,12 @@ describe('sendStandardResponse', () => { 'x-custom-header': 'custom-value', }, options) + expect(toNodeHttpHeadersSpy).toBeCalledTimes(1) + expect(toNodeHttpHeadersSpy).toBeCalledWith({ + 'content-type': 'application/json', + 'x-custom-header': 'custom-value', + }) + expect(endSpy).toBeCalledTimes(1) expect(endSpy).toBeCalledWith(toNodeHttpBodySpy.mock.results[0]!.value) @@ -103,6 +116,14 @@ describe('sendStandardResponse', () => { 'x-custom-header': 'custom-value', }, options) + expect(toNodeHttpHeadersSpy).toBeCalledTimes(1) + expect(toNodeHttpHeadersSpy).toBeCalledWith({ + 'content-disposition': 'inline; filename="blob"; filename*=utf-8\'\'blob', + 'content-length': '3', + 'content-type': 'text/plain', + 'x-custom-header': 'custom-value', + }) + expect(endSpy).toBeCalledTimes(1) expect(endSpy).toBeCalledWith() @@ -148,6 +169,12 @@ describe('sendStandardResponse', () => { 'x-custom-header': 'custom-value', }, options) + expect(toNodeHttpHeadersSpy).toBeCalledTimes(1) + expect(toNodeHttpHeadersSpy).toBeCalledWith({ + 'content-type': 'text/event-stream', + 'x-custom-header': 'custom-value', + }) + expect(endSpy).toBeCalledTimes(1) expect(endSpy).toBeCalledWith() diff --git a/packages/standard-server-node/src/response.ts b/packages/standard-server-node/src/response.ts index 4da1e6508..0a59f7b7a 100644 --- a/packages/standard-server-node/src/response.ts +++ b/packages/standard-server-node/src/response.ts @@ -2,6 +2,7 @@ import type { StandardHeaders, StandardResponse } from '@orpc/standard-server' import type { ToNodeHttpBodyOptions } from './body' import type { NodeHttpResponse } from './types' import { toNodeHttpBody } from './body' +import { toNodeHttpHeaders } from './headers' export interface SendStandardResponseOptions extends ToNodeHttpBodyOptions {} @@ -18,7 +19,9 @@ export function sendStandardResponse( const resBody = toNodeHttpBody(standardResponse.body, resHeaders, options) - res.writeHead(standardResponse.status, resHeaders) + // Node.js throws an error when a header is undefined, so remember to use toNodeHttpHeaders + // to filter out undefined headers + res.writeHead(standardResponse.status, toNodeHttpHeaders(resHeaders)) if (resBody === undefined) { res.end()