redbird
Version:
A reverse proxy with support for dynamic tables
812 lines (811 loc) • 32.1 kB
JavaScript
/*eslint-env node */
'use strict';
// Built-in NodeJS modules.
import path from 'path';
import { parse as parseUrl } from 'url';
import cluster from 'cluster';
import http, { Agent, ServerResponse } from 'http';
import https from 'https';
import http2 from 'http2';
import fs from 'fs';
import tls from 'tls';
// Third party modules.
import validUrl from 'valid-url';
import httpProxy from 'http-proxy';
import lodash from 'lodash';
import { pino } from 'pino';
import hash from 'object-hash';
import safe from 'safe-timers';
import { LRUCache } from 'lru-cache';
// Custom modules.
import * as letsencrypt from './letsencrypt.js';
const { isFunction, isObject, sortBy, uniq, remove, isString } = lodash;
const routeCache = new LRUCache({ max: 5000 });
const defaultLetsencryptPort = 3000;
const ONE_DAY = 60 * 60 * 24 * 1000;
const ONE_MONTH = ONE_DAY * 30;
export class Redbird {
get defaultResolver() {
return this._defaultResolver;
}
constructor(opts = {}) {
var _a;
this.opts = opts;
this.routing = {};
this.resolvers = [];
this.lazyCerts = {};
if (this.opts.httpProxy == undefined) {
this.opts.httpProxy = {};
}
if (opts.logger) {
this.logger = pino(opts.logger || {
name: 'redbird',
});
}
this._defaultResolver = {
fn: (host, url) => {
// Given a src resolve it to a target route if any available.
if (!host) {
return;
}
url = url || '/';
const routes = this.routing[host];
let i = 0;
if (routes) {
const len = routes.length;
//
// Find path that matches the start of req.url
//
for (i = 0; i < len; i++) {
const route = routes[i];
if (route.path === '/' || startsWith(url, route.path)) {
return route;
}
}
}
},
priority: 0,
};
if ((opts.cluster && typeof opts.cluster !== 'number') || opts.cluster > 32) {
throw Error('cluster setting must be an integer less than 32');
}
if (opts.cluster && cluster.isPrimary) {
for (let i = 0; i < opts.cluster; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
var _a;
// Fork if a worker dies.
(_a = this.logger) === null || _a === void 0 ? void 0 : _a.error({
code: code,
signal: signal,
}, 'worker died un-expectedly... restarting it.');
cluster.fork();
});
}
else {
this.resolvers = [this._defaultResolver];
opts.port = opts.port || 8080;
if (opts.letsencrypt) {
this.setupLetsencrypt(opts);
}
if (opts.resolvers) {
for (let i = 0; i < opts.resolvers.length; i++) {
this.addResolver(opts.resolvers[i].fn, opts.resolvers[i].priority);
}
}
const websocketsUpgrade = async (req, socket, head) => {
var _a;
socket.on('error', (err) => {
var _a;
(_a = this.logger) === null || _a === void 0 ? void 0 : _a.error(err, 'WebSockets error');
});
const src = this.getSource(req);
const target = await this.getTarget(src, req);
(_a = this.logger) === null || _a === void 0 ? void 0 : _a.info({ headers: req.headers, target: target }, 'upgrade to websockets');
if (target) {
if (target.useTargetHostHeader === true) {
req.headers.host = target.host;
}
proxy.ws(req, socket, head, { target });
}
else {
respondNotFound(req, socket);
}
};
//
// Create a proxy server with custom application logic
//
let agent;
if (opts.keepAlive) {
agent = this.agent = new Agent({
keepAlive: true,
});
}
const proxy = (this.proxy = httpProxy.createProxyServer({
xfwd: opts.xfwd != false,
prependPath: false,
secure: opts.secure !== false,
timeout: opts.timeout,
proxyTimeout: opts.proxyTimeout,
agent,
}));
proxy.on('proxyReq', (proxyReq, req, res, options) => {
// According to typescript this is the correct way to access the host header
// const host = req.headers.host;
const host = req['host'];
if (host != null) {
proxyReq.setHeader('host', host);
}
});
//
// Support NTLM auth
//
if (opts.ntlm) {
proxy.on('proxyRes', (proxyRes, req, res) => {
const key = 'www-authenticate';
proxyRes.headers[key] =
proxyRes.headers[key] && proxyRes.headers[key].split(',');
});
}
//
// Optionally create an https proxy server.
//
if (opts.ssl) {
if (Array.isArray(opts.ssl)) {
opts.ssl.forEach((sslOpts) => {
this.setupHttpsProxy(proxy, websocketsUpgrade, sslOpts);
});
}
else {
this.setupHttpsProxy(proxy, websocketsUpgrade, opts.ssl);
}
}
//
// Plain HTTP Proxy
//
const server = (this.server = this.setupHttpProxy(proxy, websocketsUpgrade, this.logger, opts));
server.listen(opts.port, opts.host);
const handleProxyError = (err, req, resOrSocket, target) => {
var _a, _b;
const res = resOrSocket instanceof ServerResponse ? resOrSocket : null;
//
// Send a 500 http status if headers have been sent
//
if (!res || !res.writeHead) {
(_a = this.logger) === null || _a === void 0 ? void 0 : _a.error(err, 'Proxy Error');
return;
}
else {
if (err.code === 'ECONNREFUSED') {
res.writeHead(502);
}
else if (!res.headersSent) {
res.writeHead(500, err.message, { 'content-type': 'text/plain' });
}
}
//
// Do not log this common error
//
if (err.message !== 'socket hang up') {
(_b = this.logger) === null || _b === void 0 ? void 0 : _b.error(err, 'Proxy Error');
}
//
// TODO: if err.code=ECONNREFUSED and there are more servers
// for this route, try another one.
//
res.end(err.code);
};
if (opts.errorHandler && isFunction(opts.errorHandler)) {
proxy.on('error', opts.errorHandler);
}
else {
proxy.on('error', handleProxyError);
}
(_a = this.logger) === null || _a === void 0 ? void 0 : _a.info('Started a Redbird reverse proxy server on port %s', opts.port);
}
}
setupHttpProxy(proxy, websocketsUpgrade, log, opts) {
const httpServerModule = opts.serverModule || http;
const server = (this.server = httpServerModule.createServer(async (req, res) => {
const src = this.getSource(req);
const target = await this.getTarget(src, req, res);
if (target) {
if (this.shouldRedirectToHttps(target)) {
redirectToHttps(req, res, this.opts.ssl, this.logger);
}
else {
proxy.web(req, res, { target, secure: !!opts.secure });
}
}
else {
respondNotFound(req, res);
}
}));
//
// Listen to the `upgrade` event and proxy the
// WebSocket requests as well.
//
server.on('upgrade', websocketsUpgrade);
server.on('error', function (err) {
log && log.error(err, 'Server Error');
});
return server;
}
/**
* Special resolver for handling Let's Encrypt ACME challenges.
* @param opts
*/
setupLetsencrypt(opts) {
if (!opts.letsencrypt.path) {
throw Error('Missing certificate path for Lets Encrypt');
}
const letsencryptPort = opts.letsencrypt.port || defaultLetsencryptPort;
this.letsencryptServer = letsencrypt.init(opts.letsencrypt.path, letsencryptPort, this.logger);
this.letsencryptHost = '127.0.0.1:' + letsencryptPort;
const targetHost = 'http://' + this.letsencryptHost;
const challengeResolver = (host, url) => {
if (/^\/.well-known\/acme-challenge/.test(url)) {
return `${targetHost}/${host}`;
}
};
this.addResolver(challengeResolver, 9999);
}
setupHttpsProxy(proxy, websocketsUpgrade, sslOpts) {
var _a;
let httpsModule;
this.certs = this.certs || {};
const certs = this.certs;
let ssl = {
SNICallback: async (hostname, cb) => {
var _a, _b, _c, _d, _e, _f, _g;
if (!certs[hostname]) {
if (!((_b = (_a = this.opts) === null || _a === void 0 ? void 0 : _a.letsencrypt) === null || _b === void 0 ? void 0 : _b.path)) {
console.error('Missing certificate path for Lets Encrypt');
return cb(new Error('No certs for hostname ' + hostname));
}
if (!this.lazyCerts[hostname]) {
// Check if we have a resolver that matches the hostname and has letsencrypt enabled
const results = await this.applyResolvers(this.resolvers, hostname, '', null);
const route = results.find((route) => { var _a, _b; return (_b = (_a = route === null || route === void 0 ? void 0 : route.opts) === null || _a === void 0 ? void 0 : _a.ssl) === null || _b === void 0 ? void 0 : _b.letsencrypt; });
const sslOpts = (_c = route === null || route === void 0 ? void 0 : route.opts) === null || _c === void 0 ? void 0 : _c.ssl;
if (route && sslOpts) {
this.lazyCerts[hostname] = {
email: (_d = sslOpts.letsencrypt) === null || _d === void 0 ? void 0 : _d.email,
production: (_e = sslOpts.letsencrypt) === null || _e === void 0 ? void 0 : _e.production,
renewWithin: ((_g = (_f = this.opts) === null || _f === void 0 ? void 0 : _f.letsencrypt) === null || _g === void 0 ? void 0 : _g.renewWithin) || ONE_MONTH,
};
}
else {
return cb(new Error('No certs for hostname ' + hostname));
}
}
try {
await this.updateCertificates(hostname, this.lazyCerts[hostname].email, this.lazyCerts[hostname].production, this.lazyCerts[hostname].renewWithin);
}
catch (err) {
console.error('Error getting LetsEncrypt certificates', err);
return cb(err);
}
}
else if (!certs[hostname]) {
return cb(new Error('No certs for hostname ' + hostname));
}
if (cb) {
cb(null, certs[hostname]);
}
},
//
// Default certs for clients that do not support SNI.
//
key: getCertData(sslOpts.key),
cert: getCertData(sslOpts.cert),
};
// Allows the option to disable older SSL/TLS versions
if (sslOpts.secureOptions) {
ssl.secureOptions = sslOpts.secureOptions;
}
if (sslOpts.ca) {
ssl.ca = getCertData(sslOpts.ca, true);
}
if (sslOpts.opts) {
ssl = Object.assign(Object.assign({}, sslOpts.opts), ssl);
}
if (sslOpts.http2) {
httpsModule = sslOpts.serverModule || {
createServer: (sslOpts, cb) => http2.createSecureServer(sslOpts, cb),
};
}
else {
httpsModule = sslOpts.serverModule || https;
}
const httpsServer = (this.httpsServer = httpsModule.createServer(ssl, async (req, res) => {
const src = this.getSource(req);
const httpProxyOpts = Object.assign({}, this.opts.httpProxy);
const target = await this.getTarget(src, req, res);
if (target) {
httpProxyOpts.target = target;
proxy.web(req, res, httpProxyOpts);
}
else {
respondNotFound(req, res);
}
}));
httpsServer.on('upgrade', websocketsUpgrade);
httpsServer.on('error', (err) => {
var _a;
(_a = this.logger) === null || _a === void 0 ? void 0 : _a.error(err, 'HTTPS Server Error');
});
httpsServer.on('clientError', (err) => {
var _a;
(_a = this.logger) === null || _a === void 0 ? void 0 : _a.error(err, 'HTTPS Client Error');
});
(_a = this.logger) === null || _a === void 0 ? void 0 : _a.info('Listening to HTTPS requests on port %s', sslOpts.port);
httpsServer.listen(sslOpts.port, sslOpts.ip);
}
addResolver(resolverFn, priority) {
if (this.opts.cluster && cluster.isPrimary) {
return this;
}
// Check if the resolver is already added if so just update its priority
let found = false;
for (let i = 0; i < this.resolvers.length; i++) {
if (this.resolvers[i].fn === resolverFn) {
this.resolvers[i].priority = priority || 0;
found = true;
break;
}
}
if (!found) {
this.resolvers.push({
fn: resolverFn,
priority: priority || 0,
});
}
this.resolvers = sortBy(uniq(this.resolvers), 'priority').reverse();
}
removeResolver(resolverFn) {
if (this.opts.cluster && cluster.isPrimary) {
return this;
}
// Since unique resolvers are not checked for performance,
// just remove every existence.
this.resolvers = this.resolvers.filter(function (resolver) {
return resolverFn !== resolver.fn;
});
}
async register(src, target, opts) {
var _a, _b, _c;
if (this.opts.cluster && cluster.isPrimary) {
return;
}
// allow registering with src or target as an object to pass in
// options specific to each one.
if (src && src.src) {
target = src.target;
opts = src;
src = src.src;
}
else if (target && target.target) {
opts = target;
target = target.target;
}
if (!src || !target) {
throw Error('Cannot register a new route with unspecified src or target');
}
const routing = this.routing;
src = prepareUrl(src);
if (opts) {
const ssl = opts.ssl;
if (ssl) {
if (!this.httpsServer) {
throw Error('Cannot register https routes without defining a ssl port');
}
if (!this.certs[src.hostname]) {
if (ssl.key || ssl.cert || ssl.ca) {
this.certs[src.hostname] = createCredentialContext(ssl.key, ssl.cert, ssl.ca);
}
else if (ssl.letsencrypt) {
if (!this.opts.letsencrypt || !this.opts.letsencrypt.path) {
console.error('Missing certificate path for Lets Encrypt');
return;
}
if (!ssl.letsencrypt.lazy) {
(_a = this.logger) === null || _a === void 0 ? void 0 : _a.info('Getting Lets Encrypt certificates for %s', src.hostname);
await this.updateCertificates(src.hostname, ssl.letsencrypt.email, ssl.letsencrypt.production, this.opts.letsencrypt.renewWithin || ONE_MONTH);
}
else {
// We need to store the letsencrypt options for this domain somewhere
(_b = this.logger) === null || _b === void 0 ? void 0 : _b.info('Lazy loading Lets Encrypt certificates for %s', src.hostname);
this.lazyCerts[src.hostname] = Object.assign(Object.assign({}, ssl.letsencrypt), { renewWithin: this.opts.letsencrypt.renewWithin || ONE_MONTH });
}
}
else {
// Trigger the use of the default certificates.
this.certs[src.hostname] = void 0;
}
}
}
}
target = buildTarget(target, opts);
const hostname = src.hostname;
const host = (routing[hostname] = routing[hostname] || []);
const pathname = src.pathname || '/';
let route = host.find((route) => route.path === pathname);
if (!route) {
route = {
path: pathname,
rr: 0,
urls: [],
opts: Object.assign({}, opts),
};
host.push(route);
//
// Sort routes
//
routing[src.hostname] = sortBy(host, function (_route) {
return -_route.path.length;
});
}
route.urls.push(target);
(_c = this.logger) === null || _c === void 0 ? void 0 : _c.info({ from: src, to: target }, 'Registered a new route');
}
async updateCertificates(domain, email, production, renewWithin, renew) {
var _a, _b, _c;
try {
const certs = await letsencrypt.getCertificates(domain, email, (_a = this.opts.letsencrypt) === null || _a === void 0 ? void 0 : _a.port, production, renew, this.logger);
if (certs) {
const opts = {
key: certs.privkey,
cert: certs.cert + certs.chain,
};
this.certs[domain] = tls.createSecureContext(opts).context;
//
// TODO: cluster friendly
//
let renewTime = certs.expiresAt - Date.now() - renewWithin;
renewTime =
renewTime > 0 ? renewTime : this.opts.letsencrypt.minRenewTime || 60 * 60 * 1000;
(_b = this.logger) === null || _b === void 0 ? void 0 : _b.info('Renewal of %s in %s days', domain, Math.floor(renewTime / ONE_DAY));
const renewCertificate = () => {
var _a;
(_a = this.logger) === null || _a === void 0 ? void 0 : _a.info('Renewing letscrypt certificates for %s', domain);
this.updateCertificates(domain, email, production, renewWithin, true);
};
this.certs[domain].renewalTimeout = safe.setTimeout(renewCertificate, renewTime);
}
else {
//
// TODO: Try again, but we need an exponential backof to avoid getting banned.
//
(_c = this.logger) === null || _c === void 0 ? void 0 : _c.info('Could not get any certs for %s', domain);
}
}
catch (err) {
console.error('Error getting LetsEncrypt certificates', err);
}
}
unregister(src, target) {
var _a;
if (this.opts.cluster && cluster.isPrimary) {
return this;
}
if (!src) {
return this;
}
const srcURL = prepareUrl(src);
const routes = this.routing[srcURL.hostname] || [];
const pathname = srcURL.pathname || '/';
let i;
for (i = 0; i < routes.length; i++) {
if (routes[i].path === pathname) {
break;
}
}
if (i < routes.length) {
const route = routes[i];
if (target) {
const targetURL = prepareUrl(target);
remove(route.urls, (url) => {
return url.href === targetURL.href;
});
}
else {
route.urls = [];
}
if (route.urls.length === 0) {
routes.splice(i, 1);
const certs = this.certs;
if (certs) {
if (certs[srcURL.hostname] && certs[srcURL.hostname].renewalTimeout) {
safe.clearTimeout(certs[srcURL.hostname].renewalTimeout);
}
delete certs[srcURL.hostname];
}
}
(_a = this.logger) === null || _a === void 0 ? void 0 : _a.info({ from: src, to: target }, 'Unregistered a route');
}
return this;
}
applyResolvers(resolvers, host, url, req) {
return Promise.all(resolvers.map((resolver) => resolver.fn(host, url, req)));
}
/**
* Resolves to route
* @param host
* @param url
* @returns {*}
*/
async resolve(host, url, req) {
try {
host = host.toLowerCase();
const resolverResults = await this.applyResolvers(this.resolvers, host, url, req);
for (let i = 0; i < resolverResults.length; i++) {
const route = resolverResults[i];
if (route) {
const builtRoute = buildRoute(route);
if (builtRoute) {
// ensure resolved route has path that prefixes URL
// no need to check for native routes.
if (!builtRoute.isResolved ||
builtRoute.path === '/' ||
startsWith(url, builtRoute.path)) {
return builtRoute;
}
}
}
}
}
catch (err) {
console.error('Resolvers error:', err);
}
}
async getTarget(src, req, res) {
var _a, _b, _c, _d;
const url = req.url;
const route = await this.resolve(src, url, req);
if (!route) {
(_a = this.logger) === null || _a === void 0 ? void 0 : _a.warn({ src: src, url: url }, 'no valid route found for given source');
return;
}
const pathname = route.path;
if (pathname.length > 1) {
//
// remove prefix from src
//
req._url = url; // save original url (hacky but works quite well)
req.url = url.substr(pathname.length) || '';
}
//
// Perform Round-Robin on the available targets
// TODO: if target errors with EHOSTUNREACH we should skip this
// target and try with another.
//
const urls = route.urls;
const j = route.rr;
route.rr = (j + 1) % urls.length; // get and update Round-robin index.
const target = route.urls[j];
//
// Fix request url if targetname specified.
//
if (target.pathname) {
if (req.url) {
req.url = path.posix.join(target.pathname, req.url);
}
else {
req.url = target.pathname;
}
}
//
// Host headers are passed through from the source by default
// Often we want to use the host header of the target instead
//
if (target.useTargetHostHeader === true) {
req.host = target.host;
}
if ((_b = route.opts) === null || _b === void 0 ? void 0 : _b.onRequest) {
const resultFromRequestHandler = route.opts.onRequest(req, res, target);
if (resultFromRequestHandler !== undefined) {
(_c = this.logger) === null || _c === void 0 ? void 0 : _c.info('Proxying %s received result from onRequest handler, returning.', src + url);
return resultFromRequestHandler;
}
}
(_d = this.logger) === null || _d === void 0 ? void 0 : _d.info('Proxying %s to %s', src + url, path.posix.join(target.host, req.url));
return target;
}
getSource(req) {
if (this.opts.preferForwardedHost && req.headers['x-forwarded-host']) {
return req.headers['x-forwarded-host'].split(':')[0];
}
if (req.headers.host) {
return req.headers.host.split(':')[0];
}
}
async close() {
var _a;
this.proxy.close();
this.agent && this.agent.destroy();
// Clear any renewal timers
if (this.certs) {
Object.keys(this.certs).forEach((domain) => {
const cert = this.certs[domain];
if (cert && cert.renewalTimeout) {
safe.clearTimeout(cert.renewalTimeout);
cert.renewalTimeout = null;
}
});
}
(_a = this.letsencryptServer) === null || _a === void 0 ? void 0 : _a.close();
await Promise.all([this.server, this.httpsServer]
.filter((s) => s)
.map((server) => new Promise((resolve) => server.close(resolve))));
}
//
// Helpers
//
/**
Routing table structure. An object with hostname as key, and an array as value.
The array has one element per path associated to the given hostname.
Every path has a Round-Robin value (rr) and urls array, with all the urls available
for this target route.
{
hostA :
[
{
path: '/',
rr: 3,
urls: []
}
]
}
*/
notFound(callback) {
if (typeof callback == 'function') {
respondNotFound = callback;
}
else {
throw Error('notFound callback is not a function');
}
}
shouldRedirectToHttps(target) {
return target.sslRedirect && target.host != this.letsencryptHost;
}
}
//
// Redirect to the HTTPS proxy
//
function redirectToHttps(req, res, ssl, log) {
req.url = req._url || req.url; // Get the original url since we are going to redirect.
const targetPort = ssl.redirectPort || ssl.port;
const hostname = req.headers.host.split(':')[0] + (targetPort ? ':' + targetPort : '');
const url = 'https://' + path.posix.join(hostname, req.url);
log && log.info('Redirecting %s to %s', path.posix.join(req.headers.host, req.url), url);
//
// We can use 301 for permanent redirect, but its bad for debugging, we may have it as
// a configurable option.
//
res.writeHead(302, { Location: url });
res.end();
}
function prepareUrl(url) {
if (isString(url)) {
url = setHttp(url);
if (!validUrl.isHttpUri(url) && !validUrl.isHttpsUri(url)) {
throw Error(`uri is not a valid http uri ${url}`);
}
return parseUrl(url);
}
return url;
}
function getCertData(source, unbundle) {
let data;
// Handle different source types
if (source) {
if (Array.isArray(source)) {
// Recursively process each item in the array and flatten the result
const sources = source;
return sources.map((src) => getCertData(src, unbundle)).flat();
}
else if (Buffer.isBuffer(source)) {
// If source is a buffer, convert to string
data = source.toString('utf8');
}
else if (fs.existsSync(source)) {
// If source is a file path, read the file content
data = fs.readFileSync(source, 'utf8');
}
}
// Return unbundled certificate data if required, or raw data
if (data) {
return unbundle ? unbundleCert(data) : data;
}
return null; // Return null if no valid data is found
}
/**
Unbundles a file composed of several certificates.
http://www.benjiegillam.com/2012/06/node-dot-js-ssl-certificate-chain/
*/
function unbundleCert(bundle) {
const chain = bundle.trim().split('\n');
const ca = [];
const cert = [];
for (let i = 0, len = chain.length; i < len; i++) {
const line = chain[i].trim();
if (!(line.length !== 0)) {
continue;
}
cert.push(line);
if (line.match(/-END CERTIFICATE-/)) {
const joined = cert.join('\n');
ca.push(joined);
//cert = [];
cert.length = 0;
}
}
return ca;
}
function createCredentialContext(key, cert, ca) {
const opts = {};
opts.key = getCertData(key);
opts.cert = getCertData(cert);
if (ca) {
opts.ca = getCertData(ca, true);
}
const credentials = tls.createSecureContext(opts);
return credentials.context;
}
//
// https://stackoverflow.com/questions/18052919/javascript-regular-expression-to-add-protocol-to-url-string/18053700#18053700
// Adds http protocol if non specified.
function setHttp(link) {
if (link.search(/^http[s]?\:\/\//) === -1) {
link = 'http://' + link;
}
return link;
}
let respondNotFound = function (req, res) {
if (res instanceof ServerResponse) {
res.statusCode = 404;
}
res.write('Not Found');
res.end();
};
export const buildRoute = function (route) {
if (!isString(route) && !isObject(route)) {
return null;
}
if (isObject(route) && route.hasOwnProperty('urls') && route.hasOwnProperty('path')) {
// default route type matched.
return route;
}
const cacheKey = isString(route) ? route : hash(route);
const entry = routeCache.get(cacheKey);
if (entry) {
return entry;
}
const routeObject = { rr: 0, isResolved: true };
if (isString(route)) {
routeObject.urls = [buildTarget(route)];
routeObject.path = '/';
}
else {
if (!route.hasOwnProperty('url')) {
return null;
}
routeObject.urls = (Array.isArray(route.url) ? route.url : [route.url]).map(function (url) {
return buildTarget(url, route.opts || {});
});
routeObject.path = route.path || '/';
}
routeCache.set(cacheKey, routeObject);
return routeObject;
};
export const buildTarget = function (target, opts) {
opts = opts || {};
const targetURL = prepareUrl(target);
return Object.assign(Object.assign({}, targetURL), { sslRedirect: opts.ssl && opts.ssl.redirect !== false, useTargetHostHeader: opts.useTargetHostHeader === true });
};
function startsWith(input, str) {
return (input.slice(0, str.length) === str && (input.length === str.length || input[str.length] === '/'));
}
//# sourceMappingURL=proxy.js.map