mehserve
Version:
A simple port-sharing proxy for development on multiple local domains, supports websockets
317 lines (290 loc) • 9.82 kB
JavaScript
const async = require("async");
const express = require("express");
const httpProxy = require("http-proxy");
const { version } = require("./package");
const http = require("http");
const https = require("https");
const fs = require("fs");
const fsP = require("mz/fs");
const tls = require("tls");
const CONFIG_DIR = `${process.env.HOME}/.mehserve`;
const HTML_DIR = `${__dirname}/html`;
const PORT = process.env.PORT ? process.env.PORT : 12439;
const SSL_PORT = process.env.SSL_PORT ? process.env.SSL_PORT : 12443;
const DNS_PORT = process.env.DNS_PORT ? process.env.DNS_PORT : 15353;
const SUFFIXES = [/\.dev$/i, /\.meh$/i, /\.localhost$/i, /(\.[0-9]+){2,4}\.xip\.io$/i];
// Maximum number of attempts with exponential back-off
let EXPONENTIAL_MAXIMUM_ATTEMPTS = 25;
// Maximum delay between exponential back-off attempts
const EXPONENTIAL_MAXIMUM_DELAY = 2500;
let useExponentialBackoff = false;
for (let arg of process.argv.slice(2)) {
const matches = arg.match(/^--([a-z-]+)(?:=(.*))?$/);
if (matches) {
if (matches[1] === "exponential-backoff") {
useExponentialBackoff = true;
EXPONENTIAL_MAXIMUM_ATTEMPTS = parseInt(matches[2]) || EXPONENTIAL_MAXIMUM_ATTEMPTS;
console.log(`Exponential backoff enabled, with ${EXPONENTIAL_MAXIMUM_ATTEMPTS} attempts`);
}
}
}
const renderTemplate = function (template, templateVariables) {
if (templateVariables == null) {
templateVariables = {};
}
return template.replace(/\{\{\s*([a-zA-Z0-9_-]+)\s*\}\}/g, (_, varName) => templateVariables[varName] != null ? templateVariables[varName] : "");
};
const errorMessages = {
403: "Permission Denied"
};
const handleError = (req, res, _next) => function (error) {
let title = null;
let message = null;
let code = null;
switch (error.code) {
case "ECONNREFUSED":
code = 502;
title = "Bad Gateway: Connection Refused";
message = `Looks like you forgot to run server on port ${error.port}!`;
break;
case "ECONNRESET":
code = 502;
title = "Bad Gateway: Connection Reset";
message = `The server abruptly closed its end of the connection ─ perhaps it crashed?`;
break;
default:
{
const errorCodeAsInt = error.code ? parseInt(String(error.code), 10) : null;
code = errorCodeAsInt && errorCodeAsInt >= 400 && errorCodeAsInt < 600 ? errorCodeAsInt : 500;
title = errorMessages[code] || `Internal Server Error`;
message = error.message || "Something bad happened!";
}
}
res.statusCode = code;
const content = renderTemplate(fs.readFileSync(`${HTML_DIR}/error.html`, "utf8"), {
code,
title,
message,
version,
details: error.stack
});
res.setHeader("Content-Type", "text/html; charset=UTF-8");
res.setHeader("Content-Length", content.length);
res.end(content);
};
const readConfig = (req, res, next) => async.waterfall([
// Determine host from header
function (done) {
const hostHeader = req.headers.host || "localhost";
let host = hostHeader.split(":", 1)[0];
for (let suffixRegexp of SUFFIXES) {
if (suffixRegexp.test(host)) {
host = host.replace(suffixRegexp, "");
break;
}
}
done(null, host);
},
// Determine which config to use
function (host, done) {
if (host.match(/\.ssl\.(crt|key)$/i)) {
const err = new Error(`Access forbidden to host '${host}'`);
err.code = 403;
done(err);
return;
}
const split = host.split(".");
const options = [];
for (let i = 0, end = split.length; i < end; i++) {
options.push(split.slice(split.length - i - 1).join("."));
}
options.push("default");
const exists = (option, done) => fs.exists(`${CONFIG_DIR}/${option}`, done);
async.detectSeries(options, exists, function (configName) {
if (configName) {
done(null, configName);
return;
}
const err = new Error(`Configuration not found for host '${host}'`);
err.code = 500;
done(err);
});
},
// Get stats
(configName, done) => fs.stat(`${CONFIG_DIR}/${configName}`, (err, stats) => done(err, configName, stats)),
// Interpret stats
function (configName, stats, done) {
let config;
if (stats.isDirectory()) {
config = {
type: "static",
path: `${CONFIG_DIR}/${configName}`
};
} else {
const contents = fs.readFileSync(`${CONFIG_DIR}/${configName}`, "utf8");
if (contents[0] === "{") {
config = JSON.parse(contents);
} else {
const lines = contents.split("\n");
if (lines[0].match(/^[0-9]+$/)) {
config = {
type: "port",
port: parseInt(lines[0], 10)
};
} else if (lines[0].match(/^\//)) {
config = {
type: "static",
path: `${lines[0]}`
};
} else {
config = {};
}
}
}
done(null, config);
}], function (error, config) {
if (error) {
handleError(req, res, next)(error);
return;
}
req.config = config;
next();
});
const handle = function (req, res, next) {
if (req.config.type === "port") {
forward(req, res, next);
} else if (req.config.type === "static") {
serve(req, res, next);
} else {
const err = new Error("Config not understood");
err.code = 500;
err.meta = req.config;
next(err);
}
};
const staticMiddlewares = {};
var serve = function (req, res, next) {
const { config } = req;
const { path } = config;
if (staticMiddlewares[path] == null) {
staticMiddlewares[path] = express.static(path);
}
staticMiddlewares[path](req, res, next);
};
const proxy = httpProxy.createProxyServer({ host: "localhost", ws: true });
proxy.on("error", function (e) {
console.error("http-proxy emitted an error:");
console.error(e.stack);
});
proxy.on("proxyReq", function (proxyReq, req, _res, _options) {
proxyReq.setHeader("x-forwarded-proto", req.connection.encrypted ? "https" : "http");
});
var proxyWithExponentialBackoff = function (req, res, next, attempts) {
if (attempts == null) {
attempts = 0;
}
const { config } = req;
const { port } = config;
const cb = function (err) {
if (!err) {
next();
} else if (req.method === "GET" && attempts < EXPONENTIAL_MAXIMUM_ATTEMPTS) {
// To avoid memory leaks, we need to clear event listeners previously set
// via the proxy code:
// https://github.com/nodejitsu/node-http-proxy/blob/c979ba9f2cbb6988a210ca42bf59698545496723/lib/http-proxy/passes/web-incoming.js#L137-L143
req.removeAllListeners("aborted");
req.removeAllListeners("error");
const nextDelay = Math.min(EXPONENTIAL_MAXIMUM_DELAY, Math.ceil(1 + Math.random() * Math.pow(attempts, 2) * 10));
setTimeout(() => proxyWithExponentialBackoff(req, res, next, attempts + 1), nextDelay);
} else {
handleError(req, res, next)(err);
}
};
proxy.web(req, res, { target: { port } }, cb);
};
var forward = (req, res, next) => {
proxyWithExponentialBackoff(req, res, next, useExponentialBackoff ? 0 : EXPONENTIAL_MAXIMUM_ATTEMPTS);
};
const upgrade = (req, socket, head) => readConfig(req, null, function (err) {
if (err) {
socket.close();
return;
}
const { config } = req;
const { port } = config;
proxy.ws(req, socket, head, { target: { port } });
});
const secureContextContainerCache = {};
const MAX_SSL_CACHE_AGE_IN_MILLISECONDS = 1000 * 30;
async function createSecureContext(servername) {
let host = servername.split(":", 1)[0];
if (!(await fsP.exists(`${CONFIG_DIR}/${host}.ssl.key`))) {
for (let suffixRegexp of SUFFIXES) {
if (suffixRegexp.test(host)) {
const shorterHost = host.replace(suffixRegexp, "");
if (await fsP.exists(`${CONFIG_DIR}/${shorterHost}.ssl.key`)) {
host = shorterHost;
break;
}
}
}
}
const [key, cert] = await Promise.all([fsP.readFile(`${CONFIG_DIR}/${host}.ssl.key`, "utf8"), fsP.readFile(`${CONFIG_DIR}/${host}.ssl.crt`, "utf8")]);
const context = tls.createSecureContext({
key,
cert
});
secureContextContainerCache[servername] = {
context,
createdAt: Date.now()
};
return context;
}
async function getSecureContext(servername) {
const contextContainer = secureContextContainerCache[servername];
const isValid = contextContainer => contextContainer.createdAt > Date.now() - MAX_SSL_CACHE_AGE_IN_MILLISECONDS;
if (contextContainer && isValid(contextContainer)) {
return contextContainer.context;
}
if (contextContainer) {
// Give ourselves a minute to create a new context, let old requests continue in the mean time.
contextContainer.createdAt = Date.now() + 60 * 1000;
createSecureContext(servername).then(null, e => {
console.error(`Could not generate secure context for '${servername}'`);
console.error(e);
});
return contextContainer.context;
} else {
return createSecureContext(servername);
}
}
const server = express();
server.use(readConfig);
server.use(handle);
var httpServer = http.createServer(server);
httpServer.listen(PORT, function () {
const { port } = httpServer.address();
console.log(`mehserve v${version} listening on port ${port}`);
});
var httpsServer = https.createServer({
async SNICallback(servername, cb) {
let ctx, err;
try {
ctx = await getSecureContext(servername);
} catch (e) {
console.error(`Could not generate secure context for server '${servername}': ${e.message}`);
err = e;
} finally {
cb(err, ctx);
}
}
}, server);
httpsServer.listen(SSL_PORT, function () {
const { port } = httpsServer.address();
console.log(`mehserve v${version} (SSL) listening on port ${port}`);
});
httpServer.on("upgrade", upgrade);
httpsServer.on("upgrade", upgrade);
const dnsServer = require("./dnsserver");
dnsServer.serve(DNS_PORT);
//# sourceMappingURL=index.js.map