Mirror: A Node.js fetch shim using built-in Request, Response, and Headers (but without native fetch)
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

patch: Preserve casing of headers when passed as non-Headers object (#41)

authored by

Phil Pluckthun and committed by
GitHub
a76b014c 32ed3198

+164 -28
+5
.changeset/breezy-hounds-battle.md
··· 1 + --- 2 + 'fetch-nodeshim': patch 3 + --- 4 + 5 + Preserve casing of headers when they're passed as non-Headers input, i.e. as tuple list or dictionary
+3
src/__tests__/fetch-proxied.test.ts
··· 59 59 headers: expect.objectContaining({ 60 60 connection: 'keep-alive', 61 61 }), 62 + rawHeaders: expect.objectContaining({ 63 + Connection: 'keep-alive', 64 + }), 62 65 inspect: true, 63 66 method: 'GET', 64 67 url: '/inspect',
+88
src/__tests__/fetch.test.ts
··· 201 201 headers: expect.objectContaining({ host: 'example.com' }), 202 202 }); 203 203 }); 204 + 205 + it('should preserve header casing when headers are passed as a plain object', async () => { 206 + const response = await fetch(new URL('inspect', baseURL), { 207 + headers: { 208 + 'X-Custom-Header': 'abc', 209 + Authorization: 'Bearer token', 210 + 'content-type': 'text/plain', 211 + }, 212 + }); 213 + const { rawHeaders } = (await response.json()) as any; 214 + expect(rawHeaders).toMatchObject({ 215 + 'X-Custom-Header': 'abc', 216 + Authorization: 'Bearer token', 217 + 'content-type': 'text/plain', 218 + }); 219 + }); 220 + 221 + it('should preserve header casing when headers are passed as an array of tuples', async () => { 222 + const response = await fetch(new URL('inspect', baseURL), { 223 + headers: [ 224 + ['X-Custom-Header', 'abc'], 225 + ['Authorization', 'Bearer token'], 226 + ], 227 + }); 228 + const { rawHeaders } = (await response.json()) as any; 229 + expect(rawHeaders).toMatchObject({ 230 + 'X-Custom-Header': 'abc', 231 + Authorization: 'Bearer token', 232 + }); 233 + }); 234 + 235 + it('should merge tuple headers with the same name into a combined value', async () => { 236 + const response = await fetch(new URL('inspect', baseURL), { 237 + headers: [ 238 + ['X-Custom-Header', 'abc'], 239 + ['X-Custom-Header', 'def'], 240 + ], 241 + }); 242 + const { headers } = (await response.json()) as any; 243 + expect(headers['x-custom-header']).toBe('abc, def'); 244 + }); 245 + 246 + it('should merge tuple headers with the same name but different casing into a combined value', async () => { 247 + const response = await fetch(new URL('inspect', baseURL), { 248 + headers: [ 249 + ['X-Custom-Header', 'abc'], 250 + ['X-custom-header', 'def'], 251 + ], 252 + }); 253 + const { headers } = (await response.json()) as any; 254 + expect(headers['x-custom-header']).toBe('abc, def'); 255 + }); 256 + 257 + it('should lowercase header names when headers are passed as a Headers instance', async () => { 258 + const response = await fetch(new URL('inspect', baseURL), { 259 + headers: new Headers({ 260 + 'X-Custom-Header': 'abc', 261 + Authorization: 'Bearer token', 262 + }), 263 + }); 264 + const { rawHeaders } = (await response.json()) as any; 265 + expect(rawHeaders).toMatchObject({ 266 + 'x-custom-header': 'abc', 267 + authorization: 'Bearer token', 268 + }); 269 + }); 204 270 }); 205 271 206 272 describe('redirects', () => { ··· 237 303 method: outputMethod, 238 304 body: outputMethod === 'GET' ? '' : 'a=1', 239 305 }); 306 + } 307 + ); 308 + 309 + it.each([[301], [302], [303]])( 310 + 'should remove body, Content-Length, and Content-Type headers on %d redirect that changes method to GET', 311 + async code => { 312 + const response = await fetch(new URL(`redirect/${code}`, baseURL), { 313 + method: 'POST', 314 + body: 'a=1', 315 + headers: { 316 + 'Content-Type': 'application/x-www-form-urlencoded', 317 + 'Content-Length': '3', 318 + }, 319 + }); 320 + expect(response.url).toBe(`${baseURL}inspect`); 321 + const inspect: any = await response.json(); 322 + expect(inspect).toMatchObject({ 323 + method: 'GET', 324 + body: '', 325 + }); 326 + expect(inspect.headers).not.toHaveProperty('content-type'); 327 + expect(inspect.headers).not.toHaveProperty('content-length'); 240 328 } 241 329 ); 242 330
+6
src/__tests__/utils/server.js
··· 442 442 body += c; 443 443 }); 444 444 request.on('end', () => { 445 + // Convert rawHeaders array to an object preserving original casing 446 + const rawHeadersObj = {}; 447 + for (let i = 0; i < request.rawHeaders.length; i += 2) { 448 + rawHeadersObj[request.rawHeaders[i]] = request.rawHeaders[i + 1]; 449 + } 445 450 res.end( 446 451 JSON.stringify({ 447 452 inspect: true, 448 453 method: request.method, 449 454 url: request.url, 450 455 headers: request.headers, 456 + rawHeaders: rawHeadersObj, 451 457 body, 452 458 }) 453 459 );
+62 -28
src/fetch.ts
··· 6 6 7 7 import { extractBody } from './body'; 8 8 import { createContentDecoder } from './encoding'; 9 - import { URL, Request, RequestInit, Response } from './webstd'; 9 + import { 10 + URL, 11 + Request, 12 + RequestInit, 13 + Response, 14 + HeadersInit, 15 + Headers, 16 + } from './webstd'; 10 17 import { getHttpsAgent, getHttpAgent } from './agent'; 11 18 12 19 /** Maximum allowed redirects (matching Chromium's limit) */ 13 20 const MAX_REDIRECTS = 20; 14 21 22 + const DEFAULT_TIMEOUT = 30_000; 23 + 15 24 const parseURL = (input: string, base?: string | URL): URL | null => { 16 25 try { 17 26 return new URL(input, base); ··· 20 29 } 21 30 }; 22 31 32 + const isHeaders = (x: unknown): x is Headers => 33 + x != null && 34 + typeof x === 'object' && 35 + (('append' in x && typeof x.append === 'function') || x instanceof Headers); 36 + 23 37 /** Convert Node.js raw headers array to Headers */ 24 38 const headersOfRawHeaders = (rawHeaders: readonly string[]): Headers => { 25 39 const headers = new Headers(); ··· 27 41 headers.append(rawHeaders[i], rawHeaders[i + 1]); 28 42 return headers; 29 43 }; 44 + 45 + type HeadersDict = Record<string, string | readonly string[]>; 30 46 31 47 /** Assign Headers to a Node.js OutgoingMessage (request) */ 32 48 const assignOutgoingMessageHeaders = ( 33 49 outgoing: http.OutgoingMessage, 34 - headers: Headers 35 - ) => { 50 + headers: HeadersInit 51 + ): HeadersDict => { 36 52 // Preassemble array headers, mostly only for Set-Cookie 37 53 // We're avoiding `getSetCookie` since support is unclear in Node 18 38 - const collection: Record<string, string | string[]> = {}; 39 - for (const [key, value] of headers) { 40 - if (Array.isArray(collection[key])) { 41 - collection[key].push(value); 42 - } else if (collection[key] != undefined) { 43 - collection[key] = [collection[key], value]; 44 - } else { 45 - collection[key] = value; 54 + let collection: HeadersDict; 55 + if (!Array.isArray(headers) && !isHeaders(headers)) { 56 + collection = headers; 57 + } else { 58 + collection = Object.create(null); 59 + const canonicalNames = new Map(); 60 + for (const [name, value] of headers) { 61 + const lowerKey = name.toLowerCase(); 62 + let key = canonicalNames.get(lowerKey) ?? name; 63 + if (!canonicalNames.has(lowerKey)) canonicalNames.set(lowerKey, name); 64 + if (Array.isArray(collection[key])) { 65 + collection[key].push(value); 66 + } else if (collection[key] != undefined) { 67 + collection[key] = [collection[key] as string, value]; 68 + } else { 69 + collection[key] = value; 70 + } 46 71 } 47 72 } 48 73 // We don't use `setHeaders` due to a Bun bug (Fix: https://github.com/oven-sh/bun/pull/27050) 49 74 for (const key in collection) { 50 75 outgoing.setHeader(key, collection[key]); 76 + } 77 + return collection; 78 + }; 79 + 80 + const stripRedirectHeaders = (headers: HeadersDict | undefined) => { 81 + if (headers) { 82 + for (const key in headers) { 83 + switch (key.toLowerCase()) { 84 + case 'content-length': 85 + case 'content-type': 86 + delete headers[key]; 87 + } 88 + } 51 89 } 52 90 }; 53 91 ··· 160 198 let requestBody = extractBody(initBody); 161 199 let redirects = 0; 162 200 163 - const requestHeaders = new Headers( 164 - init?.headers ?? (initFromRequest ? input.headers : undefined) 165 - ); 166 - 167 - let DEFAULT_TIMEOUT = 5_000; 168 - if (requestHeaders.get('accept')?.includes('text/html')) { 169 - DEFAULT_TIMEOUT = 30_000; 170 - } 201 + let requestHeaders = 202 + init?.headers ?? (initFromRequest ? input.headers : undefined); 171 203 172 204 const requestOptions = { 173 205 ...urlToHttpOptions(requestUrl), ··· 263 295 ) { 264 296 requestBody = extractBody(null); 265 297 requestOptions.method = 'GET'; 266 - requestHeaders.delete('Content-Length'); 298 + stripRedirectHeaders(requestHeaders as HeadersDict); 267 299 } else if ( 268 300 requestBody.body != null && 269 301 requestBody.contentLength == null ··· 315 347 316 348 outgoing.on('error', destroy); 317 349 318 - if (!requestHeaders.has('Accept')) { 319 - requestHeaders.set('Accept', '*/*'); 350 + if (requestHeaders) { 351 + requestHeaders = assignOutgoingMessageHeaders(outgoing, requestHeaders); 320 352 } 321 - if (!requestHeaders.has('Content-Type') && requestBody.contentType) { 322 - requestHeaders.set('Content-Type', requestBody.contentType); 353 + 354 + if (!outgoing.hasHeader('Accept')) { 355 + outgoing.setHeader('Accept', '*/*'); 356 + } 357 + if (!outgoing.hasHeader('Content-Type') && requestBody.contentType) { 358 + outgoing.setHeader('Content-Type', requestBody.contentType); 323 359 } 324 360 325 361 if ( 326 362 requestBody.body == null && 327 363 (method === 'POST' || method === 'PUT' || method === 'PATCH') 328 364 ) { 329 - requestHeaders.set('Content-Length', '0'); 365 + outgoing.setHeader('Content-Length', '0'); 330 366 } else if (requestBody.body != null && requestBody.contentLength != null) { 331 - requestHeaders.set('Content-Length', `${requestBody.contentLength}`); 367 + outgoing.setHeader('Content-Length', `${requestBody.contentLength}`); 332 368 } 333 - 334 - assignOutgoingMessageHeaders(outgoing, requestHeaders); 335 369 336 370 if (requestBody.body == null) { 337 371 outgoing.end();