Files
discord-rpg-concept/web/src/server.ts
syntaxbullet 17cb70ec00 feat: integrate real data into dashboard
- Created dashboard service with DB queries for users, economy, events
- Added client stats provider with 30s caching for Discord metrics
- Implemented /api/stats endpoint aggregating all dashboard data
- Created useDashboardStats React hook with auto-refresh
- Updated Dashboard.tsx to display real data with loading/error states
- Added comprehensive test coverage (11 tests passing)
- Replaced all mock values with live Discord and database metrics
2026-01-08 18:50:44 +01:00

176 lines
6.1 KiB
TypeScript

/**
* Web server factory module.
* Exports a function to create and start the web server.
* This allows the server to be started in-process from the main application.
*/
import { serve, spawn, type Subprocess } from "bun";
import { join, resolve, dirname } from "path";
export interface WebServerConfig {
port?: number;
hostname?: string;
}
export interface WebServerInstance {
server: ReturnType<typeof serve>;
stop: () => Promise<void>;
url: string;
}
/**
* Creates and starts the web server.
*
* Automatically handles building the frontend:
* - In development: Spawns 'bun run build.ts --watch'
* - In production: Assumes 'dist' is already built (or builds once)
*/
export async function createWebServer(config: WebServerConfig = {}): Promise<WebServerInstance> {
const { port = 3000, hostname = "localhost" } = config;
// Resolve directories
// server.ts is in web/src/, so we go up one level to get web/
const currentDir = dirname(new URL(import.meta.url).pathname);
const webRoot = resolve(currentDir, "..");
const distDir = join(webRoot, "dist");
// Manage build process in development
let buildProcess: Subprocess | undefined;
const isDev = process.env.NODE_ENV !== "production";
if (isDev) {
console.log("🛠️ Starting Web Bundler in Watch Mode...");
try {
buildProcess = spawn(["bun", "run", "build.ts", "--watch"], {
cwd: webRoot,
stdout: "inherit",
stderr: "inherit",
});
} catch (error) {
console.error("Failed to start build process:", error);
}
}
const server = serve({
port,
hostname,
async fetch(req) {
const url = new URL(req.url);
// API routes
if (url.pathname === "/api/health") {
return Response.json({ status: "ok", timestamp: Date.now() });
}
if (url.pathname === "/api/stats") {
try {
// Import services (dynamic to avoid circular deps)
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
const { getClientStats } = await import("../../bot/lib/clientStats");
// Fetch all data in parallel
const [clientStats, activeUsers, totalUsers, economyStats, recentEvents] = await Promise.all([
Promise.resolve(getClientStats()),
dashboardService.getActiveUserCount(),
dashboardService.getTotalUserCount(),
dashboardService.getEconomyStats(),
dashboardService.getRecentEvents(10),
]);
const stats = {
guilds: {
count: clientStats.guilds,
},
users: {
active: activeUsers,
total: totalUsers,
},
commands: {
total: clientStats.commandsRegistered,
},
ping: {
avg: clientStats.ping,
},
economy: {
totalWealth: economyStats.totalWealth.toString(),
avgLevel: economyStats.avgLevel,
topStreak: economyStats.topStreak,
},
recentEvents: recentEvents.map(event => ({
...event,
timestamp: event.timestamp.toISOString(),
})),
uptime: clientStats.uptime,
lastCommandTimestamp: clientStats.lastCommandTimestamp,
};
return Response.json(stats);
} catch (error) {
console.error("Error fetching dashboard stats:", error);
return Response.json(
{ error: "Failed to fetch dashboard statistics" },
{ status: 500 }
);
}
}
// Static File Serving
let pathName = url.pathname;
if (pathName === "/") pathName = "/index.html";
// Construct safe file path
// Remove leading slash to join correctly
const safePath = join(distDir, pathName.replace(/^\//, ""));
// Security check: ensure path is within distDir
if (!safePath.startsWith(distDir)) {
return new Response("Forbidden", { status: 403 });
}
const fileRef = Bun.file(safePath);
if (await fileRef.exists()) {
return new Response(fileRef);
}
// SPA Fallback: Serve index.html for unknown non-file routes
// If the path looks like a file (has extension), return 404
// Otherwise serve index.html
const parts = pathName.split("/");
const lastPart = parts[parts.length - 1];
if (lastPart?.includes(".")) {
return new Response("Not Found", { status: 404 });
}
return new Response(Bun.file(join(distDir, "index.html")));
},
development: isDev,
});
const url = `http://${hostname}:${port}`;
return {
server,
url,
stop: async () => {
if (buildProcess) {
buildProcess.kill();
}
server.stop(true);
},
};
}
/**
* Starts the web server from the main application root.
* Kept for backward compatibility, but assumes webProjectPath is handled internally or ignored
* in favor of relative path resolution from this file.
*/
export async function startWebServerFromRoot(
webProjectPath: string,
config: WebServerConfig = {}
): Promise<WebServerInstance> {
// Current implementation doesn't need CWD switching thanks to absolute path resolution
return createWebServer(config);
}