Initial commit: Gaming Hub foundation

Plugin-based Discord bot framework with web frontend:
- Core: Discord.js client, SSE broadcast, JSON persistence
- Plugin system: lifecycle hooks (init, onReady, routes, snapshot, destroy)
- Web: React 19 + Vite 6 + TypeScript, tab-based navigation
- Docker: multi-stage build (Node 24, static ffmpeg, yt-dlp)
- GitLab CI: Kaniko with LAN registry caching

Ready for plugin development.
This commit is contained in:
Claude Code 2026-03-05 22:52:13 +01:00
parent 1ae431dd2f
commit ae1c41f0ae
19 changed files with 954 additions and 0 deletions

24
server/package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "gaming-hub-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts",
"start": "node dist/index.js"
},
"dependencies": {
"discord.js": "^14.18.0",
"@discordjs/voice": "^0.18.0",
"@discordjs/opus": "^0.9.0",
"sodium-native": "^4.3.1",
"express": "^5.0.0",
"prism-media": "^1.3.5"
},
"devDependencies": {
"typescript": "^5.7.0",
"@types/node": "^24.0.0",
"@types/express": "^5.0.0",
"tsx": "^4.19.0"
}
}

View file

@ -0,0 +1,14 @@
import { Client, GatewayIntentBits } from 'discord.js';
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildPresences,
GatewayIntentBits.MessageContent,
],
});
export default client;

View file

@ -0,0 +1,39 @@
import fs from 'node:fs';
import path from 'node:path';
const DATA_DIR = process.env.DATA_DIR ?? '/data';
const stateFile = path.join(DATA_DIR, 'hub-state.json');
let state: Record<string, any> = {};
export function loadState(): void {
try {
if (fs.existsSync(stateFile)) {
state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
}
} catch (e) {
console.error('Failed to load state:', e);
}
}
export function saveState(): void {
try {
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2));
} catch (e) {
console.error('Failed to save state:', e);
}
}
export function getState<T = any>(key: string, defaultValue?: T): T {
return (state[key] as T) ?? (defaultValue as T);
}
export function setState(key: string, value: any): void {
state[key] = value;
saveState();
}
export function getFullState(): Record<string, any> {
return { ...state };
}

39
server/src/core/plugin.ts Normal file
View file

@ -0,0 +1,39 @@
import { Client } from 'discord.js';
import express from 'express';
export interface Plugin {
name: string;
version: string;
description: string;
/** Called once when plugin is loaded */
init(ctx: PluginContext): Promise<void>;
/** Called when Discord client is ready */
onReady?(ctx: PluginContext): Promise<void>;
/** Called to register Express routes */
registerRoutes?(app: express.Application, ctx: PluginContext): void;
/** Called to build SSE snapshot data for new clients */
getSnapshot?(ctx: PluginContext): Record<string, any>;
/** Called on graceful shutdown */
destroy?(ctx: PluginContext): Promise<void>;
}
export interface PluginContext {
client: Client;
dataDir: string;
}
const loadedPlugins: Plugin[] = [];
export function registerPlugin(plugin: Plugin): void {
loadedPlugins.push(plugin);
console.log(`[Plugin] Registered: ${plugin.name} v${plugin.version}`);
}
export function getPlugins(): Plugin[] {
return [...loadedPlugins];
}

22
server/src/core/sse.ts Normal file
View file

@ -0,0 +1,22 @@
import { Response } from 'express';
const sseClients = new Set<Response>();
export function addSSEClient(res: Response): void {
sseClients.add(res);
}
export function removeSSEClient(res: Response): void {
sseClients.delete(res);
}
export function sseBroadcast(data: Record<string, any>): void {
const msg = `data: ${JSON.stringify(data)}\n\n`;
for (const c of sseClients) {
try { c.write(msg); } catch { sseClients.delete(c); }
}
}
export function getSSEClientCount(): number {
return sseClients.size;
}

136
server/src/index.ts Normal file
View file

