/** * lib/http.mjs — 의존성 없는 작은 HTTP 앱 프레임워크(라우터·JSON·쿠키·정적 파일·오류 형식). * * 왜 직접 만드나: 형제 제품(Webmaker·AriMail)과 같이 외부 npm 의존성 없이 운영하기 위해서다. 필요한 기능은 * 경로 매개변수(`/api/sites/:id`), JSON 본문, 쿠키, 신뢰 프록시 IP, 정적 파일, 일관된 오류 응답뿐이다. * * 핸들러: async (ctx) => 반환값 * - 객체를 돌려주면 JSON(200). `ctx.json(obj, status)`·`ctx.html(str)`·`ctx.redirect(url)` 로 다른 응답. * - 예외: `ctx.error(status, code, message)` 가 만든 HttpError → { ok:false, error, message, detail? }. 그 밖의 예외는 500 + 기록. * 미들웨어: app.use(async (ctx) => { ... }) — 모든 라우트 전에 실행(세션 해석 등). 반환값이 있으면 그것으로 응답(차단). * 보안 기본값: 모든 동적 응답 `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `X-Frame-Options: SAMEORIGIN`. * 접속 IP: nginx 가 붙인 `X-Real-IP` 를 소켓 주소가 루프백일 때만 믿는다(클라이언트가 넣은 헤더를 믿지 않음). */ import http from 'node:http'; import { readFileSync, statSync, createReadStream, existsSync } from 'node:fs'; import { join, normalize, extname } from 'node:path'; export class HttpError extends Error { /** @param {number} status @param {string} code 기계용 오류 코드 @param {string} message 사용자용 설명 @param {object} [detail] 화면이 쓸 추가 정보(비밀 없음) */ constructor(status, code, message, detail) { super(message || code); this.status = status; this.code = code; this.detail = detail; } } const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.txt': 'text/plain; charset=utf-8', '.woff2': 'font/woff2' }; const MAX_BODY = 2 * 1024 * 1024; // JSON 본문 상한(2MB). 파일 업로드는 별도 스트리밍 라우트가 맡는다. /** 쿠키 헤더 파싱 */ export function parseCookies(header) { const out = {}; for (const part of String(header || '').split(';')) { const i = part.indexOf('='); if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim()); } return out; } /** Set-Cookie 문자열 만들기 */ export function cookie(name, value, { maxAge = 0, path = '/', secure = true, sameSite = 'Lax', httpOnly = true } = {}) { return `${name}=${encodeURIComponent(value)}; Path=${path}; ${httpOnly ? 'HttpOnly; ' : ''}SameSite=${sameSite}${secure ? '; Secure' : ''}${maxAge ? `; Max-Age=${maxAge}` : '; Max-Age=0'}`; } /** 경로 패턴(`/a/:id/b`)을 정규식으로 */ function compile(pattern) { const keys = []; const re = new RegExp('^' + pattern.replace(/\/+$/, '').replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '(.*)').replace(/:([a-zA-Z_]+)/g, (_, k) => { keys.push(k); return '([^/]+)'; }) + '/?$'); return { re, keys }; } /** * 앱 만들기. * @param {{ log?: Function, trustProxy?: boolean, onError?: Function }} opts */ export function createApp(opts = {}) { const routes = []; const middlewares = []; const statics = []; const log = opts.log || ((...a) => console.log(...a)); const app = { /** 라우트 등록. method 는 'GET'|'POST'|'PUT'|'PATCH'|'DELETE'|'*' */ route(method, pattern, handler) { routes.push({ method: method.toUpperCase(), ...compile(pattern), pattern, handler }); return app; }, get: (p, h) => app.route('GET', p, h), post: (p, h) => app.route('POST', p, h), put: (p, h) => app.route('PUT', p, h), patch: (p, h) => app.route('PATCH', p, h), delete: (p, h) => app.route('DELETE', p, h), use(fn) { middlewares.push(fn); return app; }, /** 정적 파일: prefix 아래 요청을 dir 에서 낸다(경로 탈출 차단). */ static(prefix, dir) { statics.push({ prefix: prefix.replace(/\/$/, ''), dir }); return app; }, /** 요청 하나 처리(테스트에서도 직접 부른다). */ async handle(req, res) { const started = Date.now(); const url = new URL(req.url, 'http://localhost'); const ctx = makeCtx(req, res, url, opts); try { for (const s of statics) { if (ctx.method === 'GET' && (url.pathname === s.prefix || url.pathname.startsWith(s.prefix + '/'))) { const rel = normalize(url.pathname.slice(s.prefix.length) || '/').replace(/^(\.\.(\/|\\|$))+/, ''); const file = join(s.dir, rel); if (!file.startsWith(s.dir)) throw new HttpError(404, 'not_found', '페이지를 찾을 수 없습니다'); if (existsSync(file) && statSync(file).isFile()) return sendFile(res, file); } } for (const m of middlewares) { const r = await m(ctx); if (r !== undefined) return finish(ctx, r); if (ctx.ended) return; } for (const r of routes) { if (r.method !== '*' && r.method !== ctx.method) continue; const m = r.re.exec(url.pathname); if (!m) continue; r.keys.forEach((k, i) => { ctx.params[k] = decodeURIComponent(m[i + 1]); }); const out = await r.handler(ctx); return finish(ctx, out); } throw new HttpError(404, 'not_found', '페이지를 찾을 수 없습니다'); } catch (e) { return sendError(ctx, e, log, opts); } finally { if (opts.access) log(`${ctx.method} ${url.pathname} ${res.statusCode} ${Date.now() - started}ms`); } }, /** 서버 시작 */ listen(port, host = '127.0.0.1') { const server = http.createServer((req, res) => app.handle(req, res)); server.keepAliveTimeout = 65000; return new Promise((resolve) => server.listen(port, host, () => resolve(server))); }, routes, }; return app; } /** 요청 컨텍스트 */ function makeCtx(req, res, url, opts) { const sock = req.socket?.remoteAddress || ''; const loop = sock === '127.0.0.1' || sock === '::1' || sock === '::ffff:127.0.0.1'; const ip = (opts.trustProxy !== false && loop && req.headers['x-real-ip']) ? String(req.headers['x-real-ip']) : sock.replace(/^::ffff:/, ''); let bodyCache; const ctx = { req, res, method: req.method.toUpperCase(), path: url.pathname, url, params: {}, query: Object.fromEntries(url.searchParams), cookies: parseCookies(req.headers.cookie), ip, headers: req.headers, user: null, session: null, ended: false, status: 200, outHeaders: {}, secure: req.headers['x-forwarded-proto'] === 'https' || !!req.socket?.encrypted, /** JSON 본문(한 번만 읽고 캐시). 비어 있으면 {} */ async body() { if (bodyCache !== undefined) return bodyCache; const chunks = []; let size = 0; for await (const c of req) { size += c.length; if (size > MAX_BODY) throw new HttpError(413, 'body_too_large', '요청 본문이 너무 큽니다'); chunks.push(c); } const text = Buffer.concat(chunks).toString('utf8'); if (!text) return (bodyCache = {}); const ct = String(req.headers['content-type'] || ''); if (ct.includes('application/x-www-form-urlencoded')) return (bodyCache = Object.fromEntries(new URLSearchParams(text))); try { return (bodyCache = JSON.parse(text)); } catch (_) { throw new HttpError(400, 'bad_json', '요청 본문이 올바른 JSON 이 아닙니다'); } }, set(status, headers = {}) { ctx.status = status; Object.assign(ctx.outHeaders, headers); }, setCookie(str) { const prev = ctx.outHeaders['Set-Cookie']; ctx.outHeaders['Set-Cookie'] = prev ? [].concat(prev, str) : str; }, json(obj, status = 200) { ctx.status = status; return { __json: obj }; }, html(str, status = 200) { ctx.status = status; return { __html: str }; }, text(str, status = 200) { ctx.status = status; return { __text: str }; }, redirect(url, status = 302) { ctx.status = status; ctx.outHeaders.Location = url; return { __text: '' }; }, error(status, code, message, detail) { return new HttpError(status, code, message, detail); }, }; return ctx; } function baseHeaders(ctx) { return { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'X-Frame-Options': 'SAMEORIGIN', ...ctx.outHeaders }; } function finish(ctx, out) { if (ctx.ended) return; ctx.ended = true; const h = baseHeaders(ctx); if (out && out.__html !== undefined) { ctx.res.writeHead(ctx.status, { 'Content-Type': 'text/html; charset=utf-8', ...h }); return ctx.res.end(out.__html); } if (out && out.__text !== undefined) { ctx.res.writeHead(ctx.status, { 'Content-Type': 'text/plain; charset=utf-8', ...h }); return ctx.res.end(out.__text); } const body = out && out.__json !== undefined ? out.__json : out; ctx.res.writeHead(ctx.status, { 'Content-Type': 'application/json; charset=utf-8', ...h }); ctx.res.end(JSON.stringify(body === undefined ? { ok: true } : body)); } function sendError(ctx, e, log, opts) { if (ctx.ended) return; const status = e instanceof HttpError ? e.status : (e && e.status) || 500; const code = e instanceof HttpError ? e.code : (e && e.code) || 'server_error'; if (status >= 500) { log(`[http] ${ctx.method} ${ctx.path} 500: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); if (opts.onError) try { opts.onError(e, ctx); } catch (_) {} } ctx.ended = true; ctx.res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...baseHeaders(ctx) }); ctx.res.end(JSON.stringify({ ok: false, error: code, message: status >= 500 ? '일시적인 오류가 발생했습니다. 잠시 후 다시 시도하세요' : (e.message || code), ...(e.detail ? { detail: e.detail } : {}) })); } function sendFile(res, file) { const type = MIME[extname(file).toLowerCase()] || 'application/octet-stream'; res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-cache', 'X-Content-Type-Options': 'nosniff' }); createReadStream(file).pipe(res); } /** 간단한 HTML 이스케이프 */ export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); /** 테스트용: 앱에 가짜 요청을 보내고 { status, headers, json, text } 를 돌려준다(소켓 없이). */ export async function invoke(app, method, path, { body, headers = {}, cookies = {}, ip = '127.0.0.1' } = {}) { const { PassThrough } = await import('node:stream'); const req = new PassThrough(); req.method = method; req.url = path; req.headers = { 'content-type': 'application/json', ...headers }; if (Object.keys(cookies).length) req.headers.cookie = Object.entries(cookies).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('; '); req.socket = { remoteAddress: ip }; if (body !== undefined) req.end(typeof body === 'string' ? body : JSON.stringify(body)); else req.end(); const out = { status: 0, headers: {}, chunks: [] }; const res = { writeHead(s, h) { out.status = s; out.headers = h || {}; }, end(d) { if (d) out.chunks.push(Buffer.from(d)); out.done = true; resolve(); }, write(d) { out.chunks.push(Buffer.from(d)); }, statusCode: 200, on() {}, once() {}, emit() {} }; let resolve; const p = new Promise((r) => { resolve = r; }); await app.handle(req, res); await p; const text = Buffer.concat(out.chunks).toString('utf8'); let json = null; try { json = JSON.parse(text); } catch (_) {} const setCookie = [].concat(out.headers['Set-Cookie'] || []); return { status: out.status, headers: out.headers, text, json, cookies: Object.fromEntries(setCookie.map((c) => { const [kv] = c.split(';'); const i = kv.indexOf('='); return [kv.slice(0, i), decodeURIComponent(kv.slice(i + 1))]; })) }; }