2025-04-02 06:50:39 -04:00

963 lines
23 KiB
JavaScript

'use strict';
const assert = require('node:assert');
const { kDestroyed, kBodyUsed, kListeners, kBody } = require('./symbols');
const { IncomingMessage } = require('node:http');
const stream = require('node:stream');
const net = require('node:net');
const { Blob } = require('node:buffer');
const nodeUtil = require('node:util');
const { stringify } = require('node:querystring');
const { EventEmitter: EE } = require('node:events');
const { InvalidArgumentError } = require('./errors');
const { headerNameLowerCasedRecord } = require('./constants');
const { tree } = require('./tree');
const [nodeMajor, nodeMinor] = process.versions.node
.split('.', 2)
.map((v) => Number(v));
class BodyAsyncIterable {
constructor(body) {
this[kBody] = body;
this[kBodyUsed] = false;
}
async *[Symbol.asyncIterator]() {
assert(!this[kBodyUsed], 'disturbed');
this[kBodyUsed] = true;
yield* this[kBody];
}
}
/**
* @param {*} body
* @returns {*}
*/
function wrapRequestBody(body) {
if (isStream(body)) {
// TODO (fix): Provide some way for the user to cache the file to e.g. /tmp
// so that it can be dispatched again?
// TODO (fix): Do we need 100-expect support to provide a way to do this properly?
if (bodyLength(body) === 0) {
body.on('data', function () {
assert(false);
});
}
if (typeof body.readableDidRead !== 'boolean') {
body[kBodyUsed] = false;
EE.prototype.on.call(body, 'data', function () {
this[kBodyUsed] = true;
});
}
return body;
} else if (body && typeof body.pipeTo === 'function') {
// TODO (fix): We can't access ReadableStream internal state
// to determine whether or not it has been disturbed. This is just
// a workaround.
return new BodyAsyncIterable(body);
} else if (
body &&
typeof body !== 'string' &&
!ArrayBuffer.isView(body) &&
isIterable(body)
) {
// TODO: Should we allow re-using iterable if !this.opts.idempotent
// or through some other flag?
return new BodyAsyncIterable(body);
} else {
return body;
}
}
/**
* @param {*} obj
* @returns {obj is import('node:stream').Stream}
*/
function isStream(obj) {
return (
obj &&
typeof obj === 'object' &&
typeof obj.pipe === 'function' &&
typeof obj.on === 'function'
);
}
/**
* @param {*} object
* @returns {object is Blob}
* based on https://github.com/node-fetch/fetch-blob/blob/8ab587d34080de94140b54f07168451e7d0b655e/index.js#L229-L241 (MIT License)
*/
function isBlobLike(object) {
if (object === null) {
return false;
} else if (object instanceof Blob) {
return true;
} else if (typeof object !== 'object') {
return false;
} else {
const sTag = object[Symbol.toStringTag];
return (
(sTag === 'Blob' || sTag === 'File') &&
(('stream' in object && typeof object.stream === 'function') ||
('arrayBuffer' in object && typeof object.arrayBuffer === 'function'))
);
}
}
/**
* @param {string} url The URL to add the query params to
* @param {import('node:querystring').ParsedUrlQueryInput} queryParams The object to serialize into a URL query string
* @returns {string} The URL with the query params added
*/
function serializePathWithQuery(url, queryParams) {
if (url.includes('?') || url.includes('#')) {
throw new Error(
'Query params cannot be passed when url already contains "?" or "#".'
);
}
const stringified = stringify(queryParams);
if (stringified) {
url += '?' + stringified;
}
return url;
}
/**
* @param {number|string|undefined} port
* @returns {boolean}
*/
function isValidPort(port) {
const value = parseInt(port, 10);
return value === Number(port) && value >= 0 && value <= 65535;
}
/**
* Check if the value is a valid http or https prefixed string.
*
* @param {string} value
* @returns {boolean}
*/
function isHttpOrHttpsPrefixed(value) {
return (
value != null &&
value[0] === 'h' &&
value[1] === 't' &&
value[2] === 't' &&
value[3] === 'p' &&
(value[4] === ':' || (value[4] === 's' && value[5] === ':'))
);
}
/**
* @param {string|URL|Record<string,string>} url
* @returns {URL}
*/
function parseURL(url) {
if (typeof url === 'string') {
/**
* @type {URL}
*/
url = new URL(url);
if (!isHttpOrHttpsPrefixed(url.origin || url.protocol)) {
throw new InvalidArgumentError(
'Invalid URL protocol: the URL must start with `http:` or `https:`.'
);
}
return url;
}
if (!url || typeof url !== 'object') {
throw new InvalidArgumentError(
'Invalid URL: The URL argument must be a non-null object.'
);
}
if (!(url instanceof URL)) {
if (
url.port != null &&
url.port !== '' &&
isValidPort(url.port) === false
) {
throw new InvalidArgumentError(
'Invalid URL: port must be a valid integer or a string representation of an integer.'
);
}
if (url.path != null && typeof url.path !== 'string') {
throw new InvalidArgumentError(
'Invalid URL path: the path must be a string or null/undefined.'
);
}
if (url.pathname != null && typeof url.pathname !== 'string') {
throw new InvalidArgumentError(
'Invalid URL pathname: the pathname must be a string or null/undefined.'
);
}
if (url.hostname != null && typeof url.hostname !== 'string') {
throw new InvalidArgumentError(
'Invalid URL hostname: the hostname must be a string or null/undefined.'
);
}
if (url.origin != null && typeof url.origin !== 'string') {
throw new InvalidArgumentError(
'Invalid URL origin: the origin must be a string or null/undefined.'
);
}
if (!isHttpOrHttpsPrefixed(url.origin || url.protocol)) {
throw new InvalidArgumentError(
'Invalid URL protocol: the URL must start with `http:` or `https:`.'
);
}
const port =
url.port != null ? url.port
: url.protocol === 'https:' ? 443
: 80;
let origin =
url.origin != null ?
url.origin
: `${url.protocol || ''}//${url.hostname || ''}:${port}`;
let path =
url.path != null ? url.path : `${url.pathname || ''}${url.search || ''}`;
if (origin[origin.length - 1] === '/') {
origin = origin.slice(0, origin.length - 1);
}
if (path && path[0] !== '/') {
path = `/${path}`;
}
// new URL(path, origin) is unsafe when `path` contains an absolute URL
// From https://developer.mozilla.org/en-US/docs/Web/API/URL/URL:
// If first parameter is a relative URL, second param is required, and will be used as the base URL.
// If first parameter is an absolute URL, a given second param will be ignored.
return new URL(`${origin}${path}`);
}
if (!isHttpOrHttpsPrefixed(url.origin || url.protocol)) {
throw new InvalidArgumentError(
'Invalid URL protocol: the URL must start with `http:` or `https:`.'
);
}
return url;
}
/**
* @param {string|URL|Record<string, string>} url
* @returns {URL}
*/
function parseOrigin(url) {
url = parseURL(url);
if (url.pathname !== '/' || url.search || url.hash) {
throw new InvalidArgumentError('invalid url');
}
return url;
}
/**
* @param {string} host
* @returns {string}
*/
function getHostname(host) {
if (host[0] === '[') {
const idx = host.indexOf(']');
assert(idx !== -1);
return host.substring(1, idx);
}
const idx = host.indexOf(':');
if (idx === -1) return host;
return host.substring(0, idx);
}
/**
* IP addresses are not valid server names per RFC6066
* Currently, the only server names supported are DNS hostnames
* @param {string|null} host
* @returns {string|null}
*/
function getServerName(host) {
if (!host) {
return null;
}
assert(typeof host === 'string');
const servername = getHostname(host);
if (net.isIP(servername)) {
return '';
}
return servername;
}
/**
* @function
* @template T
* @param {T} obj
* @returns {T}
*/
function deepClone(obj) {
return JSON.parse(JSON.stringify(obj));
}
/**
* @param {*} obj
* @returns {obj is AsyncIterable}
*/
function isAsyncIterable(obj) {
return !!(obj != null && typeof obj[Symbol.asyncIterator] === 'function');
}
/**
* @param {*} obj
* @returns {obj is Iterable}
*/
function isIterable(obj) {
return !!(
obj != null &&
(typeof obj[Symbol.iterator] === 'function' ||
typeof obj[Symbol.asyncIterator] === 'function')
);
}
/**
* @param {Blob|Buffer|import ('stream').Stream} body
* @returns {number|null}
*/
function bodyLength(body) {
if (body == null) {
return 0;
} else if (isStream(body)) {
const state = body._readableState;
return (
state &&
state.objectMode === false &&
state.ended === true &&
Number.isFinite(state.length)
) ?
state.length
: null;
} else if (isBlobLike(body)) {
return body.size != null ? body.size : null;
} else if (isBuffer(body)) {
return body.byteLength;
}
return null;
}
/**
* @param {import ('stream').Stream} body
* @returns {boolean}
*/
function isDestroyed(body) {
return (
body && !!(body.destroyed || body[kDestroyed] || stream.isDestroyed?.(body))
);
}
/**
* @param {import ('stream').Stream} stream
* @param {Error} [err]
* @returns {void}
*/
function destroy(stream, err) {
if (stream == null || !isStream(stream) || isDestroyed(stream)) {
return;
}
if (typeof stream.destroy === 'function') {
if (Object.getPrototypeOf(stream).constructor === IncomingMessage) {
// See: https://github.com/nodejs/node/pull/38505/files
stream.socket = null;
}
stream.destroy(err);
} else if (err) {
queueMicrotask(() => {
stream.emit('error', err);
});
}
if (stream.destroyed !== true) {
stream[kDestroyed] = true;
}
}
const KEEPALIVE_TIMEOUT_EXPR = /timeout=(\d+)/;
/**
* @param {string} val
* @returns {number | null}
*/
function parseKeepAliveTimeout(val) {
const m = val.match(KEEPALIVE_TIMEOUT_EXPR);
return m ? parseInt(m[1], 10) * 1000 : null;
}
/**
* Retrieves a header name and returns its lowercase value.
* @param {string | Buffer} value Header name
* @returns {string}
*/
function headerNameToString(value) {
return typeof value === 'string' ?
(headerNameLowerCasedRecord[value] ?? value.toLowerCase())
: (tree.lookup(value) ?? value.toString('latin1').toLowerCase());
}
/**
* Receive the buffer as a string and return its lowercase value.
* @param {Buffer} value Header name
* @returns {string}
*/
function bufferToLowerCasedHeaderName(value) {
return tree.lookup(value) ?? value.toString('latin1').toLowerCase();
}
/**
* @param {(Buffer | string)[]} headers
* @param {Record<string, string | string[]>} [obj]
* @returns {Record<string, string | string[]>}
*/
function parseHeaders(headers, obj) {
if (obj === undefined) obj = {};
for (let i = 0; i < headers.length; i += 2) {
const key = headerNameToString(headers[i]);
let val = obj[key];
if (val) {
if (typeof val === 'string') {
val = [val];
obj[key] = val;
}
val.push(headers[i + 1].toString('utf8'));
} else {
const headersValue = headers[i + 1];
if (typeof headersValue === 'string') {
obj[key] = headersValue;
} else {
obj[key] =
Array.isArray(headersValue) ?
headersValue.map((x) => x.toString('utf8'))
: headersValue.toString('utf8');
}
}
}
// See https://github.com/nodejs/node/pull/46528
if ('content-length' in obj && 'content-disposition' in obj) {
obj['content-disposition'] = Buffer.from(
obj['content-disposition']
).toString('latin1');
}
return obj;
}
/**
* @param {Buffer[]} headers
* @returns {string[]}
*/
function parseRawHeaders(headers) {
const headersLength = headers.length;
/**
* @type {string[]}
*/
const ret = new Array(headersLength);
let hasContentLength = false;
let contentDispositionIdx = -1;
let key;
let val;
let kLen = 0;
for (let n = 0; n < headersLength; n += 2) {
key = headers[n];
val = headers[n + 1];
typeof key !== 'string' && (key = key.toString());
typeof val !== 'string' && (val = val.toString('utf8'));
kLen = key.length;
if (
kLen === 14 &&
key[7] === '-' &&
(key === 'content-length' || key.toLowerCase() === 'content-length')
) {
hasContentLength = true;
} else if (
kLen === 19 &&
key[7] === '-' &&
(key === 'content-disposition' ||
key.toLowerCase() === 'content-disposition')
) {
contentDispositionIdx = n + 1;
}
ret[n] = key;
ret[n + 1] = val;
}
// See https://github.com/nodejs/node/pull/46528
if (hasContentLength && contentDispositionIdx !== -1) {
ret[contentDispositionIdx] = Buffer.from(
ret[contentDispositionIdx]
).toString('latin1');
}
return ret;
}
/**
* @param {string[]} headers
* @param {Buffer[]} headers
*/
function encodeRawHeaders(headers) {
if (!Array.isArray(headers)) {
throw new TypeError('expected headers to be an array');
}
return headers.map((x) => Buffer.from(x));
}
/**
* @param {*} buffer
* @returns {buffer is Buffer}
*/
function isBuffer(buffer) {
// See, https://github.com/mcollina/undici/pull/319
return buffer instanceof Uint8Array || Buffer.isBuffer(buffer);
}
/**
* Asserts that the handler object is a request handler.
*
* @param {object} handler
* @param {string} method
* @param {string} [upgrade]
* @returns {asserts handler is import('../api/api-request').RequestHandler}
*/
function assertRequestHandler(handler, method, upgrade) {
if (!handler || typeof handler !== 'object') {
throw new InvalidArgumentError('handler must be an object');
}
if (typeof handler.onRequestStart === 'function') {
// TODO (fix): More checks...
return;
}
if (typeof handler.onConnect !== 'function') {
throw new InvalidArgumentError('invalid onConnect method');
}
if (typeof handler.onError !== 'function') {
throw new InvalidArgumentError('invalid onError method');
}
if (
typeof handler.onBodySent !== 'function' &&
handler.onBodySent !== undefined
) {
throw new InvalidArgumentError('invalid onBodySent method');
}
if (upgrade || method === 'CONNECT') {
if (typeof handler.onUpgrade !== 'function') {
throw new InvalidArgumentError('invalid onUpgrade method');
}
} else {
if (typeof handler.onHeaders !== 'function') {
throw new InvalidArgumentError('invalid onHeaders method');
}
if (typeof handler.onData !== 'function') {
throw new InvalidArgumentError('invalid onData method');
}
if (typeof handler.onComplete !== 'function') {
throw new InvalidArgumentError('invalid onComplete method');
}
}
}
/**
* A body is disturbed if it has been read from and it cannot be re-used without
* losing state or data.
* @param {import('node:stream').Readable} body
* @returns {boolean}
*/
function isDisturbed(body) {
// TODO (fix): Why is body[kBodyUsed] needed?
return !!(body && (stream.isDisturbed(body) || body[kBodyUsed]));
}
/**
* @typedef {object} SocketInfo
* @property {string} [localAddress]
* @property {number} [localPort]
* @property {string} [remoteAddress]
* @property {number} [remotePort]
* @property {string} [remoteFamily]
* @property {number} [timeout]
* @property {number} bytesWritten
* @property {number} bytesRead
*/
/**
* @param {import('net').Socket} socket
* @returns {SocketInfo}
*/
function getSocketInfo(socket) {
return {
localAddress: socket.localAddress,
localPort: socket.localPort,
remoteAddress: socket.remoteAddress,
remotePort: socket.remotePort,
remoteFamily: socket.remoteFamily,
timeout: socket.timeout,
bytesWritten: socket.bytesWritten,
bytesRead: socket.bytesRead,
};
}
/**
* @param {Iterable} iterable
* @returns {ReadableStream}
*/
function ReadableStreamFrom(iterable) {
// We cannot use ReadableStream.from here because it does not return a byte stream.
let iterator;
return new ReadableStream({
async start() {
iterator = iterable[Symbol.asyncIterator]();
},
pull(controller) {
async function pull() {
const { done, value } = await iterator.next();
if (done) {
queueMicrotask(() => {
controller.close();
controller.byobRequest?.respond(0);
});
} else {
const buf = Buffer.isBuffer(value) ? value : Buffer.from(value);
if (buf.byteLength) {
controller.enqueue(new Uint8Array(buf));
} else {
return await pull();
}
}
}
return pull();
},
async cancel() {
await iterator.return();
},
type: 'bytes',
});
}
/**
* The object should be a FormData instance and contains all the required
* methods.
* @param {*} object
* @returns {object is FormData}
*/
function isFormDataLike(object) {
return (
object &&
typeof object === 'object' &&
typeof object.append === 'function' &&
typeof object.delete === 'function' &&
typeof object.get === 'function' &&
typeof object.getAll === 'function' &&
typeof object.has === 'function' &&
typeof object.set === 'function' &&
object[Symbol.toStringTag] === 'FormData'
);
}
function addAbortListener(signal, listener) {
if ('addEventListener' in signal) {
signal.addEventListener('abort', listener, { once: true });
return () => signal.removeEventListener('abort', listener);
}
signal.once('abort', listener);
return () => signal.removeListener('abort', listener);
}
/**
* @function
* @param {string} value
* @returns {string}
*/
const toUSVString = (() => {
if (typeof String.prototype.toWellFormed === 'function') {
/**
* @param {string} value
* @returns {string}
*/
return (value) => `${value}`.toWellFormed();
} else {
/**
* @param {string} value
* @returns {string}
*/
return nodeUtil.toUSVString;
}
})();
/**
* @param {*} value
* @returns {boolean}
*/
// TODO: move this to webidl
const isUSVString = (() => {
if (typeof String.prototype.isWellFormed === 'function') {
/**
* @param {*} value
* @returns {boolean}
*/
return (value) => `${value}`.isWellFormed();
} else {
/**
* @param {*} value
* @returns {boolean}
*/
return (value) => toUSVString(value) === `${value}`;
}
})();
/**
* @see https://tools.ietf.org/html/rfc7230#section-3.2.6
* @param {number} c
* @returns {boolean}
*/
function isTokenCharCode(c) {
switch (c) {
case 0x22:
case 0x28:
case 0x29:
case 0x2c:
case 0x2f:
case 0x3a:
case 0x3b:
case 0x3c:
case 0x3d:
case 0x3e:
case 0x3f:
case 0x40:
case 0x5b:
case 0x5c:
case 0x5d:
case 0x7b:
case 0x7d:
// DQUOTE and "(),/:;<=>?@[\]{}"
return false;
default:
// VCHAR %x21-7E
return c >= 0x21 && c <= 0x7e;
}
}
/**
* @param {string} characters
* @returns {boolean}
*/
function isValidHTTPToken(characters) {
if (characters.length === 0) {
return false;
}
for (let i = 0; i < characters.length; ++i) {
if (!isTokenCharCode(characters.charCodeAt(i))) {
return false;
}
}
return true;
}
// headerCharRegex have been lifted from
// https://github.com/nodejs/node/blob/main/lib/_http_common.js
/**
* Matches if val contains an invalid field-vchar
* field-value = *( field-content / obs-fold )
* field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]
* field-vchar = VCHAR / obs-text
*/
const headerCharRegex = /[^\t\x20-\x7e\x80-\xff]/;
/**
* @param {string} characters
* @returns {boolean}
*/
function isValidHeaderValue(characters) {
return !headerCharRegex.test(characters);
}
const rangeHeaderRegex = /^bytes (\d+)-(\d+)\/(\d+)?$/;
/**
* @typedef {object} RangeHeader
* @property {number} start
* @property {number | null} end
* @property {number | null} size
*/
/**
* Parse accordingly to RFC 9110
* @see https://www.rfc-editor.org/rfc/rfc9110#field.content-range
* @param {string} [range]
* @returns {RangeHeader|null}
*/
function parseRangeHeader(range) {
if (range == null || range === '') return { start: 0, end: null, size: null };
const m = range ? range.match(rangeHeaderRegex) : null;
return m ?
{
start: parseInt(m[1]),
end: m[2] ? parseInt(m[2]) : null,
size: m[3] ? parseInt(m[3]) : null,
}
: null;
}
/**
* @template {import("events").EventEmitter} T
* @param {T} obj
* @param {string} name
* @param {(...args: any[]) => void} listener
* @returns {T}
*/
function addListener(obj, name, listener) {
const listeners = (obj[kListeners] ??= []);
listeners.push([name, listener]);
obj.on(name, listener);
return obj;
}
/**
* @template {import("events").EventEmitter} T
* @param {T} obj
* @returns {T}
*/
function removeAllListeners(obj) {
if (obj[kListeners] != null) {
for (const [name, listener] of obj[kListeners]) {
obj.removeListener(name, listener);
}
obj[kListeners] = null;
}
return obj;
}
/**
* @param {import ('../dispatcher/client')} client
* @param {import ('../core/request')} request
* @param {Error} err
*/
function errorRequest(client, request, err) {
try {
request.onError(err);
assert(request.aborted);
} catch (err) {
client.emit('error', err);
}
}
const kEnumerableProperty = Object.create(null);
kEnumerableProperty.enumerable = true;
const normalizedMethodRecordsBase = {
delete: 'DELETE',
DELETE: 'DELETE',
get: 'GET',
GET: 'GET',
head: 'HEAD',
HEAD: 'HEAD',
options: 'OPTIONS',
OPTIONS: 'OPTIONS',
post: 'POST',
POST: 'POST',
put: 'PUT',
PUT: 'PUT',
};
const normalizedMethodRecords = {
...normalizedMethodRecordsBase,
patch: 'patch',
PATCH: 'PATCH',
};
// Note: object prototypes should not be able to be referenced. e.g. `Object#hasOwnProperty`.
Object.setPrototypeOf(normalizedMethodRecordsBase, null);
Object.setPrototypeOf(normalizedMethodRecords, null);
module.exports = {
kEnumerableProperty,
isDisturbed,
toUSVString,
isUSVString,
isBlobLike,
parseOrigin,
parseURL,
getServerName,
isStream,
isIterable,
isAsyncIterable,
isDestroyed,
headerNameToString,
bufferToLowerCasedHeaderName,
addListener,
removeAllListeners,
errorRequest,
parseRawHeaders,
encodeRawHeaders,
parseHeaders,
parseKeepAliveTimeout,
destroy,
bodyLength,
deepClone,
ReadableStreamFrom,
isBuffer,
assertRequestHandler,
getSocketInfo,
isFormDataLike,
serializePathWithQuery,
addAbortListener,
isValidHTTPToken,
isValidHeaderValue,
isTokenCharCode,
parseRangeHeader,
normalizedMethodRecordsBase,
normalizedMethodRecords,
isValidPort,
isHttpOrHttpsPrefixed,
nodeMajor,
nodeMinor,
safeHTTPMethods: Object.freeze(['GET', 'HEAD', 'OPTIONS', 'TRACE']),
wrapRequestBody,
};