From 17a3072ba7ac91558cd72ea0177e765a7c23959d Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 19 Aug 2026 12:08:01 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=9F=B0=ED=83=80=EC=9E=84=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=EC=9D=91=EB=8B=B5=EC=9D=84=20=ED=95=9C=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=BA=90=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공개 navigation REST 응답에 content ETag와 1시간 fresh cache를 적용한다. 만료 뒤 조건부 요청은 내용이 같으면 304를 반환하고 운영 JSON이 바뀌면 새 본문과 ETag를 제공한다. --- .../src/navigation/runtimeNavigationConfig.ts | 16 ++- .../src/navigation/runtimeNavigationRoute.ts | 28 +++++ app/gateway-api/src/server.ts | 6 +- .../test/runtimeNavigationRoute.test.ts | 102 ++++++++++++++++++ docs/runtime-navigation.md | 21 ++-- 5 files changed, 161 insertions(+), 12 deletions(-) create mode 100644 app/gateway-api/src/navigation/runtimeNavigationRoute.ts create mode 100644 app/gateway-api/test/runtimeNavigationRoute.test.ts diff --git a/app/gateway-api/src/navigation/runtimeNavigationConfig.ts b/app/gateway-api/src/navigation/runtimeNavigationConfig.ts index b32cb5e8..8e1ffe3e 100644 --- a/app/gateway-api/src/navigation/runtimeNavigationConfig.ts +++ b/app/gateway-api/src/navigation/runtimeNavigationConfig.ts @@ -1,9 +1,14 @@ import fs from 'node:fs/promises'; +import { createHash } from 'node:crypto'; import type { RuntimeNavigationConfig } from '@sammo-ts/common/navigation/menuConfig'; import { z } from 'zod'; -const zId = z.string().min(1).max(80).regex(/^[a-z0-9][a-z0-9-]*$/u); +const zId = z + .string() + .min(1) + .max(80) + .regex(/^[a-z0-9][a-z0-9-]*$/u); const zLabel = z.string().min(1).max(80); const zInternalPath = z .string() @@ -98,6 +103,10 @@ export class RuntimeNavigationConfigStore { ) {} async get(): Promise { + return (await this.getWithEtag()).config; + } + + async getWithEtag(): Promise<{ config: RuntimeNavigationConfig; etag: string }> { const configPath = await this.resolveConfigPath(); let raw: unknown; try { @@ -109,7 +118,10 @@ export class RuntimeNavigationConfigStore { if (!parsed.success) { throw new Error(`메뉴 설정 파일이 올바르지 않습니다: ${configPath}: ${parsed.error.message}`); } - return parsed.data; + return { + config: parsed.data, + etag: `"${createHash('sha256').update(JSON.stringify(parsed.data)).digest('hex')}"`, + }; } private async resolveConfigPath(): Promise { diff --git a/app/gateway-api/src/navigation/runtimeNavigationRoute.ts b/app/gateway-api/src/navigation/runtimeNavigationRoute.ts new file mode 100644 index 00000000..1f7248d8 --- /dev/null +++ b/app/gateway-api/src/navigation/runtimeNavigationRoute.ts @@ -0,0 +1,28 @@ +import type { FastifyInstance } from 'fastify'; + +import type { RuntimeNavigationConfigStore } from './runtimeNavigationConfig.js'; + +export const runtimeNavigationCacheControl = 'public, max-age=3600, must-revalidate'; + +export const matchesIfNoneMatch = (header: string | undefined, etag: string): boolean => { + if (!header) return false; + return header.split(',').some((candidate) => { + const value = candidate.trim(); + return value === '*' || value.replace(/^W\//u, '') === etag; + }); +}; + +export const registerRuntimeNavigationRoute = ( + app: FastifyInstance, + navigationConfig: RuntimeNavigationConfigStore +): void => { + app.get('/navigation', async (request, reply) => { + const current = await navigationConfig.getWithEtag(); + void reply.header('Cache-Control', runtimeNavigationCacheControl); + void reply.header('ETag', current.etag); + if (matchesIfNoneMatch(request.headers['if-none-match'], current.etag)) { + return reply.code(304).send(); + } + return current.config; + }); +}; diff --git a/app/gateway-api/src/server.ts b/app/gateway-api/src/server.ts index 056120af..e4319692 100644 --- a/app/gateway-api/src/server.ts +++ b/app/gateway-api/src/server.ts @@ -32,6 +32,7 @@ import { installGatewayShutdownController } from './lifecycle/shutdownController import { RemoteUserIconStore } from './account/remoteUserIconStore.js'; import { gatewayFastifyRouterOptions } from './fastifyOptions.js'; import { RuntimeNavigationConfigStore } from './navigation/runtimeNavigationConfig.js'; +import { registerRuntimeNavigationRoute } from './navigation/runtimeNavigationRoute.js'; export const createGatewayApiServer = async () => { const config = resolveGatewayApiConfigFromEnv(); @@ -109,10 +110,7 @@ export const createGatewayApiServer = async () => { profiles, secret: config.gameTokenSecret, }); - app.get('/navigation', async (_request, reply) => { - void reply.header('Cache-Control', 'no-store'); - return navigationConfig.get(); - }); + registerRuntimeNavigationRoute(app, navigationConfig); await app.register(fastifyTRPCPlugin, { prefix: config.trpcPath, diff --git a/app/gateway-api/test/runtimeNavigationRoute.test.ts b/app/gateway-api/test/runtimeNavigationRoute.test.ts new file mode 100644 index 00000000..af2378e7 --- /dev/null +++ b/app/gateway-api/test/runtimeNavigationRoute.test.ts @@ -0,0 +1,102 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import fastify from 'fastify'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { RuntimeNavigationConfigStore } from '../src/navigation/runtimeNavigationConfig.js'; +import { + matchesIfNoneMatch, + registerRuntimeNavigationRoute, + runtimeNavigationCacheControl, +} from '../src/navigation/runtimeNavigationRoute.js'; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true }))); +}); + +const createStore = async (): Promise<{ + store: RuntimeNavigationConfigStore; + overridePath: string; + raw: { gateway: { items: Array<{ label: string }> } }; +}> => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-navigation-route-')); + temporaryDirectories.push(directory); + const overridePath = path.join(directory, 'navigation.json'); + const defaultPath = path.resolve(import.meta.dirname, '../../../resources/navigation.json'); + const raw = JSON.parse(await fs.readFile(defaultPath, 'utf8')) as { + gateway: { items: Array<{ label: string }> }; + }; + await fs.writeFile(overridePath, JSON.stringify(raw)); + return { store: new RuntimeNavigationConfigStore(overridePath, defaultPath), overridePath, raw }; +}; + +describe('runtime navigation HTTP cache', () => { + it('한 시간 fresh cache와 content ETag를 제공한다', async () => { + const { store } = await createStore(); + const app = fastify(); + registerRuntimeNavigationRoute(app, store); + + const response = await app.inject({ method: 'GET', url: '/navigation' }); + + expect(response.statusCode).toBe(200); + expect(response.headers['cache-control']).toBe(runtimeNavigationCacheControl); + expect(response.headers.etag).toMatch(/^"[a-f0-9]{64}"$/u); + expect(response.json<{ gateway: { items: Array<{ label: string }> } }>().gateway.items[0]?.label).toBe( + '공지사항' + ); + await app.close(); + }); + + it('freshness 만료 후 같은 ETag이면 본문 없이 304를 반환한다', async () => { + const { store } = await createStore(); + const app = fastify(); + registerRuntimeNavigationRoute(app, store); + const first = await app.inject({ method: 'GET', url: '/navigation' }); + + const response = await app.inject({ + method: 'GET', + url: '/navigation', + headers: { 'if-none-match': `"other", W/${first.headers.etag}` }, + }); + + expect(response.statusCode).toBe(304); + expect(response.body).toBe(''); + expect(response.headers['cache-control']).toBe(runtimeNavigationCacheControl); + expect(response.headers.etag).toBe(first.headers.etag); + await app.close(); + }); + + it('운영 JSON 내용이 바뀌면 새 ETag와 본문을 반환한다', async () => { + const { store, overridePath, raw } = await createStore(); + const app = fastify(); + registerRuntimeNavigationRoute(app, store); + const first = await app.inject({ method: 'GET', url: '/navigation' }); + raw.gateway.items[0]!.label = '운영 공지'; + await fs.writeFile(overridePath, JSON.stringify(raw)); + + const response = await app.inject({ + method: 'GET', + url: '/navigation', + headers: { 'if-none-match': first.headers.etag }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers.etag).not.toBe(first.headers.etag); + expect(response.json<{ gateway: { items: Array<{ label: string }> } }>().gateway.items[0]?.label).toBe( + '운영 공지' + ); + await app.close(); + }); +}); + +describe('matchesIfNoneMatch', () => { + it('wildcard와 weak validator를 GET 비교에서 허용한다', () => { + expect(matchesIfNoneMatch('*', '"etag"')).toBe(true); + expect(matchesIfNoneMatch('W/"etag"', '"etag"')).toBe(true); + expect(matchesIfNoneMatch('"other"', '"etag"')).toBe(false); + }); +}); diff --git a/docs/runtime-navigation.md b/docs/runtime-navigation.md index 72ab0e4e..69da6b97 100644 --- a/docs/runtime-navigation.md +++ b/docs/runtime-navigation.md @@ -8,10 +8,18 @@ Gateway 상단 메뉴와 profile 게임 화면의 공통 메뉴는 하나의 JSO ## 반영 경계 `GET /gateway/api/navigation`과 `navigation.get`은 인증 없이 현재 파일을 요청마다 -읽고 schema를 검증합니다. -Gateway와 게임 frontend는 화면을 처음 열 때 이를 조회하므로 JSON을 저장한 뒤 -브라우저를 새로고침하면 frontend 재빌드나 profile DB 초기화 없이 반영됩니다. -이미 열린 화면을 서버가 강제로 바꾸지는 않습니다. +읽고 schema를 검증합니다. REST 응답은 `Cache-Control: public, max-age=3600, +must-revalidate`와 JSON 내용 기반 `ETag`를 제공합니다. 브라우저와 공유 캐시는 한 +시간 동안 저장된 응답을 재사용하고, 만료 뒤에는 조건부 요청으로 변경 여부를 +확인하여 같으면 `304 Not Modified`를 받습니다. `immutable`이나 +`stale-while-revalidate`는 사용하지 않아 stale 응답의 허용 범위를 한 시간보다 +늘리지 않습니다. tRPC `navigation.get`에는 이 HTTP cache 계약을 적용하지 않습니다. + +Gateway와 게임 frontend는 화면을 처음 열 때 REST API를 조회합니다. 운영 JSON을 +저장한 뒤 일반 새로고침에서 보이는 메뉴는 캐시 때문에 최대 한 시간 이전 값일 수 +있으며, 캐시를 우회하는 강력 새로고침은 즉시 재검증할 수 있습니다. frontend +재빌드나 profile DB 초기화는 필요하지 않고, 이미 열린 화면을 서버가 강제로 +바꾸지는 않습니다. 운영 파일이 아직 없으면 저장소 기본값을 사용합니다. Docker entrypoint는 최초 기동 때만 저장소 기본값을 영속 경로로 복사하고, 이미 존재하는 운영 파일은 배포나 @@ -42,8 +50,9 @@ API는 오류를 반환하고 frontend는 빌드에 포함된 안전한 기본 1. `/srv/data/navigation.json`을 별도 위치에 복사해 되돌릴 파일을 확보합니다. 2. 임시 파일에서 편집하고 `jq empty`로 JSON 문법을 확인합니다. 3. 임시 파일을 운영 경로로 같은 filesystem 안에서 교체합니다. -4. `GET /gateway/api/navigation`이 성공하는지 확인합니다. -5. Gateway desktop/mobile과 실제 profile 화면을 새로고침해 순서, 링크, +4. `GET /gateway/api/navigation`이 성공하고 `Cache-Control`, `ETag`가 있는지 + 확인합니다. 같은 `ETag`를 `If-None-Match`로 보내 `304`도 확인합니다. +5. Gateway desktop/mobile과 실제 profile 화면을 강력 새로고침해 순서, 링크, dropdown과 hover/focus를 확인합니다. API 검증이 실패하면 직전 복사본을 원래 경로로 되돌립니다. 저장소 기본값으로