업로드 파일 캐시 정책 개선

This commit is contained in:
2026-07-01 18:12:43 +09:00
parent 445eb39a83
commit b26ae271d2
9 changed files with 80 additions and 13 deletions

View File

@@ -1,7 +1,14 @@
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { extname, join, relative } from 'node:path'
import { createError, getRequestURL, sendStream, setResponseHeader } from 'h3'
import {
createError,
getRequestHeader,
getRequestURL,
sendStream,
setResponseHeader,
setResponseStatus
} from 'h3'
const uploadRoot = join(process.cwd(), 'public', 'uploads')
const contentTypes = new Map([
@@ -13,6 +20,47 @@ const contentTypes = new Map([
['.svg', 'image/svg+xml'],
['.ico', 'image/x-icon']
])
const regeneratableUploadPattern = /^\/uploads\/posts\/\d{4}\/\d{2}\/thumbs\/[^/?#]+-(?:card|body|body-720|lightbox)\.webp$/i
/**
* 업로드 파일 캐시 정책을 반환한다.
* @param {string} pathname - 요청 경로
* @returns {string} Cache-Control 값
*/
const getUploadCacheControl = (pathname) => {
if (regeneratableUploadPattern.test(pathname)) {
return 'public, max-age=3600, stale-while-revalidate=86400'
}
return 'public, max-age=31536000, immutable'
}
/**
* 파일 통계 기반 약한 ETag 값을 만든다.
* @param {import('node:fs').Stats} fileStat - 파일 통계
* @returns {string} ETag 값
*/
const createUploadEtag = (fileStat) => `W/"${fileStat.size}-${Math.trunc(fileStat.mtimeMs)}"`
/**
* 조건부 요청이 현재 파일과 일치하는지 확인한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @param {string} etag - 현재 ETag
* @param {Date} modifiedAt - 마지막 수정 시각
* @returns {boolean} 변경 없음 여부
*/
const isUploadNotModified = (event, etag, modifiedAt) => {
const ifNoneMatch = String(getRequestHeader(event, 'if-none-match') || '')
if (ifNoneMatch.split(',').map((value) => value.trim()).includes(etag)) {
return true
}
const ifModifiedSince = String(getRequestHeader(event, 'if-modified-since') || '')
const modifiedSinceTime = Date.parse(ifModifiedSince)
return Number.isFinite(modifiedSinceTime) && modifiedAt.getTime() <= modifiedSinceTime
}
/**
* 업로드 요청 URL을 디스크 파일 경로로 변환한다.
@@ -59,7 +107,8 @@ const resolveUploadFilePath = (pathname) => {
* @returns {Promise<void>} 업로드 파일 스트림
*/
export default defineEventHandler(async (event) => {
const filePath = resolveUploadFilePath(getRequestURL(event).pathname)
const pathname = getRequestURL(event).pathname
const filePath = resolveUploadFilePath(pathname)
const fileStat = await stat(filePath).catch(() => null)
if (!fileStat?.isFile()) {
@@ -76,9 +125,18 @@ export default defineEventHandler(async (event) => {
setResponseHeader(event, 'content-type', contentType)
}
setResponseHeader(event, 'content-length', String(fileStat.size))
setResponseHeader(event, 'cache-control', 'no-cache')
const etag = createUploadEtag(fileStat)
setResponseHeader(event, 'cache-control', getUploadCacheControl(pathname))
setResponseHeader(event, 'last-modified', fileStat.mtime.toUTCString())
setResponseHeader(event, 'etag', etag)
if (isUploadNotModified(event, etag, fileStat.mtime)) {
setResponseStatus(event, 304)
return null
}
setResponseHeader(event, 'content-length', String(fileStat.size))
return sendStream(event, createReadStream(filePath))
})