feat: 런타임 메뉴 응답을 한 시간 캐시

공개 navigation REST 응답에 content ETag와 1시간 fresh cache를 적용한다. 만료 뒤 조건부 요청은 내용이 같으면 304를 반환하고 운영 JSON이 바뀌면 새 본문과 ETag를 제공한다.
This commit is contained in:
2026-08-19 12:08:01 +00:00
parent a7c3da2fef
commit 17a3072ba7
5 changed files with 161 additions and 12 deletions
@@ -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;
});
};
+2 -4
View File
@@ -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);
});
});