From eaef4a3f473f5976fff5edba470b59815b64cf12 Mon Sep 17 00:00:00 2001 From: David Evans Date: Sun, 29 Apr 2018 11:40:43 +0100 Subject: [PATCH] Split server code into logical chunks --- bin/handlers/render.js | 78 ++++++ bin/server.js | 126 +++------ bin/server/HttpError.js | 8 + bin/server/RequestHandler.js | 40 +++ bin/server/Server.js | 403 ++++------------------------- bin/server/StaticRequestHandler.js | 256 ++++++++++++++++++ bin/server/encoding.js | 77 ++++++ 7 files changed, 534 insertions(+), 454 deletions(-) create mode 100755 bin/handlers/render.js create mode 100755 bin/server/HttpError.js create mode 100755 bin/server/RequestHandler.js create mode 100755 bin/server/StaticRequestHandler.js create mode 100755 bin/server/encoding.js diff --git a/bin/handlers/render.js b/bin/handlers/render.js new file mode 100755 index 0000000..4407701 --- /dev/null +++ b/bin/handlers/render.js @@ -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}; diff --git a/bin/server.js b/bin/server.js index 55c1f81..c219908 100755 --- a/bin/server.js +++ b/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,25 +25,39 @@ function devMapper(file, type, data) { } } -server.addStaticResources('/', '', [ - 'index.html', - 'library.htm', - 'styles', - 'lib', - 'weblib', - 'favicon.png', - 'apple-touch-icon.png', -], devMapper); +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', + 'lib', + 'weblib', + 'favicon.png', + 'apple-touch-icon.png', + ], 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)); diff --git a/bin/server/HttpError.js b/bin/server/HttpError.js new file mode 100755 index 0000000..88f43b3 --- /dev/null +++ b/bin/server/HttpError.js @@ -0,0 +1,8 @@ +class HttpError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } +} + +module.exports = {HttpError}; diff --git a/bin/server/RequestHandler.js b/bin/server/RequestHandler.js new file mode 100755 index 0000000..8548de6 --- /dev/null +++ b/bin/server/RequestHandler.js @@ -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}; diff --git a/bin/server/Server.js b/bin/server/Server.js index 28ef82a..7cc40ca 100755 --- a/bin/server/Server.js +++ b/bin/server/Server.js @@ -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 = { - 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.running = false; 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.compressionOptions = { + level: 5, + memLevel: 9, + windowBits: 15, + }; 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; + this.shutdownCallbacks.push(callback); 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'); - } + throw new HttpError(404, 'Not Found'); } catch(e) { this._handleError(req, res, e); } @@ -381,56 +93,25 @@ 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(); - this.server.listen(port, hostname, () => { - this.running = true; - if(this.registerShutdown) { + 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; + this.port = port; + this.hostname = hostname; process.on('SIGINT', this.close); - } - resolve(this); - }); - })); + resolve(this); + }); + })); } close() { @@ -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); - target.write('\n'); + for(const handler of this.handlers) { + handler.printInfo(target); + target.write('\n'); + } target.write('Available at ' + this.baseurl() + '\n\n'); } } diff --git a/bin/server/StaticRequestHandler.js b/bin/server/StaticRequestHandler.js new file mode 100755 index 0000000..f2e1369 --- /dev/null +++ b/bin/server/StaticRequestHandler.js @@ -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}; diff --git a/bin/server/encoding.js b/bin/server/encoding.js new file mode 100755 index 0000000..e8dd68b --- /dev/null +++ b/bin/server/encoding.js @@ -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, +};