190 lines
5.7 KiB
JavaScript
190 lines
5.7 KiB
JavaScript
/**
|
|
* Минимальный HTTP-сервер без Express: node:http / node:http2
|
|
*/
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import http from 'node:http';
|
|
import http2 from 'node:http2';
|
|
import crypto from 'node:crypto';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const MIME = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.js': 'application/javascript; charset=utf-8',
|
|
'.json': 'application/json; charset=utf-8',
|
|
'.ico': 'image/x-icon',
|
|
'.svg': 'image/svg+xml',
|
|
'.png': 'image/png',
|
|
};
|
|
|
|
export function loadEnv(envPath) {
|
|
if (!fs.existsSync(envPath)) return;
|
|
for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
|
|
const t = line.trim();
|
|
if (!t || t.startsWith('#')) continue;
|
|
const i = t.indexOf('=');
|
|
if (i < 0) continue;
|
|
const key = t.slice(0, i).trim();
|
|
let val = t.slice(i + 1).trim();
|
|
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
val = val.slice(1, -1);
|
|
}
|
|
if (!(key in process.env)) process.env[key] = val;
|
|
}
|
|
}
|
|
|
|
export function sendJson(res, status, data) {
|
|
const body = JSON.stringify(data);
|
|
res.writeHead(status, {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
'Content-Length': Buffer.byteLength(body),
|
|
});
|
|
res.end(body);
|
|
}
|
|
|
|
export function readJsonBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks = [];
|
|
req.on('data', (c) => chunks.push(c));
|
|
req.on('end', () => {
|
|
if (!chunks.length) return resolve({});
|
|
try {
|
|
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
} catch {
|
|
reject(new Error('Invalid JSON'));
|
|
}
|
|
});
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
export function parseCookies(req) {
|
|
const raw = req.headers.cookie || '';
|
|
const out = {};
|
|
for (const part of raw.split(';')) {
|
|
const [k, ...v] = part.trim().split('=');
|
|
if (k) out[k] = decodeURIComponent(v.join('='));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function setCookie(res, name, value, opts = {}) {
|
|
const parts = [`${name}=${encodeURIComponent(value)}`, 'Path=/', 'HttpOnly', 'SameSite=Lax'];
|
|
if (opts.maxAge) parts.push(`Max-Age=${opts.maxAge}`);
|
|
if (opts.clear) parts.push('Max-Age=0');
|
|
const prev = res.getHeader('Set-Cookie');
|
|
const next = Array.isArray(prev) ? [...prev, parts.join('; ')] : prev ? [prev, parts.join('; ')] : [parts.join('; ')];
|
|
res.setHeader('Set-Cookie', next);
|
|
}
|
|
|
|
export function signSession(data, secret) {
|
|
const payload = Buffer.from(JSON.stringify(data)).toString('base64url');
|
|
const sig = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
|
|
return `${payload}.${sig}`;
|
|
}
|
|
|
|
export function verifySession(token, secret) {
|
|
if (!token) return null;
|
|
const dot = token.lastIndexOf('.');
|
|
if (dot < 0) return null;
|
|
const payload = token.slice(0, dot);
|
|
const sig = token.slice(dot + 1);
|
|
const expected = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
|
|
if (sig !== expected) return null;
|
|
try {
|
|
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function clientIp(req) {
|
|
return (req.socket?.remoteAddress || '').replace('::ffff:', '');
|
|
}
|
|
|
|
export function matchRoute(method, pathname, routes) {
|
|
for (const r of routes) {
|
|
if (r.method !== method) continue;
|
|
const pattern = r.path.replace(/:([^/]+)/g, '([^/]+)');
|
|
const re = new RegExp(`^${pattern}$`);
|
|
const m = pathname.match(re);
|
|
if (!m) continue;
|
|
const keys = [...r.path.matchAll(/:([^/]+)/g)].map((x) => x[1]);
|
|
const params = {};
|
|
keys.forEach((k, i) => { params[k] = decodeURIComponent(m[i + 1]); });
|
|
return { handler: r.handler, params };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function serveStatic(publicDir, pathname) {
|
|
const base = path.resolve(publicDir);
|
|
const rel = pathname === '/' ? 'index.html' : pathname.replace(/^\//, '');
|
|
const filePath = path.resolve(base, rel);
|
|
if (!filePath.startsWith(base + path.sep) && filePath !== base) return null;
|
|
try {
|
|
const stat = fs.statSync(filePath);
|
|
if (!stat.isFile()) return null;
|
|
const ext = path.extname(filePath);
|
|
return {
|
|
body: fs.readFileSync(filePath),
|
|
type: MIME[ext] || 'application/octet-stream',
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Rate limit: max запросов за windowMs на IP */
|
|
export function createRateLimiter({ windowMs, max }) {
|
|
const hits = new Map();
|
|
return (ip) => {
|
|
const now = Date.now();
|
|
let entry = hits.get(ip);
|
|
if (!entry || now - entry.start > windowMs) {
|
|
entry = { start: now, count: 0 };
|
|
hits.set(ip, entry);
|
|
}
|
|
entry.count += 1;
|
|
return entry.count <= max;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {function} onRequest async (req, res, ctx) => void
|
|
* @param {{ http2?: boolean, tls?: { key, cert }, host, port }} opts
|
|
*/
|
|
export function listen(onRequest, opts) {
|
|
const handler = async (req, res) => {
|
|
try {
|
|
await onRequest(req, res);
|
|
} catch (e) {
|
|
if (!res.headersSent) sendJson(res, 500, { error: e.message || 'Internal error' });
|
|
}
|
|
};
|
|
|
|
let server;
|
|
if (opts.tls?.key && opts.tls?.cert) {
|
|
server = http2.createSecureServer(
|
|
{ key: fs.readFileSync(opts.tls.key), cert: fs.readFileSync(opts.tls.cert) },
|
|
handler
|
|
);
|
|
} else if (opts.http2) {
|
|
server = http2.createServer(handler);
|
|
} else {
|
|
server = http.createServer(handler);
|
|
}
|
|
|
|
server.listen(opts.port, opts.host, () => {
|
|
const proto = opts.tls ? 'https' : 'http';
|
|
const h2 = opts.http2 || opts.tls ? ' HTTP/2' : '';
|
|
console.log(`Listening → ${proto}://${opts.host}:${opts.port}${h2}`);
|
|
});
|
|
return server;
|
|
}
|
|
|
|
export function dirFromMeta(metaUrl) {
|
|
return path.dirname(fileURLToPath(metaUrl));
|
|
}
|