429 lines
11 KiB
JavaScript
429 lines
11 KiB
JavaScript
'use strict';
|
|
|
|
const { InvalidArgumentError, NotSupportedError } = require('./errors');
|
|
const assert = require('node:assert');
|
|
const {
|
|
isValidHTTPToken,
|
|
isValidHeaderValue,
|
|
isStream,
|
|
destroy,
|
|
isBuffer,
|
|
isFormDataLike,
|
|
isIterable,
|
|
isBlobLike,
|
|
serializePathWithQuery,
|
|
assertRequestHandler,
|
|
getServerName,
|
|
normalizedMethodRecords,
|
|
} = require('./util');
|
|
const { channels } = require('./diagnostics.js');
|
|
const { headerNameLowerCasedRecord } = require('./constants');
|
|
|
|
// Verifies that a given path is valid does not contain control chars \x00 to \x20
|
|
const invalidPathRegex = /[^\u0021-\u00ff]/;
|
|
|
|
const kHandler = Symbol('handler');
|
|
|
|
class Request {
|
|
constructor(
|
|
origin,
|
|
{
|
|
path,
|
|
method,
|
|
body,
|
|
headers,
|
|
query,
|
|
idempotent,
|
|
blocking,
|
|
upgrade,
|
|
headersTimeout,
|
|
bodyTimeout,
|
|
reset,
|
|
expectContinue,
|
|
servername,
|
|
throwOnError,
|
|
},
|
|
handler
|
|
) {
|
|
if (typeof path !== 'string') {
|
|
throw new InvalidArgumentError('path must be a string');
|
|
} else if (
|
|
path[0] !== '/' &&
|
|
!(path.startsWith('http://') || path.startsWith('https://')) &&
|
|
method !== 'CONNECT'
|
|
) {
|
|
throw new InvalidArgumentError(
|
|
'path must be an absolute URL or start with a slash'
|
|
);
|
|
} else if (invalidPathRegex.test(path)) {
|
|
throw new InvalidArgumentError('invalid request path');
|
|
}
|
|
|
|
if (typeof method !== 'string') {
|
|
throw new InvalidArgumentError('method must be a string');
|
|
} else if (
|
|
normalizedMethodRecords[method] === undefined &&
|
|
!isValidHTTPToken(method)
|
|
) {
|
|
throw new InvalidArgumentError('invalid request method');
|
|
}
|
|
|
|
if (upgrade && typeof upgrade !== 'string') {
|
|
throw new InvalidArgumentError('upgrade must be a string');
|
|
}
|
|
|
|
if (
|
|
headersTimeout != null &&
|
|
(!Number.isFinite(headersTimeout) || headersTimeout < 0)
|
|
) {
|
|
throw new InvalidArgumentError('invalid headersTimeout');
|
|
}
|
|
|
|
if (
|
|
bodyTimeout != null &&
|
|
(!Number.isFinite(bodyTimeout) || bodyTimeout < 0)
|
|
) {
|
|
throw new InvalidArgumentError('invalid bodyTimeout');
|
|
}
|
|
|
|
if (reset != null && typeof reset !== 'boolean') {
|
|
throw new InvalidArgumentError('invalid reset');
|
|
}
|
|
|
|
if (expectContinue != null && typeof expectContinue !== 'boolean') {
|
|
throw new InvalidArgumentError('invalid expectContinue');
|
|
}
|
|
|
|
if (throwOnError != null) {
|
|
throw new InvalidArgumentError('invalid throwOnError');
|
|
}
|
|
|
|
this.headersTimeout = headersTimeout;
|
|
|
|
this.bodyTimeout = bodyTimeout;
|
|
|
|
this.method = method;
|
|
|
|
this.abort = null;
|
|
|
|
if (body == null) {
|
|
this.body = null;
|
|
} else if (isStream(body)) {
|
|
this.body = body;
|
|
|
|
const rState = this.body._readableState;
|
|
if (!rState || !rState.autoDestroy) {
|
|
this.endHandler = function autoDestroy() {
|
|
destroy(this);
|
|
};
|
|
this.body.on('end', this.endHandler);
|
|
}
|
|
|
|
this.errorHandler = (err) => {
|
|
if (this.abort) {
|
|
this.abort(err);
|
|
} else {
|
|
this.error = err;
|
|
}
|
|
};
|
|
this.body.on('error', this.errorHandler);
|
|
} else if (isBuffer(body)) {
|
|
this.body = body.byteLength ? body : null;
|
|
} else if (ArrayBuffer.isView(body)) {
|
|
this.body =
|
|
body.buffer.byteLength ?
|
|
Buffer.from(body.buffer, body.byteOffset, body.byteLength)
|
|
: null;
|
|
} else if (body instanceof ArrayBuffer) {
|
|
this.body = body.byteLength ? Buffer.from(body) : null;
|
|
} else if (typeof body === 'string') {
|
|
this.body = body.length ? Buffer.from(body) : null;
|
|
} else if (isFormDataLike(body) || isIterable(body) || isBlobLike(body)) {
|
|
this.body = body;
|
|
} else {
|
|
throw new InvalidArgumentError(
|
|
'body must be a string, a Buffer, a Readable stream, an iterable, or an async iterable'
|
|
);
|
|
}
|
|
|
|
this.completed = false;
|
|
this.aborted = false;
|
|
|
|
this.upgrade = upgrade || null;
|
|
|
|
this.path = query ? serializePathWithQuery(path, query) : path;
|
|
|
|
this.origin = origin;
|
|
|
|
this.idempotent =
|
|
idempotent == null ? method === 'HEAD' || method === 'GET' : idempotent;
|
|
|
|
this.blocking = blocking ?? this.method !== 'HEAD';
|
|
|
|
this.reset = reset == null ? null : reset;
|
|
|
|
this.host = null;
|
|
|
|
this.contentLength = null;
|
|
|
|
this.contentType = null;
|
|
|
|
this.headers = [];
|
|
|
|
// Only for H2
|
|
this.expectContinue = expectContinue != null ? expectContinue : false;
|
|
|
|
if (Array.isArray(headers)) {
|
|
if (headers.length % 2 !== 0) {
|
|
throw new InvalidArgumentError('headers array must be even');
|
|
}
|
|
for (let i = 0; i < headers.length; i += 2) {
|
|
processHeader(this, headers[i], headers[i + 1]);
|
|
}
|
|
} else if (headers && typeof headers === 'object') {
|
|
if (headers[Symbol.iterator]) {
|
|
for (const header of headers) {
|
|
if (!Array.isArray(header) || header.length !== 2) {
|
|
throw new InvalidArgumentError(
|
|
'headers must be in key-value pair format'
|
|
);
|
|
}
|
|
processHeader(this, header[0], header[1]);
|
|
}
|
|
} else {
|
|
const keys = Object.keys(headers);
|
|
for (let i = 0; i < keys.length; ++i) {
|
|
processHeader(this, keys[i], headers[keys[i]]);
|
|
}
|
|
}
|
|
} else if (headers != null) {
|
|
throw new InvalidArgumentError('headers must be an object or an array');
|
|
}
|
|
|
|
assertRequestHandler(handler, method, upgrade);
|
|
|
|
this.servername = servername || getServerName(this.host) || null;
|
|
|
|
this[kHandler] = handler;
|
|
|
|
if (channels.create.hasSubscribers) {
|
|
channels.create.publish({ request: this });
|
|
}
|
|
}
|
|
|
|
onBodySent(chunk) {
|
|
if (this[kHandler].onBodySent) {
|
|
try {
|
|
return this[kHandler].onBodySent(chunk);
|
|
} catch (err) {
|
|
this.abort(err);
|
|
}
|
|
}
|
|
}
|
|
|
|
onRequestSent() {
|
|
if (channels.bodySent.hasSubscribers) {
|
|
channels.bodySent.publish({ request: this });
|
|
}
|
|
|
|
if (this[kHandler].onRequestSent) {
|
|
try {
|
|
return this[kHandler].onRequestSent();
|
|
} catch (err) {
|
|
this.abort(err);
|
|
}
|
|
}
|
|
}
|
|
|
|
onConnect(abort) {
|
|
assert(!this.aborted);
|
|
assert(!this.completed);
|
|
|
|
if (this.error) {
|
|
abort(this.error);
|
|
} else {
|
|
this.abort = abort;
|
|
return this[kHandler].onConnect(abort);
|
|
}
|
|
}
|
|
|
|
onResponseStarted() {
|
|
return this[kHandler].onResponseStarted?.();
|
|
}
|
|
|
|
onHeaders(statusCode, headers, resume, statusText) {
|
|
assert(!this.aborted);
|
|
assert(!this.completed);
|
|
|
|
if (channels.headers.hasSubscribers) {
|
|
channels.headers.publish({
|
|
request: this,
|
|
response: { statusCode, headers, statusText },
|
|
});
|
|
}
|
|
|
|
try {
|
|
return this[kHandler].onHeaders(statusCode, headers, resume, statusText);
|
|
} catch (err) {
|
|
this.abort(err);
|
|
}
|
|
}
|
|
|
|
onData(chunk) {
|
|
assert(!this.aborted);
|
|
assert(!this.completed);
|
|
|
|
try {
|
|
return this[kHandler].onData(chunk);
|
|
} catch (err) {
|
|
this.abort(err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
onUpgrade(statusCode, headers, socket) {
|
|
assert(!this.aborted);
|
|
assert(!this.completed);
|
|
|
|
return this[kHandler].onUpgrade(statusCode, headers, socket);
|
|
}
|
|
|
|
onComplete(trailers) {
|
|
this.onFinally();
|
|
|
|
assert(!this.aborted);
|
|
assert(!this.completed);
|
|
|
|
this.completed = true;
|
|
if (channels.trailers.hasSubscribers) {
|
|
channels.trailers.publish({ request: this, trailers });
|
|
}
|
|
|
|
try {
|
|
return this[kHandler].onComplete(trailers);
|
|
} catch (err) {
|
|
// TODO (fix): This might be a bad idea?
|
|
this.onError(err);
|
|
}
|
|
}
|
|
|
|
onError(error) {
|
|
this.onFinally();
|
|
|
|
if (channels.error.hasSubscribers) {
|
|
channels.error.publish({ request: this, error });
|
|
}
|
|
|
|
if (this.aborted) {
|
|
return;
|
|
}
|
|
this.aborted = true;
|
|
|
|
return this[kHandler].onError(error);
|
|
}
|
|
|
|
onFinally() {
|
|
if (this.errorHandler) {
|
|
this.body.off('error', this.errorHandler);
|
|
this.errorHandler = null;
|
|
}
|
|
|
|
if (this.endHandler) {
|
|
this.body.off('end', this.endHandler);
|
|
this.endHandler = null;
|
|
}
|
|
}
|
|
|
|
addHeader(key, value) {
|
|
processHeader(this, key, value);
|
|
return this;
|
|
}
|
|
}
|
|
|
|
function processHeader(request, key, val) {
|
|
if (val && typeof val === 'object' && !Array.isArray(val)) {
|
|
throw new InvalidArgumentError(`invalid ${key} header`);
|
|
} else if (val === undefined) {
|
|
return;
|
|
}
|
|
|
|
let headerName = headerNameLowerCasedRecord[key];
|
|
|
|
if (headerName === undefined) {
|
|
headerName = key.toLowerCase();
|
|
if (
|
|
headerNameLowerCasedRecord[headerName] === undefined &&
|
|
!isValidHTTPToken(headerName)
|
|
) {
|
|
throw new InvalidArgumentError('invalid header key');
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(val)) {
|
|
const arr = [];
|
|
for (let i = 0; i < val.length; i++) {
|
|
if (typeof val[i] === 'string') {
|
|
if (!isValidHeaderValue(val[i])) {
|
|
throw new InvalidArgumentError(`invalid ${key} header`);
|
|
}
|
|
arr.push(val[i]);
|
|
} else if (val[i] === null) {
|
|
arr.push('');
|
|
} else if (typeof val[i] === 'object') {
|
|
throw new InvalidArgumentError(`invalid ${key} header`);
|
|
} else {
|
|
arr.push(`${val[i]}`);
|
|
}
|
|
}
|
|
val = arr;
|
|
} else if (typeof val === 'string') {
|
|
if (!isValidHeaderValue(val)) {
|
|
throw new InvalidArgumentError(`invalid ${key} header`);
|
|
}
|
|
} else if (val === null) {
|
|
val = '';
|
|
} else {
|
|
val = `${val}`;
|
|
}
|
|
|
|
if (request.host === null && headerName === 'host') {
|
|
if (typeof val !== 'string') {
|
|
throw new InvalidArgumentError('invalid host header');
|
|
}
|
|
// Consumed by Client
|
|
request.host = val;
|
|
} else if (
|
|
request.contentLength === null &&
|
|
headerName === 'content-length'
|
|
) {
|
|
request.contentLength = parseInt(val, 10);
|
|
if (!Number.isFinite(request.contentLength)) {
|
|
throw new InvalidArgumentError('invalid content-length header');
|
|
}
|
|
} else if (request.contentType === null && headerName === 'content-type') {
|
|
request.contentType = val;
|
|
request.headers.push(key, val);
|
|
} else if (
|
|
headerName === 'transfer-encoding' ||
|
|
headerName === 'keep-alive' ||
|
|
headerName === 'upgrade'
|
|
) {
|
|
throw new InvalidArgumentError(`invalid ${headerName} header`);
|
|
} else if (headerName === 'connection') {
|
|
const value = typeof val === 'string' ? val.toLowerCase() : null;
|
|
if (value !== 'close' && value !== 'keep-alive') {
|
|
throw new InvalidArgumentError('invalid connection header');
|
|
}
|
|
|
|
if (value === 'close') {
|
|
request.reset = true;
|
|
}
|
|
} else if (headerName === 'expect') {
|
|
throw new NotSupportedError('expect header not supported');
|
|
} else {
|
|
request.headers.push(key, val);
|
|
}
|
|
}
|
|
|
|
module.exports = Request;
|