// 전국 5만 개 지역 대응 온디맨드 초경량 SSR 프로덕션 서버 (Node.js 내장 모듈 기반 / Docker 최적화) import http from 'http'; import fs from 'fs'; import path from 'path'; import zlib from 'zlib'; import { fileURLToPath, pathToFileURL } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const PORT = Number(process.env.PORT) || 80; const HOST = process.env.HOST || '0.0.0.0'; const rootDir = path.resolve(__dirname); const distDir = path.join(rootDir, 'dist'); const distSsrDir = path.join(rootDir, 'dist-ssr'); const rawTemplatePath = path.join(distDir, '_template.html'); const indexTemplatePath = path.join(distDir, 'index.html'); const ssrEntryPath = path.join(distSsrDir, 'entry-server.js'); const HEAD_PLACEHOLDER = ''; const HTML_PLACEHOLDER = ''; // 템플릿 로드 (_template.html 우선, 없으면 dist/index.html 사용) let template = ''; try { if (fs.existsSync(rawTemplatePath)) { template = fs.readFileSync(rawTemplatePath, 'utf-8'); } else { template = fs.readFileSync(indexTemplatePath, 'utf-8'); } } catch (e) { console.warn('[SSR Server] dist/index.html 을 아직 읽을 수 없습니다. 빌드(npm run build:fast)가 필요합니다.'); } // SSR 렌더러 모듈 로드 let ssrRender = null; async function loadSsrEntry() { if (fs.existsSync(ssrEntryPath)) { const mod = await import(pathToFileURL(ssrEntryPath).href); ssrRender = mod.render; console.log('[SSR Server] SSR 렌더러(dist-ssr/entry-server.js) 로드 완료'); } else { console.warn('[SSR Server] dist-ssr/entry-server.js 를 찾을 수 없습니다. 빌드(npm run build:server)가 필요합니다.'); } } // 인메모리 캐시 (최대 5,000개 URL 캐싱, LRU 방식) const MAX_CACHE_SIZE = 5000; const htmlCache = new Map(); function getCachedHtml(url) { if (htmlCache.has(url)) { const data = htmlCache.get(url); // 캐시 순서 갱신 (LRU) htmlCache.delete(url); htmlCache.set(url, data); return data; } return null; } function setCachedHtml(url, html) { if (htmlCache.size >= MAX_CACHE_SIZE) { const oldestKey = htmlCache.keys().next().value; htmlCache.delete(oldestKey); } htmlCache.set(url, html); } // MIME 타입 매핑 const MIME_TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.xml': 'application/xml; charset=utf-8', '.txt': 'text/plain; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.woff': 'font/woff' }; // Gzip 압축 전송 유틸 (HEAD 요청 규격 완벽 대응 및 사전 압축 버퍼 즉시 전송) function sendResponse(req, res, statusCode, contentType, body, extraHeaders = {}, preGzipped = null) { const isHead = req.method === 'HEAD'; const acceptEncoding = req.headers['accept-encoding'] || ''; const headers = { 'Content-Type': contentType, ...extraHeaders }; if (typeof body === 'string') { body = Buffer.from(body, 'utf-8'); } if (acceptEncoding.includes('gzip') && body.length > 512) { if (preGzipped) { headers['Content-Encoding'] = 'gzip'; headers['Content-Length'] = preGzipped.length; res.writeHead(statusCode, headers); res.end(isHead ? undefined : preGzipped); return; } zlib.gzip(body, (err, compressed) => { if (err) { headers['Content-Length'] = body.length; res.writeHead(statusCode, headers); res.end(isHead ? undefined : body); return; } headers['Content-Encoding'] = 'gzip'; headers['Content-Length'] = compressed.length; res.writeHead(statusCode, headers); res.end(isHead ? undefined : compressed); }); } else { headers['Content-Length'] = body.length; res.writeHead(statusCode, headers); res.end(isHead ? undefined : body); } } // 정적 파일 서빙 시도 function tryServeStatic(req, res, pathname) { // dist 디렉터리 내 정적 자산 경로 const filePath = path.join(distDir, pathname); // 보안: 디렉터리 트래버설 방지 if (!filePath.startsWith(distDir)) { return false; } if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { const ext = path.extname(filePath).toLowerCase(); const contentType = MIME_TYPES[ext] || 'application/octet-stream'; const isImmutable = pathname.startsWith('/assets/'); const cacheControl = isImmutable ? 'public, max-age=31536000, immutable' : 'public, max-age=86400'; const content = fs.readFileSync(filePath); sendResponse(req, res, 200, contentType, content, { 'Cache-Control': cacheControl }); return true; } return false; } const server = http.createServer(async (req, res) => { try { let rawPathname = '/'; try { const safeHost = (req.headers.host || 'localhost').replace(/[^a-zA-Z0-9.:-]/g, ''); const reqUrl = new URL(req.url || '/', `http://${safeHost || 'localhost'}`); rawPathname = decodeURIComponent(reqUrl.pathname); } catch { rawPathname = (req.url || '/').split('?')[0] || '/'; } // 헬스체크 엔드포인트 (Docker / Nginx Proxy Manager 상태 확인용) if (rawPathname === '/healthz') { sendResponse(req, res, 200, 'text/plain; charset=utf-8', 'OK', { 'Cache-Control': 'no-store' }); return; } // 브라우저 기본 /favicon.ico 요청 시 정적 아이콘으로 즉시 응답 (불필요한 SSR/404 방지) if (rawPathname === '/favicon.ico') { if (tryServeStatic(req, res, '/img/pavicon.png') || tryServeStatic(req, res, '/favicon.svg')) { return; } res.writeHead(204); res.end(); return; } // 1. 정적 파일 우선 서빙 (/assets/*, /img/*, /sitemap*.xml, /robots.txt, /favicon.svg 등, 단 '/' 제외) if (rawPathname !== '/' && tryServeStatic(req, res, rawPathname)) { return; } // 끝 슬래시 정규화 (루트 '/' 제외, 끝 슬래시 요청 시 표준 URL로 301 영구 리다이렉트하여 중복 색인 방지) if (rawPathname.length > 1 && rawPathname.endsWith('/')) { const normalized = rawPathname.replace(/\/+$/, ''); res.writeHead(301, { Location: normalized, 'Cache-Control': 'public, max-age=86400' }); res.end(); return; } const pathname = rawPathname; const isAdmin = pathname === '/admin' || pathname.startsWith('/admin/') || pathname.endsWith('/admin'); // 2. 캐시 확인 (인메모리 캐시 히트 시 사전 압축 버퍼로 0.0001초 즉시 응답, 관리자 경로 제외) if (!isAdmin) { const cached = getCachedHtml(pathname); if (cached) { sendResponse( req, res, cached.statusCode, 'text/html; charset=utf-8', cached.html, { 'X-Cache': 'HIT', 'Cache-Control': 'public, max-age=3600' }, cached.gzipped ); return; } } // 3. 온디맨드 실시간 SSR 렌더링 (전국 50,380개 지역 페이지 전체 200 OK 및 고유 메타·본문 생성) if (ssrRender && template) { try { const { html, head, routeName } = await ssrRender(pathname); if (routeName === 'NotFound') { let notFoundHtml = template; notFoundHtml = notFoundHtml.replace(/