Files
NexusGate/lib/http.js

78 lines
2.5 KiB
JavaScript

'use strict';
const fs = require('node:fs');
const path = require('node:path');
const MIME = {
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.json': 'application/json; charset=utf-8'
};
function sendJson(res, status, data, headers = {}) {
const body = JSON.stringify(data);
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body), ...headers });
res.end(body);
}
function sendError(res, status, message, code = 'request_error') {
sendJson(res, status, { error: code, message });
}
async function readJson(req, maxBytes = 1024 * 1024) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > maxBytes) {
const error = new Error('Request body is too large');
error.statusCode = 413;
throw error;
}
chunks.push(chunk);
}
if (!chunks.length) return {};
try {
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
} catch {
const error = new Error('Invalid JSON body');
error.statusCode = 400;
throw error;
}
}
function route(pattern, pathname) {
const names = [];
const regex = new RegExp(`^${pattern.replace(/:[A-Za-z0-9_]+/g, (part) => {
names.push(part.slice(1));
return '([^/]+)';
})}/?$`);
const match = pathname.match(regex);
if (!match) return null;
return Object.fromEntries(names.map((name, index) => [name, decodeURIComponent(match[index + 1])]));
}
async function serveStatic(publicDir, pathname, res) {
const relative = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '');
const target = path.resolve(publicDir, relative);
if (!target.startsWith(path.resolve(publicDir) + path.sep) && target !== path.resolve(publicDir, 'index.html')) return false;
try {
const stat = await fs.promises.stat(target);
if (!stat.isFile()) return false;
const body = await fs.promises.readFile(target);
res.writeHead(200, {
'content-type': MIME[path.extname(target)] || 'application/octet-stream',
'content-length': body.length,
// The UI is intentionally tiny; never mix an old app.js with a new stylesheet after ng update.
'cache-control': 'no-store',
'x-content-type-options': 'nosniff'
});
res.end(body);
return true;
} catch (error) {
if (error.code === 'ENOENT') return false;
throw error;
}
}
module.exports = { sendJson, sendError, readJson, route, serveStatic };