290 lines
9.9 KiB
JavaScript
290 lines
9.9 KiB
JavaScript
// 전국 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 = '<!--app-head-->';
|
|
const HTML_PLACEHOLDER = '<!--app-html-->';
|
|
|
|
// 템플릿 로드 (_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 압축 전송 유틸
|
|
function sendResponse(req, res, statusCode, contentType, body, extraHeaders = {}) {
|
|
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) {
|
|
zlib.gzip(body, (err, compressed) => {
|
|
if (err) {
|
|
res.writeHead(statusCode, headers);
|
|
res.end(body);
|
|
return;
|
|
}
|
|
headers['Content-Encoding'] = 'gzip';
|
|
headers['Content-Length'] = compressed.length;
|
|
res.writeHead(statusCode, headers);
|
|
res.end(compressed);
|
|
});
|
|
} else {
|
|
headers['Content-Length'] = body.length;
|
|
res.writeHead(statusCode, headers);
|
|
res.end(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;
|
|
}
|
|
|
|
// 끝 슬래시 정규화 (루트 '/' 제외)
|
|
const pathname = rawPathname.length > 1 && rawPathname.endsWith('/')
|
|
? rawPathname.replace(/\/+$/, '')
|
|
: rawPathname;
|
|
|
|
const isAdmin = pathname === '/admin' || pathname.startsWith('/admin/') || pathname.endsWith('/admin');
|
|
|
|
// 2. 캐시 확인 (인메모리 캐시 히트 시 0.001초 응답, 관리자 경로 제외)
|
|
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'
|
|
});
|
|
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(/<title\s+data-default[\s\S]*?<\/title>\s*/i, '');
|
|
notFoundHtml = notFoundHtml.replace(/<[^>]+data-default[^>]*>\s*/gi, '');
|
|
notFoundHtml = notFoundHtml
|
|
.replace(HEAD_PLACEHOLDER, head || '<title>페이지를 찾을 수 없습니다</title><meta name="robots" content="noindex" />')
|
|
.replace(HTML_PLACEHOLDER, html || '');
|
|
sendResponse(req, res, 404, 'text/html; charset=utf-8', notFoundHtml, {
|
|
'X-Robots-Tag': 'noindex, nofollow',
|
|
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 정상 완성형 HTML 조립
|
|
let fullHtml = template;
|
|
if (head) {
|
|
fullHtml = fullHtml.replace(/<title\s+data-default[\s\S]*?<\/title>\s*/i, '');
|
|
fullHtml = fullHtml.replace(/<[^>]+data-default[^>]*>\s*/gi, '');
|
|
fullHtml = fullHtml.replace(HEAD_PLACEHOLDER, head);
|
|
} else {
|
|
fullHtml = fullHtml.replace(HEAD_PLACEHOLDER, '');
|
|
}
|
|
fullHtml = fullHtml.replace(HTML_PLACEHOLDER, html);
|
|
|
|
if (isAdmin || routeName === 'AdminHome' || routeName === 'LocationSubjectFullAdmin') {
|
|
sendResponse(req, res, 200, 'text/html; charset=utf-8', fullHtml, {
|
|
'X-Robots-Tag': 'noindex, nofollow',
|
|
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 캐시에 저장 (다음 요청부터 0.001초 응답)
|
|
setCachedHtml(pathname, { statusCode: 200, html: fullHtml });
|
|
|
|
sendResponse(req, res, 200, 'text/html; charset=utf-8', fullHtml, {
|
|
'X-Cache': 'MISS',
|
|
'Cache-Control': 'public, max-age=3600'
|
|
});
|
|
return;
|
|
} catch (err) {
|
|
console.error(`[SSR Error] ${pathname} 렌더링 중 오류:`, err);
|
|
}
|
|
}
|
|
|
|
// 4. 예외 시 SPA 기본 index.html 폴백 서빙
|
|
if (template) {
|
|
sendResponse(req, res, 200, 'text/html; charset=utf-8', template);
|
|
} else {
|
|
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
res.end('서버 빌드 산출물이 존재하지 않습니다. npm run build:fast 를 먼저 실행해주세요.');
|
|
}
|
|
} catch (fatalErr) {
|
|
console.error('[HTTP Handler Error]:', fatalErr);
|
|
if (!res.headersSent) {
|
|
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
}
|
|
res.end('Internal Server Error');
|
|
}
|
|
});
|
|
|
|
// 비정상 봇/스캐너 패킷으로 인한 프로세스 종료 원천 차단
|
|
process.on('uncaughtException', (err) => {
|
|
console.error('[Uncaught Exception]:', err);
|
|
});
|
|
process.on('unhandledRejection', (reason) => {
|
|
console.error('[Unhandled Rejection]:', reason);
|
|
});
|
|
|
|
await loadSsrEntry();
|
|
|
|
server.listen(PORT, HOST, () => {
|
|
console.log(`====================================================`);
|
|
console.log(`🚀 티처PICK 전국 50,380개 온디맨드 SSR 서버 구동 시작!`);
|
|
console.log(`📡 리스닝 주소: http://${HOST}:${PORT}`);
|
|
console.log(`⚡ 특징: Nginx Proxy Manager (teacherpick_frontend:80) 연동 + 실시간 SSR + 메모리 캐싱`);
|
|
console.log(`====================================================`);
|
|
});
|
|
|
|
process.on('SIGTERM', () => {
|
|
server.close(() => process.exit(0));
|
|
});
|
|
process.on('SIGINT', () => {
|
|
server.close(() => process.exit(0));
|
|
});
|