'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} 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} 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} [obj] * @returns {Record} */ 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, };