Skip to content

Commit

Permalink
feat: support IncomingMessage responses
Browse files Browse the repository at this point in the history
  • Loading branch information
kettanaito committed Oct 1, 2023
1 parent d7e1e30 commit 8cd47cb
Show file tree
Hide file tree
Showing 5 changed files with 253 additions and 35 deletions.
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ npm install @har-tools/http-message

Creates a new HTTP message from the given start line, heades, and optional body.

### `HttpMessage.from(request)`
### `HttpMessage.fromRequest(request)`

Creates a new HTTP message from the given Fetch API `Request` instance.
- `request: Request`

Creates a new HTTP message from the given request instance.

```js
const request = new Request('https://example.com', {
Expand All @@ -32,9 +34,11 @@ console.log(message)
// Hello world!
```

### `HttpMessage.from(response)`
### `HttpMessage.fromResponse(response)`

- `response: Response | http.IncomingMessage`

Creates a new HTTP message from the given Fetch API `Respnse` instance.
Creates a new HTTP message from the given response instane.

```js
const response = new Response(
Expand All @@ -59,3 +63,5 @@ console.log(message)
//
// {"id":1,"name":"John"}
```

> Make sure to _clone the response_ before passing it to the `.fromResponse()` method. This method reads the response body.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,9 @@
"@types/node": "^20.8.0",
"typescript": "^5.2.2",
"vitest": "^0.34.6"
},
"dependencies": {
"@open-draft/deferred-promise": "^2.2.0",
"outvariant": "^1.4.0"
}
}
14 changes: 14 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

160 changes: 137 additions & 23 deletions src/HttpMessage.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,115 @@
import { STATUS_CODES } from 'node:http'
import { STATUS_CODES, IncomingMessage, IncomingHttpHeaders } from 'node:http'
import { invariant } from 'outvariant'
import { DeferredPromise } from '@open-draft/deferred-promise'

export type RawHeaders = Record<string, string>

