import { LRUCache } from 'lru-cache' import { defineEventHandler, createError, type H3Event } from 'h3' import { getGameDomain, getPathLocale, getIntroRedirectPath, isExternalUrl, getPathAfterLanguage, } from '#layers/utils/urlUtil' import { ssrGetFinalLocale } from '#layers/utils/localeUtil' import { isStaticFile } from '#layers/utils/commonUtil' import { getTrueClientIp } from '#layers/utils/apiUtil' import type { GameDataResponse } from '#layers/types/api/gameData' import type { ResGetInspectionData, WebInspectionData, } from '#layers/types/InspectionType' /** * 캐시 제어 헤더를 설정하는 공통 함수 * * @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초 동안 캐시 유지 }) /** * 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 || '' const [pathPart, queryString = ''] = path.split('?') const arrPath = pathPart.split('/') // 최종 언어 세팅된 경로 생성 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() } } /** * 특정 경로를 건너뛰어야 하는지 확인 */ function shouldSkipPath(path: string): boolean { return ( path.startsWith('/_nuxt/') || path.startsWith('/__nuxt') || path.startsWith('/api/') || path.startsWith('/_i18n/') || path.includes('/assets/') || path.includes('favicon') || path === '/robots.txt' || path === '/sitemap.xml' ) } export default defineEventHandler(async event => { const runtimeConfig = useRuntimeConfig() // HMR 요청 필터링 (개발 모드에서 중복 실행 방지) if (shouldSkipPath(event.path)) { return } // 이미 응답이 종료되었는지 확인 (리다이렉트 등으로 인한 중복 실행 방지) if (event.node.res.headersSent || event.node.res.writableEnded) { return } const stoveApiServerBaseUrl = runtimeConfig.public.stoveApiUrlServer let gameDataResponse: GameDataResponse | null = null let gameDataLangCodes: string[] | null = null let gameDataDefaultLocale: string | null = null let finalLocale: string try { const gameApiUrl = `${stoveApiServerBaseUrl}/pub-comm/v1.0/template/game` const gameDomain = getGameDomain(event) const queryParams: Record = { game_domain: gameDomain || '', lang_code: getPathLocale(event?.node.req.url), } const response = await $fetch(gameApiUrl, { query: queryParams, }) gameDataResponse = response gameDataLangCodes = response?.value?.lang_codes || null gameDataDefaultLocale = response?.value?.default_lang_code || null event.context.gameDomain = gameDomain } catch (error) { // eslint-disable-next-line no-console console.error('gameData load error:', error) } if (gameDataResponse?.code === 0 && 'value' in gameDataResponse) { // ### 정상 응답 처리 ------------------------------------------------------------- const gameDataValue = gameDataResponse.value event.context.gameData = gameDataValue event.context.googleAnalyticsId = gameDataValue?.ga_code console.log('🚀 ~ gameData response:', event.context.gameData) // ------------------------------------------------------------------------------- // [Inspection Middleware] // ------------------------------------------------------------------------------- const fullPath = event.path // 1-1. 정적 파일 패스 if (isStaticFile(event.path)) return // 1-2. /inspection 패스 if (fullPath.includes('/inspection')) return // 1-3. 특정 경로 패스 (API, 리소스) if (shouldSkipPath(fullPath)) return // 캐시 키 생성 (게임 ID 포함하여 충돌 방지) const gameId = gameDataValue?.game_id || 'default' const cacheKey = `inspection:${gameId}` try { // 2. 언어 코드 추출 finalLocale = ssrGetFinalLocale( event?.node.req.url, event.node.req.headers, gameDataLangCodes, gameDataDefaultLocale ) // 초기화 let inspectionData // 3. 캐시된 데이터가 없거나 만료되었을 때만 API 호출 const cachedData = cache.get(cacheKey) if (cachedData) { inspectionData = cachedData } else { const inspectionApiUrl = `${stoveApiServerBaseUrl}/pub-comm/v3.0/inspection/${gameDataValue?.game_id}` const response = await $fetch(inspectionApiUrl, { method: 'GET', headers: { 'Content-Type': 'application/json', }, }) inspectionData = response?.value?.inspection as WebInspectionData if (inspectionData) { 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 설정 * - 화이트 리스트 체크 */ // 점검 url path 가 아닐 경우, no-cache 설정 const inspectionPath = `/${finalLocale}/inspection` if (fullPath !== inspectionPath) { setCacheHeaders(event, 'no-cache') } // 점검 중일 때 IP 필터링 활성화 여부 확인 if (inspectionData?.ip_filter_use_yn === 'Y') { const clientIP = getTrueClientIp(event.node.req as any) // 허용된 IP 목록 확인 const allowedIPs = inspectionData?.ip_filter_list || [] if (!clientIP || !allowedIPs.includes(clientIP)) { // 허용되지 않은 IP인 경우 점검 페이지로 이동 event.node.res.statusCode = 302 event.node.res.setHeader('Location', inspectionPath) event.node.res.end() } else { // 화이트 리스트인 경우 // ------------------------------------------------------------------------------- // [Locale Middleware] // ------------------------------------------------------------------------------- fnLocaleMiddleware(event, finalLocale) } } else { if (!event.node.res.headersSent && !event.node.res.writableEnded) { 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 = fullPath === '' || 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') } } // ------------------------------------------------------------------------------- // [Locale Middleware] // ------------------------------------------------------------------------------- fnLocaleMiddleware(event, finalLocale) // ------------------------------------------------------------------------------- // [Home Redirect] // ------------------------------------------------------------------------------- if (!event.node.res.headersSent && !event.node.res.writableEnded) { const gamePath = getPathAfterLanguage(fullPath) const isRootPath = gamePath === '' || gamePath === '/' if (isRootPath) { // intro.page_url이 있으면 해당 URL로 리다이렉트, 없으면 /home으로 const introPageUrl = gameDataValue?.intro?.page_url const currentFullUrl = event.node.req.url || fullPath const redirectPath = getIntroRedirectPath( introPageUrl, finalLocale, currentFullUrl ) // 무한 리다이렉트 방지: 현재 경로와 리다이렉트할 URL 비교 const currentPathOnly = fullPath.split('?')[0] // 현재 경로 (쿼리스트링 제외) const normalizedFinalUrl = redirectPath.split('?')[0] // 리다이렉트 경로 (쿼리스트링 제외) const isExternal = isExternalUrl(redirectPath) const isSamePath = !isExternal && currentPathOnly === normalizedFinalUrl if (!isSamePath) { // 다른 경로에서 접근한 경우에만 리다이렉트 event.node.res.statusCode = 302 event.node.res.setHeader('Location', redirectPath) event.node.res.end() } } } } // 정상 접속 허용 } catch (error) { console.error('gameData inspection error:', error) } } else { // ### 에러 응답 처리 ------------------------------------------------------------- // 언어 코드 추출 시도 let errorLocale = 'ko' // 기본값 try { errorLocale = ssrGetFinalLocale( event?.node.req.url, event.node.req.headers, gameDataLangCodes, gameDataDefaultLocale ) } catch (e) { // eslint-disable-next-line no-console console.error('Locale extraction error:', e) } // 91001 에러인 경우 바로 리다이렉트 if (gameDataResponse?.code === 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: gameDataResponse?.code || 500, statusMessage: gameDataResponse?.message, }) } })