226 lines
5.5 KiB
TypeScript
226 lines
5.5 KiB
TypeScript
import http from 'http'
|
|
import crypto from 'crypto'
|
|
import { URL } from 'url'
|
|
import { WebSocketServer, type WebSocket } from 'ws'
|
|
|
|
type SessionState = Record<string, unknown>
|
|
|
|
type Session = {
|
|
id: string
|
|
createdAt: number
|
|
updatedAt: number
|
|
state: SessionState | null
|
|
}
|
|
|
|
type ClientInfo = {
|
|
gameId: string
|
|
socket: WebSocket
|
|
}
|
|
|
|
const port = Number(process.env.REALTIME_PORT || 8787)
|
|
const sessionTtlMs = 1000 * 60 * 60 * 6
|
|
|
|
const sessions = new Map<string, Session>()
|
|
const clients = new Map<string, ClientInfo>()
|
|
|
|
const json = (res: http.ServerResponse, statusCode: number, body: unknown) => {
|
|
res.writeHead(statusCode, {
|
|
'content-type': 'application/json',
|
|
'cache-control': 'no-store'
|
|
})
|
|
res.end(JSON.stringify(body))
|
|
}
|
|
|
|
const createGameId = () => crypto.randomBytes(4).toString('hex').toUpperCase()
|
|
|
|
const normalizeGameId = (value: string) => value.toUpperCase().replace(/[^A-Z0-9]/g, '')
|
|
|
|
const ensureSession = (gameId: string) => {
|
|
const now = Date.now()
|
|
const existing = sessions.get(gameId)
|
|
if (existing) return existing
|
|
const session: Session = {
|
|
id: gameId,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
state: null
|
|
}
|
|
sessions.set(gameId, session)
|
|
return session
|
|
}
|
|
|
|
const broadcast = (gameId: string, payload: unknown, exceptSocket?: WebSocket) => {
|
|
const message = JSON.stringify(payload)
|
|
clients.forEach((client) => {
|
|
if (client.gameId !== gameId) return
|
|
if (client.socket.readyState !== client.socket.OPEN) return
|
|
if (exceptSocket && client.socket === exceptSocket) return
|
|
client.socket.send(message)
|
|
})
|
|
}
|
|
|
|
const server = http.createServer((req, res) => {
|
|
if (!req.url) {
|
|
json(res, 400, { error: 'Missing URL' })
|
|
return
|
|
}
|
|
|
|
const requestUrl = new URL(req.url, `http://localhost:${port}`)
|
|
const pathname = requestUrl.pathname
|
|
|
|
if (req.method === 'GET' && pathname === '/health') {
|
|
json(res, 200, { ok: true })
|
|
return
|
|
}
|
|
|
|
if (req.method === 'POST' && pathname === '/sessions') {
|
|
let body = ''
|
|
req.on('data', (chunk) => {
|
|
body += chunk.toString()
|
|
})
|
|
req.on('end', () => {
|
|
let requestedId = ''
|
|
if (body.trim()) {
|
|
try {
|
|
const parsed = JSON.parse(body) as { gameId?: string }
|
|
if (typeof parsed.gameId === 'string') {
|
|
requestedId = normalizeGameId(parsed.gameId)
|
|
}
|
|
} catch {
|
|
json(res, 400, { error: 'Invalid JSON payload' })
|
|
return
|
|
}
|
|
}
|
|
|
|
let gameId = requestedId
|
|
if (!gameId) {
|
|
gameId = createGameId()
|
|
while (sessions.has(gameId)) gameId = createGameId()
|
|
} else if (sessions.has(gameId)) {
|
|
json(res, 409, { error: 'Game ID already exists' })
|
|
return
|
|
}
|
|
|
|
const session = ensureSession(gameId)
|
|
json(res, 201, { gameId: session.id })
|
|
})
|
|
return
|
|
}
|
|
|
|
if (req.method === 'GET' && pathname.startsWith('/sessions/')) {
|
|
const gameId = pathname.split('/').filter(Boolean)[1]
|
|
if (!gameId) {
|
|
json(res, 400, { error: 'Missing gameId' })
|
|
return
|
|
}
|
|
const session = sessions.get(gameId.toUpperCase())
|
|
if (!session) {
|
|
json(res, 404, { error: 'Session not found' })
|
|
return
|
|
}
|
|
json(res, 200, { gameId: session.id, hasState: !!session.state })
|
|
return
|
|
}
|
|
|
|
json(res, 404, { error: 'Not found' })
|
|
})
|
|
|
|
const wss = new WebSocketServer({ noServer: true })
|
|
|
|
server.on('upgrade', (req, socket, head) => {
|
|
if (!req.url) {
|
|
socket.destroy()
|
|
return
|
|
}
|
|
|
|
const requestUrl = new URL(req.url, `http://localhost:${port}`)
|
|
if (requestUrl.pathname !== '/ws') {
|
|
socket.destroy()
|
|
return
|
|
}
|
|
|
|
const gameId = requestUrl.searchParams.get('gameId')?.toUpperCase()
|
|
const clientId = requestUrl.searchParams.get('clientId') || crypto.randomUUID()
|
|
|
|
if (!gameId) {
|
|
socket.destroy()
|
|
return
|
|
}
|
|
|
|
const session = ensureSession(gameId)
|
|
session.updatedAt = Date.now()
|
|
|
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
clients.set(clientId, { gameId, socket: ws })
|
|
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: 'session:init',
|
|
gameId,
|
|
state: session.state
|
|
})
|
|
)
|
|
|
|
ws.on('message', (buffer) => {
|
|
let parsed: { type?: string; state?: SessionState; teamId?: string } | null = null
|
|
try {
|
|
parsed = JSON.parse(buffer.toString()) as {
|
|
type?: string
|
|
state?: SessionState
|
|
teamId?: string
|
|
}
|
|
} catch {
|
|
return
|
|
}
|
|
if (!parsed) return
|
|
|
|
if (parsed.type === 'state:update' && parsed.state) {
|
|
const targetSession = ensureSession(gameId)
|
|
targetSession.state = parsed.state
|
|
targetSession.updatedAt = Date.now()
|
|
|
|
broadcast(
|
|
gameId,
|
|
{
|
|
type: 'state:update',
|
|
gameId,
|
|
state: targetSession.state
|
|
},
|
|
ws
|
|
)
|
|
return
|
|
}
|
|
|
|
if (parsed.type === 'guess:request' && parsed.teamId) {
|
|
broadcast(
|
|
gameId,
|
|
{
|
|
type: 'guess:request',
|
|
gameId,
|
|
teamId: parsed.teamId
|
|
},
|
|
ws
|
|
)
|
|
}
|
|
})
|
|
|
|
ws.on('close', () => {
|
|
clients.delete(clientId)
|
|
})
|
|
})
|
|
})
|
|
|
|
setInterval(() => {
|
|
const threshold = Date.now() - sessionTtlMs
|
|
sessions.forEach((session, id) => {
|
|
if (session.updatedAt >= threshold) return
|
|
const hasClient = Array.from(clients.values()).some((client) => client.gameId === id)
|
|
if (!hasClient) sessions.delete(id)
|
|
})
|
|
}, 1000 * 60)
|
|
|
|
server.listen(port, () => {
|
|
// eslint-disable-next-line no-console
|
|
console.log(`Realtime server listening on :${port}`)
|
|
})
|