human/server/serve.js

213 lines
8.2 KiB
JavaScript
Raw Normal View History

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
*/
const fs = require('fs');
2020-11-06 17:39:39 +01:00
const zlib = require('zlib');
2020-12-12 16:15:51 +01:00
const http = require('http');
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 log = require('@vladmandic/pilogger');
2020-11-17 05:58:06 +01:00
const build = require('./build.js');
2020-11-06 05:46:37 +01:00
// 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:
2020-11-17 05:58:06 +01:00
// openssl req -x509 -newkey rsa:4096 -nodes -keyout https.key -out https.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-17 05:58:06 +01:00
key: fs.readFileSync('server/https.key'),
cert: fs.readFileSync('server/https.crt'),
2021-03-29 20:40:34 +02:00
defaultFolder: 'demo',
defaultFile: 'index.html',
2020-12-12 16:15:51 +01:00
httpPort: 10030,
httpsPort: 10031,
2021-02-13 14:42:10 +01:00
insecureHTTPParser: false,
minElapsed: 2,
2021-03-24 16:08:49 +01:00
monitor: ['package.json', 'config.ts', 'demo/*.js', 'demo/*.html', 'src'],
2020-11-06 05:46:37 +01:00
};
// just some predefined mime types
const mime = {
2021-02-13 14:42:10 +01:00
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
2020-11-06 05:46:37 +01:00
'.png': 'image/png',
'.jpg': 'image/jpg',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.svg': 'image/svg+xml',
'.wav': 'audio/wav',
'.mp4': 'video/mp4',
2021-02-13 14:42:10 +01:00
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
2020-11-06 05:46:37 +01:00
'.wasm': 'application/wasm',
};
2021-02-08 17:39:09 +01:00
let last = Date.now();
async function buildAll(evt, msg) {
const now = Date.now();
2021-02-21 13:20:58 +01:00
if ((now - last) > options.minElapsed) build.build(evt, msg, true);
2021-02-08 17:39:09 +01:00
else log.state('Build: merge event file', msg, evt);
last = now;
}
2020-11-06 05:46:37 +01:00
// 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
2021-02-08 17:39:09 +01:00
.on('add', (evt) => buildAll(evt, 'add'))
.on('change', (evt) => buildAll(evt, 'modify'))
.on('unlink', (evt) => buildAll(evt, 'remove'))
2020-11-06 05:46:37 +01:00
.on('error', (err) => log.error(`Client watcher error: ${err}`))
.on('ready', () => log.state('Monitoring:', options.monitor));
}
// get file content for a valid url request
2021-03-29 20:40:34 +02:00
/*
2020-11-07 16:37:19 +01:00
function handle(url) {
return new Promise(async (resolve) => {
let obj = { ok: false, file: decodeURI(url) };
if (!fs.existsSync(obj.file)) {
resolve(obj);
} else {
obj.stat = fs.statSync(obj.file);
if (obj.stat.isFile()) obj.ok = true;
if (!obj.ok && obj.stat.isDirectory()) {
2021-03-29 20:40:34 +02:00
if (fs.existsSync(path.join(obj.file, options.defaultFile))) {
obj = await handle(path.join(obj.file, options.defaultFile));
} else if (fs.existsSync(path.join(obj.file, options.defaultFolder, options.defaultFile))) {
obj = await handle(path.join(obj.file, options.defaultFolder, options.defaultFile));
} else {
2021-03-29 20:40:34 +02:00
obj.ok = obj.stat.isDirectory();
}
}
resolve(obj);
2020-11-06 05:46:37 +01:00
}
});
}
2021-03-29 20:40:34 +02:00
*/
function handle(url) {
const result = { ok: false, stat: {}, file: '' };
const checkFile = (f) => {
result.file = f;
if (fs.existsSync(f)) {
result.stat = fs.statSync(f);
if (result.stat.isFile()) {
result.ok = true;
return true;
}
}
return false;
2021-04-03 16:49:14 +02:00
};
2021-03-29 20:40:34 +02:00
const checkFolder = (f) => {
result.file = f;
if (fs.existsSync(f)) {
result.stat = fs.statSync(f);
if (result.stat.isDirectory()) {
result.ok = true;
return true;
}
}
return false;
2021-04-03 16:49:14 +02:00
};
2021-03-29 20:40:34 +02:00
return new Promise((resolve) => {
if (checkFile(path.join(process.cwd(), url))) resolve(result);
else if (checkFile(path.join(process.cwd(), url, options.defaultFile))) resolve(result);
else if (checkFile(path.join(process.cwd(), options.defaultFolder, url))) resolve(result);
else if (checkFile(path.join(process.cwd(), options.defaultFolder, url, options.defaultFile))) resolve(result);
else if (checkFolder(path.join(process.cwd(), url))) resolve(result);
else if (checkFolder(path.join(process.cwd(), options.defaultFolder, url))) resolve(result);
else resolve(result);
});
}
2020-11-06 05:46:37 +01:00
// process http requests
async function httpRequest(req, res) {
2021-03-29 20:40:34 +02:00
handle(decodeURI(req.url)).then((result) => {
2020-11-07 16:37:19 +01:00
// 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;
if (!result || !result.ok || !result.stat) {
res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
2020-11-06 05:46:37 +01:00
res.end('Error 404: Not Found\n', 'utf-8');
2021-03-21 12:49:55 +01:00
log.warn(`${req.method}/${req.httpVersion}`, res.statusCode, decodeURI(req.url), ip);
2020-11-06 05:46:37 +01:00
} else {
if (result?.stat?.isFile()) {
const ext = String(path.extname(result.file)).toLowerCase();
const contentType = mime[ext] || 'application/octet-stream';
const accept = req.headers['accept-encoding'] ? req.headers['accept-encoding'].includes('br') : false; // does target accept brotli compressed data
res.writeHead(200, {
// '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-Content-Type-Options': 'nosniff',
});
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
2020-11-06 17:39:39 +01:00
// 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());
2020-11-06 17:39:39 +01:00
// 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);
}
if (result?.stat?.isDirectory()) {
res.writeHead(200, { 'Content-Language': 'en', 'Content-Type': 'application/json; charset=utf-8', 'Last-Modified': result.stat.mtime, 'Cache-Control': 'no-cache', 'X-Content-Type-Options': 'nosniff' });
let dir = fs.readdirSync(result.file);
2021-03-21 12:49:55 +01:00
dir = dir.map((f) => path.join(decodeURI(req.url), f));
res.end(JSON.stringify(dir), 'utf-8');
log.data(`${req.method}/${req.httpVersion}`, res.statusCode, 'directory/json', 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();
2021-03-29 20:40:34 +02:00
process.chdir(path.join(__dirname, '..'));
2021-02-13 15:16:41 +01:00
if (options.httpPort && options.httpPort > 0) {
const server1 = http.createServer(options, httpRequest);
server1.on('listening', () => log.state('HTTP server listening:', options.httpPort));
server1.on('error', (err) => log.error('HTTP server:', err.message || err));
server1.listen(options.httpPort);
}
if (options.httpsPort && options.httpsPort > 0) {
const server2 = http2.createSecureServer(options, httpRequest);
server2.on('listening', () => log.state('HTTP2 server listening:', options.httpsPort));
server2.on('error', (err) => log.error('HTTP2 server:', err.message || err));
server2.listen(options.httpsPort);
}
2021-02-21 13:20:58 +01:00
await build.build('all', 'startup', true);
2020-11-06 05:46:37 +01:00
}
main();