/** * Минимальный 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)); }