griffinwebserver_v2
Version:
Basic webserver library to serve files in a folder. But can also have specific paths like /api/ that is handled by custom function and supports middleware.
240 lines (220 loc) • 8.8 kB
JavaScript
const http = require('node:http');
const url = require('node:url');
const fs = require('node:fs');
const path = require('node:path');
const fsProm = fs.promises;
// helper functions
function isObject(a) {
return !!a && a.constructor === Object;
}
function isArray(a) {
return !!a && a.constructor === Array;
}
const mimeType = {
'.7z': 'application/x-7z-compressed',
'.acc': 'audio/aac',
'.arc': 'application/x-freearc',
'.avi': 'video/x-msvideo',
'.bmp': 'image/bmp',
'.bz': 'application/x-bzip',
'.bz2': 'application/x-bzip2',
'.csv': 'text/csv',
'.css': 'text/css',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.eot': 'application/vnd.ms-fontobject',
'.epub': 'application/epub+zip',
'.gif': 'image/gif',
'.gz': 'application/gzip',
'.htm': 'text/html',
'.html': 'text/html',
'.ico': 'image/x-icon',
'.ics': 'text/calendar',
'.jar': 'application/java-archive',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.js': 'text/javascript',
'.json': 'application/json',
'.md': 'text/markdown',
'.mid': 'audio/midi',
'.midi': 'audio/x-midi',
'.mjs': 'text/javascript',
'.mp3': 'audio/mpeg',
'.mp4': 'video/mp4',
'.mpeg': 'video/mpeg',
'.mpg': 'video/mpeg',
'.odp': 'application/vnd.oasis.opendocument.presentation',
'.odt': 'application/vnd.oasis.opendocument.text',
'.ods': 'application/vnd.oasis.opendocument.spreadsheet',
'.ogg': 'audio/ogg',
'.oga': 'audio/ogg',
'.ogv': 'video/ogg',
'.opus': 'audio/ogg',
'.otf': 'font/otf',
'.pdf': 'application/pdf',
'.png': 'image/png',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.rar': 'application/x-rar-compressed',
'.rtf': 'application/rtf',
'.sh': 'application/x-sh',
'.svg': 'image/svg+xml',
'.tar': 'application/x-tar',
'.tif': 'image/tiff',
'.tiff': 'image/tiff',
'.tgz': 'application/x-compressed',
'.ttf': 'font/ttf',
'.txt': 'text/plain',
'.wav': 'audio/wav',
'.weba': 'audio/webm',
'.webm': 'video/webm',
'.webp': 'image/webp',
'.woff': 'application/font-woff',
'.woff2': 'application/font-woff2',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.xml': 'text/xml',
'.xul': 'application/vnd.mozilla.xul+xml',
'.zip': 'application/zip',
};
module.exports = class WebServer {
constructor(webfolder = 'www', port = 80, endpoints = [], middlewares = []) {
this.webfolder = webfolder;
// if array just use the array, if object, just add the object within a array. default to empty array
// todo validate handlers
// Middlewares will run before the handlers and endpoints, should normaly not end the request
this.middlewares = isArray(middlewares) ? middlewares : isObject(middlewares) ? [middlewares] : [];
// Handlers deprecated, but work like endpoints for now
this.endpoints = isArray(endpoints) ? endpoints : isObject(endpoints) ? [endpoints] : [];
const webfolderEndpoint = this.static(this.webfolder);
this.endpoints.push({ pathName: '/', handler: webfolderEndpoint });
this.port = port;
if (typeof this.port !== 'number') {
console.log('Warning: port not number ' + this.port + ' using default port 80');
this.port = 80;
} else {
this.port = Math.floor(this.port);
}
}
addMiddleware(pathName, handler) {
const newHandler = { pathName: pathName, handler: handler };
newHandler.handler.pathName = pathName;
this.middlewares.push(newHandler);
}
addEndpoint(pathName, handler) {
const newHandler = { pathName: pathName, handler: handler };
newHandler.handler.pathName = pathName;
this.endpoints.push(newHandler);
}
// legacy method name to add endpoints
addHandler(pathName, handler) {
this.addEndpoint(pathName, handler);
}
// TODO, figure out how to remove the
static(staticPath) {
//endpoint
let folderPath = staticPath;
// if webfolder is a absolute path, use that path, otherwise use relative path from the folder were the program is installed.
if (folderPath[0] !== '/') folderPath = path.join(__dirname, folderPath);
return function (req, res) {
let filePath = path.join(folderPath, req.urlPart);
return fsProm
.stat(filePath)
.then((stat) => {
// if is a directory, then look for index.html
if (stat.isDirectory()) {
filePath = path.join(filePath, 'index.html');
return fsProm.stat(filePath);
}
return stat;
})
.then((stat) => {
// based on the URL path, extract the file extension. e.g. .js, .doc, ...
const ext = path.parse(filePath).ext.toLowerCase();
// if the file is found, set Content-type and send data
res.setHeader('Content-Length', stat.size);
res.setHeader('Content-type', mimeType[ext] || 'application/octet-stream');
res.streaming = true;
const stream = fs.createReadStream(filePath);
stream.pipe(res);
stream.on('open', () => {
console.log('serve file ' + filePath);
});
stream.on('error', (err) => {
res.statusCode = !err.code || err.code === 'ENOENT' ? 404 : 500;
if (process.env.NODE_ENV === 'development') {
res.end('Error ' + res.statusCode + ': ' + err.message);
} else {
res.end('Error ' + res.statusCode);
}
});
})
.catch((err) => {
res.statusCode = !err.code || err.code === 'ENOENT' ? 404 : 500;
if (process.env.NODE_ENV === 'development') {
res.end('Error ' + res.statusCode + ': ' + err.message);
} else {
res.end('Error ' + res.statusCode);
}
});
};
}
webHandler(req, res) {
// there is no way in nodejs to detect protocol, and honestly, there is almost always a nginx proxy in between so would only mess things upp.
const baseUrl = 'http://' + req.headers.host + '/';
const parsedUrl = new URL(req.url, baseUrl);
// Filter out middleares and endpoints based on the pathName. Sort them to run the most accurate one first (longest match) first.
const middlewaresToRun = this.parent.middlewares.filter((h) => h?.pathName && h.pathName === parsedUrl.pathname.slice(0, h.pathName.length)).sort((a, b) => b.pathName.length - a.pathName.length);
const endpointsToRun = this.parent.endpoints.filter((h) => h?.pathName && h.pathName === parsedUrl.pathname.slice(0, h.pathName.length)).sort((a, b) => b.pathName.length - a.pathName.length);
// combine middlewares and endpoints to run into an array (in the correct order)
const allHandlersToRun = [...middlewaresToRun, ...endpointsToRun];
// custom promise generator for this project..
function promisify(call) {
// if call is a promise, then just return it.
if (call instanceof Promise) return call;
// if call.handler is a function then run it.
if (typeof call.handler === 'function') {
const result = call.handler(req, res);
// if call.handler generated a promise just return it
if (result instanceof Promise) return result;
// if call.handler generated anything but a promise, create a promise and resolve it with the result for consistency.
return new Promise((resolve) => resolve(result));
} else {
// call is a function.
return Promise.resolve(call);
}
}
function requestHandler() {
if (allHandlersToRun.length === 0)
return promisify({
handler: (req, res) => {
res.statusCode = 404;
res.end('404 - Not found');
},
});
const handlerToRun = allHandlersToRun.shift();
req.urlPart = path.join('/', req.url.slice(handlerToRun.pathName.length));
const p = promisify(handlerToRun);
// make this recurse
p.then(() => {
// writableEnded should really not happen. But it make some sense to not continue to loop if it have.
if (!res.writableEnded && !res.streaming) requestHandler();
}).catch((e) => {
// if anything goes wrong I guess it makes sense to show it.
console.error(e);
res.statusCode = 500;
res.end('Internal Server Error');
});
}
requestHandler();
}
start() {
this.WebServer = http.createServer(this.webHandler);
this.WebServer.parent = this;
console.log('Start webserver on port ' + this.port);
this.WebServer.listen(this.port);
this.WebServer.on('error', (err) => {
console.log('Error: ' + err);
});
}
};