Split server code into logical chunks
This commit is contained in:
parent
9b668d0fed
commit
eaef4a3f47
|
@ -0,0 +1,78 @@
|
|||
const {HttpError} = require('../server/HttpError');
|
||||
const {RequestHandler} = require('../server/RequestHandler');
|
||||
const {VirtualSequenceDiagram} = require('../../lib/sequence-diagram');
|
||||
|
||||
function beginTimer() {
|
||||
return process.hrtime();
|
||||
}
|
||||
|
||||
function endTimer(timer) {
|
||||
const delay = process.hrtime(timer);
|
||||
return delay[0] * 1e9 + delay[1];
|
||||
}
|
||||
|
||||
const NUM_MATCH = '[0-9]+(?:\\.[0-9]+)?';
|
||||
const MATCH_RENDER = new RegExp(
|
||||
'^/render/' +
|
||||
`(?:(?:w(${NUM_MATCH}))?(?:h(${NUM_MATCH}))?/|z(${NUM_MATCH})/)?` +
|
||||
'(?:(uri|b64)/)?' +
|
||||
'(.*?)' +
|
||||
'(?:\\.(svg))?$',
|
||||
'i'
|
||||
);
|
||||
|
||||
function getNumeric(v, name) {
|
||||
if(!v) {
|
||||
return null;
|
||||
}
|
||||
const n = Number.parseFloat(v);
|
||||
if(Number.isNaN(n)) {
|
||||
throw new HttpError(400, 'Invalid value for ' + name);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function readEncoded(str, encoding) {
|
||||
switch(encoding) {
|
||||
case 'b64':
|
||||
return Buffer
|
||||
.from(decodeURIComponent(str), 'base64')
|
||||
.toString('utf8');
|
||||
case 'uri':
|
||||
return str.split('/').map((ln) => decodeURIComponent(ln)).join('\n');
|
||||
default:
|
||||
throw new HttpError(400, 'Unknown encoding');
|
||||
}
|
||||
}
|
||||
|
||||
function handleRender(req, res, {match, pickEncoding, log, writeEncoded}) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
|
||||
const encoding = pickEncoding();
|
||||
const size = {
|
||||
height: getNumeric(match[2], 'height'),
|
||||
width: getNumeric(match[1], 'width'),
|
||||
zoom: getNumeric(match[3], 'zoom'),
|
||||
};
|
||||
const code = readEncoded(match[5], (match[4] || 'uri').toLowerCase());
|
||||
const format = (match[6] || 'svg').toLowerCase();
|
||||
|
||||
const timer = beginTimer();
|
||||
const svg = VirtualSequenceDiagram.render(code, {size});
|
||||
const delay = endTimer(timer);
|
||||
log('RENDER (' + (delay / 1e6).toFixed(3) + 'ms)');
|
||||
|
||||
switch(format) {
|
||||
case 'svg':
|
||||
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
writeEncoded(encoding, svg);
|
||||
break;
|
||||
default:
|
||||
throw new HttpError(400, 'Unsupported image format');
|
||||
}
|
||||
}
|
||||
|
||||
const render = new RequestHandler('GET', MATCH_RENDER, handleRender);
|
||||
render.info = 'Rendering sequence diagrams at /render/';
|
||||
|
||||
module.exports = {render};
|
110
bin/server.js
110
bin/server.js
|
@ -1,93 +1,13 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const {VirtualSequenceDiagram} = require('../lib/sequence-diagram');
|
||||
const {HttpError, Server} = require('./server/Server');
|
||||
const {Server} = require('./server/Server');
|
||||
const {StaticRequestHandler} = require('./server/StaticRequestHandler');
|
||||
const {render} = require('./handlers/render');
|
||||
|
||||
const DEV = process.argv.includes('dev');
|
||||
const HOSTNAME = '127.0.0.1';
|
||||
const PORT = 8080;
|
||||
|
||||
function beginTimer() {
|
||||
return process.hrtime();
|
||||
}
|
||||
|
||||
function endTimer(timer) {
|
||||
const delay = process.hrtime(timer);
|
||||
return delay[0] * 1e9 + delay[1];
|
||||
}
|
||||
|
||||
const NUM_MATCH = '[0-9]+(?:\\.[0-9]+)?';
|
||||
const MATCH_RENDER = new RegExp(
|
||||
'^/render/' +
|
||||
`(?:(?:w(${NUM_MATCH}))?(?:h(${NUM_MATCH}))?/|z(${NUM_MATCH})/)?` +
|
||||
'(?:(uri|b64)/)?' +
|
||||
'(.*?)' +
|
||||
'(?:\\.(svg))?$',
|
||||
'i'
|
||||
);
|
||||
|
||||
function getNumeric(v, name) {
|
||||
if(!v) {
|
||||
return null;
|
||||
}
|
||||
const n = Number.parseFloat(v);
|
||||
if(Number.isNaN(n)) {
|
||||
throw new HttpError(400, 'Invalid value for ' + name);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function readEncoded(str, encoding) {
|
||||
switch(encoding) {
|
||||
case 'b64':
|
||||
return Buffer
|
||||
.from(decodeURIComponent(str), 'base64')
|
||||
.toString('utf8');
|
||||
case 'uri':
|
||||
return str.split('/').map((ln) => decodeURIComponent(ln)).join('\n');
|
||||
default:
|
||||
throw new HttpError(400, 'Unknown encoding');
|
||||
}
|
||||
}
|
||||
|
||||
function handleRender(req, res, {match, pickEncoding, log, writeEncoded}) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
|
||||
const encoding = pickEncoding();
|
||||
const size = {
|
||||
height: getNumeric(match[2], 'height'),
|
||||
width: getNumeric(match[1], 'width'),
|
||||
zoom: getNumeric(match[3], 'zoom'),
|
||||
};
|
||||
const code = readEncoded(match[5], (match[4] || 'uri').toLowerCase());
|
||||
const format = (match[6] || 'svg').toLowerCase();
|
||||
|
||||
const timer = beginTimer();
|
||||
const svg = VirtualSequenceDiagram.render(code, {size});
|
||||
const delay = endTimer(timer);
|
||||
log('RENDER (' + (delay / 1e6).toFixed(3) + 'ms)');
|
||||
|
||||
switch(format) {
|
||||
case 'svg':
|
||||
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
writeEncoded(encoding, svg);
|
||||
break;
|
||||
default:
|
||||
throw new HttpError(400, 'Unsupported image format');
|
||||
}
|
||||
}
|
||||
|
||||
const server = new Server()
|
||||
.addMimeType('htm', 'text/html; charset=utf-8')
|
||||
.addMimeType('html', 'text/html; charset=utf-8')
|
||||
.addMimeType('js', 'application/javascript; charset=utf-8')
|
||||
.addMimeType('mjs', 'application/javascript; charset=utf-8')
|
||||
.addMimeType('css', 'text/css; charset=utf-8')
|
||||
.addMimeType('png', 'image/png')
|
||||
.addMimeType('svg', 'image/svg+xml; charset=utf-8')
|
||||
.addHandler('GET', MATCH_RENDER, handleRender)
|
||||
.addShutdownHook(() => process.stdout.write('\nShutdown.\n'));
|
||||
|
||||
function devMapper(file, type, data) {
|
||||
if(!type.includes('text/html')) {
|
||||
return data;
|
||||
|
@ -105,7 +25,19 @@ function devMapper(file, type, data) {
|
|||
}
|
||||
}
|
||||
|
||||
server.addStaticResources('/', '', [
|
||||
const statics = new StaticRequestHandler('')
|
||||
.addMimeType('txt', 'text/plain; charset=utf-8')
|
||||
.addMimeType('htm', 'text/html; charset=utf-8')
|
||||
.addMimeType('html', 'text/html; charset=utf-8')
|
||||
.addMimeType('js', 'application/javascript; charset=utf-8')
|
||||
.addMimeType('mjs', 'application/javascript; charset=utf-8')
|
||||
.addMimeType('css', 'text/css; charset=utf-8')
|
||||
.addMimeType('png', 'image/png')
|
||||
.addMimeType('svg', 'image/svg+xml; charset=utf-8');
|
||||
|
||||
statics
|
||||
.add('/robots.txt', '')
|
||||
.addResources('/', '', [
|
||||
'index.html',
|
||||
'library.htm',
|
||||
'styles',
|
||||
|
@ -116,14 +48,16 @@ server.addStaticResources('/', '', [
|
|||
], devMapper);
|
||||
|
||||
if(DEV) {
|
||||
server.addStaticResources('/', '', [
|
||||
statics.addResources('/', '', [
|
||||
'node_modules/requirejs/require.js',
|
||||
'scripts',
|
||||
'web',
|
||||
]);
|
||||
server.setFileWatch(true);
|
||||
statics.setFileWatch(true);
|
||||
}
|
||||
|
||||
server
|
||||
new Server()
|
||||
.addHandler(render)
|
||||
.addHandler(statics)
|
||||
.listen(PORT, HOSTNAME)
|
||||
.then(() => server.printListeningInfo(process.stdout));
|
||||
.then((server) => server.printListeningInfo(process.stdout));
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
class HttpError extends Error {
|
||||
constructor(status, message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {HttpError};
|
|
@ -0,0 +1,40 @@
|
|||
class RequestHandler {
|
||||
constructor(method, matcher, handleFn) {
|
||||
this.method = method;
|
||||
this.matcher = matcher;
|
||||
this.handleFn = handleFn;
|
||||
this.info = 'Custom handler at ' + this.method + ' ' + this.matcher;
|
||||
}
|
||||
|
||||
apply(req, res, info) {
|
||||
if(req.method !== this.method) {
|
||||
return false;
|
||||
}
|
||||
const match = this.matcher.exec(req.url);
|
||||
if(!match) {
|
||||
return false;
|
||||
}
|
||||
if(this.handle(req, res, Object.assign({match}, info)) === false) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
handle(req, res, info) {
|
||||
return this.handleFn(req, res, info);
|
||||
}
|
||||
|
||||
printInfo(target) {
|
||||
target.write(this.info + '\n');
|
||||
}
|
||||
|
||||
begin() {
|
||||
return true;
|
||||
}
|
||||
|
||||
close() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {RequestHandler};
|
|
@ -1,324 +1,38 @@
|
|||
const fs = require('fs');
|
||||
const {HttpError} = require('./HttpError');
|
||||
const http = require('http');
|
||||
const stream = require('stream');
|
||||
const util = require('util');
|
||||
const zlib = require('zlib');
|
||||
const {
|
||||
parseAcceptEncoding,
|
||||
pickAcceptEncoding,
|
||||
writeEncoded,
|
||||
} = require('./encoding');
|
||||
|
||||
const PREF_ENCODINGS = ['gzip', 'deflate', 'identity'];
|
||||
|
||||
const PRE_COMPRESS_OPTS = {
|
||||
level: 9,
|
||||
memLevel: 8,
|
||||
windowBits: 15,
|
||||
};
|
||||
|
||||
const LIVE_COMPRESS_OPTS = {
|
||||
class Server {
|
||||
constructor() {
|
||||
this.running = false;
|
||||
this.handlers = [];
|
||||
this.urlMaxLength = 65536;
|
||||
this.server = http.createServer(this._handleRequest.bind(this));
|
||||
this.log = this.log.bind(this);
|
||||
this.logTarget = process.stdout;
|
||||
this.shutdownCallbacks = [];
|
||||
this.compressionOptions = {
|
||||
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));
|
||||
addHandler(handler) {
|
||||
this.handlers.push(handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
addShutdownHook(callback) {
|
||||
if(callback) {
|
||||
this.shutdownCallbacks.push(callback);
|
||||
}
|
||||
this.registerShutdown = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
@ -352,7 +66,7 @@ class Server {
|
|||
writeEncoded: (encoding, data) => writeEncoded(
|
||||
res,
|
||||
encoding,
|
||||
LIVE_COMPRESS_OPTS,
|
||||
this.compressionOptions,
|
||||
data
|
||||
),
|
||||
};
|
||||
|
@ -369,9 +83,7 @@ class Server {
|
|||
return;
|
||||
}
|
||||
}
|
||||
if(!this.staticHandler.apply(req, res, info)) {
|
||||
throw new HttpError(404, 'Not Found');
|
||||
}
|
||||
} catch(e) {
|
||||
this._handleError(req, res, e);
|
||||
}
|
||||
|
@ -381,53 +93,22 @@ class Server {
|
|||
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();
|
||||
const env = {
|
||||
hostname,
|
||||
log: this.log,
|
||||
port,
|
||||
};
|
||||
return Promise.all(this.handlers.map((h) => h.begin(env)))
|
||||
.then(() => new Promise((resolve) => {
|
||||
this.server.listen(port, hostname, () => {
|
||||
this.running = true;
|
||||
if(this.registerShutdown) {
|
||||
this.port = port;
|
||||
this.hostname = hostname;
|
||||
process.on('SIGINT', this.close);
|
||||
}
|
||||
resolve(this);
|
||||
});
|
||||
}));
|
||||
|
@ -438,20 +119,26 @@ class Server {
|
|||
return Promise.resolve(this);
|
||||
}
|
||||
this.running = false;
|
||||
this._removeWatchers();
|
||||
return new Promise((resolve) => {
|
||||
this.server.close(() => {
|
||||
const env = {
|
||||
log: this.log,
|
||||
};
|
||||
this.logTarget.write('\n'); // Skip line containing Ctrl+C indicator
|
||||
this.log('Shutting down...');
|
||||
return new Promise((resolve) => this.server.close(() => resolve()))
|
||||
.then(() => Promise.all(this.handlers.map((h) => h.close(env))))
|
||||
.then(() => {
|
||||
this.shutdownCallbacks.forEach((fn) => fn(this));
|
||||
resolve(this);
|
||||
process.removeListener('SIGINT', this.close);
|
||||
});
|
||||
this.log('Shutdown');
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
printListeningInfo(target) {
|
||||
target.write('Serving static resources:\n');
|
||||
this.staticHandler.write(target);
|
||||
for(const handler of this.handlers) {
|
||||
handler.printInfo(target);
|
||||
target.write('\n');
|
||||
}
|
||||
target.write('Available at ' + this.baseurl() + '\n\n');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,256 @@
|
|||
const {RequestHandler} = require('./RequestHandler');
|
||||
const fs = require('fs');
|
||||
const util = require('util');
|
||||
const zlib = require('zlib');
|
||||
|
||||
const MATCH_INDEX = new RegExp('^(.*/)index\\.[^./]+$', 'i');
|
||||
const PREF_ENCODINGS = ['gzip', 'deflate', 'identity'];
|
||||
|
||||
function passthroughMapper(file, type, data) {
|
||||
return data;
|
||||
}
|
||||
|
||||
function bufferIfBetter(buffer, check) {
|
||||
if(buffer.byteLength < check.byteLength) {
|
||||
return buffer;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getIndent(strings) {
|
||||
let maxLen = 0;
|
||||
for(const string of strings) {
|
||||
maxLen = Math.max(maxLen, string.length);
|
||||
}
|
||||
let indent = '';
|
||||
for(let i = 0; i < maxLen; ++ i) {
|
||||
indent += ' ';
|
||||
}
|
||||
return indent;
|
||||
}
|
||||
|
||||
class StaticRequestHandler extends RequestHandler {
|
||||
constructor(baseUrlPattern) {
|
||||
super('GET', new RegExp('^' + baseUrlPattern + '([^?]*)(\\?.*)?$'));
|
||||
|
||||
this.baseUrlPattern = baseUrlPattern;
|
||||
this.mimes = new Map();
|
||||
this.defaultMime = 'text/plain; charset=utf-8';
|
||||
this.resources = new Map();
|
||||
this.awaiting = [];
|
||||
this.watchFiles = false;
|
||||
this.watchers = [];
|
||||
|
||||
this.compressionOptions = {
|
||||
level: 9,
|
||||
memLevel: 8,
|
||||
windowBits: 15,
|
||||
};
|
||||
}
|
||||
|
||||
_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);
|
||||
}
|
||||
|
||||
_addWatchers(log) {
|
||||
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) {
|
||||
log('RELOADED ' + entry.file);
|
||||
}
|
||||
});
|
||||
});
|
||||
entry.watcher = makeWatcher();
|
||||
});
|
||||
}
|
||||
|
||||
_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, this.compressionOptions),
|
||||
util.promisify(zlib.gzip)(data, this.compressionOptions),
|
||||
])
|
||||
.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;
|
||||
});
|
||||
}
|
||||
|
||||
_addFile(path, file, mapper) {
|
||||
const type = this.mimeTypeFor(file);
|
||||
const map = mapper || passthroughMapper;
|
||||
const fn = () => util.promisify(fs.readFile)(file)
|
||||
.then((data) => map(file, type, data))
|
||||
.then((data) => this._add(path, type, data));
|
||||
this.watchers.push({file, fn, watcher: null});
|
||||
return fn();
|
||||
}
|
||||
|
||||
_addDir(path, file, mapper) {
|
||||
return util.promisify(fs.readdir)(file)
|
||||
.then((subFiles) => this._addResources(
|
||||
path + '/',
|
||||
file + '/',
|
||||
subFiles.filter((sub) => !sub.startsWith('.')),
|
||||
mapper
|
||||
));
|
||||
}
|
||||
|
||||
_addResource(path, file, mapper) {
|
||||
return util.promisify(fs.stat)(file)
|
||||
.then((stats) => {
|
||||
if(stats.isDirectory()) {
|
||||
return this._addDir(path, file, mapper);
|
||||
} else {
|
||||
return this._addFile(path, file, mapper);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_addResources(basePath, baseFs, files, mapper) {
|
||||
return Promise.all(files.map((file) => this._addResource(
|
||||
basePath + file,
|
||||
baseFs + file,
|
||||
mapper
|
||||
)));
|
||||
}
|
||||
|
||||
addMimeType(extension, type) {
|
||||
this.mimes.set(extension, type);
|
||||
return this;
|
||||
}
|
||||
|
||||
mimeTypeFor(file) {
|
||||
const ext = file.substr(file.lastIndexOf('.') + 1);
|
||||
return this.mimes.get(ext) || this.defaultMime;
|
||||
}
|
||||
|
||||
add(path, type, content = null) {
|
||||
let promise = null;
|
||||
if(content === null) {
|
||||
promise = this._add(path, this.mimeTypeFor(path), type);
|
||||
} else {
|
||||
promise = this._add(path, type, content);
|
||||
}
|
||||
this.awaiting.push(promise);
|
||||
return this;
|
||||
}
|
||||
|
||||
addFile(path, file, mapper = null) {
|
||||
this.awaiting.push(this._addFile(path, file, mapper));
|
||||
return this;
|
||||
}
|
||||
|
||||
addDir(basePath, baseFile, mapper = null) {
|
||||
this.awaiting.push(this._addDir(basePath, baseFile, mapper));
|
||||
return this;
|
||||
}
|
||||
|
||||
addResource(path, file, mapper = null) {
|
||||
this.awaiting.push(this._addResource(path, file, mapper));
|
||||
return this;
|
||||
}
|
||||
|
||||
addResources(basePath, baseFs, files, mapper = null) {
|
||||
this.awaiting.push(
|
||||
this._addResources(basePath, baseFs, files, mapper)
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
setFileWatch(enabled) {
|
||||
this.watchFiles = enabled;
|
||||
return this;
|
||||
}
|
||||
|
||||
printInfo(target) {
|
||||
target.write(
|
||||
'Serving static resources at ' +
|
||||
(this.baseUrlPattern || '/') + ':\n'
|
||||
);
|
||||
|
||||
const indent = getIndent(this.resources.keys());
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
begin({log}) {
|
||||
return Promise.all(this.awaiting).then(() => {
|
||||
if(this.watchFiles) {
|
||||
this._addWatchers(log);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.watchers.forEach((entry) => {
|
||||
if(entry.watcher) {
|
||||
entry.watcher.close();
|
||||
entry.watcher = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {StaticRequestHandler};
|
|
@ -0,0 +1,77 @@
|
|||
const {HttpError} = require('./HttpError');
|
||||
const {Readable} = require('stream');
|
||||
const zlib = require('zlib');
|
||||
|
||||
const MATCH_ACCEPT = new RegExp('^ *([^;]+)(?:;q=([0-9]+(?:\\.[0-9]+)?))? *$');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function writeEncoded(res, encoding, compressionOpts, data) {
|
||||
if(encoding === 'identity') {
|
||||
res.end(data);
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader('Content-Encoding', encoding);
|
||||
|
||||
const raw = new 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');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseAcceptEncoding,
|
||||
pickAcceptEncoding,
|
||||
writeEncoded,
|
||||
};
|
Loading…
Reference in New Issue