src/daemon/group.js
const fs = require("fs");
const path = require("path");
const EventEmitter = require("events");
const url = require("url");
const once = require("once");
const getPort = require("get-port");
const matcher = require("matcher");
const respawn = require("respawn");
const afterAll = require("after-all");
const httpProxy = require("http-proxy");
const serverReady = require("server-ready");
const log = require("./log");
const tcpProxy = require("./tcp-proxy");
const daemonConf = require("../conf");
const getCmd = require("../get-cmd");
module.exports = () => new Group();
class Group extends EventEmitter {
constructor() {
super();
this._list = {};
this._proxy = httpProxy.createProxyServer({
xfwd: true
});
// `http-proxy` requires that at least 1 listener exists to not raise
// an exception. See https://github.com/http-party/node-http-proxy/blob/9b96cd725127a024dabebec6c7ea8c807272223d/lib/http-proxy/index.js#L119
this._proxy.on("error", err => console.error(err));
}
_output(id, data) {
this.emit("output", id, data);
}
_log(mon, logFile, data) {
mon.tail = mon.tail
.concat(data)
.split("\n")
.slice(-100)
.join("\n");
if (logFile) {
fs.appendFile(logFile, data, err => {
if (err) log(err.message);
});
}
}
_change() {
this.emit("change", this._list);
}
//
// Conf
//
list() {
return this._list;
}
find(id) {
return this._list[id];
}
add(id, conf) {
if (conf.target) {
log(`Add target ${id}`);
this._list[id] = conf;
this._change();
return;
}
log(`Add server ${id}`);
const HTTP_PROXY = `http://127.0.0.1:${daemonConf.port}/proxy.pac`;
conf.env = {
...process.env,
...conf.env
};
if (conf.httpProxyEnv) {
conf.env = {
HTTP_PROXY,
HTTPS_PROXY: HTTP_PROXY,
http_proxy: HTTP_PROXY,
https_proxy: HTTP_PROXY,
...conf.env
};
}
let logFile;
if (conf.out) {
logFile = path.resolve(conf.cwd, conf.out);
}
const command = getCmd(conf.cmd);
const mon = respawn(command, {
...conf,
maxRestarts: 0
});
this._list[id] = mon;
// Add proxy config
mon.xfwd = conf.xfwd || false;
mon.changeOrigin = conf.changeOrigin || false;
// Emit output
mon.on("stdout", data => this._output(id, data));
mon.on("stderr", data => this._output(id, data));
mon.on("warn", data => this._output(id, data));
// Emit change
mon.on("start", () => this._change());
mon.on("stop", () => this._change());
mon.on("crash", () => this._change());
mon.on("sleep", () => this._change());
mon.on("exit", () => this._change());
// Log status
mon.on("start", () => log(id, "has started"));
mon.on("stop", () => log(id, "has stopped"));
mon.on("crash", () => log(id, "has crashed"));
mon.on("sleep", () => log(id, "is sleeping"));
mon.on("exit", () => log(id, "child process has exited"));
// Handle logs
mon.tail = "";
mon.on("stdout", data => this._log(mon, logFile, data));
mon.on("stderr", data => this._log(mon, logFile, data));
mon.on("warn", data => this._log(mon, logFile, data));
mon.on("start", () => {
mon.tail = "";
if (logFile) {
fs.unlink(logFile, err => {
if (err) log(err.message);
});
}
});
this._change();
}
remove(id, cb) {
const item = this.find(id);
if (item) {
delete this._list[id];
this._change();
if (item.stop) {
item.stop(cb);
item.removeAllListeners();
return;
}
}
cb && cb();
}
stopAll(cb) {
const next = afterAll(cb);
Object.keys(this._list).forEach(key => {
if (this._list[key].stop) {
this._list[key].stop(next());
}
});
}
update(id, conf) {
this.remove(id, () => this.add(id, conf));
}
//
// Hostname resolver
//
resolve(str) {
log(`Resolve ${str}`);
const arr = Object.keys(this._list)
.sort()
.reverse()
.map(h => ({
host: h,
isStrictMatch: matcher.isMatch(str, h),
isWildcardMatch: matcher.isMatch(str, `*.${h}`)
}));
const strictMatch = arr.find(h => h.isStrictMatch);
const wildcardMatch = arr.find(h => h.isWildcardMatch);
if (strictMatch) return strictMatch.host;
if (wildcardMatch) return wildcardMatch.host;
}
//
// Middlewares
//
exists(req, res, next) {
// Resolve using either hostname `app.tld`
// or id param `http://localhost:2000/app`
const tld = new RegExp(`.${daemonConf.tld}$`);
const id = req.params.id
? this.resolve(req.params.id)
: this.resolve(req.hostname.replace(tld, ""));
// Find item
const item = this.find(id);
// Not found
if (!id || !item) {
const msg = `Can't find server id: ${id}`;
log(msg);
return res.status(404).send(msg);
}
req.chalet = {
id,
item
};
next();
}
start(req, res, next) {
const { item } = req.chalet;
if (item.start) {
if (item.env.PORT) {
item.start();
next();
} else {
getPort()
.then(port => {
item.env.PORT = port;
item.start();
next();
})
.catch(error => {
next(error);
});
}
} else {
next();
}
}
stop(req, res, next) {
const { item } = req.chalet;
if (item.stop) {
item.stop();
}
next();
}
proxyWeb(req, res, target) {
const { xfwd, changeOrigin } = req.chalet.item;
this._proxy.web(
req,
res,
{
target,
xfwd,
changeOrigin
},
err => {
log("Proxy - Error", err.message);
const server = req.chalet.item;
const view = server.start ? "server-error" : "target-error";
res.status(502).render(view, {
err,
serverReady,
server
});
}
);
}
proxy(req, res) {
const [hostname, port] = req.headers.host && req.headers.host.split(":");
const { item } = req.chalet;
// Handle case where port is set
// http://app.test:5000 should proxy to http://localhost:5000
if (port) {
const target = `http://127.0.0.1:${port}`;
log(`Proxy - http://${req.headers.host} → ${target}`);
return this.proxyWeb(req, res, target);
}
// Make sure to send only one response
const send = once(() => {
const { target } = item;
log(`Proxy - http://${hostname} → ${target}`);
this.proxyWeb(req, res, target);
});
if (item.start) {
// Set target
item.target = `http://localhost:${item.env.PORT}`;
// If server stops, no need to wait for timeout
item.once("stop", send);
// When PORT is open, proxy
serverReady(item.env.PORT, send);
} else {
// Send immediatly if item is not a server started by a command
send();
}
}
redirect(req, res) {
const { id } = req.params;
const { item } = req.chalet;
// Make sure to send only one response
const send = once(() => {
log(`Redirect - ${id} → ${item.target}`);
res.redirect(item.target);
});
if (item.start) {
// Set target
item.target = `http://${req.hostname}:${item.env.PORT}`;
// If server stops, no need to wait for timeout
item.once("stop", send);
// When PORT is open, redirect
serverReady(item.env.PORT, send);
} else {
// Send immediatly if item is not a server started by a command
send();
}
}
parseHost(host) {
const [hostname, port] = host.split(":");
const tld = new RegExp(`.${daemonConf.tld}$`);
const id = this.resolve(hostname.replace(tld, ""));
return { id, hostname, port };
}
// Needed to proxy WebSocket from CONNECT
handleUpgrade(req, socket, head) {
if (req.headers.host) {
const { host } = req.headers;
const { id, port } = this.parseHost(host);
const item = this.find(id);
if (item) {
let target;
if (port && port !== "80") {
target = `ws://127.0.0.1:${port}`;
} else if (item.start) {
target = `ws://127.0.0.1:${item.env.PORT}`;
} else {
const { hostname, port } = new url.URL(item.target);
const targetPort = port || 80;
target = `ws://${hostname}:${targetPort}`;
}
log(`WebSocket - ${host} → ${target}`);
this._proxy.ws(req, socket, head, { target }, err => {
log("WebSocket - Error", err.message);
});
} else {
log(`WebSocket - No server matching ${id}`);
}
} else {
log("WebSocket - No host header found");
}
}
// Handle CONNECT, used by WebSockets and https when accessing .test domains
handleConnect(req, socket, head) {
if (req.headers.host) {
const { host } = req.headers;
const { id, hostname, port } = this.parseHost(host);
// If https make socket go through https proxy on 2001
// TODO find a way to detect https and wss without relying on port number
if (port === "443") {
return tcpProxy.proxy(socket, daemonConf.port + 1, hostname);
}
const item = this.find(id);
if (item) {
if (port && port !== "80") {
log(`Connect - ${host} → ${port}`);
tcpProxy.proxy(socket, port);
} else if (item.start) {
const { PORT } = item.env;
log(`Connect - ${host} → ${PORT}`);
tcpProxy.proxy(socket, PORT);
} else {
const { hostname, port } = new url.URL(item.target);
const targetPort = port || 80;
log(`Connect - ${host} → ${hostname}:${port}`);
tcpProxy.proxy(socket, targetPort, hostname);
}
} else {
log(`Connect - Can't find server for ${id}`);
socket.end();
}
} else {
log("Connect - No host header found");
}
}
}