/**
* HTTP Networking APIs
*
* The HTTP interfaces are built to make it easier to use traditionally difficult protocol
* features. By never buffering complete requests or responses, users can stream data
* instead, making data transmission more efficient and flexible.
*
* @see {@link https://undici.nodejs.org/#/}
*
* @module HTTP
*/
import net from 'net';
import assert from 'assert';
import { EventEmitter } from 'events';
const binding = process.binding('http_parser');
/**
* A list of the HTTP methods that are supported by the parser.
* @constant {string[]}
*/
export const METHODS = [
'ACL',
'BIND',
'CHECKOUT',
'CONNECT',
'COPY',
'DELETE',
'GET',
'HEAD',
'LINK',
'LOCK',
'M-SEARCH',
'MERGE',
'MKACTIVITY',
'MKCALENDAR',
'MKCOL',
'MOVE',
'NOTIFY',
'OPTIONS',
'PATCH',
'POST',
'PROPFIND',
'PROPPATCH',
'PURGE',
'PUT',
'REBIND',
'REPORT',
'SEARCH',
'SOURCE',
'SUBSCRIBE',
'TRACE',
'UNBIND',
'UNLINK',
'UNLOCK',
'UNSUBSCRIBE',
];
/**
* A collection of all the standard HTTP response status codes.
* @constant {string[]}
*/
export const STATUS_CODES = {
100: 'Continue',
101: 'Switching Protocols',
102: 'Processing',
103: 'Early Hints',
200: 'OK',
201: 'Created',
202: 'Accepted',
203: 'Non-Authoritative Information',
204: 'No Content',
205: 'Reset Content',
206: 'Partial Content',
207: 'Multi-Status',
208: 'Already Reported',
226: 'IM Used',
300: 'Multiple Choices',
301: 'Moved Permanently',
302: 'Found',
303: 'See Other',
304: 'Not Modified',
305: 'Use Proxy',
307: 'Temporary Redirect',
308: 'Permanent Redirect',
400: 'Bad Request',
401: 'Unauthorized',
402: 'Payment Required',
403: 'Forbidden',
404: 'Not Found',
405: 'Method Not Allowed',
406: 'Not Acceptable',
407: 'Proxy Authentication Required',
408: 'Request Timeout',
409: 'Conflict',
410: 'Gone',
411: 'Length Required',
412: 'Precondition Failed',
413: 'Payload Too Large',
414: 'URI Too Long',
415: 'Unsupported Media Type',
416: 'Range Not Satisfiable',
417: 'Expectation Failed',
418: "I'm a Teapot",
421: 'Misdirected Request',
422: 'Unprocessable Entity',
423: 'Locked',
424: 'Failed Dependency',
425: 'Too Early',
426: 'Upgrade Required',
428: 'Precondition Required',
429: 'Too Many Requests',
431: 'Request Header Fields Too Large',
451: 'Unavailable For Legal Reasons',
500: 'Internal Server Error',
501: 'Not Implemented',
502: 'Bad Gateway',
503: 'Service Unavailable',
504: 'Gateway Timeout',
505: 'HTTP Version Not Supported',
506: 'Variant Also Negotiates',
507: 'Insufficient Storage',
508: 'Loop Detected',
509: 'Bandwidth Limit Exceeded',
510: 'Not Extended',
511: 'Network Authentication Required',
};
function makeDeferredPromise() {
// Extract the resolve method from the promise.
const promiseExt = {};
const promise = new Promise((resolve, reject) => {
promiseExt.resolve = resolve;
promiseExt.reject = reject;
});
return { promise, promiseExt };
}
function concatUint8Arrays(...arrays) {
return arrays.reduce(
(acc, array) => new Uint8Array([...acc, ...array]),
new Uint8Array(0)
);
}
function toUint8Array(data, encoding) {
if (!(data instanceof Uint8Array)) {
return new TextEncoder(encoding).encode(data);
}
return data;
}
function isIterable(input) {
if (input === null || input === undefined) return false;
return (
typeof input[Symbol.iterator] === 'function' ||
typeof input[Symbol.asyncIterator] === 'function'
);
}
function isString(input) {
if (input === null || input === undefined) return false;
return typeof input === 'string';
}
function isTypedArray(input) {
if (input === null || input === undefined) return false;
return input instanceof Uint8Array;
}
function isAcceptableBodyType(input) {
if (input === null || input === undefined) return true;
return isString(input) || isTypedArray(input) || isIterable(input);
}
function assertChunkType(chunk) {
if (!isString(chunk) && !isTypedArray(chunk)) {
throw new Error('Each chunk must be of type string or Uint8Array.');
}
}
const capitalizeFirstLetter = (s) => s.charAt(0).toUpperCase() + s.slice(1);
function formatHeaders(headers) {
const kHeaders = {};
for (const [key, value] of headers.entries()) {
const name = key.split('-').map(capitalizeFirstLetter).join('-');
kHeaders[name] = value;
}
return kHeaders;
}
async function* wrapIterable(iterable) {
let result;
let iterator = iterable[Symbol.asyncIterator]();
while ((result = await iterator.next())) {
if (result.done) break;
yield result.value;
}
}
const urlRegex = new RegExp('^(.*:)//([A-Za-z0-9-.]+)(:[0-9]+)?(.*)$');
/**
* An outgoing HTTP request to a remote host.
* @ignore
*/
class Request {
#hostname;
#port;
#path;
#method;
#timeout;
#throwOnError;
#body;
#bodyLength;
#socket;
#headers;
#isChunkedEncoding;
#signal;
constructor(url, options) {
// Include protocol in URL.
const checkedUrl = url.includes('://') ? url : 'http://' + url;
const [_, protocol, hostname, port, path] = urlRegex.exec(checkedUrl); // eslint-disable-line no-unused-vars
// Only HTTP requests are supported.
if (protocol !== 'http:') {
throw new Error(`Protocol "${protocol}" not supported.`);
}
this.#hostname = hostname;
this.#port = port ? Number(port.replace(':', '')) : 80;
this.#path = path || '/';
this.#method = options.method.toUpperCase();
this.#signal = options.signal;
// Check if HTTP method is valid.
if (!METHODS.includes(this.#method)) {
throw new Error(`HTTP method "${this.#method}" is not recognized.`);
}
this.#timeout = options.timeout;
this.#throwOnError = options.throwOnError;
this.#body = options.body;
this.#bodyLength = this.#body?.length || 0;
// Check HTTP's body (if specified)
if (this.#body && !isAcceptableBodyType(this.#body)) {
throw new TypeError(
'The body must be of type string, Uint8Array or an iterable object.'
);
}
this.#headers = new Map();
this.#headers.set('host', this.#hostname + ':' + this.#port);
this.#headers.set('user-agent', `dune/${process.version}`);
this.#headers.set('accept', '*/*');
this.#headers.set('connection', 'close');
this.#headers.set('content-length', this.#bodyLength);
// Check if encoding should be chunked.
if (
isIterable(this.#body) &&
!(isString(this.#body) || isTypedArray(this.#body))
) {
// Content-Length and Transfer-Encoding are mutual exclusive HTTP headers.
this.#isChunkedEncoding = true;
this.#headers.set('transfer-encoding', 'chunked');
this.#headers.delete('content-length');
}
// Override headers with user-defined ones.
for (const [name, value] of Object.entries(options.headers)) {
this.#headers.set(name.toLowerCase(), value);
}
this.#socket = new net.Socket();
}
async send() {
// Start building the HTTP message.
const encoder = new TextEncoder();
const reqHeaders = [`${this.#method} ${this.#path} HTTP/1.1`];
// Format and append HTTP headers to message.
const headers = formatHeaders(this.#headers);
for (const [name, value] of Object.entries(headers)) {
reqHeaders.push(`${name.trim()}: ${value}`);
}
const reqHeadersString = reqHeaders.join('\r\n');
const reqHeadersBytes = encoder.encode(`${reqHeadersString}\r\n\r\n`);
// Write headers to the socket.
await this.#socket.connect(this.#port, this.#hostname);
await this.#socket.write(reqHeadersBytes);
// Subscribe to the abort-controller if provided.
if (this.#signal) {
this.#signal.addEventListener('abort', () => this.#socket.destroy());
}
// Write body to the socket (sized).
if (this.#body && !this.#isChunkedEncoding) {
this.#socket.write(this.#body);
}
// Write body to the socket (chunked).
if (this.#body && this.#isChunkedEncoding) {
for await (const chunk of this.#body) {
assertChunkType(chunk);
await this.#socket.write(`${chunk.length.toString(16)}\r\n`);
await this.#socket.write(chunk);
await this.#socket.write('\r\n');
}
// Write the final chunk of size 0 to indicate the end of the body.
await this.#socket.write('0\r\n\r\n');
}
this.#socket.setTimeout(this.#timeout);
// Set up a buffer to hold the incoming data.
let buffer = new Uint8Array();
for await (const data of wrapIterable(this.#socket)) {
// Concatenate existing buffer with new data.
buffer = concatUint8Arrays(buffer, data);
const metadata = binding.parseResponse(buffer);
// Response headers are still incomplete.
if (!metadata) continue;
// Check status code and throw if requested.
if (metadata.statusCode >= 400 && this.#throwOnError) {
const message = STATUS_CODES[metadata.statusCode];
throw new Error(`HTTP request failed with error: "${message}"`);
}
// Remove headers data from buffer.
buffer = buffer.subarray(metadata.marker);
return new IncomingResponse(metadata, buffer, this.#socket);
}
}
}
/**
* An HTTP response from a remote host.
*/
class IncomingResponse {
#statusCode;
#headers;
#body;
constructor(metadata, buffer, socket) {
this.#statusCode = metadata.statusCode;
this.#headers = metadata.headers;
this.#body = new Body(metadata, buffer, socket, false);
}
/**
* Retrieves the status code of the HTTP response.
*
* @returns {number} - The HTTP status code.
*/
get statusCode() {
return this.#statusCode;
}
/**
* Retrieves the headers of the HTTP response.
*
* @returns {Object} - An object containing the HTTP response headers.
*/
get headers() {
return this.#headers;
}
/**
* Retrieves the body of the HTTP response.
*
* @returns {Body} - An instance of the HTTP `Body` class.
*/
get body() {
return this.#body;
}
}
/**
* A wrapper around a request/response HTTP body.
*/
class Body {
#socket;
#body;
#bodyLength;
#isChunked;
#isComplete;
#keepAlive;
constructor({ headers }, buffer, socket, keepAlive = true) {
this.#body = buffer;
this.#bodyLength = Number.parseInt(headers['content-length']) || 0;
this.#isChunked = headers['transfer-encoding']?.includes('chunked');
this.#isComplete = this.#body?.length === this.#bodyLength;
this.#keepAlive = keepAlive;
this.#socket = socket;
if (this.#isComplete && !this.#isChunked && !keepAlive) {
this.#socket.end();
this.#socket = undefined;
}
}
/**
* Formats the body to a UTF-8 string.
*
* @returns {Promise<string>} The complete body as a UTF-8 string.
*/
async text() {
const string = [];
const decoder = new TextDecoder();
const asyncIterator = this[Symbol.asyncIterator]();
for await (const chunk of asyncIterator) {
string.push(decoder.decode(chunk));
}
return string.join('');
}
/**
* Formats the body to an actual JSON object.
*
* @returns {Promise<Object>} The complete body as a JavaScript object.
*/
async json() {
const data = await this.text();
return JSON.parse(data);
}
/**
* The HTTP body should be async iterable.
* @ignore
*/
async *[Symbol.asyncIterator](signal) {
// Close socket on stream pipeline errors.
if (signal) signal.on('uncaughtStreamException', () => this.#socket.end());
if (this.#isComplete && !this.#isChunked) {
const remainingContent = this.#body.subarray(0, this.#bodyLength);
this.#body = this.#body.subarray(remainingContent.length);
yield remainingContent;
return;
}
// TODO: Check if chunks are available from the start.
// Node.js for example combines the first chunk with the HTTP headers when
// sending responses with chunked encoding.
for await (const newData of wrapIterable(this.#socket)) {
// Mix current body with new data.
this.#body = concatUint8Arrays(this.#body, newData);
if (this.#isChunked) {
// Try extracting available chunks.
const result = binding.parseChunks(this.#body);
// No results means not enough bytes to extract the next chunk.
if (result) {
this.#body = this.#body.subarray(result.position);
yield* result.chunks;
if (result.done) break;
}
} else {
// Note: The following code handles the case when the HTTP's body
// length is already known from the `Content-Length` header
// but, it comes to us in multiple TCP packets.
if (this.#body.length >= this.#bodyLength) {
yield this.#body.subarray(0, this.#bodyLength);
this.#body = this.#body.subarray(this.#bodyLength);
break;
}
}
}
// Close TCP socket on not keep-alive connections.
if (!this.#keepAlive) this.#socket.end();
}
}
const kAsyncGenerator = Symbol('kAsyncGenerator');
/**
* An object capable of serving HTTP requests.
*
* @fires request - Emitted each time there is a request.
* @fires close - Emitted when the server closes.
* @fires clientError - Emitted when a client connection emits an 'error' event.
*/
export class Server extends EventEmitter {
#tcp;
#pushQueue;
#pullQueue;
/**
* Creates a new Server instance.
*
* @returns {Server} An instance of the HTTP `Server` class.
*/
constructor() {
super();
this.#pushQueue = [];
this.#pullQueue = [];
// Setting up the underling TCP server.
this.#tcp = net.createServer(this.#handleConnectionSafely.bind(this));
this.#tcp.on('close', () => this.emit('close'));
}
/**
* An object representing an HTTP connection.
*
* @typedef Connection
* @property {ServerRequest} request - The incoming HTTP request.
* @property {ServerResponse} response - The HTTP response that will be sent by the server.
*/
/**
* Waits for a client to connect and accepts the HTTP request.
*
* @returns {Promise<Connection>} An object representing an HTTP connection.
*/
accept() {
// No available requests yet.
if (this.#pushQueue.length === 0) {
const { promise, promiseExt } = makeDeferredPromise();
this.#pullQueue.push(promiseExt);
return promise;
}
const conn = this.#pushQueue.shift();
const action = conn instanceof Error ? Promise.reject : Promise.resolve;
return action.call(Promise, conn);
}
async #handleConnectionSafely(socket) {
try {
await this.#handleConnection(socket);
} catch (err) {
// Don't crash the server for a single misbehaving socket.
if (err?.code !== 'ERR_CONNECTION_RESET') {
throw err;
}
}
}
async #handleConnection(socket) {
// Set-up client event dispatcher.
socket.on('error', (err) => this.emit('clientError', err));
// Set up a buffer to hold the incoming data.
let buffer = new Uint8Array();
for await (const data of socket) {
// Concatenate existing buffer with new data.
buffer = concatUint8Arrays(buffer, data);
// Try parsing the HTTP headers.
let metadata;
try {
metadata = binding.parseRequest(buffer);
} catch (_) {
const message = 'HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n';
await socket.write(message);
break;
}
// Request headers are still incomplete.
if (!metadata) continue;
buffer = buffer.subarray(metadata.marker);
// Create the request and response streams.
const request = new ServerRequest(metadata, buffer, socket);
const response = new ServerResponse(metadata, socket);
// Check if a request handler is specified; if so, emit the 'request' event.
const hasRequestHandler = this.listenerCount('request') > 0;
hasRequestHandler
? this.emit('request', request, response)
: this.#asyncDispatch({ request, response });
// Hack: To support persistent connections, we employ this technique to delay
// accepting a new request from the same socket until the current
// request-response cycle is complete.
await new Promise((resolve) => response.once('finish', resolve));
// Connection should close based on headers.
if (response.getHeader('connection') === 'close') break;
}
}
#asyncDispatch(socket) {
if (this.#pullQueue.length === 0) {
this.#pushQueue.push(socket);
return;
}
const promise = this.#pullQueue.shift();
const action = socket instanceof Error ? promise.reject : promise.resolve;
action(socket);
}
/**
* Information about the host TCP socket.
*
* @typedef SocketHost
* @property {number} port - The port number on the local machine.
* @property {string} family - The IP family of the local address (`IPv4` or `IPv6`).
* @property {string} address - The local IP address.
*/
/**
* Starts listening for incoming HTTP connections.
*
* @param {(string|number)} port - The port number or string on which the server should listen.
* @param {string} host - The hostname or IP address on which the server will listen.
* @returns {Promise<SocketHost>} The host information where the server is listening.
*/
async listen(...args) {
return this.#tcp.listen(...args);
}
/**
* Stops the server from accepting new connections.
*/
async close() {
await this.#tcp.close();
}
async *[kAsyncGenerator]() {
let socket;
while ((socket = await this.accept())) {
yield socket;
}
}
/**
* The server should be async iterable.
* @ignore
*/
[Symbol.asyncIterator]() {
const iterator = { return: () => this.close() };
return Object.assign(this[kAsyncGenerator](), iterator);
}
}
/**
* A server-side request object for handling HTTP requests.
*
* @property {string} httpVersion - The HTTP version specified in the request.
* @property {string} method - The HTTP method used for the request
* @property {string} url - The URL being requested.
* @property {Object} headers - An object containing the request headers.
*/
export class ServerRequest {
#body;
constructor(metadata, buffer, socket) {
this.httpVersion = `1.${metadata.version}`;
this.method = metadata.method;
this.url = metadata.path;
this.headers = metadata.headers;
this.#body = new Body(metadata, buffer, socket);
}
/**
* Formats the body to a UTF-8 string.
*
* @returns {Promise<string>} The body content as a UTF-8 string.
*/
async text() {
return this.#body.text();
}
/**
* Formats the body to an actual JSON object.
*
* @returns {Promise<Object>} The body content as JavaScript object.
*/
async json() {
return this.#body.json();
}
/**
* The HTTP request should be async iterable.
* @ignore
*/
async *[Symbol.asyncIterator](signal) {
yield* this.#body[Symbol.asyncIterator](signal);
}
}
/**
* A server-side response object for handling HTTP requests.
*/
class ServerResponse extends EventEmitter {
#socket;
#headers;
#headersSent;
#code;
#message;
#writtenOnce;
#version;
constructor({ version, headers }, socket) {
super();
this.#socket = socket;
this.#headersSent = false;
this.#code = 200;
this.#message = STATUS_CODES[this.#code];
this.#writtenOnce = false;
this.#version = version;
const keepAlive = headers?.connection !== 'close';
// Set default headers.
this.#headers = new Map();
this.#headers.set('date', new Date().toGMTString());
this.#headers.set('connection', keepAlive ? 'keep-alive' : 'close');
this.#headers.set('transfer-encoding', 'chunked');
}
/**
* Writes a chunk of the response body.
*
* @param {(String|Uint8Array)} data - The data to be written to the response body.
* @param {String} [encoding] - The character encoding to use.
*/
async write(data, encoding = 'utf-8') {
// Check the data argument type.
if (!(data instanceof Uint8Array) && typeof data !== 'string') {
throw new TypeError(
`The "data" argument must be of type string or Uint8Array.`
);
}
const content = toUint8Array(data, encoding);
// Make sure headers are sent to client.
if (!this.#headersSent) {
await this.#sendHeaders();
}
const chunkLength = content.length.toString(16);
const chunkedEncoding = this.hasHeader('transfer-encoding');
// Chunkify the provided content.
if (chunkedEncoding) {
await this.#socket.write(`${chunkLength}\r\n`);
await this.#socket.write(content);
await this.#socket.write('\r\n');
this.#writtenOnce = true;
return;
}
await this.#socket.write(content);
this.#writtenOnce = true;
}
/**
* Signals that all of the response headers and body have been sent.
*
* @param {string|Uint8Array} [data] - The final data chunk to be sent with the response.
* @param {string} [encoding] - The character encoding to use.
*/
async end(data, encoding = 'utf-8') {
// If data is given, write to stream.
if (data) {
const content = toUint8Array(data, encoding);
const shouldSetLength = !this.#writtenOnce && !this.#headersSent;
// Note: If the `.end` is called without any `.write` we can
// set the content-length of the response.
if (shouldSetLength) {
this.setHeader('content-length', content.length);
}
await this.write(content);
}
// On chunked response send end-chunk.
if (this.getHeader('transfer-encoding')?.includes('chunked')) {
await this.#socket.write(`0\r\n\r\n`);
}
this.emit('finish');
}
/**
* Sends a response header to the request.
*
* @param {number} code - The HTTP status code for the response.
* @param {string} [message] - A human-readable status message corresponding to the status code.
* @param {Object} [headers] - Additional header fields to include in the response.
*/
async writeHead(...args) {
// Do not send headers multiple times.
if (this.#headersSent) {
throw new Error('Cannot set headers after they are sent.');
}
// Parse variadic arguments.
const [code, message, headers] = this.#parseWriteHeadArgs(args);
if (STATUS_CODES[code] === undefined) {
throw new RangeError(`Not valid HTTP status code "${code}".`);
}
if (typeof message !== 'string') {
throw new TypeError('The "message" argument must be of type string.');
}
this.#code = code;
this.#message = message;
// Override headers with user-defined ones.
for (const [name, value] of Object.entries(headers)) {
assert.string(name);
this.#headers.set(name.toLowerCase(), String(value));
}
await this.#sendHeaders();
}
#parseWriteHeadArgs(args) {
// Use default values on empty or single argument(s).
if (args.length < 2) return [args[0], STATUS_CODES[args[0]], {}];
return typeof args[1] === 'object'
? [args[0], STATUS_CODES[args[0]], args[1]]
: [args[0], args[1], args[2] || {}];
}
/**
* Checks for HTTP header violations, mutual exclusions, etc.
* @ignore
*/
#checkHeaders() {
// HTTP 1.0 doesn't support other encoding.
if (this.#version === 0) {
this.removeHeader('transfer-encoding');
this.removeHeader('content-length');
this.setHeader('connection', 'close');
}
// Per section 3.3.1 of RFC7230:
// A server MUST NOT send a Transfer-Encoding header field in any response
// with a status code of 1xx (Informational) or 204 (No Content).
if (this.#code < 200 || this.#code === 204) {
this.removeHeader('transfer-encoding');
}
// Content-Length and Transfer-Encoding are mutual exclusive HTTP headers.
let hasLength = this.hasHeader('content-length');
let hasEncoding = this.hasHeader('transfer-encoding') && !hasLength;
if (hasLength) {
this.removeHeader('transfer-encoding');
}
if (hasEncoding) {
this.removeHeader('content-length');
}
}
/**
* Writes raw headers to the TCP stream.
* @ignore
*/
async #sendHeaders() {
// Start building the HTTP message.
const encoder = new TextEncoder();
const resHeaders = [
`HTTP/1.${this.#version} ${this.#code} ${this.#message}`,
];
// Check for HTTP header rule violations.
this.#checkHeaders();
// Format and append HTTP headers to message.
const headers = formatHeaders(this.#headers);
for (const [name, value] of Object.entries(headers)) {
resHeaders.push(`${name.trim()}: ${value}`);
}
const resHeadersString = resHeaders.join('\r\n');
const resHeadersBytes = encoder.encode(`${resHeadersString}\r\n\r\n`);
// Write headers to the socket.
await this.#socket.write(resHeadersBytes);
this.#headersSent = true;
}
/**
* Sets a single header value for implicit headers.
*
* @param {String} name - The name of the header.
* @param {String} value - The value to be set for the header.
*/
setHeader(name, value = '') {
// Check for correct types on provided params.
if (typeof name !== 'string') {
throw new TypeError('The "name" argument must be of type string.');
}
if (this.#headersSent) {
throw new Error('Cannot set headers after they are sent.');
}
this.#headers.set(name.toLowerCase(), String(value));
}
/**
* Reads out a header value from raw headers.
*
* @param {string} name - The name of the header to retrieve.
* @returns {string} The value of the requested header.
*/
getHeader(name) {
// Check for correct types on provided params.
if (typeof name !== 'string') {
throw new TypeError('The "name" argument must be of type string.');
}
return this.#headers.get(name.toLowerCase());
}
/**
* Returns an array containing the unique names of the current outgoing headers.
*
* @returns {Array<string>} An array of strings, each representing the name of headers.
*/
getHeaderNames() {
return Array.from(this.#headers.keys());
}
/**
* Returns true if the header identified is currently set.
*
* @param {string} name - The name of the header to check.
* @returns {boolean} Indicating whether the specified header is set.
*/
hasHeader(name) {
// Check for correct types on provided params.
if (typeof name !== 'string') {
throw new TypeError('The "name" argument must be of type string.');
}
return this.#headers.has(name.toLowerCase());
}
/**
* Removes a header that's queued for implicit sending.
*
* @param {String} name - The name of the header to remove.
*/
removeHeader(name) {
// Check for correct types on provided params.
if (typeof name !== 'string') {
throw new TypeError('The "name" argument must be of type string.');
}
if (this.#headersSent) {
throw new Error('Cannot remove headers after they are sent.');
}
this.#headers.delete(name.toLowerCase());
}
/**
* Returns a copy of the current outgoing headers.
*
* @returns {Object} Key-value pair corresponds to a header name and its value.
*/
getHeaders() {
return Object.fromEntries(this.#headers);
}
/**
* True if headers were sent, false otherwise (read-only).
*
* @returns {boolean} Indicates if the headers have been sent.
*/
get headersSent() {
return this.#headersSent;
}
/**
* Reference to the underlying TCP socket.
*
* @returns {Socket} An instance of the underline TPC connection.
*/
get socket() {
return this.#socket;
}
}
/**
* Creates a promise that rejects when the 'abort' event is triggered.
*
* @param {AbortSignal} signal
* @returns Promise<void>
* @ignore
*/
function cancelation(signal) {
return new Promise((_, reject) => {
signal.addEventListener('abort', () => reject(signal.reason));
});
}
// Default options for HTTP requests.
const defaultOptions = {
method: 'GET',
headers: {},
body: null,
timeout: 30000,
throwOnError: false,
signal: null,
};
/**
* Performs an HTTP request.
*
* @param {string} url - The URL to which the HTTP request is sent.
* @param {Object} [options] - Configuration options for the HTTP request.
* @param {string} [options.method] - The HTTP method to be used (e.g., `GET`, `POST`).
* @param {Object} [options.headers] - An object containing request headers.
* @param {(string|Uint8Array|Readable)} [options.body] - The body of the request.
* @param {Number} [options.timeout] - A timeout in milliseconds for the request.
* @param {boolean} [options.throwOnError] - Will throw an error for non-2xx response codes.
* @param {AbortSignal} [options.signal] - An AbortSignal to cancel the request.
* @returns {Promise<IncomingResponse>} Containing the HTTP response.
*/
export function request(url, options = {}) {
// Check URL param type.
if (typeof url !== 'string') {
throw new TypeError('The "url" argument must be of type string.');
}
// Check if the operation has been already aborted.
options?.signal?.throwIfAborted();
const configuration = Object.assign(defaultOptions, options);
const request = new Request(url, configuration);
const { signal } = configuration;
// Note: In case an abort-signal has been provided we should wrap
// a promise around its event emitter.
return signal
? Promise.race([request.send(), cancelation(signal)])
: request.send();
}
/**
* Creates a new HTTP server.
*
* @param {Function} [onRequest] - A function that is called whenever the server receives a HTTP request.
* @returns {Server} Representing the newly created HTTP server.
*/
export function createServer(onRequest) {
// Instantiate a new HTTP server.
const server = new Server();
if (onRequest) {
assert.isFunction(onRequest);
server.on('request', onRequest);
}
return server;
}
export default { METHODS, STATUS_CODES, Server, createServer, request };