diff --git a/docs/superpowers/plans/2026-05-13-one-port-websocket.md b/docs/superpowers/plans/2026-05-13-one-port-websocket.md new file mode 100644 index 0000000..6f24814 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-one-port-websocket.md @@ -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`. diff --git a/public/index.html b/public/index.html index c28bc13..bf36b44 100644 --- a/public/index.html +++ b/public/index.html @@ -178,7 +178,8 @@ function initWebSocket() { 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.onopen = () => { diff --git a/src/config.ts b/src/config.ts index 00e93bb..ce708b3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -24,7 +24,9 @@ const configSchema = z.object({ AUDIO_CHANNELS: z.coerce.number().positive().default(2), AVATAR_SIZE: z.coerce.number().positive().default(64), 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; diff --git a/src/webserver.ts b/src/webserver.ts index 4100633..a060acb 100644 --- a/src/webserver.ts +++ b/src/webserver.ts @@ -46,9 +46,9 @@ export function startWebserver(port: number = 3000) { const app = express(); const server = http.createServer(app); - const wsPort = port + 1; - const wss = new WebSocketServer({ port: wsPort, host: "0.0.0.0" }); - wsLogger.info({ wsPort }, "WebSocket server listening"); + const wsPath = "/ws"; + const wss = new WebSocketServer({ server, path: wsPath }); + wsLogger.info({ port, wsPath }, "WebSocket server listening"); // Security headers app.use(helmet()); @@ -199,7 +199,7 @@ export function startWebserver(port: number = 3000) { }, 20); wss.on("connection", (ws) => { - wsLogger.info({ wsPort }, "New WebSocket connection"); + wsLogger.info({ port, wsPath }, "New WebSocket connection"); wsClients.add(ws); ws.send( diff --git a/tests/config.test.ts b/tests/config.test.ts index a6d7241..9eb73e6 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,17 +1,34 @@ -import { describe, expect, it } from "vitest"; -import { parseBoolean, parsePositiveNumber } from "../src/config"; +import process from "node:process"; +import { afterEach, describe, expect, it, vi } from "vitest"; -describe("config parsers", () => { - it("parses boolean values", () => { - expect(parseBoolean("true", false)).toBe(true); - expect(parseBoolean("false", true)).toBe(false); - expect(parseBoolean(undefined, true)).toBe(true); - }); +const originalEnv = process.env; - it("parses positive numbers", () => { - expect(parsePositiveNumber("5000", 0)).toBe(5000); - expect(parsePositiveNumber("0", 123)).toBe(123); - expect(parsePositiveNumber("bad", 123)).toBe(123); - expect(parsePositiveNumber(undefined, 123)).toBe(123); +afterEach(() => { + process.env = originalEnv; + vi.resetModules(); +}); + +describe("loadConfig", () => { + it("loads required values and coerces optional values", async () => { + process.env = { + ...originalEnv, + 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"); }); });