feat: implement one-port WebSocket server with updated connection handling and configuration

This commit is contained in:
MythEclipse
2026-05-13 17:49:33 +07:00
parent f9a4b4a92d
commit 0e056732bc
5 changed files with 188 additions and 19 deletions

View File

@@ -0,0 +1,149 @@
# One-Port WebSocket Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Serve Express HTTP endpoints, static frontend, and WebSocket traffic on one `WEBSERVER_PORT` using WebSocket path `/ws`.
**Architecture:** `src/webserver.ts` should create one `http.Server` from the Express app, attach `WebSocketServer` to that same server with `path: "/ws"`, and remove `port + 1`. `public/index.html` should connect to `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws` so dev, production, and reverse proxy setups use the same host and port.
**Tech Stack:** TypeScript, Express, Node HTTP server, `ws`, Bun scripts, Biome, TypeScript compiler.
---
## File Structure
- Modify `src/webserver.ts`: change WebSocket server construction and logs from separate port to shared HTTP server path `/ws`.
- Modify `public/index.html`: change browser WebSocket URL from hardcoded `:3001` to same-origin `/ws`.
- No new files required.
---
### Task 1: Attach WebSocket to Existing HTTP Server
**Files:**
- Modify: `src/webserver.ts`
- [ ] **Step 1: Update WebSocket server creation**
Replace this code in `src/webserver.ts`:
```ts
const wsPort = port + 1;
const wss = new WebSocketServer({ port: wsPort, host: "0.0.0.0" });
wsLogger.info({ wsPort }, "WebSocket server listening");
```
With:
```ts
const wsPath = "/ws";
const wss = new WebSocketServer({ server, path: wsPath });
wsLogger.info({ port, wsPath }, "WebSocket server listening");
```
- [ ] **Step 2: Update connection log**
Replace this code in `src/webserver.ts`:
```ts
wsLogger.info({ wsPort }, "New WebSocket connection");
```
With:
```ts
wsLogger.info({ port, wsPath }, "New WebSocket connection");
```
- [ ] **Step 3: Run typecheck**
Run:
```bash
bun run typecheck
```
Expected: command exits `0`.
---
### Task 2: Update Browser WebSocket URL
**Files:**
- Modify: `public/index.html`
- [ ] **Step 1: Replace hardcoded WebSocket port**
Replace this code in `public/index.html`:
```js
socket = new WebSocket(`ws://${window.location.hostname}:3001`);
```
With:
```js
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
socket = new WebSocket(`${wsProtocol}//${window.location.host}/ws`);
```
- [ ] **Step 2: Run lint and build**
Run:
```bash
bun run lint && bun run build
```
Expected: both commands exit `0`.
---
### Task 3: Verify One-Port Behavior
**Files:**
- Verify: `src/webserver.ts`
- Verify: `public/index.html`
- [ ] **Step 1: Start dev server**
Run:
```bash
bun run dev
```
Expected logs include Express web interface on configured port and WebSocket server listening with `{ port: 3000, wsPath: "/ws" }`.
- [ ] **Step 2: Browser smoke test**
Open:
```text
http://localhost:3000
```
Expected: page loads and browser WebSocket connects to:
```text
ws://localhost:3000/ws
```
- [ ] **Step 3: Endpoint smoke test**
Run:
```bash
curl http://localhost:3000/health
curl http://localhost:3000/metrics
```
Expected: `/health` returns JSON and `/metrics` returns Prometheus text.
---
## Self-Review
- Spec coverage: Covers one-port HTTP/WebSocket server, `/ws` path, frontend URL update, and verification.
- Placeholder scan: No TBD/TODO placeholders.
- Type consistency: Uses `wsPath` in both server creation and logs; frontend connects to `/ws`.

View File

@@ -178,7 +178,8 @@
function initWebSocket() { function initWebSocket() {
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return; if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return;
socket = new WebSocket(`ws://${window.location.hostname}:3001`); const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
socket = new WebSocket(`${wsProtocol}//${window.location.host}/ws`);
socket.binaryType = 'arraybuffer'; socket.binaryType = 'arraybuffer';
socket.onopen = () => { socket.onopen = () => {

View File

@@ -24,7 +24,9 @@ const configSchema = z.object({
AUDIO_CHANNELS: z.coerce.number().positive().default(2), AUDIO_CHANNELS: z.coerce.number().positive().default(2),
AVATAR_SIZE: z.coerce.number().positive().default(64), AVATAR_SIZE: z.coerce.number().positive().default(64),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
NODE_ENV: z.enum(["development", "production"]).default("development"), NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
}); });
export type AppConfig = z.infer<typeof configSchema>; export type AppConfig = z.infer<typeof configSchema>;

View File

@@ -46,9 +46,9 @@ export function startWebserver(port: number = 3000) {
const app = express(); const app = express();
const server = http.createServer(app); const server = http.createServer(app);
const wsPort = port + 1; const wsPath = "/ws";
const wss = new WebSocketServer({ port: wsPort, host: "0.0.0.0" }); const wss = new WebSocketServer({ server, path: wsPath });
wsLogger.info({ wsPort }, "WebSocket server listening"); wsLogger.info({ port, wsPath }, "WebSocket server listening");
// Security headers // Security headers
app.use(helmet()); app.use(helmet());
@@ -199,7 +199,7 @@ export function startWebserver(port: number = 3000) {
}, 20); }, 20);
wss.on("connection", (ws) => { wss.on("connection", (ws) => {
wsLogger.info({ wsPort }, "New WebSocket connection"); wsLogger.info({ port, wsPath }, "New WebSocket connection");
wsClients.add(ws); wsClients.add(ws);
ws.send( ws.send(

View File

@@ -1,17 +1,34 @@
import { describe, expect, it } from "vitest"; import process from "node:process";
import { parseBoolean, parsePositiveNumber } from "../src/config"; import { afterEach, describe, expect, it, vi } from "vitest";
describe("config parsers", () => { const originalEnv = process.env;
it("parses boolean values", () => {
expect(parseBoolean("true", false)).toBe(true); afterEach(() => {
expect(parseBoolean("false", true)).toBe(false); process.env = originalEnv;
expect(parseBoolean(undefined, true)).toBe(true); vi.resetModules();
}); });
it("parses positive numbers", () => { describe("loadConfig", () => {
expect(parsePositiveNumber("5000", 0)).toBe(5000); it("loads required values and coerces optional values", async () => {
expect(parsePositiveNumber("0", 123)).toBe(123); process.env = {
expect(parsePositiveNumber("bad", 123)).toBe(123); ...originalEnv,
expect(parsePositiveNumber(undefined, 123)).toBe(123); DISCORD_TOKEN: "token",
VOICE_CHANNEL_ID: "voice-channel",
GUILD_ID: "guild",
VERBOSE: "true",
WEBSERVER_PORT: "4000",
NODE_ENV: "test",
};
const { loadConfig } = await import("../src/config");
const config = loadConfig(process.env);
expect(config.DISCORD_TOKEN).toBe("token");
expect(config.VOICE_CHANNEL_ID).toBe("voice-channel");
expect(config.GUILD_ID).toBe("guild");
expect(config.VERBOSE).toBe(true);
expect(config.WEBSERVER_PORT).toBe(4000);
expect(config.RECORDINGS_DIR).toBe("./recordings");
expect(config.NODE_ENV).toBe("test");
}); });
}); });