feat: implement real-time event handling and SSE support; add event publishing and subscription mechanisms
This commit is contained in:
@@ -2,6 +2,7 @@ export interface GameApiConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
trpcPath: string;
|
||||
eventsPath: string;
|
||||
profile: string;
|
||||
scenario: string;
|
||||
profileName: string;
|
||||
@@ -37,6 +38,7 @@ export const resolveGameApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.env
|
||||
host: env.GAME_API_HOST ?? '0.0.0.0',
|
||||
port: parseNumber(env.GAME_API_PORT, 14000, 'GAME_API_PORT'),
|
||||
trpcPath: env.TRPC_PATH ?? '/trpc',
|
||||
eventsPath: env.GAME_API_EVENTS_PATH ?? '/events',
|
||||
profile,
|
||||
scenario,
|
||||
profileName,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
import type { RealtimeEvent } from '@sammo-ts/common';
|
||||
|
||||
export type RealtimeListener = (event: RealtimeEvent) => void;
|
||||
|
||||
export const parseRealtimeEvent = (message: string): RealtimeEvent | null => {
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(message) as RealtimeEvent;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed.type !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Redis pub/sub 이벤트를 SSE 구독자에게 전달하는 중계 허브.
|
||||
export class RedisRealtimeEventHub {
|
||||
private readonly listeners = new Set<RealtimeListener>();
|
||||
private subscribed = false;
|
||||
|
||||
constructor(
|
||||
private readonly redis: RedisConnector['client'],
|
||||
private readonly channel: string
|
||||
) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.subscribed) {
|
||||
return;
|
||||
}
|
||||
await this.redis.subscribe(this.channel, (message) => {
|
||||
const event = parseRealtimeEvent(message);
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
for (const listener of this.listeners) {
|
||||
listener(event);
|
||||
}
|
||||
});
|
||||
this.subscribed = true;
|
||||
}
|
||||
|
||||
subscribe(listener: RealtimeListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.subscribed) {
|
||||
await this.redis.unsubscribe(this.channel);
|
||||
this.subscribed = false;
|
||||
}
|
||||
await this.redis.quit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
import { buildGameEventChannel, type RealtimeEvent } from '@sammo-ts/common';
|
||||
|
||||
// 게임 서버의 실시간 이벤트를 Redis pub/sub 채널로 송신한다.
|
||||
export const publishRealtimeEvent = async (
|
||||
redis: RedisConnector['client'],
|
||||
profileName: string,
|
||||
event: RealtimeEvent
|
||||
): Promise<void> => {
|
||||
const channel = buildGameEventChannel(profileName);
|
||||
await redis.publish(channel, JSON.stringify(event));
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface SseFrame {
|
||||
event?: string;
|
||||
data?: string;
|
||||
id?: string;
|
||||
retry?: number;
|
||||
}
|
||||
|
||||
const splitLines = (value: string): string[] => value.split(/\r?\n/);
|
||||
|
||||
export const formatSseFrame = (frame: SseFrame): string => {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (frame.event) {
|
||||
lines.push(`event: ${frame.event}`);
|
||||
}
|
||||
if (frame.id) {
|
||||
lines.push(`id: ${frame.id}`);
|
||||
}
|
||||
if (frame.retry !== undefined) {
|
||||
lines.push(`retry: ${frame.retry}`);
|
||||
}
|
||||
|
||||
const dataLines = splitLines(frame.data ?? '');
|
||||
for (const line of dataLines) {
|
||||
lines.push(`data: ${line}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
insertMessage,
|
||||
type MessageView,
|
||||
} from '../../messages/store.js';
|
||||
import { publishRealtimeEvent } from '../../realtime/publisher.js';
|
||||
|
||||
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
||||
|
||||
@@ -255,6 +256,19 @@ export const messagesRouter = router({
|
||||
draft
|
||||
);
|
||||
|
||||
try {
|
||||
await publishRealtimeEvent(ctx.redis, ctx.profile.name, {
|
||||
type: 'messageCreated',
|
||||
at: now.toISOString(),
|
||||
mailbox: input.mailbox,
|
||||
msgType,
|
||||
messageId: result.receiverId,
|
||||
senderId: general.id,
|
||||
});
|
||||
} catch {
|
||||
// 실시간 알림 실패는 메시지 전송 실패로 취급하지 않는다.
|
||||
}
|
||||
|
||||
return { msgType, msgId: result.receiverId };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fastify, { type FastifyRequest } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import { buildGameEventChannel, type GameSessionTokenPayload } from '@sammo-ts/common';
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
createRedisConnector,
|
||||
@@ -12,11 +13,13 @@ import { resolveGameApiConfigFromEnv } from './config.js';
|
||||
import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './context.js';
|
||||
import { buildTurnDaemonStreamKeys } from './daemon/streamKeys.js';
|
||||
import { RedisTurnDaemonTransport } from './daemon/redisTransport.js';
|
||||
import { InMemoryFlushStore, RedisGatewayFlushSubscriber } from './auth/flushStore.js';
|
||||
import { InMemoryFlushStore, RedisGatewayFlushSubscriber, type FlushStore } from './auth/flushStore.js';
|
||||
import { RedisAccessTokenStore } from './auth/accessTokenStore.js';
|
||||
import { appRouter } from './router.js';
|
||||
import { buildBattleSimQueueKeys } from './battleSim/keys.js';
|
||||
import { RedisBattleSimTransport } from './battleSim/redisTransport.js';
|
||||
import { RedisRealtimeEventHub } from './realtime/eventHub.js';
|
||||
import { formatSseFrame } from './realtime/sse.js';
|
||||
|
||||
const extractBearerToken = (value: string | string[] | undefined): string | null => {
|
||||
if (!value) {
|
||||
@@ -33,6 +36,25 @@ const extractBearerToken = (value: string | string[] | undefined): string | null
|
||||
return header.trim();
|
||||
};
|
||||
|
||||
const resolveAuthFromToken = async (
|
||||
token: string | null,
|
||||
accessTokenStore: RedisAccessTokenStore,
|
||||
flushStore: FlushStore
|
||||
): Promise<GameSessionTokenPayload | null> => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const stored = await accessTokenStore.get(token);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
const flushedAt = flushStore.getFlushedAt(stored.user.id);
|
||||
if (flushedAt && new Date(stored.issuedAt) <= flushedAt) {
|
||||
return null;
|
||||
}
|
||||
return stored;
|
||||
};
|
||||
|
||||
export const createGameApiServer = async () => {
|
||||
const config = resolveGameApiConfigFromEnv();
|
||||
const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile }));
|
||||
@@ -56,6 +78,13 @@ export const createGameApiServer = async () => {
|
||||
const flushSubscriber = new RedisGatewayFlushSubscriber(flushSubscriberClient, config.flushChannel, flushStore);
|
||||
await flushSubscriber.start();
|
||||
const accessTokenStore = new RedisAccessTokenStore(redis.client, config.profileName);
|
||||
const realtimeSubscriberClient = redis.client.duplicate();
|
||||
await realtimeSubscriberClient.connect();
|
||||
const realtimeHub = new RedisRealtimeEventHub(
|
||||
realtimeSubscriberClient,
|
||||
buildGameEventChannel(config.profileName)
|
||||
);
|
||||
await realtimeHub.start();
|
||||
|
||||
const app = fastify({
|
||||
logger: true,
|
||||
@@ -72,14 +101,7 @@ export const createGameApiServer = async () => {
|
||||
router: appRouter,
|
||||
createContext: async ({ req }: { req: FastifyRequest }) => {
|
||||
const token = extractBearerToken(req.headers.authorization);
|
||||
let auth = null;
|
||||
if (token) {
|
||||
const stored = await accessTokenStore.get(token);
|
||||
if (stored) {
|
||||
const flushedAt = flushStore.getFlushedAt(stored.user.id);
|
||||
auth = flushedAt && new Date(stored.issuedAt) <= flushedAt ? null : stored;
|
||||
}
|
||||
}
|
||||
const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore);
|
||||
return createGameApiContext({
|
||||
db: postgres.prisma,
|
||||
redis: redis.client,
|
||||
@@ -99,6 +121,69 @@ export const createGameApiServer = async () => {
|
||||
},
|
||||
});
|
||||
|
||||
app.get(config.eventsPath, async (request, reply) => {
|
||||
const query = request.query as { token?: string };
|
||||
const tokenFromHeader = extractBearerToken(request.headers.authorization);
|
||||
const tokenFromQuery = typeof query.token === 'string' ? query.token : null;
|
||||
const auth = await resolveAuthFromToken(tokenFromHeader ?? tokenFromQuery, accessTokenStore, flushStore);
|
||||
|
||||
if (!auth) {
|
||||
await reply.status(401).send({ ok: false, error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
reply.hijack();
|
||||
reply.raw.setHeader('Content-Type', 'text/event-stream');
|
||||
reply.raw.setHeader('Cache-Control', 'no-cache');
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||
request.raw.setTimeout(0);
|
||||
reply.raw.setTimeout?.(0);
|
||||
reply.raw.flushHeaders?.();
|
||||
|
||||
const sendFrame = (payload: string) => {
|
||||
try {
|
||||
reply.raw.write(payload);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
sendFrame(
|
||||
formatSseFrame({
|
||||
event: 'ready',
|
||||
data: JSON.stringify({ at: new Date().toISOString() }),
|
||||
})
|
||||
);
|
||||
|
||||
const unsubscribe = realtimeHub.subscribe((event) => {
|
||||
sendFrame(
|
||||
formatSseFrame({
|
||||
event: event.type,
|
||||
data: JSON.stringify(event),
|
||||
id: event.at,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
sendFrame(
|
||||
formatSseFrame({
|
||||
event: 'ping',
|
||||
data: JSON.stringify({ at: new Date().toISOString() }),
|
||||
})
|
||||
);
|
||||
}, 15000);
|
||||
|
||||
const close = () => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
};
|
||||
|
||||
request.raw.on('close', close);
|
||||
request.raw.on('aborted', close);
|
||||
});
|
||||
|
||||
app.get('/healthz', async () => ({
|
||||
ok: true,
|
||||
profile: config.profileName,
|
||||
@@ -107,6 +192,7 @@ export const createGameApiServer = async () => {
|
||||
app.addHook('onClose', async () => {
|
||||
await flushSubscriber.stop();
|
||||
await flushSubscriberClient.quit();
|
||||
await realtimeHub.stop();
|
||||
await redis.disconnect();
|
||||
await postgres.disconnect();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user