Files
doka2-lobby-list/index.js
T
ab 33e01a4a8f
Docker image / build-and-push (push) Successful in 3m3s
Fixed reconnect
2026-07-22 17:35:23 +03:00

1167 lines
33 KiB
JavaScript

const crypto = require('crypto');
const fs = require('fs');
const http = require('http');
const path = require('path');
const { EventEmitter } = require('events');
const SteamUser = require('steam-user');
const protobuf = require('protobufjs');
const APP_VERSION = require('./package.json').version;
const DOTA_APPID = 570;
const DEFAULT_PORT = 3000;
const DEFAULT_REFRESH_MS = 3 * 60 * 1000;
const READY_TIMEOUT_MS = 60 * 1000;
const LOBBY_TIMEOUT_MS = 30 * 1000;
const WATCHDOG_INTERVAL_MS = 30 * 1000;
const GC_RELAUNCH_AFTER_MS = 2 * 60 * 1000;
const GC_RELOG_AFTER_MS = 10 * 60 * 1000;
const STEAM_STUCK_AFTER_MS = 10 * 60 * 1000;
const AUTH_FILE = process.env.STEAM_AUTH_FILE || path.join(__dirname, '.steam-auth.json');
const CREDENTIALS_FILE = process.env.STEAM_CREDENTIALS_FILE || path.join(path.dirname(AUTH_FILE), '.steam-credentials.json');
const ADMIN_PASSWORD = process.env.AUTH_ADMIN_PASSWORD || process.env.ADMIN_PASSWORD || '';
const MSG = {
GCClientWelcome: 4004,
GCClientHello: 4006,
JoinableCustomLobbiesRequest: 7468,
JoinableCustomLobbiesResponse: 7469,
};
const REGIONS = {
0: 'Auto',
1: 'US West',
2: 'US East',
3: 'Europe',
5: 'Singapore',
6: 'Dubai',
7: 'Australia',
8: 'Stockholm',
9: 'Austria',
10: 'Brazil',
11: 'South Africa',
14: 'Chile',
15: 'Peru',
16: 'India',
19: 'Japan',
37: 'Taiwan',
38: 'Argentina',
};
const args = process.argv.slice(2);
function hasArg(name) {
return args.includes(name);
}
function argVal(name) {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
}
function numberOption(name, envName, fallback) {
const raw = argVal(name) ?? process.env[envName];
if (raw === undefined) return fallback;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : fallback;
}
function printUsage() {
console.log(`Usage:
node index.js [--port <n>] [--region <n>] [--game-id <workshop id>]
Options:
--port Web UI port, default ${DEFAULT_PORT}
--region GC server region, default 0 (${REGIONS[0]})
--game-id Workshop id of a custom game, filters server-side
--once One-shot console mode, supports --game and --map filters
--refresh-ms Background refresh interval, default ${DEFAULT_REFRESH_MS}
Environment:
AUTH_ADMIN_PASSWORD Password required for /admin auth editing
STEAM_AUTH_FILE Refresh-token file, default ${AUTH_FILE}
STEAM_CREDENTIALS_FILE Steam account/password file, default ${CREDENTIALS_FILE}
STEAM_ACCOUNT / STEAM_PASSWORD Optional fallback credentials
Regions:
${Object.entries(REGIONS).map(([k, v]) => `${k}=${v}`).join(', ')}`);
}
if (hasArg('--help') || hasArg('-h')) {
printUsage();
process.exit(0);
}
const port = numberOption('--port', 'PORT', DEFAULT_PORT);
const refreshIntervalMs = numberOption('--refresh-ms', 'REFRESH_MS', DEFAULT_REFRESH_MS);
const serverRegion = numberOption('--region', 'DOTA_REGION', 0);
const customGameId = argVal('--game-id') ?? process.env.DOTA_CUSTOM_GAME_ID;
const oneShot = hasArg('--once');
const initialGameFilter = argVal('--game')?.toLowerCase();
const initialMapFilter = argVal('--map')?.toLowerCase();
const proto = protobuf.loadSync(path.join(__dirname, 'dota.proto'));
const Hello = proto.lookupType('CMsgClientHello');
const LobbiesReq = proto.lookupType('CMsgJoinableCustomLobbiesRequest');
const LobbiesResp = proto.lookupType('CMsgJoinableCustomLobbiesResponse');
function ensureParentDir(file) {
fs.mkdirSync(path.dirname(file), { recursive: true });
}
class JsonStore {
constructor(file) {
this.file = file;
}
load() {
try {
const raw = fs.readFileSync(this.file, 'utf8');
return JSON.parse(raw);
} catch (err) {
if (err.code !== 'ENOENT') {
console.warn(`warn: could not read ${this.file}: ${err.message}`);
}
return null;
}
}
save(data) {
ensureParentDir(this.file);
const payload = {
...data,
updatedAt: new Date().toISOString(),
};
fs.writeFileSync(this.file, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
}
remove() {
try {
fs.unlinkSync(this.file);
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
}
}
class AuthStore extends JsonStore {
load() {
const parsed = super.load();
if (!parsed || typeof parsed.refreshToken !== 'string') return null;
return parsed;
}
}
class CredentialsStore extends JsonStore {
load() {
const parsed = super.load();
if (!parsed || typeof parsed.accountName !== 'string' || typeof parsed.password !== 'string') return null;
return parsed;
}
}
function decodeJwtPayload(token) {
const [, payload] = String(token).split('.');
if (!payload) return null;
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
try {
return JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
} catch {
return null;
}
}
function tokenExpiresSoon(token) {
const payload = decodeJwtPayload(token);
if (!payload?.exp) return false;
return payload.exp * 1000 < Date.now() + 24 * 60 * 60 * 1000;
}
function tokenExpiresAt(token) {
const payload = decodeJwtPayload(token);
if (!payload?.exp) return null;
return new Date(payload.exp * 1000).toISOString();
}
function resolveCredentials(credentialsStore) {
const stored = credentialsStore.load();
if (stored?.accountName && stored?.password) {
return {
accountName: stored.accountName,
password: stored.password,
source: 'file',
updatedAt: stored.updatedAt || null,
};
}
if (process.env.STEAM_ACCOUNT && process.env.STEAM_PASSWORD) {
return {
accountName: process.env.STEAM_ACCOUNT,
password: process.env.STEAM_PASSWORD,
source: 'env',
updatedAt: null,
};
}
return {
accountName: '',
password: '',
source: 'missing',
updatedAt: null,
};
}
function createLogOnDetails({ authStore, accountName, accountPassword, forcePassword = false }) {
const auth = authStore.load();
const tokenBelongsToAccount = !accountName || !auth?.accountName || auth.accountName === accountName;
const canRefreshWithPassword = Boolean(accountName && accountPassword);
const canUseToken = auth?.refreshToken
&& tokenBelongsToAccount
&& (!tokenExpiresSoon(auth.refreshToken) || !canRefreshWithPassword);
if (!forcePassword && canUseToken) {
const details = {
refreshToken: auth.refreshToken,
machineName: 'doka-lobby',
};
if (auth.steamID) details.steamID = auth.steamID;
return {
source: 'refresh-token',
details,
};
}
if (!accountName || !accountPassword) {
return { source: 'missing', details: null };
}
return {
source: 'password',
details: {
accountName,
password: accountPassword,
machineName: 'doka-lobby',
},
};
}
class DotaLobbyClient extends EventEmitter {
constructor({ authStore, credentialsStore }) {
super();
this.authStore = authStore;
this.credentialsStore = credentialsStore;
this.credentials = resolveCredentials(credentialsStore);
this.user = null;
this.connectionState = 'idle';
this.gcState = 'idle';
this.authSource = null;
this.loggedOn = false;
this.loggingOn = false;
this.dotaLaunched = false;
this.gcReady = false;
this.forcePasswordLogin = false;
this.helloTimer = null;
this.reconnectTimer = null;
this.readyWaiters = new Set();
this.pendingLobbyRequest = null;
this.steamGuardPrompt = null;
this.steamGuardCallback = null;
this.gcDownSince = null;
this.steamDownSince = null;
this.lastGcRelaunchAt = 0;
this.watchdogTimer = setInterval(() => this.watchdog(), WATCHDOG_INTERVAL_MS);
this.watchdogTimer.unref();
this.createSteamUser();
}
createSteamUser() {
this.user = new SteamUser({ autoRelogin: true, renewRefreshTokens: true });
this.bindEvents();
}
status() {
return {
steam: this.connectionState,
gc: this.gcState,
authSource: this.authSource,
loggedOn: this.loggedOn,
dotaLaunched: this.dotaLaunched,
gcReady: this.gcReady,
credentialsSource: this.credentials.source,
steamGuard: this.steamGuardPrompt,
};
}
setConnectionState(state) {
if (this.connectionState === state) return;
this.connectionState = state;
this.emit('change');
}
setGcState(state) {
if (this.gcState === state) return;
this.gcState = state;
this.emit('change');
}
bindEvents() {
this.user.on('loggedOn', () => {
this.loggingOn = false;
this.loggedOn = true;
this.dotaLaunched = false;
this.gcReady = false;
this.steamGuardCallback = null;
this.steamGuardPrompt = null;
this.clearReconnectTimer();
this.setConnectionState('online');
console.log('Logged into Steam, launching Dota 2...');
this.launchDota();
this.emit('change');
});
this.user.on('steamGuard', (domain, callback, lastCodeWrong) => {
this.steamGuardCallback = callback;
this.steamGuardPrompt = {
required: true,
domain: domain || null,
lastCodeWrong: Boolean(lastCodeWrong),
requestedAt: new Date().toISOString(),
};
this.setConnectionState('steam-guard');
this.emit('change');
});
this.user.on('refreshToken', (refreshToken) => {
const steamID = this.user.steamID?.getSteamID64?.();
this.authStore.save({
refreshToken,
steamID,
accountName: this.credentials.accountName,
});
this.authSource = 'refresh-token';
console.log(`Saved Steam refresh token to ${this.authStore.file}`);
this.emit('change');
});
this.user.on('disconnected', (eresult, msg) => {
this.loggedOn = false;
this.loggingOn = false;
this.dotaLaunched = false;
this.gcReady = false;
this.steamGuardCallback = null;
this.steamGuardPrompt = null;
this.clearHelloTimer();
this.rejectReadyWaiters(new Error(`Steam disconnected: ${msg || eresult || 'unknown reason'}`));
this.rejectLobbyRequest(new Error(`Steam disconnected: ${msg || eresult || 'unknown reason'}`));
this.setConnectionState('reconnecting');
this.setGcState('idle');
this.emit('change');
});
this.user.on('error', (err) => {
console.error('Steam error:', err.message);
this.loggedOn = false;
this.loggingOn = false;
this.dotaLaunched = false;
this.gcReady = false;
this.steamGuardCallback = null;
this.steamGuardPrompt = null;
this.clearHelloTimer();
this.rejectReadyWaiters(err);
this.rejectLobbyRequest(err);
this.setConnectionState('error');
this.setGcState('idle');
if (this.authSource === 'refresh-token' && this.credentials.accountName && this.credentials.password) {
console.warn('warn: refresh token login failed, falling back to password login.');
this.forcePasswordLogin = true;
try {
this.authStore.remove();
} catch (removeErr) {
console.warn(`warn: could not remove auth file: ${removeErr.message}`);
}
}
this.scheduleReconnect();
this.emit('change');
});
this.user.on('appQuit', (appid) => {
if (appid !== DOTA_APPID || !this.loggedOn) return;
this.dotaLaunched = false;
this.gcReady = false;
this.clearHelloTimer();
this.setGcState('reconnecting');
this.launchDota(true);
});
this.user.on('receivedFromGC', (appid, msgType, payload) => {
if (appid !== DOTA_APPID) return;
if (msgType === MSG.GCClientWelcome) {
this.handleGcWelcome();
return;
}
if (msgType === MSG.JoinableCustomLobbiesResponse) {
this.handleLobbyResponse(payload);
}
});
}
start() {
if (this.loggedOn || this.loggingOn || this.connectionState === 'reconnecting' || this.steamGuardCallback) return;
this.credentials = resolveCredentials(this.credentialsStore);
const logOn = createLogOnDetails({
authStore: this.authStore,
accountName: this.credentials.accountName,
accountPassword: this.credentials.password,
forcePassword: this.forcePasswordLogin,
});
if (!logOn.details) {
this.authSource = null;
this.setConnectionState('auth-required');
throw new Error('Steam credentials are missing. Open Admin auth and save account credentials.');
}
this.authSource = logOn.source;
this.loggingOn = true;
this.setConnectionState('connecting');
console.log(`Logging into Steam using ${logOn.source}...`);
this.user.logOn(logOn.details);
}
restartWithCredentials({ accountName, password, clearRefreshToken }) {
this.credentialsStore.save({ accountName, password });
this.credentials = resolveCredentials(this.credentialsStore);
this.forcePasswordLogin = true;
if (clearRefreshToken) {
this.authStore.remove();
}
this.resetSteamUser();
this.start();
}
resetSteamUser() {
this.clearReconnectTimer();
this.clearHelloTimer();
this.rejectReadyWaiters(new Error('Steam connection reset.'));
this.rejectLobbyRequest(new Error('Steam connection reset.'));
this.steamGuardCallback = null;
this.steamGuardPrompt = null;
if (this.user) {
this.user.removeAllListeners();
try {
if (this.loggedOn || this.loggingOn) this.user.logOff();
} catch (err) {
console.warn(`warn: could not log off old Steam session: ${err.message}`);
}
}
this.loggedOn = false;
this.loggingOn = false;
this.dotaLaunched = false;
this.gcReady = false;
this.connectionState = 'idle';
this.gcState = 'idle';
this.authSource = null;
this.createSteamUser();
this.emit('change');
}
submitSteamGuardCode(code) {
if (!this.steamGuardCallback) {
throw new Error('Steam Guard code is not currently requested.');
}
const callback = this.steamGuardCallback;
this.steamGuardCallback = null;
this.steamGuardPrompt = {
required: false,
submittedAt: new Date().toISOString(),
};
this.setConnectionState('connecting');
callback(code);
this.emit('change');
}
// Periodic self-healing: the GC can silently drop our session (e.g. GC
// maintenance restarts) while hello messages keep going into the void.
// Escalate: relaunch Dota after GC_RELAUNCH_AFTER_MS, then tear down and
// rebuild the whole Steam connection after GC_RELOG_AFTER_MS. Also rebuilds
// the connection if Steam itself stays offline longer than STEAM_STUCK_AFTER_MS.
watchdog() {
const now = Date.now();
if (this.steamGuardCallback) {
// Waiting on user input; nothing to heal automatically.
this.gcDownSince = null;
this.steamDownSince = null;
return;
}
if (!this.loggedOn) {
this.gcDownSince = null;
if (!this.steamDownSince) {
this.steamDownSince = now;
return;
}
if (now - this.steamDownSince >= STEAM_STUCK_AFTER_MS) {
this.steamDownSince = now;
console.warn('watchdog: Steam connection stuck offline, rebuilding session...');
this.hardReset();
}
return;
}
this.steamDownSince = null;
if (this.gcReady) {
this.gcDownSince = null;
return;
}
if (!this.gcDownSince) {
this.gcDownSince = now;
return;
}
if (now - this.gcDownSince >= GC_RELOG_AFTER_MS) {
this.gcDownSince = now;
console.warn('watchdog: GC unavailable despite relaunches, rebuilding Steam session...');
this.hardReset();
return;
}
if (now - this.gcDownSince >= GC_RELAUNCH_AFTER_MS && now - this.lastGcRelaunchAt >= GC_RELAUNCH_AFTER_MS) {
this.lastGcRelaunchAt = now;
console.warn('watchdog: GC not ready, relaunching Dota 2...');
this.clearHelloTimer();
this.launchDota(true);
}
}
hardReset() {
this.gcDownSince = null;
this.resetSteamUser();
try {
this.start();
} catch (err) {
console.error(`watchdog: could not restart Steam connection: ${err.message}`);
}
}
scheduleReconnect() {
if (this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
try {
this.start();
} catch (err) {
console.error(err.message);
}
}, 15000);
}
clearReconnectTimer() {
if (!this.reconnectTimer) return;
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
launchDota(force = false) {
if (!this.loggedOn) return;
if (this.dotaLaunched && !force) return;
try {
this.user.gamesPlayed([DOTA_APPID], force);
this.dotaLaunched = true;
this.setGcState('connecting');
this.startHelloLoop();
} catch (err) {
this.setGcState('error');
this.emit('warning', err);
}
}
startHelloLoop() {
if (!this.loggedOn || this.gcReady || this.helloTimer) return;
const sendHello = () => {
if (!this.loggedOn || this.gcReady) return;
try {
this.user.sendToGC(DOTA_APPID, MSG.GCClientHello, {}, Hello.encode({ engine: 1 }).finish());
} catch (err) {
this.setGcState('error');
this.emit('warning', err);
}
};
sendHello();
this.helloTimer = setInterval(sendHello, 3000);
}
clearHelloTimer() {
if (!this.helloTimer) return;
clearInterval(this.helloTimer);
this.helloTimer = null;
}
handleGcWelcome() {
this.clearHelloTimer();
this.gcReady = true;
this.setGcState('ready');
console.log('Connected to Dota 2 game coordinator.');
for (const waiter of this.readyWaiters) {
clearTimeout(waiter.timeout);
waiter.resolve();
}
this.readyWaiters.clear();
}
rejectReadyWaiters(err) {
for (const waiter of this.readyWaiters) {
clearTimeout(waiter.timeout);
waiter.reject(err);
}
this.readyWaiters.clear();
}
waitForReady(timeoutMs = READY_TIMEOUT_MS) {
if (this.gcReady) return Promise.resolve();
return new Promise((resolve, reject) => {
const waiter = {
resolve,
reject,
timeout: setTimeout(() => {
this.readyWaiters.delete(waiter);
this.gcReady = false;
this.setGcState('timeout');
if (this.loggedOn) {
this.clearHelloTimer();
this.launchDota(true);
}
reject(new Error('Dota 2 game coordinator did not become ready in time.'));
}, timeoutMs),
};
this.readyWaiters.add(waiter);
try {
this.start();
if (this.loggedOn) {
this.launchDota();
this.startHelloLoop();
}
} catch (err) {
clearTimeout(waiter.timeout);
this.readyWaiters.delete(waiter);
reject(err);
}
});
}
async requestLobbies({ region, gameId }) {
await this.waitForReady();
if (this.pendingLobbyRequest) {
throw new Error('A lobby request is already in progress.');
}
return new Promise((resolve, reject) => {
const request = {
resolve,
reject,
timeout: setTimeout(() => {
if (this.pendingLobbyRequest !== request) return;
this.pendingLobbyRequest = null;
this.gcReady = false;
this.setGcState('timeout');
this.clearHelloTimer();
this.launchDota(true);
reject(new Error('No lobby list response from GC after 30s.'));
}, LOBBY_TIMEOUT_MS),
};
this.pendingLobbyRequest = request;
const payload = { serverRegion: region };
if (gameId) payload.customGameId = gameId;
try {
this.user.sendToGC(DOTA_APPID, MSG.JoinableCustomLobbiesRequest, {}, LobbiesReq.encode(payload).finish());
} catch (err) {
clearTimeout(request.timeout);
this.pendingLobbyRequest = null;
reject(err);
}
});
}
handleLobbyResponse(payload) {
const request = this.pendingLobbyRequest;
if (!request) return;
clearTimeout(request.timeout);
this.pendingLobbyRequest = null;
try {
const decoded = LobbiesResp.toObject(LobbiesResp.decode(payload), { longs: String });
request.resolve(decoded.lobbies || []);
} catch (err) {
request.reject(err);
}
}
rejectLobbyRequest(err) {
if (!this.pendingLobbyRequest) return;
clearTimeout(this.pendingLobbyRequest.timeout);
this.pendingLobbyRequest.reject(err);
this.pendingLobbyRequest = null;
}
}
const gameNameCache = new Map();
async function resolveGameNames(ids) {
const unique = [...new Set(ids.map((id) => String(id || '')).filter(Boolean))];
const missing = unique.filter((id) => !gameNameCache.has(id));
for (let i = 0; i < missing.length; i += 100) {
const chunk = missing.slice(i, i + 100);
const body = new URLSearchParams({ itemcount: String(chunk.length) });
chunk.forEach((id, index) => body.append(`publishedfileids[${index}]`, id));
try {
const res = await fetch('https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/', {
method: 'POST',
body,
});
const json = await res.json();
for (const detail of json.response?.publishedfiledetails || []) {
gameNameCache.set(String(detail.publishedfileid), detail.title || String(detail.publishedfileid));
}
} catch (err) {
console.error(`warn: could not resolve custom game names: ${err.message}`);
for (const id of chunk) gameNameCache.set(id, id);
}
}
return gameNameCache;
}
function normalizeLobby(lobby, names) {
const customGameIdValue = String(lobby.customGameId || '');
const memberCount = Number(lobby.memberCount || 0);
const maxPlayerCount = Number(lobby.maxPlayerCount || 0);
return {
id: String(lobby.lobbyId || ''),
customGameId: customGameIdValue,
game: names.get(customGameIdValue) || customGameIdValue,
lobby: lobby.lobbyName || '',
map: lobby.customMapName || '',
memberCount,
maxPlayerCount,
openSlots: Math.max(0, maxPlayerCount - memberCount),
players: `${memberCount}/${maxPlayerCount || '?'}`,
regionId: Number(lobby.serverRegion || 0),
region: REGIONS[lobby.serverRegion] || String(lobby.serverRegion || ''),
hasPassKey: Boolean(lobby.hasPassKey),
leader: lobby.leaderName || '',
leaderAccountId: String(lobby.leaderAccountId || ''),
};
}
function filterRows(rows, { game, map }) {
return rows
.filter((row) => !game || String(row.game).toLowerCase().includes(game))
.filter((row) => !map || String(row.map ?? '').toLowerCase().includes(map));
}
function tableRows(rows) {
return rows.map((row) => ({
game: row.game,
lobby: row.lobby,
map: row.map,
players: row.players,
region: row.region,
pass: row.hasPassKey ? 'yes' : '',
leader: row.leader,
}));
}
const state = {
status: 'starting',
lobbies: [],
totalLobbies: 0,
refreshCount: 0,
refreshInProgress: false,
lastUpdatedAt: null,
lastStartedAt: null,
lastError: null,
nextRefreshAt: null,
};
const authStore = new AuthStore(AUTH_FILE);
const credentialsStore = new CredentialsStore(CREDENTIALS_FILE);
const dota = new DotaLobbyClient({ authStore, credentialsStore });
const sseClients = new Set();
let refreshTimer = null;
let activeRefresh = null;
function publicState() {
return {
...state,
version: APP_VERSION,
connection: dota.status(),
refreshIntervalMs,
regions: REGIONS,
auth: {
adminConfigured: Boolean(ADMIN_PASSWORD),
credentialsConfigured: Boolean(dota.credentials.accountName && dota.credentials.password),
tokenConfigured: Boolean(authStore.load()?.refreshToken),
},
serverFilters: {
region: serverRegion,
regionName: REGIONS[serverRegion] || String(serverRegion),
customGameId: customGameId || null,
},
};
}
function adminAuthState() {
const credentials = resolveCredentials(credentialsStore);
const auth = authStore.load();
return {
adminConfigured: Boolean(ADMIN_PASSWORD),
files: {
authFile: authStore.file,
credentialsFile: credentialsStore.file,
},
credentials: {
accountName: credentials.accountName,
password: credentials.password,
source: credentials.source,
updatedAt: credentials.updatedAt,
},
refreshToken: {
exists: Boolean(auth?.refreshToken),
accountName: auth?.accountName || null,
steamID: auth?.steamID || null,
updatedAt: auth?.updatedAt || null,
expiresAt: auth?.refreshToken ? tokenExpiresAt(auth.refreshToken) : null,
},
connection: dota.status(),
};
}
function sendJson(res, statusCode, data) {
const body = JSON.stringify(data);
res.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
'Cache-Control': 'no-store',
});
res.end(body);
}
function sendText(res, statusCode, text) {
res.writeHead(statusCode, {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Length': Buffer.byteLength(text),
});
res.end(text);
}
function safeEqual(a, b) {
const left = Buffer.from(String(a || ''));
const right = Buffer.from(String(b || ''));
if (left.length !== right.length) return false;
return crypto.timingSafeEqual(left, right);
}
function requireAdmin(req, res) {
if (!ADMIN_PASSWORD) {
sendJson(res, 503, { error: 'Admin password is not configured. Set AUTH_ADMIN_PASSWORD.' });
return false;
}
const supplied = req.headers['x-admin-password'];
if (!safeEqual(supplied, ADMIN_PASSWORD)) {
sendJson(res, 401, { error: 'Invalid admin password.' });
return false;
}
return true;
}
function readJsonBody(req, maxBytes = 64 * 1024) {
return new Promise((resolve, reject) => {
let body = '';
req.setEncoding('utf8');
req.on('data', (chunk) => {
body += chunk;
if (Buffer.byteLength(body) > maxBytes) {
reject(new Error('Request body is too large.'));
req.destroy();
}
});
req.on('end', () => {
if (!body) {
resolve({});
return;
}
try {
resolve(JSON.parse(body));
} catch {
reject(new Error('Request body must be valid JSON.'));
}
});
req.on('error', reject);
});
}
function broadcastState() {
const data = `data: ${JSON.stringify(publicState())}\n\n`;
for (const client of sseClients) {
client.write(data);
}
}
dota.on('change', broadcastState);
dota.on('warning', (err) => {
state.lastError = err.message;
broadcastState();
});
function scheduleNextRefresh(delay = refreshIntervalMs) {
if (refreshTimer) clearTimeout(refreshTimer);
state.nextRefreshAt = new Date(Date.now() + delay).toISOString();
broadcastState();
refreshTimer = setTimeout(() => {
refreshTimer = null;
refreshLobbies('timer').catch((err) => {
console.error(err.message);
});
}, delay);
}
async function refreshLobbies(reason) {
if (activeRefresh) return activeRefresh;
activeRefresh = (async () => {
state.status = 'refreshing';
state.refreshInProgress = true;
state.lastStartedAt = new Date().toISOString();
state.lastError = null;
state.nextRefreshAt = null;
broadcastState();
try {
const rawLobbies = await dota.requestLobbies({ region: serverRegion, gameId: customGameId });
const names = await resolveGameNames(rawLobbies.map((lobby) => lobby.customGameId));
const lobbies = rawLobbies.map((lobby) => normalizeLobby(lobby, names));
state.lobbies = lobbies;
state.totalLobbies = rawLobbies.length;
state.refreshCount += 1;
state.lastUpdatedAt = new Date().toISOString();
state.status = 'ready';
console.log(`Lobby refresh (${reason}) completed: ${lobbies.length} lobbies.`);
} catch (err) {
state.status = state.lobbies.length ? 'stale' : 'error';
state.lastError = err.message;
console.error(`Lobby refresh (${reason}) failed: ${err.message}`);
} finally {
state.refreshInProgress = false;
activeRefresh = null;
scheduleNextRefresh();
broadcastState();
}
return publicState();
})();
return activeRefresh;
}
function serveStatic(req, res) {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const files = {
'/': ['index.html', 'text/html; charset=utf-8'],
'/app.js': ['app.js', 'text/javascript; charset=utf-8'],
'/styles.css': ['styles.css', 'text/css; charset=utf-8'],
'/admin': ['admin.html', 'text/html; charset=utf-8'],
'/admin/': ['admin.html', 'text/html; charset=utf-8'],
'/admin.js': ['admin.js', 'text/javascript; charset=utf-8'],
};
const match = files[url.pathname];
if (!match) return false;
const [file, contentType] = match;
const filePath = path.join(__dirname, 'public', file);
fs.readFile(filePath, (err, data) => {
if (err) {
sendText(res, 500, err.message);
return;
}
res.writeHead(200, {
'Content-Type': contentType,
'Cache-Control': 'no-store',
'Content-Length': data.length,
});
res.end(data);
});
return true;
}
function createServer() {
return http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
if (req.method === 'GET' && url.pathname === '/api/state') {
sendJson(res, 200, publicState());
return;
}
if (req.method === 'POST' && url.pathname === '/api/refresh') {
try {
const snapshot = await refreshLobbies('manual');
sendJson(res, 200, snapshot);
} catch (err) {
sendJson(res, 500, { error: err.message });
}
return;
}
if (url.pathname.startsWith('/api/admin/')) {
if (!requireAdmin(req, res)) return;
try {
if (req.method === 'GET' && url.pathname === '/api/admin/auth') {
sendJson(res, 200, adminAuthState());
return;
}
if (req.method === 'PUT' && url.pathname === '/api/admin/auth') {
const body = await readJsonBody(req);
const accountName = String(body.accountName || '').trim();
const password = String(body.password || '');
if (!accountName || !password) {
sendJson(res, 400, { error: 'Steam account and password are required.' });
return;
}
dota.restartWithCredentials({
accountName,
password,
clearRefreshToken: body.clearRefreshToken !== false,
});
refreshLobbies('auth-update').catch((err) => {
console.error(err.message);
});
sendJson(res, 200, adminAuthState());
return;
}
if (req.method === 'POST' && url.pathname === '/api/admin/steam-guard') {
const body = await readJsonBody(req);
const code = String(body.code || '').trim();
if (!code) {
sendJson(res, 400, { error: 'Steam Guard code is required.' });
return;
}
dota.submitSteamGuardCode(code);
refreshLobbies('steam-guard').catch((err) => {
console.error(err.message);
});
sendJson(res, 200, adminAuthState());
return;
}
if (req.method === 'POST' && url.pathname === '/api/admin/clear-token') {
authStore.remove();
dota.forcePasswordLogin = true;
sendJson(res, 200, adminAuthState());
return;
}
} catch (err) {
sendJson(res, 500, { error: err.message });
return;
}
sendText(res, 404, 'Not found');
return;
}
if (req.method === 'GET' && url.pathname === '/events') {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-store',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write(`data: ${JSON.stringify(publicState())}\n\n`);
const heartbeat = setInterval(() => res.write(': keep-alive\n\n'), 25000);
sseClients.add(res);
req.on('close', () => {
clearInterval(heartbeat);
sseClients.delete(res);
});
return;
}
if (req.method === 'GET' && url.pathname === '/health') {
sendJson(res, 200, { ok: true, status: state.status, connection: dota.status() });
return;
}
if (req.method === 'GET' && serveStatic(req, res)) return;
sendText(res, 404, 'Not found');
});
}
async function runOnce() {
try {
const rawLobbies = await dota.requestLobbies({ region: serverRegion, gameId: customGameId });
const names = await resolveGameNames(rawLobbies.map((lobby) => lobby.customGameId));
const rows = rawLobbies.map((lobby) => normalizeLobby(lobby, names));
const matched = filterRows(rows, { game: initialGameFilter, map: initialMapFilter });
console.log(`\n${matched.length} of ${rows.length} lobbies match:`);
if (matched.length) console.table(tableRows(matched));
process.exit(0);
} catch (err) {
console.error(err.message);
process.exit(1);
}
}
if (oneShot) {
runOnce();
} else {
const server = createServer();
server.listen(port, () => {
console.log(`Dota lobby web UI: http://localhost:${port}`);
if (!ADMIN_PASSWORD) {
console.warn('warn: AUTH_ADMIN_PASSWORD is not set; Steam auth cannot be edited from the web UI.');
}
refreshLobbies('startup').catch((err) => {
console.error(err.message);
});
});
}