export class HttpMessage {
static httpVersion = 'HTTP/1.0'

static async from(instance: Request | Response): Promise<HttpMessage> {
const startLine =
instance instanceof Request
? `${instance.method} ${instance.url} ${HttpMessage.httpVersion}`
: `${HttpMessage.httpVersion} ${instance.status} ${
instance.statusText || STATUS_CODES[instance.status]
}`
const body = await getBodyOrUndefined(instance)
static async fromRequest(request: Request): Promise<HttpMessage> {
const startLine = `${request.method} ${request.url} ${HttpMessage.httpVersion}`
const headers = Object.fromEntries(request.headers.entries())
const body = await extractFetchBody(request)

return new HttpMessage(startLine, instance.headers, body)
return new HttpMessage(startLine, headers, body)
}

static async fromResponse(
response: Response | IncomingMessage
): Promise<HttpMessage> {
// Fetch API response.
if (response instanceof Response) {
const statusText = response.statusText || STATUS_CODES[response.status]
const startLine = `${HttpMessage.httpVersion} ${response.status} ${statusText}`
const headers = Object.fromEntries(response.headers.entries())
const body = await extractFetchBody(response)

return new HttpMessage(startLine, headers, body)
}

// http.IncomingMessage response.
if (response instanceof IncomingMessage) {
const status = response.statusCode || 200
const statusText = response.statusMessage || STATUS_CODES[status]
// Infer the HTTP version from IncomingMessage directly.
const httpVersion = response.httpVersion || HttpMessage.httpVersion
const startLine = `${httpVersion} ${status} ${statusText}`
const headers = headersFromIncomingHttpHeaders(response.headers)
const body = await extractHttpIncomingMessageBody(response)

return new HttpMessage(startLine, headers, body)
}

invariant(
false,
'Failed to create HTTP message from response: expected a Fetch API Response instance or http.IncomingMessage but got %s',
response != null
? // @ts-expect-error
response.constructor?.name
: typeof response
)
}

/**
* Encoding of this HTTP message.
* Inferred from the `Content-Encoding` header, otherwise undefined.
*/
public encoding?: BufferEncoding

/**
* Content type of this HTTP message.
* Inferred from the `Content-Type` header, otherwise an empty string.
*/
public mimeType: string

/**
* HTTP headers string of this message.
* Includes a double CRLF at the end if this message has body.
* @example
* "content-type: text/plain;charset=UTF-8"
*/
public headers: string

/**
* Total number of bytes from the start of the HTTP message
* until, and including, the double CRLF before the body.
*/
public headersSize: number

/**
* Size of the request body in bytes.
*/
public bodySize: number

constructor(
/**
* Start line of the HTTP message.
* @example
* // For requests:
* "GET /resource HTTP/1.0"
* // For responses:
* "HTTP/1.0 200 OK"
*/
protected startLine: string,
headers: Headers,
public rawHeaders: RawHeaders,
public body: string | undefined
) {
this.encoding = (headers.get('Content-Encoding') ||
const fetchHeaders = new Headers(this.rawHeaders)

this.encoding = (fetchHeaders.get('Content-Encoding') ||
undefined) as BufferEncoding

this.mimeType = headers.get('Content-Type') || ''
this.mimeType = fetchHeaders.get('Content-Type') || ''
this.bodySize =
body == null ? 0 : Buffer.from(body, this.encoding).byteLength

const headersFields = toRawHeaders(headers)
if (headersFields.length === 0) {
const headerLines = toHeaderLines(this.rawHeaders)

if (headerLines.length === 0) {
this.headers = ''
} else {
this.headers = `${headersFields.join('\r\n')}\r\n`
this.headers = `${headerLines.join('\r\n')}\r\n`

if (this.bodySize > 0) {
this.headers += '\r\n'
Expand All @@ -63,6 +128,16 @@ export class HttpMessage {
return this.headersSize + this.bodySize
}

/**
* Returns a string representation of this HTTP message.
* @example
* message.toString()
* `HTTP/1.0 200 OK
* content-type: text/plain;charset=UTF-8
* x-custom-header: Value
*
* Hello world!`
*/
public toString(): string {
let message = `${this.startLine}\r\n`
const rawHeaders = this.headers
Expand All @@ -80,17 +155,34 @@ export class HttpMessage {
}
}

function toRawHeaders(headers: Headers): Array<string> {
const raw: Array<string> = []
function toHeaderLines(rawHeaders: RawHeaders): Array<string> {
const lines: Array<string> = []

headers.forEach((value, name) => {
raw.push(`${name}: ${value}`)
})
for (const [name, value] of Object.entries(rawHeaders)) {
lines.push(`${name}: ${value}`)
}

return raw
return lines
}

async function getBodyOrUndefined(
function headersFromIncomingHttpHeaders(
headers: IncomingHttpHeaders
): RawHeaders {
const result: RawHeaders = {}

for (const [name, value] of Object.entries(headers)) {
if (typeof value === 'undefined') {
continue
}

const resolvedValue = Array.isArray(value) ? value.join(', ') : value
result[name] = resolvedValue
}

return result
}

async function extractFetchBody(
instance: Request | Response
): Promise<string | undefined> {
if (!instance.body) {
Expand All @@ -102,3 +194,25 @@ async function getBodyOrUndefined(
// That's also the right surface to clone the instance.
return instance.text()
}

async function extractHttpIncomingMessageBody(
message: IncomingMessage
): Promise<string | undefined> {
const responseBodyPromise = new DeferredPromise<string | undefined>()
const chunks: Array<Buffer> = []

invariant(
message.readable,
'Failed to read the body of IncomingMessage: message already read'
)

message.on('data', (chunk) => chunks.push(chunk))
message.on('error', (error) => responseBodyPromise.reject(error))
message.on('end', () => {
const text =
chunks.length === 0 ? undefined : Buffer.concat(chunks).toString('utf8')
responseBodyPromise.resolve(text)
})

return responseBodyPromise
}
Loading

0 comments on commit 8cd47cb

Please sign in to comment.