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"
>
+
+
+
+