Files
AriWeb-Suite/lib/http.mjs
T

158 lines
12 KiB
JavaScript

/**
* 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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))]; })) };
}