Replace server with own implementation, along with a new server-side rendering API

This commit is contained in:
David Evans 2018-04-28 22:58:36 +01:00
parent 27d0916da8
commit 2947e4f008
9 changed files with 1213 additions and 1115 deletions

View File

@ -22,9 +22,6 @@
<FileRef <FileRef
location = "container:.gitignore"> location = "container:.gitignore">
</FileRef> </FileRef>
<FileRef
location = "container:editor-dev.htm">
</FileRef>
<FileRef <FileRef
location = "container:index.html"> location = "container:index.html">
</FileRef> </FileRef>

129
bin/server.js Executable file
View File

@ -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(/<!--* *DEV *-*>?([^]*?)(?:<!)?-* *\/DEV *-->/g, '$1')
.replace(/<!--* *LIVE[^]*? *\/LIVE *-->/g, '');
} else {
return code
.replace(/<!--* *LIVE *-*>?([^]*?)(?:<!)?-* *\/LIVE *-->/g, '$1')
.replace(/<!--* *DEV[^]*? *\/DEV *-->/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));

459
bin/server/Server.js Executable file
View File

@ -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};

View File

@ -16,25 +16,22 @@ To get started, you can clone this repository and run:
```shell ```shell
npm install; npm install;
npm start; npm start -- dev;
``` ```
This will launch a server in the project directory. You can now open This will launch a server in the project directory. You can now open
several pages: several pages:
* [http://localhost:8080/](http://localhost:8080/): * [http://localhost:8080/](http://localhost:8080/):
the main editor (uses minified sources, so you won't see your changes the main editor
immediately)
* [http://localhost:8080/editor-dev.htm](http://localhost:8080/editor-dev.htm):
the main editor, using non-minified sources (good for development)
* [http://localhost:8080/library.htm](http://localhost:8080/library.htm): * [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 **NOTE**: When running in `dev` mode, this project uses web modules,
recent browsers. In particular, note that FireFox 59 does not support which are only supported by recent browsers. In particular, note that
web modules unless a flag is set (FireFox 60 will support them fully). FireFox 59 does not support web modules unless a flag is set (FireFox
The editor and library page do not require web modules, so should have 60 will support them fully). Production mode does not require web
wider support. modules, so should have wider support.
To run the tests and linter, run the command: To run the tests and linter, run the command:
@ -48,6 +45,16 @@ And to rebuild the minified sources, run:
npm run minify; 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 ## Commands
The available commands are: The available commands are:
@ -86,9 +93,8 @@ The high-level structure is:
Useful helpers can also be found in `/scripts/core/*` and Useful helpers can also be found in `/scripts/core/*` and
`/scripts/svg/*`. `/scripts/svg/*`.
The live editor (index.htm & editor-dev.htm) uses the source in The live editor (index.htm) uses the source in `/web/editor.mjs` and
`/web/editor.mjs` and `/web/interface/*`. Other pages use sources in `/web/interface/*`.
the root of `/web` as their entry-points.
## Testing ## Testing

View File

@ -1,86 +0,0 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="content-security-policy" content="
base-uri 'self';
default-src 'none';
script-src 'self' https://cdnjs.cloudflare.com https://unpkg.com;
style-src 'self'
https://cdnjs.cloudflare.com
'sha256-s7UPtBgvov5WNF9C1DlTZDpqwLgEmfiWha5a5p/Zn7E='
;
font-src 'self' data:;
img-src 'self' blob:;
form-action 'none';
">
<title>Sequence Diagram</title>
<link rel="icon" href="favicon.png">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<link rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.37.0/codemirror.min.css"
integrity="sha256-I8NyGs4wjbMuBSUE40o55W6k6P7tu/7G28/JGUUYCIs="
crossorigin="anonymous"
>
<link rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.37.0/addon/hint/show-hint.min.css"
integrity="sha256-Ng5EdzHS/CC37tR7tE75e4Th9+fBvOB4eYITOkXS22Q="
crossorigin="anonymous"
>
<link rel="stylesheet" href="styles/editor.css">
<script
src="node_modules/requirejs/require.js"
crossorigin="anonymous"
></script>
<meta
name="cdn-split"
content="https://unpkg.com/split.js@1.3.5/split.min.js"
data-integrity="sha256-vBu0/2gpeLlfmY2yFkr8eHTMuYKBw2M+/Id/qZx27F4="
>
<meta
name="cdn-cm/lib/codemirror"
content="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.37.0/codemirror.min.js"
data-integrity="sha256-U/4XQwZXDFDdAHjIZt1Lm7sFfmMiFDZzFYprq6XJ0gk="
>
<meta
name="cdn-cm/addon/hint/show-hint"
content="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.37.0/addon/hint/show-hint.min.js"
data-integrity="sha256-/Cxd7R7oycnq3vuRycj68ToCzdZ73tux8nSULZiWWK0="
>
<meta
name="cdn-cm/addon/edit/trailingspace"
content="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.37.0/addon/edit/trailingspace.min.js"
data-integrity="sha256-IazJOM2ma6yJ/doWlcIy+Ica+UAgYJDDyYf/qgkLUNM="
>
<meta
name="cdn-cm/addon/comment/comment"
content="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.37.0/addon/comment/comment.min.js"
data-integrity="sha256-xRNygSqAYMT9wcso0FgZEY3ROGoD6JdvYd8M9IjSuNg="
>
<script src="web/editor.mjs" type="module"></script>
</head>
<body>
<div id="loader">
<h1>Sequence Diagram Online Editor</h1>
<p class="loadmsg">Loading&hellip;</p>
<noscript><p class="noscript">This tool requires Javascript!<p></noscript>
<nav>
<a href="library.htm" target="_blank">Library</a>
<a href="https://github.com/davidje13/SequenceDiagram" target="_blank">GitHub</a>
</nav>
</div>
</body>
</html>

View File

@ -32,14 +32,6 @@
<link rel="stylesheet" href="styles/editor.css"> <link rel="stylesheet" href="styles/editor.css">
<script src="lib/sequence-diagram-web.min.js"></script>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.5/require.min.js"
integrity="sha256-0SGl1PJNDyJwcV5T+weg2zpEMrh7xvlwO4oXgvZCeZk="
crossorigin="anonymous"
></script>
<meta <meta
name="cdn-split" name="cdn-split"
content="https://unpkg.com/split.js@1.3.5/split.min.js" content="https://unpkg.com/split.js@1.3.5/split.min.js"
@ -70,7 +62,23 @@
data-integrity="sha256-xRNygSqAYMT9wcso0FgZEY3ROGoD6JdvYd8M9IjSuNg=" data-integrity="sha256-xRNygSqAYMT9wcso0FgZEY3ROGoD6JdvYd8M9IjSuNg="
> >
<!-- DEV
<script
src="node_modules/requirejs/require.js"
crossorigin="anonymous"
></script>
<script src="web/editor.mjs" type="module"></script>
/DEV -->
<!-- LIVE -->
<script src="lib/sequence-diagram-web.min.js"></script>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.5/require.min.js"
integrity="sha256-0SGl1PJNDyJwcV5T+weg2zpEMrh7xvlwO4oXgvZCeZk="
crossorigin="anonymous"
></script>
<script src="weblib/editor.min.js"></script> <script src="weblib/editor.min.js"></script>
<!-- /LIVE -->
</head> </head>
<body> <body>

View File

@ -57,7 +57,13 @@
crossorigin="anonymous" crossorigin="anonymous"
></script> ></script>
<!-- DEV
<script src="scripts/standalone-web.mjs" type="module"></script>
/DEV -->
<!-- LIVE -->
<script src="lib/sequence-diagram-web.min.js"></script> <script src="lib/sequence-diagram-web.min.js"></script>
<!-- /LIVE -->
<script>document.addEventListener('DOMContentLoaded', () => { <script>document.addEventListener('DOMContentLoaded', () => {

1578
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -30,7 +30,7 @@
"minify-web": "rollup --config web/rollup.config.js && uglifyjs --compress --mangle --warn --output weblib/editor.min.js -- weblib/editor.js", "minify-web": "rollup --config web/rollup.config.js && uglifyjs --compress --mangle --warn --output weblib/editor.min.js -- weblib/editor.js",
"minify": "npm run minify-lib && npm run minify-web", "minify": "npm run minify-lib && npm run minify-web",
"prepublishOnly": "npm run minify-lib && npm run generate-screenshots && npm test", "prepublishOnly": "npm run minify-lib && npm run generate-screenshots && npm test",
"start": "http-server", "start": "bin/server.js",
"test": "npm run unit-test && npm run web-test && npm run lint && echo 'PASSED :)'", "test": "npm run unit-test && npm run web-test && npm run lint && echo 'PASSED :)'",
"unit-test": "rollup --config spec/support/rollup.config.js && node -r source-map-support/register node_modules/.bin/jasmine --config=spec/support/jasmine.json", "unit-test": "rollup --config spec/support/rollup.config.js && node -r source-map-support/register node_modules/.bin/jasmine --config=spec/support/jasmine.json",
"web-test": "karma start spec/support/karma.conf.js --single-run", "web-test": "karma start spec/support/karma.conf.js --single-run",
@ -41,7 +41,6 @@
"codemirror": "^5.37.0", "codemirror": "^5.37.0",
"eslint": "^4.19.1", "eslint": "^4.19.1",
"eslint-plugin-jasmine": "^2.9.3", "eslint-plugin-jasmine": "^2.9.3",
"http-server": "^0.10.0",
"jasmine": "^3.1.0", "jasmine": "^3.1.0",
"karma": "^2.0.2", "karma": "^2.0.2",
"karma-chrome-launcher": "^2.2.0", "karma-chrome-launcher": "^2.2.0",