feat: 런타임 메뉴 응답을 한 시간 캐시
공개 navigation REST 응답에 content ETag와 1시간 fresh cache를 적용한다. 만료 뒤 조건부 요청은 내용이 같으면 304를 반환하고 운영 JSON이 바뀌면 새 본문과 ETag를 제공한다.
This commit is contained in:
@@ -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<RuntimeNavigationConfig> {
|
||||
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<string> {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 검증이 실패하면 직전 복사본을 원래 경로로 되돌립니다. 저장소 기본값으로
|
||||
|
||||
Reference in New Issue
Block a user