Skip to content

Commit d1abdaa

Browse files
committed
feat(http-request): add readIncomingResponse and type aliases
Add readIncomingResponse() to read and buffer a client-side IncomingResponse into an HttpResponse. Useful for converting raw responses from code that bypasses httpRequest() (e.g. multipart form-data uploads) into the standard HttpResponse interface. Add IncomingResponse and IncomingRequest type aliases to disambiguate Node's IncomingMessage which is used for both client responses and server requests.
1 parent 89158d6 commit d1abdaa

2 files changed

Lines changed: 204 additions & 4 deletions

File tree

src/http-request.ts

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ function getFs() {
3232

3333
import type { IncomingHttpHeaders, IncomingMessage } from 'http'
3434

35+
/** IncomingMessage received as a response to a client request (http.request callback). */
36+
export type IncomingResponse = IncomingMessage
37+
38+
/** IncomingMessage received as a request in a server handler (http.createServer callback). */
39+
export type IncomingRequest = IncomingMessage
40+
3541
import type { Logger } from './logger.js'
3642

3743
let _crypto: typeof import('node:crypto') | undefined
@@ -392,11 +398,62 @@ export interface HttpResponse {
392398
*/
393399
text(): string
394400
/**
395-
* The underlying Node.js IncomingMessage for advanced use cases
401+
* The underlying Node.js IncomingResponse for advanced use cases
396402
* (e.g., streaming, custom header inspection). Only available when
397403
* the response was not consumed by the convenience methods.
398404
*/
399-
rawResponse?: IncomingMessage | undefined
405+
rawResponse?: IncomingResponse | undefined
406+
}
407+
408+
/**
409+
* Read and buffer a client-side IncomingResponse into an HttpResponse.
410+
*
411+
* Useful when you have a raw response from code that bypasses
412+
* `httpRequest()` (e.g., multipart form-data uploads via `http.request()`,
413+
* or responses from third-party HTTP libraries) and need to convert it
414+
* into the standard HttpResponse interface.
415+
*
416+
* @param msg - The IncomingResponse (IncomingMessage from a client request) to read
417+
* @returns Promise resolving to a fully-buffered HttpResponse
418+
*
419+
* @example
420+
* ```ts
421+
* import http from 'node:http'
422+
* import { readIncomingResponse } from '@socketsecurity/lib/http-request'
423+
*
424+
* const req = http.request('https://example.com', async (msg) => {
425+
* const response = await readIncomingResponse(msg)
426+
* console.log(response.ok, response.status)
427+
* console.log(response.json())
428+
* })
429+
* req.end()
430+
* ```
431+
*/
432+
export async function readIncomingResponse(
433+
msg: IncomingResponse,
434+
): Promise<HttpResponse> {
435+
const chunks: Buffer[] = []
436+
for await (const chunk of msg) {
437+
chunks.push(chunk as Buffer)
438+
}
439+
const body = Buffer.concat(chunks)
440+
const status = msg.statusCode ?? 0
441+
const statusText = msg.statusMessage ?? ''
442+
return {
443+
arrayBuffer: () =>
444+
body.buffer.slice(
445+
body.byteOffset,
446+
body.byteOffset + body.byteLength,
447+
) as ArrayBuffer,
448+
body,
449+
headers: msg.headers,
450+
json: <T = unknown>() => JSON.parse(body.toString('utf8')) as T,
451+
ok: status >= 200 && status < 300,
452+
rawResponse: msg,
453+
status,
454+
statusText,
455+
text: () => body.toString('utf8'),
456+
}
400457
}
401458

402459
/**
@@ -804,7 +861,7 @@ async function httpDownloadAttempt(
804861
/* c8 ignore start - External HTTP/HTTPS download request */
805862
const request = httpModule.request(
806863
requestOptions,
807-
(res: IncomingMessage) => {
864+
(res: IncomingResponse) => {
808865
// Handle redirects
809866
if (
810867
followRedirects &&
@@ -1039,7 +1096,7 @@ async function httpRequestAttempt(
10391096
/* c8 ignore start - External HTTP/HTTPS request */
10401097
const request = httpModule.request(
10411098
requestOptions,
1042-
(res: IncomingMessage) => {
1099+
(res: IncomingResponse) => {
10431100
if (
10441101
followRedirects &&
10451102
res.statusCode &&

test/unit/http-request.test.mts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,13 @@ import {
2525
httpRequest,
2626
httpText,
2727
parseChecksums,
28+
readIncomingResponse,
2829
} from '@socketsecurity/lib/http-request'
2930
import type {
3031
HttpHookRequestInfo,
3132
HttpHookResponseInfo,
33+
IncomingRequest,
34+
IncomingResponse,
3235
} from '@socketsecurity/lib/http-request'
3336
import { Logger } from '@socketsecurity/lib/logger'
3437
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
@@ -216,6 +219,12 @@ afterAll(async () => {
216219
})
217220
})
218221

222+
function makeRawRequest(url: string): Promise<http.IncomingMessage> {
223+
return new Promise((resolve, reject) => {
224+
http.get(url, resolve).on('error', reject)
225+
})
226+
}
227+
219228
describe('http-request', () => {
220229
describe('httpRequest', () => {
221230
it('should make a simple GET request', async () => {
@@ -2117,4 +2126,138 @@ abc123def456789012345678901234567890123456789012345678901234abcd
21172126
}
21182127
})
21192128
})
2129+
2130+
describe('type aliases', () => {
2131+
it('IncomingResponse should be assignable from http.IncomingMessage', () => {
2132+
const msg: http.IncomingMessage = {} as http.IncomingMessage
2133+
const response: IncomingResponse = msg
2134+
expect(response).toBe(msg)
2135+
})
2136+
2137+
it('IncomingRequest should be assignable from http.IncomingMessage', () => {
2138+
const msg: http.IncomingMessage = {} as http.IncomingMessage
2139+
const request: IncomingRequest = msg
2140+
expect(request).toBe(msg)
2141+
})
2142+
})
2143+
2144+
describe('readIncomingResponse', () => {
2145+
it('should read a 200 JSON response', async () => {
2146+
const msg = await makeRawRequest(`${httpBaseUrl}/json`)
2147+
const response = await readIncomingResponse(msg)
2148+
2149+
expect(response.ok).toBe(true)
2150+
expect(response.status).toBe(200)
2151+
expect(response.statusText).toBe('OK')
2152+
expect(response.json()).toEqual({ message: 'Hello, World!', status: 'success' })
2153+
expect(response.headers['content-type']).toBe('application/json')
2154+
expect(response.rawResponse).toBe(msg)
2155+
})
2156+
2157+
it('should read a plain text response', async () => {
2158+
const msg = await makeRawRequest(`${httpBaseUrl}/text`)
2159+
const response = await readIncomingResponse(msg)
2160+
2161+
expect(response.ok).toBe(true)
2162+
expect(response.status).toBe(200)
2163+
expect(response.text()).toBe('Plain text response')
2164+
})
2165+
2166+
it('should handle 404 responses', async () => {
2167+
const msg = await makeRawRequest(`${httpBaseUrl}/not-found`)
2168+
const response = await readIncomingResponse(msg)
2169+
2170+
expect(response.ok).toBe(false)
2171+
expect(response.status).toBe(404)
2172+
expect(response.statusText).toBe('Not Found')
2173+
expect(response.text()).toBe('Not Found')
2174+
})
2175+
2176+
it('should handle 500 server errors', async () => {
2177+
const msg = await makeRawRequest(`${httpBaseUrl}/server-error`)
2178+
const response = await readIncomingResponse(msg)
2179+
2180+
expect(response.ok).toBe(false)
2181+
expect(response.status).toBe(500)
2182+
expect(response.text()).toBe('Internal Server Error')
2183+
})
2184+
2185+
it('should provide arrayBuffer from body', async () => {
2186+
const msg = await makeRawRequest(`${httpBaseUrl}/text`)
2187+
const response = await readIncomingResponse(msg)
2188+
const ab = response.arrayBuffer()
2189+
2190+
expect(ab).toBeInstanceOf(ArrayBuffer)
2191+
expect(ab.byteLength).toBeGreaterThan(0)
2192+
expect(Buffer.from(ab).toString('utf8')).toBe('Plain text response')
2193+
})
2194+
2195+
it('should handle binary response data', async () => {
2196+
const msg = await makeRawRequest(`${httpBaseUrl}/binary`)
2197+
const response = await readIncomingResponse(msg)
2198+
2199+
expect(response.ok).toBe(true)
2200+
expect(response.body.length).toBe(7)
2201+
expect(response.body[0]).toBe(0x00)
2202+
expect(response.body[1]).toBe(0x01)
2203+
expect(response.body[6]).toBe(0xfd)
2204+
})
2205+
2206+
it('should produce same result as httpRequest for same endpoint', async () => {
2207+
const msg = await makeRawRequest(`${httpBaseUrl}/json`)
2208+
const fromRaw = await readIncomingResponse(msg)
2209+
const fromLib = await httpRequest(`${httpBaseUrl}/json`)
2210+
2211+
expect(fromRaw.ok).toBe(fromLib.ok)
2212+
expect(fromRaw.status).toBe(fromLib.status)
2213+
expect(fromRaw.json()).toEqual(fromLib.json())
2214+
expect(fromRaw.text()).toBe(fromLib.text())
2215+
})
2216+
2217+
it('should handle large response bodies', async () => {
2218+
const msg = await makeRawRequest(`${httpBaseUrl}/large-body`)
2219+
const response = await readIncomingResponse(msg)
2220+
2221+
expect(response.ok).toBe(true)
2222+
expect(response.text()).toBe('X'.repeat(10_000))
2223+
expect(response.body.length).toBe(10_000)
2224+
})
2225+
2226+
it('should default status to 0 when statusCode is undefined', async () => {
2227+
const { Readable } = await import('node:stream')
2228+
const fakeMsg = new Readable({
2229+
read() {
2230+
this.push('body')
2231+
this.push(null)
2232+
},
2233+
}) as unknown as IncomingResponse
2234+
Object.assign(fakeMsg, {
2235+
headers: {},
2236+
statusCode: undefined,
2237+
statusMessage: undefined,
2238+
})
2239+
2240+
const response = await readIncomingResponse(fakeMsg)
2241+
2242+
expect(response.status).toBe(0)
2243+
expect(response.statusText).toBe('')
2244+
expect(response.ok).toBe(false)
2245+
expect(response.text()).toBe('body')
2246+
})
2247+
2248+
it('should preserve response headers', async () => {
2249+
const msg = await makeRawRequest(`${httpBaseUrl}/json`)
2250+
const response = await readIncomingResponse(msg)
2251+
2252+
expect(response.headers['content-type']).toBe('application/json')
2253+
expect(response.headers).toBeDefined()
2254+
})
2255+
2256+
it('should throw on invalid JSON from json()', async () => {
2257+
const msg = await makeRawRequest(`${httpBaseUrl}/text`)
2258+
const response = await readIncomingResponse(msg)
2259+
2260+
expect(() => response.json()).toThrow()
2261+
})
2262+
})
21202263
})

0 commit comments

Comments
 (0)