From 2947e4f008e40a72c1eb6ce8e5873f87f9492729 Mon Sep 17 00:00:00 2001 From: David Evans Date: Sat, 28 Apr 2018 22:58:36 +0100 Subject: [PATCH] Replace server with own implementation, along with a new server-side rendering API --- .../contents.xcworkspacedata | 3 - bin/server.js | 129 ++ bin/server/Server.js | 459 +++++ docs/CONTRIBUTING.md | 34 +- editor-dev.htm | 86 - index.html | 24 +- library.htm | 6 + package-lock.json | 1584 ++++++----------- package.json | 3 +- 9 files changed, 1213 insertions(+), 1115 deletions(-) create mode 100755 bin/server.js create mode 100755 bin/server/Server.js delete mode 100644 editor-dev.htm diff --git a/SequenceDiagram.xcworkspace/contents.xcworkspacedata b/SequenceDiagram.xcworkspace/contents.xcworkspacedata index 23f9371..e000973 100644 --- a/SequenceDiagram.xcworkspace/contents.xcworkspacedata +++ b/SequenceDiagram.xcworkspace/contents.xcworkspacedata @@ -22,9 +22,6 @@ - - diff --git a/bin/server.js b/bin/server.js new file mode 100755 index 0000000..55c1f81 --- /dev/null +++ b/bin/server.js @@ -0,0 +1,129 @@ +#!/usr/bin/env node + +const {VirtualSequenceDiagram} = require('../lib/sequence-diagram'); +const {HttpError, Server} = require('./server/Server'); + +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; + } + + const code = data.toString('utf8'); + if(DEV) { + return code + .replace(//g, '$1') + .replace(//g, ''); + } else { + return code + .replace(//g, '$1') + .replace(//g, ''); + } +} + +server.addStaticResources('/', '', [ + 'index.html', + 'library.htm', + 'styles', + 'lib', + 'weblib', + 'favicon.png', + 'apple-touch-icon.png', +], devMapper); + +if(DEV) { + server.addStaticResources('/', '', [ + 'node_modules/requirejs/require.js', + 'scripts', + 'web', + ]); + server.setFileWatch(true); +} + +server + .listen(PORT, HOSTNAME) + .then(() => server.printListeningInfo(process.stdout)); diff --git a/bin/server/Server.js b/bin/server/Server.js new file mode 100755 index 0000000..28ef82a --- /dev/null +++ b/bin/server/Server.js @@ -0,0 +1,459 @@ +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}; diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index a92f9e1..d04bf39 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -16,25 +16,22 @@ To get started, you can clone this repository and run: ```shell npm install; -npm start; +npm start -- dev; ``` This will launch a server in the project directory. You can now open several pages: * [http://localhost:8080/](http://localhost:8080/): - the main editor (uses minified sources, so you won't see your changes - immediately) -* [http://localhost:8080/editor-dev.htm](http://localhost:8080/editor-dev.htm): - the main editor, using non-minified sources (good for development) + the main editor * [http://localhost:8080/library.htm](http://localhost:8080/library.htm): - the library sample page (uses minified sources) + the library sample page -**NOTE**: This project uses web modules, which are only supported by -recent browsers. In particular, note that FireFox 59 does not support -web modules unless a flag is set (FireFox 60 will support them fully). -The editor and library page do not require web modules, so should have -wider support. +**NOTE**: When running in `dev` mode, this project uses web modules, +which are only supported by recent browsers. In particular, note that +FireFox 59 does not support web modules unless a flag is set (FireFox +60 will support them fully). Production mode does not require web +modules, so should have wider support. To run the tests and linter, run the command: @@ -48,6 +45,16 @@ And to rebuild the minified sources, run: npm run minify; ``` +To check that the code works with minified sources, run: + +```shell +npm start; +``` + +(index.htm and library.htm will now run with minified sources, but note +that it will not perform hot-reloading; you will need to restart the +server if you make changes) + ## Commands The available commands are: @@ -86,9 +93,8 @@ The high-level structure is: Useful helpers can also be found in `/scripts/core/*` and `/scripts/svg/*`. -The live editor (index.htm & editor-dev.htm) uses the source in -`/web/editor.mjs` and `/web/interface/*`. Other pages use sources in -the root of `/web` as their entry-points. +The live editor (index.htm) uses the source in `/web/editor.mjs` and +`/web/interface/*`. ## Testing diff --git a/editor-dev.htm b/editor-dev.htm deleted file mode 100644 index 1b7f633..0000000 --- a/editor-dev.htm +++ /dev/null @@ -1,86 +0,0 @@ - - - - - -Sequence Diagram - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

Sequence Diagram Online Editor

-

Loading…

-
- - - diff --git a/index.html b/index.html index edfb9d6..cfce4a6 100644 --- a/index.html +++ b/index.html @@ -32,14 +32,6 @@ - - - - + + + + + + diff --git a/library.htm b/library.htm index 227a254..71da1cc 100644 --- a/library.htm +++ b/library.htm @@ -57,7 +57,13 @@ crossorigin="anonymous" > + + + +