Add private admin console and disconnect handling

This commit is contained in:
2026-08-17 14:49:22 -07:00
parent 65e9917fd3
commit 8b1d14dcea
10 changed files with 201 additions and 9 deletions
+19 -3
View File
@@ -1,4 +1,5 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import { randomBytes } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -9,6 +10,7 @@ import { generateFloor } from '../../../packages/dungeon-generator/src/index.js'
import { LiveTwitchAdapter, SyntheticTwitchAdapter } from '../../../packages/twitch-adapter/src/index.js'
import { loadConfig } from './config.js'
import { TwitchOAuthService } from './twitchOAuth.js'
import { createAdminServer } from './admin.js'
const config=loadConfig(), startedAt=Date.now();let id=0
const game=new Game({clock:{now:()=>Date.now()},random:{next:()=>Math.random()},ids:{next:p=>`${p}-${++id}`},generateFloor,resurrectionRewardId:config.resurrectionRewardId})
@@ -23,7 +25,18 @@ function html(res:ServerResponse,status:number,title:string,message:string){cons
async function body(req:IncomingMessage):Promise<unknown>{const chunks:Buffer[]=[];for await(const chunk of req)chunks.push(Buffer.from(chunk));if(chunks.reduce((n,b)=>n+b.length,0)>64_000)throw new Error('Request too large');return JSON.parse(Buffer.concat(chunks).toString('utf8')||'{}')}
function bearer(req:IncomingMessage):string|null{const h=req.headers.authorization;return h?.startsWith('Bearer ')?h.slice(7):null}
function sessionUser(token:string|null):string|null{if(!token)return null;const session=sessions.get(token);if(!session||session.expiresAt<=Date.now()){if(session)sessions.delete(token);return null}return session.userId}
function connectedClientCount(userId:string):number{let count=0;for(const clientUserId of clients.values())if(clientUserId===userId)count++;return count}
function authenticateClient(ws:WebSocket,userId:string|null):boolean{
const previousUserId=clients.get(ws)??null
if(previousUserId===userId)return false
clients.set(ws,userId)
let changed=false
if(previousUserId && connectedClientCount(previousUserId)===0)changed=game.disconnect(previousUserId)||changed
if(userId)changed=game.bindExtension(userId,true)||changed
return changed
}
function broadcast(){const sequence=game.snapshot().nextEventSequence-1;for(const [ws,userId] of clients)if(ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({type:'snapshot',sequence,state:userId?game.personalizedSnapshot(userId):game.snapshot()}))}
export const adminServer=config.adminEnabled?createAdminServer({game,password:config.adminPassword,csrfToken:randomBytes(32).toString('base64url'),onStateChange:broadcast,status:async()=>{const oauthStatus=oauth?await oauth.status():{configured:false,authorized:false};return{uptimeSeconds:Math.floor((Date.now()-startedAt)/1000),twitchReady:twitch.ready,twitchMode:config.twitchEnabled?'configured':'synthetic',oauthConfigured:oauthStatus.configured,oauthAuthorized:oauthStatus.authorized}}}):null
export const server=createServer(async(req,res)=>{
try{
@@ -53,7 +66,7 @@ export const server=createServer(async(req,res)=>{
if(req.method==='POST'&&url.pathname==='/api/extension/session'){
const data=await body(req) as any,identity=await twitch.verifyExtensionToken(String(data.token??''))
if(!identity)return json(res,401,{error:'UNAUTHENTICATED'})
const token=`session-${crypto.randomUUID()}`;sessions.set(token,{userId:identity.twitchUserId,expiresAt:Date.now()+15*60_000});game.bindExtension(identity.twitchUserId)
const token=`session-${crypto.randomUUID()}`;sessions.set(token,{userId:identity.twitchUserId,expiresAt:Date.now()+15*60_000});game.bindExtension(identity.twitchUserId,connectedClientCount(identity.twitchUserId)>0)
return json(res,200,{token,twitchUserId:identity.twitchUserId,state:game.personalizedSnapshot(identity.twitchUserId)})
}
if(req.method==='POST'&&url.pathname==='/api/extension/spawn'){
@@ -78,7 +91,10 @@ export const server=createServer(async(req,res)=>{
})
const wss=new WebSocketServer({noServer:true})
server.on('upgrade',(req,socket,head)=>{const url=new URL(req.url??'/',config.publicBaseUrl);if(url.pathname!=='/ws'){socket.destroy();return}wss.handleUpgrade(req,socket,head,ws=>wss.emit('connection',ws,req))})
wss.on('connection',ws=>{clients.set(ws,null);ws.send(JSON.stringify({type:'snapshot',sequence:game.snapshot().nextEventSequence-1,state:game.snapshot()}));ws.on('message',raw=>{try{const message=JSON.parse(raw.toString());if(message.type==='authenticate'){const userId=sessionUser(typeof message.token==='string'?message.token:null);clients.set(ws,userId);if(userId)ws.send(JSON.stringify({type:'snapshot',sequence:game.snapshot().nextEventSequence-1,state:game.personalizedSnapshot(userId)}))}}catch{/* ignore malformed client messages */}});ws.on('close',()=>clients.delete(ws))})
wss.on('connection',ws=>{clients.set(ws,null);ws.send(JSON.stringify({type:'snapshot',sequence:game.snapshot().nextEventSequence-1,state:game.snapshot()}));ws.on('message',raw=>{try{const message=JSON.parse(raw.toString());if(message.type==='authenticate'){const userId=sessionUser(typeof message.token==='string'?message.token:null),changed=authenticateClient(ws,userId);if(changed)broadcast();else if(userId)ws.send(JSON.stringify({type:'snapshot',sequence:game.snapshot().nextEventSequence-1,state:game.personalizedSnapshot(userId)}))}}catch{/* ignore malformed client messages */}});ws.on('close',()=>{const userId=clients.get(ws)??null;clients.delete(ws);if(userId && connectedClientCount(userId)===0 && game.disconnect(userId))broadcast()})})
setInterval(()=>{const before=game.snapshot().nextEventSequence;game.tick();if(game.snapshot().nextEventSequence!==before)broadcast()},250).unref()
if(twitch instanceof LiveTwitchAdapter)void twitch.start({onSpawn:message=>{game.spawn(message);broadcast()},onRedemption:message=>{game.resurrect(message);broadcast()},onConnection:ready=>console.log(JSON.stringify({level:'info',component:'twitch-adapter',ready}))}).catch(error=>console.error(JSON.stringify({level:'error',component:'twitch-adapter',message:error instanceof Error?error.message:'Connection failed'})))
if(process.env.NODE_ENV!=='test')server.listen(config.port,()=>console.log(`Twungeon listening on ${config.publicBaseUrl}`))
if(process.env.NODE_ENV!=='test'){
server.listen(config.port,()=>console.log(`Twungeon listening on ${config.publicBaseUrl}`))
adminServer?.listen(config.adminPort,config.adminHost,()=>console.log(`Twungeon admin listening on http://${config.adminHost}:${config.adminPort}`))
}