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,
|
||||
|
||||
Reference in New Issue
Block a user