diff --git a/.changeset/breezy-hounds-battle.md b/.changeset/breezy-hounds-battle.md new file mode 100644 index 0000000..74326fc --- /dev/null +++ b/.changeset/breezy-hounds-battle.md @@ -0,0 +1,5 @@ +--- +'fetch-nodeshim': patch +--- + +Preserve casing of headers when they're passed as non-Headers input, i.e. as tuple list or dictionary diff --git a/src/__tests__/fetch-proxied.test.ts b/src/__tests__/fetch-proxied.test.ts index 71a6e74..1cb2b9a 100644 --- a/src/__tests__/fetch-proxied.test.ts +++ b/src/__tests__/fetch-proxied.test.ts @@ -59,6 +59,9 @@ describe('fetch via HTTP proxy', () => { headers: expect.objectContaining({ connection: 'keep-alive', }), + rawHeaders: expect.objectContaining({ + Connection: 'keep-alive', + }), inspect: true, method: 'GET', url: '/inspect', diff --git a/src/__tests__/fetch.test.ts b/src/__tests__/fetch.test.ts index 3b652bd..5b12360 100644 --- a/src/__tests__/fetch.test.ts +++ b/src/__tests__/fetch.test.ts @@ -201,6 +201,72 @@ describe(fetch, () => { headers: expect.objectContaining({ host: 'example.com' }), }); }); + + it('should preserve header casing when headers are passed as a plain object', async () => { + const response = await fetch(new URL('inspect', baseURL), { + headers: { + 'X-Custom-Header': 'abc', + Authorization: 'Bearer token', + 'content-type': 'text/plain', + }, + }); + const { rawHeaders } = (await response.json()) as any; + expect(rawHeaders).toMatchObject({ + 'X-Custom-Header': 'abc', + Authorization: 'Bearer token', + 'content-type': 'text/plain', + }); + }); + + it('should preserve header casing when headers are passed as an array of tuples', async () => { + const response = await fetch(new URL('inspect', baseURL), { + headers: [ + ['X-Custom-Header', 'abc'], + ['Authorization', 'Bearer token'], + ], + }); + const { rawHeaders } = (await response.json()) as any; + expect(rawHeaders).toMatchObject({ + 'X-Custom-Header': 'abc', + Authorization: 'Bearer token', + }); + }); + + it('should merge tuple headers with the same name into a combined value', async () => { + const response = await fetch(new URL('inspect', baseURL), { + headers: [ + ['X-Custom-Header', 'abc'], + ['X-Custom-Header', 'def'], + ], + }); + const { headers } = (await response.json()) as any; + expect(headers['x-custom-header']).toBe('abc, def'); + }); + + it('should merge tuple headers with the same name but different casing into a combined value', async () => { + const response = await fetch(new URL('inspect', baseURL), { + headers: [ + ['X-Custom-Header', 'abc'], + ['X-custom-header', 'def'], + ], + }); + const { headers } = (await response.json()) as any; + expect(headers['x-custom-header']).toBe('abc, def'); + }); + + it('should lowercase header names when headers are passed as a Headers instance', async () => { + const response = await fetch(new URL('inspect', baseURL), { + headers: new Headers({ + 'X-Custom-Header': 'abc', + Authorization: 'Bearer token', + }), + }); + const { rawHeaders } = (await response.json()) as any; + expect(rawHeaders).toMatchObject({ + 'x-custom-header': 'abc', + authorization: 'Bearer token', + }); + }); }); describe('redirects', () => { @@ -240,6 +306,28 @@ describe(fetch, () => { } ); + it.each([[301], [302], [303]])( + 'should remove body, Content-Length, and Content-Type headers on %d redirect that changes method to GET', + async code => { + const response = await fetch(new URL(`redirect/${code}`, baseURL), { + method: 'POST', + body: 'a=1', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': '3', + }, + }); + expect(response.url).toBe(`${baseURL}inspect`); + const inspect: any = await response.json(); + expect(inspect).toMatchObject({ + method: 'GET', + body: '', + }); + expect(inspect.headers).not.toHaveProperty('content-type'); + expect(inspect.headers).not.toHaveProperty('content-length'); + } + ); + it('should not follow non-GET redirect if body is a readable stream', async () => { await expect(() => fetch(new URL('redirect/307', baseURL), { diff --git a/src/__tests__/utils/server.js b/src/__tests__/utils/server.js index 8ea628c..a6211db 100644 --- a/src/__tests__/utils/server.js +++ b/src/__tests__/utils/server.js @@ -442,12 +442,18 @@ export default class TestServer { body += c; }); request.on('end', () => { + // Convert rawHeaders array to an object preserving original casing + const rawHeadersObj = {}; + for (let i = 0; i < request.rawHeaders.length; i += 2) { + rawHeadersObj[request.rawHeaders[i]] = request.rawHeaders[i + 1]; + } res.end( JSON.stringify({ inspect: true, method: request.method, url: request.url, headers: request.headers, + rawHeaders: rawHeadersObj, body, }) ); diff --git a/src/fetch.ts b/src/fetch.ts index 374b1dd..59fec1d 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -6,12 +6,21 @@ import * as url from 'node:url'; import { extractBody } from './body'; import { createContentDecoder } from './encoding'; -import { URL, Request, RequestInit, Response } from './webstd'; +import { + URL, + Request, + RequestInit, + Response, + HeadersInit, + Headers, +} from './webstd'; import { getHttpsAgent, getHttpAgent } from './agent'; /** Maximum allowed redirects (matching Chromium's limit) */ const MAX_REDIRECTS = 20; +const DEFAULT_TIMEOUT = 30_000; + const parseURL = (input: string, base?: string | URL): URL | null => { try { return new URL(input, base); @@ -20,6 +29,11 @@ const parseURL = (input: string, base?: string | URL): URL | null => { } }; +const isHeaders = (x: unknown): x is Headers => + x != null && + typeof x === 'object' && + (('append' in x && typeof x.append === 'function') || x instanceof Headers); + /** Convert Node.js raw headers array to Headers */ const headersOfRawHeaders = (rawHeaders: readonly string[]): Headers => { const headers = new Headers(); @@ -28,27 +42,51 @@ const headersOfRawHeaders = (rawHeaders: readonly string[]): Headers => { return headers; }; +type HeadersDict = Record; + /** Assign Headers to a Node.js OutgoingMessage (request) */ const assignOutgoingMessageHeaders = ( outgoing: http.OutgoingMessage, - headers: Headers -) => { + headers: HeadersInit +): HeadersDict => { // Preassemble array headers, mostly only for Set-Cookie // We're avoiding `getSetCookie` since support is unclear in Node 18 - const collection: Record = {}; - for (const [key, value] of headers) { - if (Array.isArray(collection[key])) { - collection[key].push(value); - } else if (collection[key] != undefined) { - collection[key] = [collection[key], value]; - } else { - collection[key] = value; + let collection: HeadersDict; + if (!Array.isArray(headers) && !isHeaders(headers)) { + collection = headers; + } else { + collection = Object.create(null); + const canonicalNames = new Map(); + for (const [name, value] of headers) { + const lowerKey = name.toLowerCase(); + let key = canonicalNames.get(lowerKey) ?? name; + if (!canonicalNames.has(lowerKey)) canonicalNames.set(lowerKey, name); + if (Array.isArray(collection[key])) { + collection[key].push(value); + } else if (collection[key] != undefined) { + collection[key] = [collection[key] as string, value]; + } else { + collection[key] = value; + } } } // We don't use `setHeaders` due to a Bun bug (Fix: https://github.com/oven-sh/bun/pull/27050) for (const key in collection) { outgoing.setHeader(key, collection[key]); } + return collection; +}; + +const stripRedirectHeaders = (headers: HeadersDict | undefined) => { + if (headers) { + for (const key in headers) { + switch (key.toLowerCase()) { + case 'content-length': + case 'content-type': + delete headers[key]; + } + } + } }; /** Normalize methods and disallow special methods */ @@ -160,14 +198,8 @@ async function _fetch( let requestBody = extractBody(initBody); let redirects = 0; - const requestHeaders = new Headers( - init?.headers ?? (initFromRequest ? input.headers : undefined) - ); - - let DEFAULT_TIMEOUT = 5_000; - if (requestHeaders.get('accept')?.includes('text/html')) { - DEFAULT_TIMEOUT = 30_000; - } + let requestHeaders = + init?.headers ?? (initFromRequest ? input.headers : undefined); const requestOptions = { ...urlToHttpOptions(requestUrl), @@ -263,7 +295,7 @@ async function _fetch( ) { requestBody = extractBody(null); requestOptions.method = 'GET'; - requestHeaders.delete('Content-Length'); + stripRedirectHeaders(requestHeaders as HeadersDict); } else if ( requestBody.body != null && requestBody.contentLength == null @@ -315,24 +347,26 @@ async function _fetch( outgoing.on('error', destroy); - if (!requestHeaders.has('Accept')) { - requestHeaders.set('Accept', '*/*'); + if (requestHeaders) { + requestHeaders = assignOutgoingMessageHeaders(outgoing, requestHeaders); + } + + if (!outgoing.hasHeader('Accept')) { + outgoing.setHeader('Accept', '*/*'); } - if (!requestHeaders.has('Content-Type') && requestBody.contentType) { - requestHeaders.set('Content-Type', requestBody.contentType); + if (!outgoing.hasHeader('Content-Type') && requestBody.contentType) { + outgoing.setHeader('Content-Type', requestBody.contentType); } if ( requestBody.body == null && (method === 'POST' || method === 'PUT' || method === 'PATCH') ) { - requestHeaders.set('Content-Length', '0'); + outgoing.setHeader('Content-Length', '0'); } else if (requestBody.body != null && requestBody.contentLength != null) { - requestHeaders.set('Content-Length', `${requestBody.contentLength}`); + outgoing.setHeader('Content-Length', `${requestBody.contentLength}`); } - assignOutgoingMessageHeaders(outgoing, requestHeaders); - if (requestBody.body == null) { outgoing.end(); } else if (requestBody.body instanceof Uint8Array) {