Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 45 additions & 4 deletions src/http-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ function getFs() {

import type { IncomingHttpHeaders, IncomingMessage } from 'http'

/** IncomingMessage received as a response to a client request (http.request callback). */
export type IncomingResponse = IncomingMessage

/** IncomingMessage received as a request in a server handler (http.createServer callback). */
export type IncomingRequest = IncomingMessage

import type { Logger } from './logger.js'

let _crypto: typeof import('node:crypto') | undefined
Expand Down Expand Up @@ -392,11 +398,46 @@ export interface HttpResponse {
*/
text(): string
/**
* The underlying Node.js IncomingMessage for advanced use cases
* The underlying Node.js IncomingResponse for advanced use cases
* (e.g., streaming, custom header inspection). Only available when
* the response was not consumed by the convenience methods.
*/
rawResponse?: IncomingMessage | undefined
rawResponse?: IncomingResponse | undefined
}

/**
* Read and buffer a client-side IncomingResponse into an HttpResponse.
*
* Useful when you have a raw response from code that bypasses
* `httpRequest()` (e.g., multipart form-data uploads via `http.request()`,
* or responses from third-party HTTP libraries) and need to convert it
* into the standard HttpResponse interface.
*/
export async function readIncomingResponse(
msg: IncomingResponse,
): Promise<HttpResponse> {
const chunks: Buffer[] = []
for await (const chunk of msg) {
chunks.push(chunk as Buffer)
}
const body = Buffer.concat(chunks)
const status = msg.statusCode ?? 0
const statusText = msg.statusMessage ?? ''
return {
arrayBuffer: () =>
body.buffer.slice(
body.byteOffset,
body.byteOffset + body.byteLength,
) as ArrayBuffer,
body,
headers: msg.headers,
json: <T = unknown>() => JSON.parse(body.toString('utf8')) as T,
ok: status >= 200 && status < 300,
rawResponse: msg,
status,
statusText,
text: () => body.toString('utf8'),
}
}

/**
Expand Down Expand Up @@ -804,7 +845,7 @@ async function httpDownloadAttempt(
/* c8 ignore start - External HTTP/HTTPS download request */
const request = httpModule.request(
requestOptions,
(res: IncomingMessage) => {
(res: IncomingResponse) => {
// Handle redirects
if (
followRedirects &&
Expand Down Expand Up @@ -1039,7 +1080,7 @@ async function httpRequestAttempt(
/* c8 ignore start - External HTTP/HTTPS request */
const request = httpModule.request(
requestOptions,
(res: IncomingMessage) => {
(res: IncomingResponse) => {
if (
followRedirects &&
res.statusCode &&
Expand Down
143 changes: 143 additions & 0 deletions test/unit/http-request.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,13 @@ import {
httpRequest,
httpText,
parseChecksums,
readIncomingResponse,
} from '@socketsecurity/lib/http-request'
import type {
HttpHookRequestInfo,
HttpHookResponseInfo,
IncomingRequest,
IncomingResponse,
} from '@socketsecurity/lib/http-request'
import { Logger } from '@socketsecurity/lib/logger'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
Expand Down Expand Up @@ -216,6 +219,12 @@ afterAll(async () => {
})
})

function makeRawRequest(url: string): Promise<http.IncomingMessage> {
return new Promise((resolve, reject) => {
http.get(url, resolve).on('error', reject)
})
}

describe('http-request', () => {
describe('httpRequest', () => {
it('should make a simple GET request', async () => {
Expand Down Expand Up @@ -2117,4 +2126,138 @@ abc123def456789012345678901234567890123456789012345678901234abcd
}
})
})

describe('type aliases', () => {
it('IncomingResponse should be assignable from http.IncomingMessage', () => {
const msg: http.IncomingMessage = {} as http.IncomingMessage
const response: IncomingResponse = msg
expect(response).toBe(msg)
})

it('IncomingRequest should be assignable from http.IncomingMessage', () => {
const msg: http.IncomingMessage = {} as http.IncomingMessage
const request: IncomingRequest = msg
expect(request).toBe(msg)
})
})

describe('readIncomingResponse', () => {
it('should read a 200 JSON response', async () => {
const msg = await makeRawRequest(`${httpBaseUrl}/json`)
const response = await readIncomingResponse(msg)

expect(response.ok).toBe(true)
expect(response.status).toBe(200)
expect(response.statusText).toBe('OK')
expect(response.json()).toEqual({ message: 'Hello, World!', status: 'success' })
expect(response.headers['content-type']).toBe('application/json')
expect(response.rawResponse).toBe(msg)
})

it('should read a plain text response', async () => {
const msg = await makeRawRequest(`${httpBaseUrl}/text`)
const response = await readIncomingResponse(msg)

expect(response.ok).toBe(true)
expect(response.status).toBe(200)
expect(response.text()).toBe('Plain text response')
})

it('should handle 404 responses', async () => {
const msg = await makeRawRequest(`${httpBaseUrl}/not-found`)
const response = await readIncomingResponse(msg)

expect(response.ok).toBe(false)
expect(response.status).toBe(404)
expect(response.statusText).toBe('Not Found')
expect(response.text()).toBe('Not Found')
})

it('should handle 500 server errors', async () => {
const msg = await makeRawRequest(`${httpBaseUrl}/server-error`)
const response = await readIncomingResponse(msg)

expect(response.ok).toBe(false)
expect(response.status).toBe(500)
expect(response.text()).toBe('Internal Server Error')
})

it('should provide arrayBuffer from body', async () => {
const msg = await makeRawRequest(`${httpBaseUrl}/text`)
const response = await readIncomingResponse(msg)
const ab = response.arrayBuffer()

expect(ab).toBeInstanceOf(ArrayBuffer)
expect(ab.byteLength).toBeGreaterThan(0)
expect(Buffer.from(ab).toString('utf8')).toBe('Plain text response')
})

it('should handle binary response data', async () => {
const msg = await makeRawRequest(`${httpBaseUrl}/binary`)
const response = await readIncomingResponse(msg)

expect(response.ok).toBe(true)
expect(response.body.length).toBe(7)
expect(response.body[0]).toBe(0x00)
expect(response.body[1]).toBe(0x01)
expect(response.body[6]).toBe(0xfd)
})

it('should produce same result as httpRequest for same endpoint', async () => {
const msg = await makeRawRequest(`${httpBaseUrl}/json`)
const fromRaw = await readIncomingResponse(msg)
const fromLib = await httpRequest(`${httpBaseUrl}/json`)

expect(fromRaw.ok).toBe(fromLib.ok)
expect(fromRaw.status).toBe(fromLib.status)
expect(fromRaw.json()).toEqual(fromLib.json())
expect(fromRaw.text()).toBe(fromLib.text())
})

it('should handle large response bodies', async () => {
const msg = await makeRawRequest(`${httpBaseUrl}/large-body`)
const response = await readIncomingResponse(msg)

expect(response.ok).toBe(true)
expect(response.text()).toBe('X'.repeat(10_000))
expect(response.body.length).toBe(10_000)
})

it('should default status to 0 when statusCode is undefined', async () => {
const { Readable } = await import('node:stream')
const fakeMsg = new Readable({
read() {
this.push('body')
this.push(null)
},
}) as unknown as IncomingResponse
Object.assign(fakeMsg, {
headers: {},
statusCode: undefined,
statusMessage: undefined,
})

const response = await readIncomingResponse(fakeMsg)

expect(response.status).toBe(0)
expect(response.statusText).toBe('')
expect(response.ok).toBe(false)
expect(response.text()).toBe('body')
})

it('should preserve response headers', async () => {
const msg = await makeRawRequest(`${httpBaseUrl}/json`)
const response = await readIncomingResponse(msg)

expect(response.headers['content-type']).toBe('application/json')
expect(response.headers).toBeDefined()
})

it('should throw on invalid JSON from json()', async () => {
const msg = await makeRawRequest(`${httpBaseUrl}/text`)
const response = await readIncomingResponse(msg)

expect(() => response.json()).toThrow()
})
})
})
Loading