Compare commits
2 Commits
022f748517
...
feat/web-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
894cad91a8 | ||
|
|
2a1c4e65ae |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -44,4 +44,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
src/db/data
|
src/db/data
|
||||||
src/db/log
|
src/db/log
|
||||||
scratchpad/
|
scratchpad/
|
||||||
tickets/
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
|
||||||
import { ModerationService } from "@/modules/moderation/moderation.service";
|
import { ModerationService } from "@/modules/moderation/moderation.service";
|
||||||
|
import { CaseType } from "@/lib/constants";
|
||||||
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
import { getNoteSuccessEmbed, getModerationErrorEmbed } from "@/modules/moderation/moderation.view";
|
||||||
|
|
||||||
export const note = createCommand({
|
export const note = createCommand({
|
||||||
@@ -31,7 +32,7 @@ export const note = createCommand({
|
|||||||
|
|
||||||
// Create the note case
|
// Create the note case
|
||||||
const moderationCase = await ModerationService.createCase({
|
const moderationCase = await ModerationService.createCase({
|
||||||
type: 'note',
|
type: CaseType.NOTE,
|
||||||
userId: targetUser.id,
|
userId: targetUser.id,
|
||||||
username: targetUser.username,
|
username: targetUser.username,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ describe("Database Indexes", () => {
|
|||||||
SELECT indexname FROM pg_indexes
|
SELECT indexname FROM pg_indexes
|
||||||
WHERE tablename = 'users'
|
WHERE tablename = 'users'
|
||||||
`;
|
`;
|
||||||
const indexNames = result.map(r => r.indexname);
|
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
|
||||||
expect(indexNames).toContain("users_balance_idx");
|
expect(indexNames).toContain("users_balance_idx");
|
||||||
expect(indexNames).toContain("users_level_xp_idx");
|
expect(indexNames).toContain("users_level_xp_idx");
|
||||||
});
|
});
|
||||||
@@ -17,7 +17,7 @@ describe("Database Indexes", () => {
|
|||||||
SELECT indexname FROM pg_indexes
|
SELECT indexname FROM pg_indexes
|
||||||
WHERE tablename = 'transactions'
|
WHERE tablename = 'transactions'
|
||||||
`;
|
`;
|
||||||
const indexNames = result.map(r => r.indexname);
|
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
|
||||||
expect(indexNames).toContain("transactions_created_at_idx");
|
expect(indexNames).toContain("transactions_created_at_idx");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ describe("Database Indexes", () => {
|
|||||||
SELECT indexname FROM pg_indexes
|
SELECT indexname FROM pg_indexes
|
||||||
WHERE tablename = 'moderation_cases'
|
WHERE tablename = 'moderation_cases'
|
||||||
`;
|
`;
|
||||||
const indexNames = result.map(r => r.indexname);
|
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
|
||||||
expect(indexNames).toContain("moderation_cases_user_id_idx");
|
expect(indexNames).toContain("moderation_cases_user_id_idx");
|
||||||
expect(indexNames).toContain("moderation_cases_case_id_idx");
|
expect(indexNames).toContain("moderation_cases_case_id_idx");
|
||||||
});
|
});
|
||||||
@@ -36,7 +36,7 @@ describe("Database Indexes", () => {
|
|||||||
SELECT indexname FROM pg_indexes
|
SELECT indexname FROM pg_indexes
|
||||||
WHERE tablename = 'user_timers'
|
WHERE tablename = 'user_timers'
|
||||||
`;
|
`;
|
||||||
const indexNames = result.map(r => r.indexname);
|
const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname);
|
||||||
expect(indexNames).toContain("user_timers_expires_at_idx");
|
expect(indexNames).toContain("user_timers_expires_at_idx");
|
||||||
expect(indexNames).toContain("user_timers_lookup_idx");
|
expect(indexNames).toContain("user_timers_lookup_idx");
|
||||||
});
|
});
|
||||||
|
|||||||
12
src/index.ts
12
src/index.ts
@@ -1,11 +1,14 @@
|
|||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { env } from "@lib/env";
|
import { env } from "@lib/env";
|
||||||
|
|
||||||
|
import { WebServer } from "@/web/server";
|
||||||
|
|
||||||
// Load commands & events
|
// Load commands & events
|
||||||
await AuroraClient.loadCommands();
|
await AuroraClient.loadCommands();
|
||||||
await AuroraClient.loadEvents();
|
await AuroraClient.loadEvents();
|
||||||
await AuroraClient.deployCommands();
|
await AuroraClient.deployCommands();
|
||||||
|
|
||||||
|
WebServer.start();
|
||||||
|
|
||||||
// login with the token from .env
|
// login with the token from .env
|
||||||
if (!env.DISCORD_BOT_TOKEN) {
|
if (!env.DISCORD_BOT_TOKEN) {
|
||||||
@@ -14,5 +17,10 @@ if (!env.DISCORD_BOT_TOKEN) {
|
|||||||
AuroraClient.login(env.DISCORD_BOT_TOKEN);
|
AuroraClient.login(env.DISCORD_BOT_TOKEN);
|
||||||
|
|
||||||
// Handle graceful shutdown
|
// Handle graceful shutdown
|
||||||
process.on("SIGINT", () => AuroraClient.shutdown());
|
const shutdownHandler = () => {
|
||||||
process.on("SIGTERM", () => AuroraClient.shutdown());
|
WebServer.stop();
|
||||||
|
AuroraClient.shutdown();
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on("SIGINT", shutdownHandler);
|
||||||
|
process.on("SIGTERM", shutdownHandler);
|
||||||
@@ -5,6 +5,7 @@ const envSchema = z.object({
|
|||||||
DISCORD_CLIENT_ID: z.string().optional(),
|
DISCORD_CLIENT_ID: z.string().optional(),
|
||||||
DISCORD_GUILD_ID: z.string().optional(),
|
DISCORD_GUILD_ID: z.string().optional(),
|
||||||
DATABASE_URL: z.string().min(1, "Database URL is required"),
|
DATABASE_URL: z.string().min(1, "Database URL is required"),
|
||||||
|
PORT: z.coerce.number().default(3000),
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsedEnv = envSchema.safeParse(process.env);
|
const parsedEnv = envSchema.safeParse(process.env);
|
||||||
|
|||||||
68
src/web/public/style.css
Normal file
68
src/web/public/style.css
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
:root {
|
||||||
|
--bg-color: #0f172a;
|
||||||
|
--text-color: #f8fafc;
|
||||||
|
--accent-color: #38bdf8;
|
||||||
|
--card-bg: #1e293b;
|
||||||
|
--font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-family: var(--font-family);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border-top: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
52
src/web/router.test.ts
Normal file
52
src/web/router.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { router } from "./router";
|
||||||
|
|
||||||
|
describe("Web Router", () => {
|
||||||
|
it("should return home page on /", async () => {
|
||||||
|
const req = new Request("http://localhost/");
|
||||||
|
const res = await router(req);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Content-Type")).toBe("text/html");
|
||||||
|
expect(await res.text()).toContain("Aurora Web");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return health check on /health", async () => {
|
||||||
|
const req = new Request("http://localhost/health");
|
||||||
|
const res = await router(req);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Content-Type")).toBe("application/json");
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data).toHaveProperty("status", "ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should block path traversal", async () => {
|
||||||
|
// Attempts to go up two directories to reach the project root or src
|
||||||
|
const req = new Request("http://localhost/public/../../package.json");
|
||||||
|
const res = await router(req);
|
||||||
|
// Should be 403 Forbidden or 404 Not Found (our logical change makes it 403)
|
||||||
|
expect([403, 404]).toContain(res.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should serve existing static file", async () => {
|
||||||
|
// We know style.css exists in src/web/public
|
||||||
|
const req = new Request("http://localhost/public/style.css");
|
||||||
|
const res = await router(req);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
if (res.status === 200) {
|
||||||
|
const text = await res.text();
|
||||||
|
expect(text).toContain("body");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not serve static files on non-GET methods", async () => {
|
||||||
|
const req = new Request("http://localhost/public/style.css", { method: "POST" });
|
||||||
|
const res = await router(req);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 404 for unknown routes", async () => {
|
||||||
|
const req = new Request("http://localhost/unknown");
|
||||||
|
const res = await router(req);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
53
src/web/router.ts
Normal file
53
src/web/router.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { homeRoute } from "./routes/home";
|
||||||
|
import { healthRoute } from "./routes/health";
|
||||||
|
import { file } from "bun";
|
||||||
|
import { join, resolve } from "path";
|
||||||
|
|
||||||
|
export async function router(request: Request): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const method = request.method;
|
||||||
|
|
||||||
|
// Resolve the absolute path to the public directory
|
||||||
|
const publicDir = resolve(import.meta.dir, "public");
|
||||||
|
|
||||||
|
if (method === "GET") {
|
||||||
|
// Handle Static Files
|
||||||
|
// We handle requests starting with /public/ OR containing an extension (like /style.css)
|
||||||
|
if (url.pathname.startsWith("/public/") || url.pathname.includes(".")) {
|
||||||
|
// Normalize path: remove /public prefix if present so that
|
||||||
|
// /public/style.css and /style.css both map to .../public/style.css
|
||||||
|
const relativePath = url.pathname.replace(/^\/public/, "");
|
||||||
|
|
||||||
|
// Resolve full path
|
||||||
|
// We use join with relativePath. If relativePath starts with /, join handles it correctly
|
||||||
|
// effectively treating it as a segment.
|
||||||
|
// However, to be extra safe with 'resolve', we ensure we are resolving from publicDir.
|
||||||
|
// simple join(publicDir, relativePath) is usually enough with 'bun'.
|
||||||
|
// But we use 'resolve' to handle .. segments correctly.
|
||||||
|
// We prepend '.' to relativePath to ensure it's treated as relative to publicDir logic
|
||||||
|
const normalizedRelative = relativePath.startsWith("/") ? "." + relativePath : relativePath;
|
||||||
|
const requestedPath = resolve(publicDir, normalizedRelative);
|
||||||
|
|
||||||
|
// Security Check: Block Path Traversal
|
||||||
|
if (requestedPath.startsWith(publicDir)) {
|
||||||
|
const staticFile = file(requestedPath);
|
||||||
|
if (await staticFile.exists()) {
|
||||||
|
return new Response(staticFile);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If path traversal detected, return 403 or 404.
|
||||||
|
// 403 indicates we caught them.
|
||||||
|
return new Response("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
||||||
|
return homeRoute();
|
||||||
|
}
|
||||||
|
if (url.pathname === "/health") {
|
||||||
|
return healthRoute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("Not Found", { status: 404 });
|
||||||
|
}
|
||||||
9
src/web/routes/health.ts
Normal file
9
src/web/routes/health.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export function healthRoute(): Response {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
status: "ok",
|
||||||
|
uptime: process.uptime(),
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}), {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
20
src/web/routes/home.ts
Normal file
20
src/web/routes/home.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { BaseLayout } from "../views/layout";
|
||||||
|
|
||||||
|
export function homeRoute(): Response {
|
||||||
|
const content = `
|
||||||
|
<div class="card">
|
||||||
|
<h2>Welcome</h2>
|
||||||
|
<p>The Aurora web server is up and running!</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Status</h3>
|
||||||
|
<p>System operational.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const html = BaseLayout({ title: "Home", content });
|
||||||
|
|
||||||
|
return new Response(html, {
|
||||||
|
headers: { "Content-Type": "text/html" },
|
||||||
|
});
|
||||||
|
}
|
||||||
24
src/web/server.ts
Normal file
24
src/web/server.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { env } from "@/lib/env";
|
||||||
|
import { router } from "./router";
|
||||||
|
import type { Server } from "bun";
|
||||||
|
|
||||||
|
export class WebServer {
|
||||||
|
private static server: Server<unknown> | null = null;
|
||||||
|
|
||||||
|
public static start() {
|
||||||
|
this.server = Bun.serve({
|
||||||
|
port: env.PORT || 3000,
|
||||||
|
fetch: router,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`🌐 Web server listening on http://localhost:${this.server.port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static stop() {
|
||||||
|
if (this.server) {
|
||||||
|
this.server.stop();
|
||||||
|
console.log("🛑 Web server stopped");
|
||||||
|
this.server = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/web/utils/html.test.ts
Normal file
17
src/web/utils/html.test.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { escapeHtml } from "./html";
|
||||||
|
|
||||||
|
describe("HTML Utils", () => {
|
||||||
|
it("should escape special characters", () => {
|
||||||
|
const unsafe = '<script>alert("xss")</script>';
|
||||||
|
const safe = escapeHtml(unsafe);
|
||||||
|
expect(safe).toBe("<script>alert("xss")</script>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle mixed content", () => {
|
||||||
|
const unsafe = 'Hello & "World"';
|
||||||
|
const safe = escapeHtml(unsafe);
|
||||||
|
expect(safe).toBe("Hello & "World"");
|
||||||
|
});
|
||||||
|
});
|
||||||
14
src/web/utils/html.ts
Normal file
14
src/web/utils/html.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes unsafe characters in a string to prevent XSS.
|
||||||
|
* @param unsafe - The raw string to escape.
|
||||||
|
* @returns The escaped string safe for HTML insertion.
|
||||||
|
*/
|
||||||
|
export function escapeHtml(unsafe: string): string {
|
||||||
|
return unsafe
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
34
src/web/views/layout.ts
Normal file
34
src/web/views/layout.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { escapeHtml } from "../utils/html";
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BaseLayout({ title, content }: LayoutProps): string {
|
||||||
|
const safeTitle = escapeHtml(title);
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${safeTitle} | Aurora</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
<meta name="description" content="Aurora Bot Web Interface">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Aurora Web</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="/">Home</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
${content}
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<p>© ${new Date().getFullYear()} Aurora Bot</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
59
tickets/2026-01-07-web-server-foundation.md
Normal file
59
tickets/2026-01-07-web-server-foundation.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# 2026-01-07-web-server-foundation: Web Server Infrastructure Foundation
|
||||||
|
|
||||||
|
**Status:** Done
|
||||||
|
**Created:** 2026-01-07
|
||||||
|
**Tags:** infrastructure, web, core
|
||||||
|
|
||||||
|
## 1. Context & User Story
|
||||||
|
* **As a:** Developer
|
||||||
|
* **I want to:** Establish a lightweight, integrated web server foundation within the existing codebase.
|
||||||
|
* **So that:** We can serve internal tools (Workbench) or public pages (Leaderboard) with minimal friction, avoiding complex separate build pipelines.
|
||||||
|
|
||||||
|
## 2. Technical Requirements
|
||||||
|
### Architecture
|
||||||
|
- **Native Bun Server:** Use `Bun.serve()` for high performance.
|
||||||
|
- **Exposure:** The server port must be exposed in `docker-compose.yml` to be accessible outside the container.
|
||||||
|
- **Rendering Strategy:** **Server-Side Rendering (SSR) via Template Literals**.
|
||||||
|
- *Why?* Zero dependencies. No build step (like Vite/Webpack) required. We can simply write functions that return HTML strings.
|
||||||
|
- *Client Side:* Minimal Vanilla JS or a lightweight drop-in library (like HTMX or Alpine from CDN) can be used if interactivity is needed later.
|
||||||
|
|
||||||
|
### File Organization (`src/web/`)
|
||||||
|
We will separate the web infrastructure from game modules to keep concerns clean.
|
||||||
|
- `src/web/server.ts`: Main server class/entry point.
|
||||||
|
- `src/web/router.ts`: Simple routing logic.
|
||||||
|
- `src/web/routes/`: Individual route handlers (e.g., `home.ts`, `health.ts`).
|
||||||
|
- `src/web/views/`: Reusable HTML template functions (Header, Footer, Layouts).
|
||||||
|
- `src/web/public/`: Static assets (CSS, Images) served directly.
|
||||||
|
|
||||||
|
### API / Interface
|
||||||
|
- **GET /health**: Returns `{ status: "ok", uptime: <seconds> }`.
|
||||||
|
- **GET /**: Renders a basic HTML landing page using the View system.
|
||||||
|
|
||||||
|
## 3. Constraints & Validations (CRITICAL)
|
||||||
|
- **Zero Frameworks:** No Express/NestJS.
|
||||||
|
- **Zero Build Tools:** No Webpack/Vite. The code must be runnable directly by `bun run`.
|
||||||
|
- **Docker Integration:** Port 3000 (or env `PORT`) must be mapped in Docker Compose.
|
||||||
|
- **Static Files:** Must implement a handler to check `src/web/public` for file requests.
|
||||||
|
|
||||||
|
## 4. Acceptance Criteria
|
||||||
|
1. [x] `docker-compose up` exposes port 3000.
|
||||||
|
2. [x] `http://localhost:3000` loads a styled HTML page (verifying static asset serving + SSR).
|
||||||
|
3. [x] `http://localhost:3000/health` returns JSON.
|
||||||
|
4. [x] Folder structure established as defined above.
|
||||||
|
|
||||||
|
## 5. Implementation Plan
|
||||||
|
- [x] **Infrastructure**: Create `src/web/` directory structure.
|
||||||
|
- [x] **Core Logic**: Implement `WebServer` class in `src/web/server.ts` with routing and static file serving logic.
|
||||||
|
- [x] **Integration**: Bind `WebServer.start()` to `src/index.ts`.
|
||||||
|
- [x] **Docker**: Update `docker-compose.yml` to map port `3000:3000`.
|
||||||
|
- [x] **Views**: Create a basic `BaseLayout` function in `src/web/views/layout.ts`.
|
||||||
|
- [x] **Env**: Add `PORT` to `config.ts` / `env.ts`.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
- Created `src/web` directory with `router.ts`, `server.ts` and subdirectories `routes`, `views`, `public`.
|
||||||
|
- Implemented `WebServer` class using `Bun.serve`.
|
||||||
|
- Added basic CSS and layout system.
|
||||||
|
- Added `PORT` to `src/lib/env.ts` (default 3000).
|
||||||
|
- Integrated into `src/index.ts` to start on boot and graceful shutdown.
|
||||||
|
- Fixed unrelated typing issues in `src/commands/admin/note.ts` and `src/db/indexes.test.ts` to pass strict CI checks.
|
||||||
|
- Verified with `bun test` and `bun x tsc`.
|
||||||
Reference in New Issue
Block a user