Init multiplayer
All checks were successful
Deploy Feature / deploy-feature (push) Successful in 28s
All checks were successful
Deploy Feature / deploy-feature (push) Successful in 28s
This commit is contained in:
205
server/realtime-server.ts
Normal file
205
server/realtime-server.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
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 } | null = null
|
||||
try {
|
||||
parsed = JSON.parse(buffer.toString()) as { type?: string; state?: SessionState }
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!parsed || parsed.type !== 'state:update' || !parsed.state) return
|
||||
const targetSession = ensureSession(gameId)
|
||||
targetSession.state = parsed.state
|
||||
targetSession.updatedAt = Date.now()
|
||||
|
||||
broadcast(
|
||||
gameId,
|
||||
{
|
||||
type: 'state:update',
|
||||
gameId,
|
||||
state: targetSession.state
|
||||
},
|
||||
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}`)
|
||||
})
|
||||
Reference in New Issue
Block a user