httpxy
Version:
A full-featured HTTP proxy for Node.js.
581 lines (573 loc) • 17.7 kB
JavaScript
import http from 'node:http';
import https from 'node:https';
import { EventEmitter } from 'node:events';
const upgradeHeader = /(^|,)\s*upgrade\s*($|,)/i;
const isSSL = /^https|wss/;
function setupOutgoing(outgoing, options, req, forward) {
outgoing.port = options[forward || "target"].port || (isSSL.test(options[forward || "target"].protocol) ? 443 : 80);
for (const e of [
"host",
"hostname",
"socketPath",
"pfx",
"key",
"passphrase",
"cert",
"ca",
"ciphers",
"secureProtocol"
]) {
outgoing[e] = options[forward || "target"][e];
}
outgoing.method = options.method || req.method;
outgoing.headers = { ...req.headers };
if (options.headers) {
outgoing.headers = { ...outgoing.headers, ...options.headers };
}
if (options.auth) {
outgoing.auth = options.auth;
}
if (options.ca) {
outgoing.ca = options.ca;
}
if (isSSL.test(options[forward || "target"].protocol)) {
outgoing.rejectUnauthorized = options.secure === undefined ? true : options.secure;
}
outgoing.agent = options.agent || false;
outgoing.localAddress = options.localAddress;
if (!outgoing.agent) {
outgoing.headers = outgoing.headers || {};
if (typeof outgoing.headers.connection !== "string" || !upgradeHeader.test(outgoing.headers.connection)) {
outgoing.headers.connection = "close";
}
}
const target = options[forward || "target"];
const targetPath = target && options.prependPath !== false ? target.pathname || target.path || "" : "";
const parsed = new URL(req.url, "http://localhost");
let outgoingPath = options.toProxy ? req.url : parsed.pathname + parsed.search || "";
outgoingPath = options.ignorePath ? "" : outgoingPath;
outgoing.path = joinURL(targetPath, outgoingPath);
if (options.changeOrigin) {
outgoing.headers.host = requiresPort(outgoing.port, options[forward || "target"].protocol) && !hasPort(outgoing.host) ? outgoing.host + ":" + outgoing.port : outgoing.host;
}
return outgoing;
}
function joinURL(base, path) {
if (!base || base === "/") {
return path || "/";
}
if (!path || path === "/") {
return base || "/";
}
const baseHasTrailing = base[base.length - 1] === "/";
const pathHasLeading = path[0] === "/";
if (baseHasTrailing && pathHasLeading) {
return base + path.slice(1);
}
if (!baseHasTrailing && !pathHasLeading) {
return base + "/" + path;
}
return base + path;
}
function setupSocket(socket) {
socket.setTimeout(0);
socket.setNoDelay(true);
socket.setKeepAlive(true, 0);
return socket;
}
function getPort(req) {
const res = req.headers.host ? req.headers.host.match(/:(\d+)/) : "";
if (res) {
return res[1];
}
return hasEncryptedConnection(req) ? "443" : "80";
}
function hasEncryptedConnection(req) {
return Boolean(req.connection.encrypted || req.connection.pair);
}
function rewriteCookieProperty(header, config, property) {
if (Array.isArray(header)) {
return header.map(function(headerElement) {
return rewriteCookieProperty(headerElement, config, property);
});
}
return header.replace(
new RegExp(String.raw`(;\s*` + property + "=)([^;]+)", "i"),
function(match, prefix, previousValue) {
let newValue;
if (previousValue in config) {
newValue = config[previousValue];
} else if ("*" in config) {
newValue = config["*"];
} else {
return match;
}
return newValue ? prefix + newValue : "";
}
);
}
function hasPort(host) {
return !!~host.indexOf(":");
}
function requiresPort(_port, _protocol) {
const protocol = _protocol.split(":")[0];
const port = +_port;
if (!port) return false;
switch (protocol) {
case "http":
case "ws": {
return port !== 80;
}
case "https":
case "wss": {
return port !== 443;
}
case "ftp": {
return port !== 21;
}
case "gopher": {
return port !== 70;
}
case "file": {
return false;
}
}
return port !== 0;
}
function defineProxyMiddleware(m) {
return m;
}
function defineProxyOutgoingMiddleware(m) {
return m;
}
const redirectRegex = /^201|30([1278])$/;
const removeChunked = defineProxyOutgoingMiddleware((req, res, proxyRes) => {
if (req.httpVersion === "1.0") {
delete proxyRes.headers["transfer-encoding"];
}
});
const setConnection = defineProxyOutgoingMiddleware((req, res, proxyRes) => {
if (req.httpVersion === "1.0") {
proxyRes.headers.connection = req.headers.connection || "close";
} else if (req.httpVersion !== "2.0" && !proxyRes.headers.connection) {
proxyRes.headers.connection = req.headers.connection || "keep-alive";
}
});
const setRedirectHostRewrite = defineProxyOutgoingMiddleware(
(req, res, proxyRes, options) => {
if ((options.hostRewrite || options.autoRewrite || options.protocolRewrite) && proxyRes.headers.location && redirectRegex.test(String(proxyRes.statusCode))) {
const target = new URL(options.target);
const u = new URL(proxyRes.headers.location);
if (target.host !== u.host) {
return;
}
if (options.hostRewrite) {
u.host = options.hostRewrite;
} else if (options.autoRewrite) {
u.host = req.headers.host;
}
if (options.protocolRewrite) {
u.protocol = options.protocolRewrite;
}
proxyRes.headers.location = u.toString();
}
}
);
const writeHeaders = defineProxyOutgoingMiddleware(
(req, res, proxyRes, options) => {
let rewriteCookieDomainConfig = options.cookieDomainRewrite;
let rewriteCookiePathConfig = options.cookiePathRewrite;
const preserveHeaderKeyCase = options.preserveHeaderKeyCase;
let rawHeaderKeyMap;
const setHeader = function(key, header) {
if (header === undefined) {
return;
}
if (rewriteCookieDomainConfig && key.toLowerCase() === "set-cookie") {
header = rewriteCookieProperty(
header,
rewriteCookieDomainConfig,
"domain"
);
}
if (rewriteCookiePathConfig && key.toLowerCase() === "set-cookie") {
header = rewriteCookieProperty(header, rewriteCookiePathConfig, "path");
}
res.setHeader(String(key).trim(), header);
};
if (typeof rewriteCookieDomainConfig === "string") {
rewriteCookieDomainConfig = { "*": rewriteCookieDomainConfig };
}
if (typeof rewriteCookiePathConfig === "string") {
rewriteCookiePathConfig = { "*": rewriteCookiePathConfig };
}
if (preserveHeaderKeyCase && proxyRes.rawHeaders !== undefined) {
rawHeaderKeyMap = {};
for (let i = 0; i < proxyRes.rawHeaders.length; i += 2) {
const key = proxyRes.rawHeaders[i];
rawHeaderKeyMap[key.toLowerCase()] = key;
}
}
for (let key of Object.keys(proxyRes.headers)) {
const header = proxyRes.headers[key];
if (preserveHeaderKeyCase && rawHeaderKeyMap) {
key = rawHeaderKeyMap[key] || key;
}
setHeader(key, header);
}
}
);
const writeStatusCode = defineProxyOutgoingMiddleware((req, res, proxyRes) => {
if (proxyRes.statusMessage) {
res.statusCode = proxyRes.statusCode;
res.statusMessage = proxyRes.statusMessage;
} else {
res.statusCode = proxyRes.statusCode;
}
});
const webOutgoingMiddleware = [
removeChunked,
setConnection,
setRedirectHostRewrite,
writeHeaders,
writeStatusCode
];
const nativeAgents = { http: http, https: https };
const deleteLength = defineProxyMiddleware((req) => {
if ((req.method === "DELETE" || req.method === "OPTIONS") && !req.headers["content-length"]) {
req.headers["content-length"] = "0";
delete req.headers["transfer-encoding"];
}
});
const timeout = defineProxyMiddleware((req, res, options) => {
if (options.timeout) {
req.socket.setTimeout(options.timeout);
}
});
const XHeaders$1 = defineProxyMiddleware((req, res, options) => {
if (!options.xfwd) {
return;
}
const encrypted = req.isSpdy || hasEncryptedConnection(req);
const values = {
for: req.connection.remoteAddress || req.socket.remoteAddress,
port: getPort(req),
proto: encrypted ? "https" : "http"
};
for (const header of ["for", "port", "proto"]) {
req.headers["x-forwarded-" + header] = (req.headers["x-forwarded-" + header] || "") + (req.headers["x-forwarded-" + header] ? "," : "") + values[header];
}
req.headers["x-forwarded-host"] = req.headers["x-forwarded-host"] || req.headers.host || "";
});
const stream$1 = defineProxyMiddleware(
(req, res, options, server, head, callback) => {
server.emit("start", req, res, options.target || options.forward);
const agents = nativeAgents;
const http = agents.http;
const https = agents.https;
if (options.forward) {
const forwardReq = (options.forward.protocol === "https:" ? https : http).request(setupOutgoing(options.ssl || {}, options, req, "forward"));
const forwardError = createErrorHandler(forwardReq, options.forward);
req.on("error", forwardError);
forwardReq.on("error", forwardError);
(options.buffer || req).pipe(forwardReq);
if (!options.target) {
res.end();
return;
}
}
const proxyReq = (options.target.protocol === "https:" ? https : http).request(setupOutgoing(options.ssl || {}, options, req));
proxyReq.on("socket", (socket) => {
if (server && !proxyReq.getHeader("expect")) {
server.emit("proxyReq", proxyReq, req, res, options);
}
});
if (options.proxyTimeout) {
proxyReq.setTimeout(options.proxyTimeout, function() {
proxyReq.abort();
});
}
req.on("aborted", function() {
proxyReq.abort();
});
const proxyError = createErrorHandler(proxyReq, options.target);
req.on("error", proxyError);
proxyReq.on("error", proxyError);
function createErrorHandler(proxyReq2, url) {
return function proxyError2(err) {
if (req.socket.destroyed && err.code === "ECONNRESET") {
server.emit("econnreset", err, req, res, url);
return proxyReq2.abort();
}
if (callback) {
callback(err, req, res, url);
} else {
server.emit("error", err, req, res, url);
}
};
}
(options.buffer || req).pipe(proxyReq);
proxyReq.on("response", function(proxyRes) {
if (server) {
server.emit("proxyRes", proxyRes, req, res);
}
if (!res.headersSent && !options.selfHandleResponse) {
for (const pass of webOutgoingMiddleware) {
if (pass(req, res, proxyRes, options)) {
break;
}
}
}
if (res.finished) {
if (server) {
server.emit("end", req, res, proxyRes);
}
} else {
res.on("close", function() {
proxyRes.destroy();
});
proxyRes.on("end", function() {
if (server) {
server.emit("end", req, res, proxyRes);
}
});
if (!options.selfHandleResponse) {
proxyRes.pipe(res);
}
}
});
}
);
const webIncomingMiddleware = [
deleteLength,
timeout,
XHeaders$1,
stream$1
];
const checkMethodAndHeader = defineProxyMiddleware((req, socket) => {
if (req.method !== "GET" || !req.headers.upgrade) {
socket.destroy();
return true;
}
if (req.headers.upgrade.toLowerCase() !== "websocket") {
socket.destroy();
return true;
}
});
const XHeaders = defineProxyMiddleware((req, socket, options) => {
if (!options.xfwd) {
return;
}
const values = {
for: req.connection.remoteAddress || req.socket.remoteAddress,
port: getPort(req),
proto: hasEncryptedConnection(req) ? "wss" : "ws"
};
for (const header of ["for", "port", "proto"]) {
req.headers["x-forwarded-" + header] = (req.headers["x-forwarded-" + header] || "") + (req.headers["x-forwarded-" + header] ? "," : "") + values[header];
}
});
const stream = defineProxyMiddleware(
(req, socket, options, server, head, callback) => {
const createHttpHeader = function(line, headers) {
return Object.keys(headers).reduce(
function(head2, key) {
const value = headers[key];
if (!Array.isArray(value)) {
head2.push(key + ": " + value);
return head2;
}
for (const element of value) {
head2.push(key + ": " + element);
}
return head2;
},
[line]
).join("\r\n") + "\r\n\r\n";
};
setupSocket(socket);
if (head && head.length > 0) {
socket.unshift(head);
}
const proxyReq = (isSSL.test(options.target.protocol) ? https : http).request(setupOutgoing(options.ssl || {}, options, req));
if (server) {
server.emit("proxyReqWs", proxyReq, req, socket, options, head);
}
proxyReq.on("error", onOutgoingError);
proxyReq.on("response", function(res) {
if (!res.upgrade) {
socket.write(
createHttpHeader(
"HTTP/" + res.httpVersion + " " + res.statusCode + " " + res.statusMessage,
res.headers
)
);
res.pipe(socket);
}
});
proxyReq.on("upgrade", function(proxyRes, proxySocket, proxyHead) {
proxySocket.on("error", onOutgoingError);
proxySocket.on("end", function() {
server.emit("close", proxyRes, proxySocket, proxyHead);
});
socket.on("error", function() {
proxySocket.end();
});
setupSocket(proxySocket);
if (proxyHead && proxyHead.length > 0) {
proxySocket.unshift(proxyHead);
}
socket.write(
createHttpHeader("HTTP/1.1 101 Switching Protocols", proxyRes.headers)
);
proxySocket.pipe(socket).pipe(proxySocket);
server.emit("open", proxySocket);
server.emit("proxySocket", proxySocket);
});
proxyReq.end();
function onOutgoingError(err) {
if (callback) {
callback(err, req, socket);
} else {
server.emit("error", err, req, socket);
}
socket.end();
}
}
);
const websocketIncomingMiddleware = [
checkMethodAndHeader,
XHeaders,
stream
];
class ProxyServer extends EventEmitter {
_server;
_webPasses = [...webIncomingMiddleware];
_wsPasses = [...websocketIncomingMiddleware];
options;
web;
ws;
/**
* Creates the proxy server with specified options.
* @param options - Config object passed to the proxy
*/
constructor(options = {}) {
super();
this.options = options || {};
this.options.prependPath = options.prependPath !== false;
this.web = _createProxyFn("web", this);
this.ws = _createProxyFn("ws", this);
}
/**
* A function that wraps the object in a webserver, for your convenience
* @param port - Port to listen on
* @param hostname - The hostname to listen on
*/
listen(port, hostname) {
const closure = (req, res) => {
this.web(req, res);
};
this._server = this.options.ssl ? https.createServer(this.options.ssl, closure) : http.createServer(closure);
if (this.options.ws) {
this._server.on("upgrade", (req, socket, head) => {
this._ws(req, socket, head);
});
}
this._server.listen(port, hostname);
return this;
}
/**
* A function that closes the inner webserver and stops listening on given port
*/
close(callback) {
if (this._server) {
this._server.close((...args) => {
this._server = undefined;
if (callback) {
Reflect.apply(callback, undefined, args);
}
});
}
}
before(type, passName, pass) {
if (type !== "ws" && type !== "web") {
throw new Error("type must be `web` or `ws`");
}
const passes = type === "ws" ? this._wsPasses : this._webPasses;
let i = false;
for (const [idx, v] of passes.entries()) {
if (v.name === passName) {
i = idx;
}
}
if (i === false) {
throw new Error("No such pass");
}
passes.splice(i, 0, pass);
}
after(type, passName, pass) {
if (type !== "ws" && type !== "web") {
throw new Error("type must be `web` or `ws`");
}
const passes = type === "ws" ? this._wsPasses : this._webPasses;
let i = false;
for (const [idx, v] of passes.entries()) {
if (v.name === passName) {
i = idx;
}
}
if (i === false) {
throw new Error("No such pass");
}
passes.splice(i++, 0, pass);
}
}
function createProxyServer(options = {}) {
return new ProxyServer(options);
}
function _createProxyFn(type, server) {
return function(req, res, opts, head) {
const requestOptions = { ...opts, ...server.options };
for (const key of ["target", "forward"]) {
if (typeof requestOptions[key] === "string") {
requestOptions[key] = new URL(requestOptions[key]);
}
}
if (!requestOptions.target && !requestOptions.forward) {
return this.emit(
"error",
new Error("Must provide a proper URL as target")
);
}
let _resolve;
let _reject;
const callbackPromise = new Promise((resolve, reject) => {
_resolve = resolve;
_reject = reject;
});
res.on("close", () => {
_resolve();
});
res.on("error", (error) => {
_reject(error);
});
for (const pass of type === "ws" ? server._wsPasses : server._webPasses) {
const stop = pass(
req,
res,
requestOptions,
server,
head,
(error) => {
_reject(error);
}
);
if (stop) {
_resolve();
break;
}
}
return callbackPromise;
};
}
export { ProxyServer, createProxyServer };