SequenceDiagram/bin/server/Server.js

460 lines
10 KiB
JavaScript
Executable File

const fs = require('fs');
const http = require('http');
const stream = require('stream');
const util = require('util');
const zlib = require('zlib');
const PREF_ENCODINGS = ['gzip', 'deflate', 'identity'];
const PRE_COMPRESS_OPTS = {
level: 9,
memLevel: 8,
windowBits: 15,
};
const LIVE_COMPRESS_OPTS = {
level: 5,
memLevel: 9,
windowBits: 15,
};
const MATCH_ACCEPT = new RegExp('^ *([^;]+)(?:;q=([0-9]+(?:\\.[0-9]+)?))? *$');
const MATCH_INDEX = new RegExp('^(.*/)index\\.[^./]+$', 'i');
function passthroughMapper(file, type, data) {
return data;
}
class HttpError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
function parseAcceptEncoding(accept) {
// https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.3
const opts = (accept || 'identity;q=1, *;q=0.5').split(',');
const types = new Map();
opts.forEach((opt) => {
const match = MATCH_ACCEPT.exec(opt);
if(match) {
let q = Number.parseFloat(match[2] || '1');
if(q === 0) {
q = -1;
}
types.set(match[1], q);
}
});
if(!types.has('*')) {
if(!types.has('identity')) {
types.set('identity', -0.5);
}
types.set('*', -1);
}
return types;
}
function pickAcceptEncoding(types, preferred) {
let best = null;
let bestQ = -1;
const wildcard = types.get('*');
preferred.forEach((opt) => {
const q = types.get(opt) || wildcard;
if(q > bestQ) {
best = opt;
bestQ = q;
}
});
if(best === null) {
throw new HttpError(406, 'Not Acceptable');
}
return best;
}
class RequestHandler {
constructor(method, matcher, handler) {
this.method = method;
this.matcher = matcher;
this.handler = handler;
}
apply(req, res, info) {
if(req.method !== this.method) {
return false;
}
const match = this.matcher.exec(req.url);
if(!match) {
return false;
}
if(this.handler(req, res, Object.assign({match}, info)) === false) {
return false;
}
return true;
}
}
function bufferIfBetter(buffer, check) {
if(buffer.byteLength < check.byteLength) {
return buffer;
}
return null;
}
class StaticRequestHandler extends RequestHandler {
constructor(baseUrlPattern) {
super(
'GET',
new RegExp('^' + baseUrlPattern + '([^?]*)(\\?.*)?$'),
null
);
this.handler = this.handle.bind(this);
this.resources = new Map();
}
add(path, type, content) {
let data = content;
if(typeof content === 'string') {
data = Buffer.from(content, 'utf8');
}
const existing = this.resources.get(path);
if(existing && data.equals(existing.encodings.identity)) {
return Promise.resolve(false);
}
return Promise.all([
data,
util.promisify(zlib.deflate)(data, PRE_COMPRESS_OPTS),
util.promisify(zlib.gzip)(data, PRE_COMPRESS_OPTS),
])
.then(([identity, deflate, gzip]) => {
const resource = {
encodings: {
deflate: bufferIfBetter(deflate, identity),
gzip: bufferIfBetter(gzip, identity),
identity,
},
path,
type,
};
const match = MATCH_INDEX.exec(path);
if(match) {
this.resources.set(match[1], resource);
}
this.resources.set(path, resource);
return true;
});
}
handleResource(req, res, resource, {pickEncoding, log}) {
log('SERVE ' + resource.path);
const encoding = pickEncoding(
PREF_ENCODINGS.filter((enc) => (resource.encodings[enc] !== null))
);
if(encoding !== 'identity') {
res.setHeader('Content-Encoding', encoding);
}
res.setHeader('Content-Type', resource.type);
res.end(resource.encodings[encoding]);
}
handle(req, res, info) {
const resource = this.resources.get(info.match[1]);
if(!resource) {
return false;
}
return this.handleResource(req, res, resource, info);
}
write(target) {
let maxLen = 0;
for(const [path] of this.resources) {
maxLen = Math.max(maxLen, path.length);
}
let indent = '';
for(let i = 0; i < maxLen; ++ i) {
indent += ' ';
}
for(const [path, res] of this.resources) {
const {encodings} = res;
target.write(path + indent.substr(path.length));
target.write(' ' + encodings.identity.byteLength + 'b');
for(const enc of ['gzip', 'deflate']) {
const buf = encodings[enc];
if(buf !== null) {
target.write(', ' + enc + ': ' + buf.byteLength + 'b');
}
}
target.write('\n');
}
}
}
function writeEncoded(res, encoding, compressionOpts, data) {
if(encoding === 'identity') {
res.end(data);
return;
}
res.setHeader('Content-Encoding', encoding);
const raw = new stream.Readable();
raw.push(data, 'utf8');
raw.push(null);
switch(encoding) {
case 'gzip':
raw.pipe(zlib.createGzip(compressionOpts)).pipe(res);
break;
case 'deflate':
raw.pipe(zlib.createDeflate(compressionOpts)).pipe(res);
break;
default:
throw new HttpError(500, 'Failed to encode');
}
}
class Server {
constructor() {
this.awaiting = [];
this.mimes = new Map();
this.defaultMime = 'text/plain; charset=utf-8';
this.handlers = [];
this.watchFiles = false;
this.watchers = [];
this.staticHandler = new StaticRequestHandler('');
this.urlMaxLength = 65536;
this.server = http.createServer(this._handleRequest.bind(this));
this.log = this.log.bind(this);
this.logTarget = process.stdout;
this.running = false;
this.registerShutdown = false;
this.shutdownCallbacks = [];
this.close = this.close.bind(this);
}
setFileWatch(enabled) {
this.watchFiles = enabled;
return this;
}
addMimeType(extension, type) {
this.mimes.set(extension, type);
return this;
}
_addStaticFile(path, file, mapper) {
const ext = file.substr(file.lastIndexOf('.') + 1);
const type = this.mimes.get(ext) || this.defaultMime;
const map = mapper || passthroughMapper;
const fn = () => util.promisify(fs.readFile)(file)
.then((data) => map(file, type, data))
.then((data) => this.staticHandler.add(path, type, data));
this.watchers.push({file, fn, watcher: null});
return fn();
}
_addStaticDir(path, file, mapper) {
return util.promisify(fs.readdir)(file)
.then((subFiles) => this._addStaticResources(
path + '/',
file + '/',
subFiles.filter((sub) => !sub.startsWith('.')),
mapper
));
}
_addStaticResource(path, file, mapper) {
return util.promisify(fs.stat)(file)
.then((stats) => {
if(stats.isDirectory()) {
return this._addStaticDir(path, file, mapper);
} else {
return this._addStaticFile(path, file, mapper);
}
});
}
_addStaticResources(basePath, baseFs, files, mapper) {
return Promise.all(files.map((file) => this._addStaticResource(
basePath + file,
baseFs + file,
mapper
)));
}
addStaticFile(path, file, mapper = null) {
this.awaiting.push(this._addStaticFile(path, file, mapper));
return this;
}
addStaticDir(basePath, baseFile, mapper = null) {
this.awaiting.push(this._addStaticDir(basePath, baseFile, mapper));
return this;
}
addStaticResource(path, file, mapper = null) {
this.awaiting.push(this._addStaticResource(path, file, mapper));
return this;
}
addStaticResources(basePath, baseFs, files, mapper = null) {
this.awaiting.push(
this._addStaticResources(basePath, baseFs, files, mapper)
);
return this;
}
addHandler(method, matcher, handler) {
this.handlers.push(new RequestHandler(method, matcher, handler));
return this;
}
addShutdownHook(callback) {
if(callback) {
this.shutdownCallbacks.push(callback);
}
this.registerShutdown = true;
return this;
}
log(message) {
this.logTarget.write(new Date().toISOString() + ' ' + message + '\n');
}
_handleError(req, res, e) {
let status = 500;
let message = 'An internal error occurred';
if(typeof e === 'object' && e.message) {
status = e.status || 400;
message = e.message;
}
res.statusCode = status;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end(message + '\n');
}
_makeInfo(req, res) {
const acceptEncoding = parseAcceptEncoding(
req.headers['accept-encoding']
);
return {
log: this.log,
pickEncoding: (opts) => pickAcceptEncoding(
acceptEncoding,
opts || PREF_ENCODINGS
),
writeEncoded: (encoding, data) => writeEncoded(
res,
encoding,
LIVE_COMPRESS_OPTS,
data
),
};
}
_handleRequest(req, res) {
try {
if(req.url.length > this.urlMaxLength) {
throw new HttpError(400, 'Request too long');
}
const info = this._makeInfo(req, res);
for(const handler of this.handlers) {
if(handler.apply(req, res, info)) {
return;
}
}
if(!this.staticHandler.apply(req, res, info)) {
throw new HttpError(404, 'Not Found');
}
} catch(e) {
this._handleError(req, res, e);
}
}
baseurl() {
return 'http://' + this.hostname + ':' + this.port + '/';
}
_addWatchers() {
if(this.watchFiles) {
this.watchers.forEach((entry) => {
if(entry.watcher) {
return;
}
const makeWatcher = () => fs.watch(entry.file, () => {
/*
* If editor changed file by moving it, we are now
* watching the wrong file, so re-create watcher:
*/
entry.watcher.close();
entry.watcher = makeWatcher();
entry.fn().then((changed) => {
if(changed) {
this.log('RELOADED ' + entry.file);
}
});
});
entry.watcher = makeWatcher();
});
}
}
_removeWatchers() {
this.watchers.forEach((entry) => {
if(entry.watcher) {
entry.watcher.close();
entry.watcher = null;
}
});
}
listen(port, hostname) {
if(this.running) {
throw new Error('Already listening');
}
this.port = port;
this.hostname = hostname;
return Promise.all(this.awaiting).then(() => new Promise((resolve) => {
this._addWatchers();
this.server.listen(port, hostname, () => {
this.running = true;
if(this.registerShutdown) {
process.on('SIGINT', this.close);
}
resolve(this);
});
}));
}
close() {
if(!this.running) {
return Promise.resolve(this);
}
this.running = false;
this._removeWatchers();
return new Promise((resolve) => {
this.server.close(() => {
this.shutdownCallbacks.forEach((fn) => fn(this));
resolve(this);
process.removeListener('SIGINT', this.close);
});
});
}
printListeningInfo(target) {
target.write('Serving static resources:\n');
this.staticHandler.write(target);
target.write('\n');
target.write('Available at ' + this.baseurl() + '\n\n');
}
}
module.exports = {HttpError, Server};