livereloadx
Version:
An implementation of the LiveReload 2 server in Node.js
268 lines (226 loc) • 8.36 kB
JavaScript
"use strict";
var httpProxy = require('http-proxy')
, inject = require('./html').inject
, zlib = require('zlib')
, log = require('./log')('proxy')
, ServerResponse = require('http').ServerResponse
, StaticHandler = require('./static')
, url = require('url');
// inspired by webxl/grunt-reload/tasks/reload.js
function ServerResponseWrapper(req, res, port) {
this.req = req;
this.res = res;
this.port = port;
this.origWriteHead = res.writeHead;
this.origWrite = res.write;
this.origEnd = res.end;
res.writeHead = this.writeHead.bind(this);
res.write = this.write.bind(this);
res.end = this.end.bind(this);
}
ServerResponseWrapper.prototype.writeHead = function(statusCode, headers) {
this.statusCode = statusCode;
this.headers = headers;
log.debug("%s: status=%d, header=", this.req.url, statusCode, headers);
var isHtml = /html/.test(headers['content-type']);
log.debug('%s: isHtml=%d', this.req.url, isHtml);
if (isHtml) {
log.debug("%s: start injecting", this.req.url);
this.injecting = true;
this.tmpBuffer = [];
} else {
log.debug("%s: not injected", this.req.url);
this.injecting = false;
// setHeader() for logging Content-Length in server.js
if (headers && headers['content-length']) {
this.res.setHeader('Content-Length', headers['content-length']);
}
this.origWriteHead.call(this.res, statusCode, headers);
}
};
ServerResponseWrapper.prototype.write = function(chunk, encoding) {
if (this.injecting) {
log.debug("%s: add to tmpBuffer", this.req.url);
this.tmpBuffer.push(new Buffer(chunk, encoding));
} else {
log.debug("%s: call original write", this.req.url);
this.origWrite.call(this.res, chunk, encoding);
}
},
ServerResponseWrapper.prototype.end = function(chunk, encoding) {
if (!this.injecting) {
log.debug('%s: call original end', this.req.url, chunk, encoding);
this.origEnd.call(this.res, chunk, encoding);
return;
}
// get remote response body
if (chunk) { this.write(chunk, encoding); }
var buffer = Buffer.concat(this.tmpBuffer);
if (/^(gzip|deflate)$/.test(this.headers['content-encoding'])) {
// It's compressed.
log.debug('%s: end() called. decompress buffer', this.req.url);
var self = this;
this.decompress(buffer, function(err, buf) {
if (err) {
log.error('%s: error on decompressing: ', self.req.url, err);
self.finish(buffer);
} else {
log.debug('%s: decompress finished. inject snippet', self.req.url);
delete self.headers['content-encoding'];
self.finish(self.getSnippetInjectedBuffer(buf));
}
});
} else {
// Modify response and returns it to client
log.debug('%s: end() called. inject snippet', this.req.url);
this.finish(this.getSnippetInjectedBuffer(buffer));
}
};
ServerResponseWrapper.prototype.decompress = function(buffer, callback) {
if (this.headers['content-encoding'] === 'gzip') {
zlib.gunzip(buffer, callback);
} else if (this.headers['content-encoding'] === 'deflate') {
zlib.inflate(buffer, callback);
} else {
throw new Error('Invalid encoding: ' + this.headers['content-encoding']);
}
};
ServerResponseWrapper.prototype.getSnippetInjectedBuffer = function(oldBuf) {
// inject html
var newBuf = inject(oldBuf, this.port);
log.debug('%s: content-length changed: %d -> %d', this.req.url,
oldBuf.length, newBuf.length);
return newBuf;
};
ServerResponseWrapper.prototype.finish = function(buffer) {
// set Content-Length header
// `res.setHeader()` is necessary for logging Content-Length in server.js
this.headers['content-length'] = buffer.length.toString();
this.res.setHeader('Content-Length', buffer.length.toString());
log.debug('%s: finish', this.req.url);
log.debug(' - header: ', this.headers);
this.origWriteHead.call(this.res, this.statusCode, this.headers);
this.origWrite.call(this.res, buffer);
this.origEnd.call(this.res);
};
function wrapResponse(req, res, port) {
new ServerResponseWrapper(req, res, port);
}
function rewriteLocation(host, location, proxy) {
// use target rather than source, because rewrite to http from https has been done already.
// see ... https://github.com/nodejitsu/node-http-proxy/blob/v0.8.7/lib/node-http-proxy/http-proxy.js#L255
var scheme = proxy.source.https ? 'https://' : 'http://';
var fromList = [scheme + proxy.target.host + ':' + proxy.target.port + '/'];
var rewriteTo = host ? scheme + host : "";
if (proxy.source.https) {
if (+proxy.target.port === 443) {
fromList.push(scheme + proxy.target.host + '/');
}
} else {
if (+proxy.target.port === 80) {
fromList.push(scheme + proxy.target.host + '/');
}
}
fromList.some(function(from){
if (location.lastIndexOf(from, 0) === 0) {
location = rewriteTo + location.substring(from.length - 1);
return true;
}
});
return location;
}
function wrapRewriteLocation(req, res, proxy) {
var host = req.headers.host;
var writeHead = res.writeHead;
res.writeHead = function(statusCode, headers) {
if (((statusCode === 301) || (statusCode === 302)) && headers.location) {
headers.location = rewriteLocation(host, headers.location, proxy);
}
return writeHead.call(this, statusCode, headers);
};
}
function ProxyHandler(config) {
this.config = config;
this.init();
}
ProxyHandler.prototype.init = function() {
log.debug('proxy: ', this.config.proxy);
if (this.config.proxy === '') {
return;
}
var proxyUrl = url.parse(this.config.proxy);
if (!proxyUrl.protocol) {
throw new Error("proxy URL protocol is not specified:" + this.config.proxy);
}
// create HttpProxy instance
var isHttps = (proxyUrl.protocol === 'https:');
var port = proxyUrl.port || (isHttps ? 443 : 80);
if (!isHttps && proxyUrl.protocol !== 'http:') {
throw new Error("proxy URL protocol is invalid: " + this.config.proxy);
}
if (proxyUrl.path !== '/') {
log.warn("proxy URL path '%s' is ignored", proxyUrl.path);
}
this.proxy = new httpProxy.HttpProxy({
target: {
host: proxyUrl.hostname,
port: port,
https: isHttps
},
enable: {
xforward: false
},
changeOrigin: true
});
// create StaticHandler instance if prefer-local is enabled
if (this.config.preferLocal) {
this.staticHandler = new StaticHandler(this.config, this.onError.bind(this));
}
log.info("Enabled proxy mode. (proxy to '%s//%s:%d/')", proxyUrl.protocol,
proxyUrl.hostname, port);
};
ProxyHandler.prototype.handle = function(req, res) {
if (!this.proxy) {
return false;
}
if (this.staticHandler) {
if (this.staticHandler.handle(req, res)) {
return true;
}
}
this.doProxy(req, res);
return true;
};
ProxyHandler.prototype.doProxy = function(req, res) {
// rewrite location header
wrapRewriteLocation(req, res, this.proxy);
// hook response
wrapResponse(req, res, this.config.port);
// pass to http-proxy
log.debug("process: %s", req.url);
this.proxy.proxyRequest(req, res);
return true;
};
ProxyHandler.prototype.onError = function(req, res, err) {
if (err.status === 404) {
// If file not found on local dir, retrieve from remote server
log.debug("%s: not found on local. load from remote.", req.url);
this.doProxy(req, res);
} else {
// unknown error
// (ref) connect/lib/proto.js
// default to 500
if (res.statusCode < 400) { res.statusCode = 500; }
// respect err.status
if (err.status) { res.statusCode = err.status; }
// gets a basic error message
var msg = err.stack || err.toString();
log.error('Failed to read from local: %s, %s', this.req.url, msg);
if (res.headerSent) { return req.socket.destroy(); }
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Content-Length', Buffer.byteLength(msg));
if ('HEAD' === req.method) { return res.end(); }
res.end(msg);
}
};
module.exports = ProxyHandler;