@ -0,0 +1,136 @@
import express from 'express';
import path from 'node:path';
import client from './core/discord.js';
import { addSSEClient, removeSSEClient, sseBroadcast, getSSEClientCount } from './core/sse.js';
import { loadState, getFullState } from './core/persistence.js';
import { getPlugins, registerPlugin, PluginContext } from './core/plugin.js';
// ── Config ──
const PORT = Number(process.env.PORT ?? 8080);
const DATA_DIR = process.env.DATA_DIR ?? '/data';
const DISCORD_TOKEN = process.env.DISCORD_TOKEN ?? '';
// ── Persistence ──
loadState();
// ── Express ──
const app = express();
app.use(express.json());
app.use(express.static(path.join(import.meta.dirname ?? __dirname, '..', '..', 'web', 'dist')));
// ── Plugin Context ──
const ctx: PluginContext = { client, dataDir: DATA_DIR };
// ── SSE Events ──
app.get('/api/events', (_req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
res.flushHeaders();
// Send snapshot from all plugins
const snapshot: Record<string, any> = { type: 'snapshot' };
for (const p of getPlugins()) {
if (p.getSnapshot) {
Object.assign(snapshot, p.getSnapshot(ctx));
}
}
try { res.write(`data: ${JSON.stringify(snapshot)}\n\n`); } catch {}
const ping = setInterval(() => { try { res.write(':\n\n'); } catch {} }, 15_000);
addSSEClient(res);
_req.on('close', () => {
removeSSEClient(res);
clearInterval(ping);
try { res.end(); } catch {}
});
});
// ── Health ──
app.get('/api/health', (_req, res) => {
res.json({
status: 'ok',
uptime: process.uptime(),
plugins: getPlugins().map(p => ({ name: p.name, version: p.version })),
sseClients: getSSEClientCount(),
});
});
// ── API: List plugins ──
app.get('/api/plugins', (_req, res) => {
res.json(getPlugins().map(p => ({
name: p.name,
version: p.version,
description: p.description,
})));
});
// ── SPA Fallback ──
app.get('*', (_req, res) => {
res.sendFile(path.join(import.meta.dirname ?? __dirname, '..', '..', 'web', 'dist', 'index.html'));
});
// ── Discord Ready ──
client.once('ready', async () => {
console.log(`[Discord] Logged in as ${client.user?.tag}`);
console.log(`[Discord] Serving ${client.guilds.cache.size} guild(s)`);
for (const p of getPlugins()) {
if (p.onReady) {
try { await p.onReady(ctx); } catch (e) { console.error(`[Plugin:${p.name}] onReady error:`, e); }
}
}
});
// ── Init Plugins ──
async function boot(): Promise<void> {
// --- Load plugins dynamically here ---
// Example: import('./plugins/soundboard/index.js').then(m => registerPlugin(m.default));
// Init all plugins
for (const p of getPlugins()) {
try {
await p.init(ctx);
p.registerRoutes?.(app, ctx);
console.log(`[Plugin:${p.name}] Initialized`);
} catch (e) {
console.error(`[Plugin:${p.name}] Init error:`, e);
}
}
// Start Express
app.listen(PORT, () => console.log(`[HTTP] Listening on :${PORT}`));
// Login Discord
if (DISCORD_TOKEN) {
await client.login(DISCORD_TOKEN);
} else {
console.warn('[Discord] No DISCORD_TOKEN set - running without Discord');
}
}
// ── Graceful Shutdown ──
async function shutdown(signal: string): Promise<void> {
console.log(`\n[${signal}] Shutting down...`);
for (const p of getPlugins()) {
if (p.destroy) {
try { await p.destroy(ctx); } catch (e) { console.error(`[Plugin:${p.name}] destroy error:`, e); }
}
}
client.destroy();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('uncaughtException', (err) => { console.error('Uncaught:', err); });
process.on('unhandledRejection', (err) => { console.error('Unhandled:', err); });
process.on('warning', (w) => {
if (w.name === 'TimeoutNegativeWarning') return;
console.warn(w.name + ': ' + w.message);
});
boot().catch(console.error);

View file

19
server/tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}