2020-11-06 05:46:37 +01:00
|
|
|
#!/usr/bin/env -S node --trace-warnings
|
|
|
|
|
|
|
|
/*
|
|
|
|
micro http2 server with file monitoring and automatic app rebuild
|
|
|
|
- can process concurrent http requests
|
|
|
|
- monitors specified filed and folders for changes
|
|
|
|
- triggers library and application rebuild
|
|
|
|
- any build errors are immediately displayed and can be corrected without need for restart
|
2020-11-06 17:39:39 +01:00
|
|
|
- passthrough data compression
|
2020-11-06 05:46:37 +01:00
|
|
|
*/
|
|
|
|
|
2020-11-07 16:37:19 +01:00
|
|
|
const process = require('process');
|
2020-11-06 05:46:37 +01:00
|
|
|
const fs = require('fs');
|
2020-11-06 17:39:39 +01:00
|
|
|
const zlib = require('zlib');
|
2020-11-06 05:46:37 +01:00
|
|
|
const http2 = require('http2');
|
2020-11-07 16:37:19 +01:00
|
|
|
const path = require('path');
|
2020-11-06 05:46:37 +01:00
|
|
|
const chokidar = require('chokidar');
|
|
|
|
const esbuild = require('esbuild');
|
|
|
|
const log = require('@vladmandic/pilogger');
|
|
|
|
|
|
|
|
// app configuration
|
2020-11-07 16:37:19 +01:00
|
|
|
// you can provide your server key and certificate or use provided self-signed ones
|
|
|
|
// self-signed certificate generated using:
|
|
|
|
// openssl req -x509 -newkey rsa:4096 -nodes -keyout dev-server.key -out dev-server.crt -days 365 -subj "/C=US/ST=Florida/L=Miami/O=@vladmandic"
|
2020-11-06 05:46:37 +01:00
|
|
|
// client app does not work without secure server since browsers enforce https for webcam access
|
|
|
|
const options = {
|
2020-11-08 15:56:02 +01:00
|
|
|
key: fs.readFileSync('dev-server/dev-server.key'),
|
|
|
|
cert: fs.readFileSync('dev-server/dev-server.crt'),
|
|
|
|
root: '..',
|
2020-11-06 19:50:16 +01:00
|
|
|
default: 'demo/index.html',
|
2020-11-06 05:46:37 +01:00
|
|
|
port: 8000,
|
|
|
|
monitor: ['package.json', 'config.js', 'demo', 'src'],
|
|
|
|
};
|
|
|
|
|
|
|
|
// just some predefined mime types
|
|
|
|
const mime = {
|
|
|
|
'.html': 'text/html',
|
|
|
|
'.js': 'text/javascript',
|
|
|
|
'.css': 'text/css',
|
|
|
|
'.json': 'application/json',
|
|
|
|
'.png': 'image/png',
|
|
|
|
'.jpg': 'image/jpg',
|
|
|
|
'.gif': 'image/gif',
|
|
|
|
'.ico': 'image/x-icon',
|
|
|
|
'.svg': 'image/svg+xml',
|
|
|
|
'.wav': 'audio/wav',
|
|
|
|
'.mp4': 'video/mp4',
|
|
|
|
'.woff': 'application/font-woff',
|
|
|
|
'.ttf': 'application/font-ttf',
|
|
|
|
'.wasm': 'application/wasm',
|
|
|
|
};
|
|
|
|
|
|
|
|
// keeps esbuild service instance cached
|
|
|
|
let es;
|
|
|
|
|
|
|
|
// rebuild on file change
|
|
|
|
async function build(f, msg) {
|
|
|
|
log.info('Monitor: file', msg, f);
|
|
|
|
if (!es) es = await esbuild.startService();
|
|
|
|
// common build options
|
|
|
|
const cfg = {
|
2020-11-08 07:17:25 +01:00
|
|
|
minify: false,
|
2020-11-06 05:46:37 +01:00
|
|
|
bundle: true,
|
|
|
|
sourcemap: true,
|
|
|
|
logLevel: 'error',
|
|
|
|
platform: 'browser',
|
|
|
|
target: 'es2018',
|
|
|
|
format: 'esm',
|
2020-11-10 02:13:38 +01:00
|
|
|
external: ['fs', 'buffer', 'util'],
|
2020-11-06 05:46:37 +01:00
|
|
|
};
|
|
|
|
// only rebuilding esm module and demo application
|
|
|
|
// for full production build use "npm run build"
|
|
|
|
try {
|
|
|
|
// rebuild library fist
|
|
|
|
cfg.entryPoints = ['src/human.js'];
|
|
|
|
cfg.outfile = 'dist/human.esm.js';
|
|
|
|
cfg.metafile = 'dist/human.esm.json';
|
|
|
|
await es.build(cfg);
|
|
|
|
// then rebuild client app so it can use freshly rebuild library
|
|
|
|
cfg.entryPoints = ['demo/browser.js'];
|
|
|
|
cfg.outfile = 'dist/demo-browser-index.js';
|
|
|
|
cfg.metafile = 'dist/demo-browser-index.json';
|
|
|
|
await es.build(cfg);
|
2020-11-07 16:37:19 +01:00
|
|
|
// done
|
2020-11-06 05:46:37 +01:00
|
|
|
log.state('Build complete');
|
|
|
|
} catch (err) {
|
2020-11-07 16:37:19 +01:00
|
|
|
// catch errors and print where it occured
|
2020-11-06 05:46:37 +01:00
|
|
|
log.error('Build error', JSON.stringify(err.errors || err, null, 2));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// watch filesystem for any changes and notify build when needed
|
|
|
|
async function watch() {
|
|
|
|
const watcher = chokidar.watch(options.monitor, {
|
|
|
|
persistent: true,
|
|
|
|
ignorePermissionErrors: false,
|
|
|
|
alwaysStat: false,
|
|
|
|
ignoreInitial: true,
|
|
|
|
followSymlinks: true,
|
|
|
|
usePolling: false,
|
|
|
|
useFsEvents: false,
|
|
|
|
atomic: true,
|
|
|
|
});
|
2020-11-07 16:37:19 +01:00
|
|
|
// single event handler for file add/change/delete
|
2020-11-06 05:46:37 +01:00
|
|
|
watcher
|
|
|
|
.on('add', (evt) => build(evt, 'add'))
|
|
|
|
.on('change', (evt) => build(evt, 'modify'))
|
|
|
|
.on('unlink', (evt) => build(evt, 'remove'))
|
|
|
|
.on('error', (err) => log.error(`Client watcher error: ${err}`))
|
|
|
|
.on('ready', () => log.state('Monitoring:', options.monitor));
|
|
|
|
}
|
|
|
|
|
|
|
|
// get file content for a valid url request
|
2020-11-07 16:37:19 +01:00
|
|
|
function handle(url) {
|
2020-11-06 05:46:37 +01:00
|
|
|
return new Promise((resolve) => {
|
2020-11-06 17:39:39 +01:00
|
|
|
let obj = { ok: false };
|
2020-11-06 05:46:37 +01:00
|
|
|
obj.file = url;
|
|
|
|
if (!fs.existsSync(obj.file)) resolve(null);
|
|
|
|
obj.stat = fs.statSync(obj.file);
|
2020-11-06 17:39:39 +01:00
|
|
|
if (obj.stat.isFile()) obj.ok = true;
|
2020-11-06 19:50:16 +01:00
|
|
|
if (!obj.ok && obj.stat.isDirectory()) {
|
2020-11-06 05:46:37 +01:00
|
|
|
obj.file = path.join(obj.file, options.default);
|
2020-11-07 16:37:19 +01:00
|
|
|
obj = handle(obj.file);
|
2020-11-06 05:46:37 +01:00
|
|
|
}
|
|
|
|
resolve(obj);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// process http requests
|
|
|
|
async function httpRequest(req, res) {
|
2020-11-07 16:37:19 +01:00
|
|
|
handle(path.join(__dirname, options.root, req.url)).then((result) => {
|
|
|
|
// get original ip of requestor, regardless if it's behind proxy or not
|
2020-11-06 05:46:37 +01:00
|
|
|
const forwarded = (req.headers['forwarded'] || '').match(/for="\[(.*)\]:/);
|
|
|
|
const ip = (Array.isArray(forwarded) ? forwarded[1] : null) || req.headers['x-forwarded-for'] || req.ip || req.socket.remoteAddress;
|
2020-11-06 17:39:39 +01:00
|
|
|
if (!result || !result.ok) {
|
2020-11-06 05:46:37 +01:00
|
|
|
res.writeHead(404, { 'Content-Type': 'text/html' });
|
|
|
|
res.end('Error 404: Not Found\n', 'utf-8');
|
2020-11-06 17:39:39 +01:00
|
|
|
log.warn(`${req.method}/${req.httpVersion}`, res.statusCode, req.url, ip);
|
2020-11-06 05:46:37 +01:00
|
|
|
} else {
|
|
|
|
const ext = String(path.extname(result.file)).toLowerCase();
|
|
|
|
const contentType = mime[ext] || 'application/octet-stream';
|
2020-11-06 17:39:39 +01:00
|
|
|
const accept = req.headers['accept-encoding'] ? req.headers['accept-encoding'].includes('br') : false; // does target accept brotli compressed data
|
2020-11-06 05:46:37 +01:00
|
|
|
res.writeHead(200, {
|
2020-11-07 16:37:19 +01:00
|
|
|
// 'Content-Length': result.stat.size, // not using as it's misleading for compressed streams
|
|
|
|
'Content-Language': 'en', 'Content-Type': contentType, 'Content-Encoding': accept ? 'br' : '', 'Last-Modified': result.stat.mtime, 'Cache-Control': 'no-cache', 'X-Powered-By': `NodeJS/${process.version}`,
|
2020-11-06 05:46:37 +01:00
|
|
|
});
|
2020-11-06 17:39:39 +01:00
|
|
|
const compress = zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 } }); // instance of brotli compression with level 5
|
|
|
|
const stream = fs.createReadStream(result.file);
|
|
|
|
if (!accept) stream.pipe(res); // don't compress data
|
|
|
|
else stream.pipe(compress).pipe(res); // compress data
|
|
|
|
|
|
|
|
// alternative methods of sending data
|
|
|
|
/// 2. read stream and send by chunk
|
|
|
|
// const stream = fs.createReadStream(result.file);
|
|
|
|
// stream.on('data', (chunk) => res.write(chunk));
|
|
|
|
// stream.on('end', () => res.end());
|
|
|
|
|
|
|
|
// 3. read entire file and send it as blob
|
|
|
|
// const data = fs.readFileSync(result.file);
|
|
|
|
// res.write(data);
|
|
|
|
log.data(`${req.method}/${req.httpVersion}`, res.statusCode, contentType, result.stat.size, req.url, ip);
|
2020-11-06 05:46:37 +01:00
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// app main entry point
|
|
|
|
async function main() {
|
|
|
|
log.header();
|
|
|
|
await watch();
|
|
|
|
const server = http2.createSecureServer(options, httpRequest);
|
|
|
|
server.on('listening', () => log.state('HTTP2 server listening:', options.port));
|
|
|
|
server.listen(options.port);
|
2020-11-12 18:58:55 +01:00
|
|
|
await build('all', 'startup');
|
2020-11-06 05:46:37 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
main();
|