import { LRUCache } from 'lru-cache' import { getHeader, getRequestHost, defineEventHandler, createError, setCookie, type H3Event, } from 'h3' import { ssrGetFinalLocale } from '../../utils/localeUtil' import type { GameDataResponse } from '../../types/api/gameData' import type { ResGetInspectionData, WebInspectionData } from '../../types/InspectionType' import { isStaticFile } from '#layers/utils/commonUtil' import { getTrueClientIp } from '#layers/utils/apiUtil' /** * 캐시 제어 헤더를 설정하는 공통 함수 * * @param event - 이벤트 객체 * @param cacheMode - 캐시 모드 설정 ('no-cache', 'short', 'medium', 'default') * @param customMaxAge - 커스텀 max-age 값 (초 단위) */ function setCacheHeaders( event: { node: { res: { setHeader: (name: string, value: string) => void } } }, cacheMode: 'no-cache' | 'short' | 'medium' | 'default', customMaxAge?: number ): void { // 원래 setHeader 함수 참조 저장 const originalSetHeader = event.node.res.setHeader // Cache-Control 헤더 설정값 결정 let cacheControl: string switch (cacheMode) { case 'no-cache': cacheControl = 'no-cache, no-store, must-revalidate' // no-cache 모드일 때는 추가 헤더도 설정 event.node.res.setHeader('Pragma', 'no-cache') event.node.res.setHeader('Expires', '0') break case 'short': cacheControl = `public, max-age=${customMaxAge || 10}` break case 'medium': cacheControl = `public, max-age=${customMaxAge || 15}` break case 'default': default: cacheControl = `public, max-age=${customMaxAge || 60}` break } // Cache-Control 헤더를 강제로 설정하기 위해 setHeader 메소드 오버라이드 event.node.res.setHeader = function (name: string, value: string) { if (name.toLowerCase() === 'cache-control') { return originalSetHeader.call(this, name, cacheControl) } return originalSetHeader.call(this, name, value) } // 바로 캐시 제어 헤더 적용 event.node.res.setHeader('Cache-Control', cacheControl) } const cache = new LRUCache({ max: 100, // 캐시에 저장할 최대 항목 수 ttl: 1000 * 30, // 30초 동안 캐시 유지 }) /** * 최종 언어 쿠키 세팅 * * @param event - 이벤트 객체 * @param finalLocale - 최종 언어 * @param baseDomain - 기본 도메인 */ function setFinalLocaleCookie( event: H3Event, finalLocale: string, baseDomain: string ) { setCookie(event, 'LOCALE', finalLocale.toLowerCase(), { domain: baseDomain, path: '/', maxAge: 60 * 60 * 24 * 365, // 1년 (초 단위) }) } /** * Locale Middleware 역할 함수 * * @param event - 이벤트 객체 * @param finalLocale - 최종 언어 */ function fnLocaleMiddleware(event: H3Event, finalLocale: string) { // 이미 응답이 종료되었는지 확인 if (event.node.res.headersSent || event.node.res.writableEnded) { return } const path = event?.node.req.url || '' let arrPath = [] let queryString = '' if (path.includes('?')) { // 쿼리스트링 포함 시 순수 경로만 추출 arrPath = path.split('?')[0].split('/') queryString = path.split('?')[1] } else { arrPath = path.split('/') queryString = '' } // 최종 언어 세팅된 경로 생성 const pathLocale = arrPath.length > 1 ? arrPath[1] : '' // URL에서 현재 언어와 최종 언어가 다르면 리다이렉트 if (pathLocale !== finalLocale) { let newLocalePath = '' if (pathLocale === '') { newLocalePath = `/${finalLocale}` } else { arrPath[1] = finalLocale newLocalePath = arrPath.join('/') } if (queryString !== '') { newLocalePath += `?${queryString}` } event.node.res.statusCode = 302 event.node.res.setHeader('Location', newLocalePath) event.node.res.end() } } export default defineEventHandler(async event => { // HMR 요청 필터링 (개발 모드에서 중복 실행 방지) if (event.path.startsWith('/_nuxt/') || event.path.startsWith('/__nuxt')) { return } if (event.path.includes('/error')) { return } // 이미 응답이 종료되었는지 확인 (리다이렉트 등으로 인한 중복 실행 방지) if (event.node.res.headersSent || event.node.res.writableEnded) { return } const runtimeConfig = useRuntimeConfig() const iBaseApiUrl = runtimeConfig.public.stoveApiUrlServer const baseDomain = runtimeConfig.public.baseDomain const apiUrl = `${iBaseApiUrl}/pub-comm/v1.0/template/game` let initLangCodes: string[] | null = null let finalLocale: string let cleanHost: string | undefined let initDefaultLocale: string | null = null const host = (getHeader(event, 'host') || getRequestHost(event)).toString() || '' const isGameDomainExtractable = host.includes(baseDomain) if (isGameDomainExtractable) { cleanHost = host.split(':')[0] event.context.gameDomain = cleanHost } try { const queryParams: Record = { game_domain: cleanHost || '', lang_code: '', } const initResponse = (await $fetch(apiUrl, { query: queryParams, })) as GameDataResponse | null initLangCodes = initResponse?.value?.lang_codes || null initDefaultLocale = initResponse?.value?.default_lang_code || null } catch (error) { console.error('init gameData load error:', error) return } const fullPath = event.path // 1-1. 정적 파일 패스 if (isStaticFile(event.path)) { return } // 1-2. /inspection 패스 if (fullPath.includes('/inspection')) { // 리턴 되기 전 언어 쿠키 세팅 finalLocale = ssrGetFinalLocale( event?.node.req.url, event.node.req.headers, initLangCodes, initDefaultLocale ) setFinalLocaleCookie(event, finalLocale, baseDomain) return } // 정적 자산, API, 파비콘 등은 제외하고 페이지 요청만 처리 if ( fullPath.startsWith('/api/') || fullPath.startsWith('/_nuxt/') || fullPath.startsWith('/favicon') || fullPath.includes('/assets/') || fullPath.includes('.') || fullPath.startsWith('/_') ) { return } // 캐시 키 생성 const cacheKey = 'inspection' try { // 이미 응답이 종료되었는지 확인 if (event.node.res.headersSent || event.node.res.writableEnded) { return } // 2. 언어 코드 추출 finalLocale = ssrGetFinalLocale( event?.node.req.url, event.node.req.headers, initLangCodes, initDefaultLocale ) // 쿼리스트링에서 f 파라미터 값 추출 const path = event?.node.req.url || '' let fValue = '' if (path.includes('?')) { try { const queryString = path.split('?')[1] const urlParams = new URLSearchParams(queryString) fValue = urlParams.get('f') || '' } catch (e) { console.error('쿼리스트링 파싱 에러:', e) } } // 미리보기 API 호출 처리 if (fValue === 'preview') { cleanHost = 'samplegame.onstove.com' } const queryParams: Record = { game_domain: cleanHost || '', lang_code: finalLocale, } const response = (await $fetch(apiUrl, { query: queryParams, })) as GameDataResponse | null // 언어패스 쿠키 굽기 - 장기방안에서는 굽지않음 if (initLangCodes?.includes(finalLocale)) { setFinalLocaleCookie(event, finalLocale, baseDomain) } if (response?.code === 91001) { // 91001 에러 발생 시 바로 /error 페이지로 리다이렉트 if (!event.node.res.headersSent && !event.node.res.writableEnded) { const errorPath = `/${finalLocale || 'ko'}/error` event.node.res.statusCode = 302 event.node.res.setHeader('Location', errorPath) event.node.res.end() return } } if (response?.code === 0 && 'value' in response) { event.context.gameData = response.value event.context.googleAnalyticsId = response.value?.ga_code // 점검 데이터 조회 let inspectionData: WebInspectionData | undefined if (cache.has(cacheKey)) { inspectionData = cache.get(cacheKey) as WebInspectionData } else { // 점검 데이터 조회 if (response?.value?.game_id) { const inspectionApiUrl = `${iBaseApiUrl}/pub-comm/v3.0/inspection/${response?.value?.game_id}` // 직접 $fetch 사용 (composable 사용하지 않음) const inspectionResponse = await $fetch( inspectionApiUrl, { method: 'GET', headers: { 'Content-Type': 'application/json', }, } ) inspectionData = inspectionResponse?.value?.inspection cache.set(cacheKey, inspectionData) // 캐시에 저장 } } // 4. 현재 시간과 점검 기간 비교 const currentTime = Date.now() const tsStartDate = inspectionData?.ts_start_date || 0 const tsEndDate = inspectionData?.ts_end_date || 0 const timeUntilInspectionSeconds = Math.floor( (tsStartDate - currentTime) / 1000 ) // 5. 점검 상태별 캐시 설정 if ( inspectionData?.inspection_status === 1 && currentTime >= tsStartDate && currentTime <= tsEndDate ) { /** * 점검 중인 경우 * - 점검 상태가 1이고 현재 시간이 점검 시작과 종료 사이에 있는지 확인 * - 점검 URL 경로가 아닐 경우 no-cache 설정 * - 화이트 리스트 체크 */ const inspectionPath = `/${finalLocale}/inspection` const isInspectionPath = fullPath === inspectionPath // 현재 경로가 점검 페이지가 아닐 경우 캐시 헤더 설정 if (!isInspectionPath) { setCacheHeaders(event, 'no-cache') } // 응답이 이미 종료되었는지 확인 if (event.node.res.headersSent || event.node.res.writableEnded) { return } // 점검 중일 때 IP 필터링 활성화 여부 확인 if (inspectionData?.ip_filter_use_yn === 'Y') { const clientIP = getTrueClientIp(event.node.req as any) // 허용된 IP 목록 확인 if (inspectionData?.ip_filter_list?.includes(clientIP)) { // 화이트 리스트인 경우 Locale Middleware 실행 fnLocaleMiddleware(event, finalLocale) } else { // 허용되지 않은 IP인 경우 점검 페이지로 이동 event.node.res.statusCode = 302 event.node.res.setHeader('Location', inspectionPath) event.node.res.end() } } else { // IP 필터링이 비활성화된 경우 모든 사용자를 점검 페이지로 이동 event.node.res.statusCode = 302 event.node.res.setHeader('Location', inspectionPath) event.node.res.end() } } else { /** * 점검이 아닌 경우 * - 홈 경로는 no-cache * - 점검 예정 시간에 따른 캐시 설정 * - 점검 5분 전: 짧은 캐시 (10초) * - 점검 30분 전: 중간 캐시 (15초) * - 점검 30분 이후: 기본 캐시 (60초) */ // 홈 경로: 캐시 없음 const isHomePath = [ '', '/', //, ...Object.values(DEFAULT_LOCALE_COVERAGES).flatMap((locale) => [`/${locale}`, `/${locale}/`]) ].includes(fullPath) if (isHomePath) { setCacheHeaders(event, 'no-cache') } else { // 점검 예정 시간에 따른 캐시 설정 if (tsStartDate > 0 && timeUntilInspectionSeconds > 0) { if (timeUntilInspectionSeconds < 300) { // 점검 5분 전: 짧은 캐시 (10초) setCacheHeaders(event, 'short', 10) } else if (timeUntilInspectionSeconds < 1800) { // 점검 30분 전: 중간 캐시 (15초) setCacheHeaders(event, 'medium', 15) } else { // 점검 30분 이후: 기본 캐시 (60초) setCacheHeaders(event, 'default') } } } // ------------------------------------------------------------------------------- // [Root Path Redirect to /home] // 언어 코드만 있는 경로(예: /ko, /ko/)를 /home 리다이렉트 // ------------------------------------------------------------------------------- const normalizedPath = fullPath.endsWith('/') ? fullPath.slice(0, -1) : fullPath const localePath = `/${finalLocale}` if (normalizedPath === localePath) { const defaultPath = `/${finalLocale}/home` const queryString = event?.node.req.url?.includes('?') ? '?' + event.node.req.url.split('?')[1] : '' if (!event.node.res.headersSent && !event.node.res.writableEnded) { event.node.res.statusCode = 302 event.node.res.setHeader('Location', defaultPath + queryString) event.node.res.end() return } } // ------------------------------------------------------------------------------- // [Locale Middleware] // ------------------------------------------------------------------------------- fnLocaleMiddleware(event, finalLocale) } } } catch (error: any) { console.error('gameData load error:', error) // 응답이 이미 종료되었는지 확인 if (event.node.res.headersSent || event.node.res.writableEnded) { return } // 언어 코드 추출 시도 let errorLocale = 'ko' // 기본값 try { errorLocale = ssrGetFinalLocale( event?.node.req.url, event.node.req.headers, initLangCodes, initDefaultLocale ) } catch (e) { console.error('Locale extraction error:', e) } // 91001 에러인 경우 바로 리다이렉트 if (error?.statusCode === 91001 || error?.cause?.statusCode === 91001) { const errorPath = `/${errorLocale}/error` event.node.res.statusCode = 302 event.node.res.setHeader('Location', errorPath) event.node.res.end() return } // 다른 에러는 기존대로 throw throw createError({ statusCode: error?.statusCode || 500, statusMessage: error?.statusMessage, }) } })