import fastify, { type FastifyRequest } from 'fastify'; import cors from '@fastify/cors'; import fastifyStatic from '@fastify/static'; import path from 'path'; import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; import { buildGameEventChannel } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import { createGamePostgresConnector, createRedisConnector, resolvePostgresConfigFromEnv, resolveRedisConfigFromEnv, } from '@sammo-ts/infra'; import { resolveGameApiConfigFromEnv } from './config.js'; import { createGameApiContext, type DatabaseClient as _DatabaseClient } from './context.js'; import { DatabaseTurnDaemonTransport } from './daemon/databaseTransport.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) { return null; } const header = Array.isArray(value) ? value[0] : value; if (!header) { return null; } const prefix = 'Bearer '; if (header.startsWith(prefix)) { return header.slice(prefix.length).trim(); } return header.trim(); }; const resolveAuthFromToken = async ( token: string | null, accessTokenStore: RedisAccessTokenStore, flushStore: FlushStore ): Promise => { 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 })); const redis = createRedisConnector(resolveRedisConfigFromEnv()); await postgres.connect(); await redis.connect(); const turnDaemon = new DatabaseTurnDaemonTransport(postgres.prisma, config.daemonRequestTimeoutMs); const battleSim = new RedisBattleSimTransport(redis.client, { keys: buildBattleSimQueueKeys(config.profileName), requestTimeoutMs: config.battleSimRequestTimeoutMs, resultTtlSeconds: config.battleSimResultTtlSeconds, }); const flushStore = new InMemoryFlushStore(); const flushSubscriberClient = redis.client.duplicate(); await flushSubscriberClient.connect(); 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, }); await app.register(cors, { origin: true, credentials: true, }); await app.register(fastifyStatic, { root: path.resolve(process.cwd(), config.uploadDir), prefix: config.uploadPath.endsWith('/') ? config.uploadPath : `${config.uploadPath}/`, }); await app.register(fastifyTRPCPlugin, { prefix: config.trpcPath, trpcOptions: { router: appRouter, createContext: async ({ req }: { req: FastifyRequest }) => { const token = extractBearerToken(req.headers.authorization); const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore); return createGameApiContext({ requestId: (Array.isArray(req.headers['idempotency-key']) ? req.headers['idempotency-key'][0] : req.headers['idempotency-key']) || undefined, db: postgres.prisma, redis: redis.client, turnDaemon, battleSim, profile: { id: config.profile, scenario: config.scenario, name: config.profileName, }, uploadDir: path.resolve(process.cwd(), config.uploadDir), uploadPath: config.uploadPath, uploadPublicUrl: config.uploadPublicUrl, auth, accessTokenStore, flushStore, gameTokenSecret: config.gameTokenSecret, }); }, }, }); 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, })); app.addHook('onClose', async () => { await flushSubscriber.stop(); await flushSubscriberClient.quit(); await realtimeHub.stop(); await redis.disconnect(); await postgres.disconnect(); }); return { app, config, }; }; export const runGameApiServer = async (): Promise => { const { app, config } = await createGameApiServer(); await app.listen({ host: config.host, port: config.port, }); };