From 56b2fb206cf591adff0b11db18a54d5d48de2d1d Mon Sep 17 00:00:00 2001 From: Carlos Lugones Date: Wed, 4 Mar 2026 04:22:12 +0100 Subject: [PATCH 01/19] docs: added CLAUDE.md --- CLAUDE.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bc6d989 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,54 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +bun install # Install dependencies +bun run dev # Start dev server with hot reload (watches src/index.ts) +``` + +There are no tests configured (`test` script exits with error). + +**Docker:** +```bash +docker-compose up # Run production server on port 8000 +``` + +## Environment Variables + +| Variable | Default | Description | +|---|---|---| +| `PORT` | `8000` | Server port | +| `NODE_ENV` | (dev if unset) | Set to `production` to disable all logging | +| `LOG_LEVEL` | `all` | One of: `all`, `debug`, `info`, `warn`, `error` | + +## Architecture + +Single-file entry point at `src/index.ts` bootstraps an **Elysia** (Bun-native) HTTP/WebSocket server. All real-time communication happens over the `/ws` WebSocket endpoint. + +**Data flow:** +1. Client connects via WebSocket at `/ws` +2. Client sends JSON messages typed by `MessageType` enum (`src/types.ts`) +3. `index.ts` dispatches on `message.type` to `ChatManager` methods +4. `ChatManager` (`src/chat-manager.ts`) manages in-memory room state and broadcasts back to all room members + +**Key design decisions:** +- **Zero persistence**: All state lives in `ChatManager.rooms` (a `Map`). Rooms are created on first join and deleted when empty. No database, no disk writes. +- **No authentication**: Username uniqueness is enforced per-room only; duplicate usernames in the same room are rejected. +- **Logging suppressed in production**: `Logger` (`src/utils/logger.ts`) silently drops all log calls when `NODE_ENV=production`. + +**Source layout:** +- `src/index.ts` — Elysia app, WebSocket handlers, HTTP routes (`/`, `/health`) +- `src/chat-manager.ts` — `ChatManager` class: `joinRoom`, `leaveRoom`, `broadcastMessage`, `handleDisconnect` +- `src/types.ts` — All message interfaces and `MessageType` enum +- `src/config.ts` — Reads env vars into a typed `config` object +- `src/utils/logger.ts` — Leveled logger, no-ops in production +- `src/templates/index.html.ts` — HTML landing page rendered for browser visits to `/` + +**WebSocket message types** (all exchanged as JSON): +- `JOIN_ROOM` / `LEAVE_ROOM` — client-initiated room membership +- `CHAT_MESSAGE` / `IMAGE_MESSAGE` — relayed to all room members (images as base64) +- `USER_LIST` — server-broadcast after membership changes +- `ERROR` — server-sent to individual client on failure From 1012f01c6d062831a4ac4d696faf7cea33e5ca13 Mon Sep 17 00:00:00 2001 From: Carlos Lugones Date: Wed, 4 Mar 2026 04:32:32 +0100 Subject: [PATCH 02/19] fix: upgraded Elysia to v1.4.18 to fix security issue CVE-2025-66457 --- bun.lockb | Bin 4166 -> 8833 bytes package.json | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/bun.lockb b/bun.lockb index 7843a716f94aef6210c73ad1fca86e0008ec10b0..a9555c677f7292d3f5381360220e0f9f9ae7d13f 100755 GIT binary patch literal 8833 zcmeHMd0b6tAHTP5B*P@38H_?wx?9~gEov~L8CzK@((QK2t?uI9+h)B~b|y=ct!ZHh z(MV~UvJA#l6owEbvZM_eOBi~8&$;L6J%fsQ-#^~>Gk$$;zw<1=@AG|r&w9>pkO?H1pE?J5^y{^ z@8!&p+!>YQ4s1;5!5sZ^v9`F$1S)~_)+mPV8(K}J@8b$&+K|Ce6JgOxv5>(if}9?Z z&IH{JbQ0(;pmUj;{0z`tAuT2d!p=s(5NR1p!uvhPQx z+-kn=Tpl+dN$ZBdAiL(%s;#&9YyBfX>71VCCph(!U#E*UxlP(c_er-CpKQxi$4&`< zJ$b;WX0Mv$DZ;GuhOZ7zzfft|)3ADCk*>wD@4V`!cwc|H{#oSO7>3~H=f$UXWS=a{ zx%{pB;2zpJ7MrCG`XiTToH>zZ827^8z(l8Z+=dG~Zu31Gb>=k$mn=7^AGnU|vt~$+ zvqKk|)2X^iTk^M`-V~i2^1B$^@Lv&dFDd=uzYX{SA|n9@#)uTm3(eN|5sWhn3>^Wd7iILm_P7m%uBT!sV2@ke3J~$uay)VvgFmwhST>tO-=Puxkrs4cuKEr^39pLCugfJgG zA2H9iG;TKl4Bcpau>FVnXFK3n0S>mu8uMz42hPWQ%D})9aBv#)_)r`s5E%ZBe&O~y z(zty`!0`bb%mdrKna25VYK)TrIIc84IKT6o25qEq{W8E2(D+~;Shr!`nvHR~z`{iS z!FD(w;NMGd{b_*X066$SgENbi_g(iGXB!x}0S;d8=p6bG!8nzGgZzU}Ud*HW2Y?|g z^n>#bowcOUHS*D?R6f#ZZo*kh%DXi3F`UgLCfZ+78vE;A8kP5f9}=Vy7dWd)yU#%* zK^oOV^8dy6^{1~faSi?d{6F9^85&$VG^>1Ukih+{>BJG<2^0J?j+kE?Hh%`s)@Qwr zgc#tGc7<8-V6)?5zaC2^Hu2NavlpK2Zc;c)STIYmYt%ElN|S_vG+g9kEUbC{3yy?d z4c)f$Zm$&G`3`!gC)6z6e@}ar-%s8KSq+Ys>q_se$cbLmBvhSZr%xw)EBBmNY?cZ?guARe#hLDrva%b&1%`G+g@r$_hWTC)V-u zMb7Yqh#6B;b`QHL;T!f+#dNEGSasLYwsh6TtknELdv#6?bMJb;*ni()^RiOQNa-`H z<{!Q9P3clMn}&<`7n1Mr=3igBgmdR|&o!t$$4E<0aS9r3oEA1!Uym^xkdl|~KVCj& zy}``B`9s}q|8AJGrn07Hz|gJ1OXnvFIlfxoRM2qIo`Z$8A^*Y9r&SF}$-$dVKhuB3 ztWKW%Vo=_C{R+e9`B}4$)=7VfHq;sQ+8>AE%|XtS%y6g7vE<|+)-gX zpZWSnM$Za)l#nZlGb(WTH8$xCOTKm7{d@MFUfu3*9xo(H3Z_MOuRiS`P_TFWp+q|I4fODb-tU zP3->Xs=W)<#~eKKynK_ox;^*ZApc?KozC`W!*`6^WZ6qt64wwCzjElFt<7Sw@Q-wlBr59KmPi<%Pd~lMV{qUR#N4(h zR^=`CM~=zu{KL-FbI(#aQELRw{k;n5`x(45whF8Hx7bZ~UA0$^IAV3E^xlmPjZ-G{ zJYU^Md~NQkJ)@JyT{@|E&f;m8jKiIB+0uh8Zi&GrMX5g@|2`~d&gIw!qlN$)UwC(I z71mrXBdzpS=+|F-wN5!?4&#YiQA^IbE$OC`erAjOd#*XQz$er3=Hz8h$J|sph;{HdQunk!7PdvKE=NjM4 zWY0!k+8^b*yBv3E57obNGCS%M^OQkD`W^XgZ`E#N*D@WuuuYcdX}IWI#=?5xb@AMv z-hK$^tbA%Ctj;H{&<$_nOHaQZh?>?BaUYMTz@fZJ%;_r7aB?zn>w_EJ@WB!<-|NjNH!rdO!1v zJJb8E&2Lb8R_&0fd{gTEW~!@v>qDEr^Lch5%T`n1hs?CBRPe3vK2Ml+R6nC+kilOkrDh*b@X0^-ht5j3VOFd zji9?Vx(B0oUUa`icR_S-Lw786UZcAJx)-6l25SEy)$R-g3Zm!KcKgT{wU2Dk9SGqd zT&hbghzH_>c%iltAM|d5xFJr68+xZe8u3D9h#RVpxFCM$>_@ zU7U^ifX7=&l_Z<0Er-Ps0c42{hsWXJ_yvv&=rcD`_?e)=b7~wp&}Z(XN{T?k_^5Gg zL7!Pb)x}W_94(-@0c#xh!0`msA0Ee=BVgc&364ZSi7kip4vwkdSOu~Hk3vszGzCW~ z$abudcW}G~$1%tfYh;5XGC0yfqIF<{qcb?_0X70)V9jWUHIC%q2npCgE6CeB)Rk{WM3j%#3oI87PbMd%Lb+H&4|2(I;u$*`^Ierhx!6kM@12<>KM zhNkP`VyVhrs89%_*+i5|A#87`-l_`?zLf_9i1wZKdrc#NJcjsb=&;ewU`4VHkST~r zv5Kn#UNGJ;__U6YChfSD+6w@jdIDO=m+jMfvw>D_Xd~;WPp)qhM)s}jVM27wfZ9dD zka}WkHqrMdP7N{Yxn{7TUD$y-03i24upu>@+yXnWkSL+LM9F@i8QQu?kqKU~B(IK~ z+UB|kq&CZ7tl=Q-fIoeA0Wf-!94WgF>@{aTSZW@iP4w6P{A%3~!L0R}oS6b-No`?~ zO6oxhlfIofPOAlYs509MM#CD`AAp5R1H~e0e`^PXUJX#_Nmz^I?%95yT1kU-q>@dZ YYGiLTX9gVDHjtTFCui?z5oCK delta 1344 zcmZp4J*F^0OP7g(fq`>nT~^Mky}j=v{-0(HzGyjlAMdppUSGesPC4$<`|79rHh!QY zAmEx9E?=L-1>rzQ5e9~a)SSxV%tQu;9H0;{ke>{sIYD%Oes*Rm149syF9_rV)q=%$ zGBPl51Nnb}G!Ky80Hi_c10z0un}6u)Jm&?Ef84XZ*~))^_G81ogmM)@=B-*Y^DHM{ zUh>3{JNPZD{9L97CPG`DPd>nyFu8zm5 ziSix;vaEojKnf`GkWm|?QxK%U1Skqp2LeoBwI)EW6I89z%R%`aMZyXpzxMer?%U*Nvis`N<-Gp=a=T0pCnXC^aBdV)-*I?z!QY7n^E5B` zP5Pj;veI2QD0Oesmn*+ds0Z_#O+L#d;qztH|NiE!FIv`rHgIg2rSx#?xr8fEL^iMT z*n5RXR=u~zDdzg--ZxX%%xSaCPN+No>Xzr$W`>xWdadhjlMBf~%be?Xdb zvL;vfYd#WN@-K)z=IVo(f&lv)6jJ_w|Mlq#8l5(y~sK++&( zAmt!!|A7Fc9A*@Vo&nXmjc0P2L})!zPGU)FaS0-W!SV$YW1NAWv5B6MIV=l;vkoxl z0A&pI4D^f`7?OeJ0nKlKWiE&wJp)8GhGjpH9wR+NJ!6JSpdO${8|JGv=xDb(r6!3m z+Sw|Y0u3~P<>AGrXH?G5x|GSpSZASUs%L1x0L$$Y^`m;#w%S@UF=_#g0fE1alVzkO zEO09T=P8g%P~HLpNSJ5l6_+ID Date: Wed, 4 Mar 2026 04:34:32 +0100 Subject: [PATCH 03/19] docs: updated README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 69f178a..b3813dc 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,9 @@ This server works standalone. You can use this frontend client [here](https://gi ## Privacy Highlights - **No Message Storage**: Messages are not stored on the server, only relayed between authorized participants -- **No Logs**: All logs are disabled in production mode +- **No Logs**: All logs are disabled when `NODE_ENV=production`. Logs are active in development mode. - **No Third-Party Services**: Operates independently without external service dependencies -- **Encrypted Trnamission**: All messages are encrypted in transit using secure WebSockets +- **Encrypted Transmission**: Messages are transmitted over WSS (WebSocket Secure). TLS termination is handled at the infrastructure level (e.g. reverse proxy). The server itself does not manage certificates. - **Open Source**: Full transparency about how your data is handled ## Getting Started From f154920f881df0966a55add8743f0afcaae7116e Mon Sep 17 00:00:00 2001 From: Carlos Lugones Date: Wed, 4 Mar 2026 04:39:45 +0100 Subject: [PATCH 04/19] perf: included ImageMessage in the Message union type --- src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types.ts b/src/types.ts index 2def401..5d42576 100644 --- a/src/types.ts +++ b/src/types.ts @@ -106,4 +106,4 @@ export interface UserListMessage extends BaseMessage { /** * Union type of all possible message types */ -export type Message = JoinRoomMessage | LeaveRoomMessage | ChatMessage | ErrorMessage | UserListMessage; +export type Message = JoinRoomMessage | LeaveRoomMessage | ChatMessage | ImageMessage | ErrorMessage | UserListMessage; From e5c1ed22888ec6fb8ec853c85b52b5b4227f4a0a Mon Sep 17 00:00:00 2001 From: Carlos Lugones Date: Wed, 4 Mar 2026 04:42:29 +0100 Subject: [PATCH 05/19] fix: only broadcast list of users when the room still has members --- src/chat-manager.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/chat-manager.ts b/src/chat-manager.ts index 128b002..929c099 100644 --- a/src/chat-manager.ts +++ b/src/chat-manager.ts @@ -127,19 +127,19 @@ export class ChatManager { content: `@${username} left the room` }; this.broadcastMessage(roomId, message); + + // Broadcast the list of users to the room + const userListMessage: UserListMessage = { + system: true, + type: MessageType.USER_LIST, + roomId: roomId, + users: Array.from(roomData.usernames) + }; + this.broadcastMessage(roomId, userListMessage); } logger.info(`User ${username} left room ${roomId}`); - // Broadcast the list of users to the room - const userListMessage: UserListMessage = { - system: true, - type: MessageType.USER_LIST, - roomId: roomId, - users: Array.from(roomData.usernames) - }; - this.broadcastMessage(roomId, userListMessage); - return true; } From a8809125ff7e23bb2e3ee3b064a87979c53a8a17 Mon Sep 17 00:00:00 2001 From: Carlos Lugones Date: Wed, 4 Mar 2026 04:46:58 +0100 Subject: [PATCH 06/19] perf: removed redundant usernames ephemeral storage since it can be derived from client.keys() --- src/chat-manager.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/chat-manager.ts b/src/chat-manager.ts index 929c099..89da8b7 100644 --- a/src/chat-manager.ts +++ b/src/chat-manager.ts @@ -8,8 +8,6 @@ import { logger } from "./utils/logger"; interface RoomData { /** Map of usernames to ElysiaWS connections */ clients: Map; - /** Set of all usernames in the room */ - usernames: Set; } /** @@ -31,8 +29,7 @@ export class ChatManager { } this.rooms.set(roomId, { - clients: new Map(), - usernames: new Set() + clients: new Map() }); logger.info(`Room ${roomId} created`); return true; @@ -54,7 +51,7 @@ export class ChatManager { const roomData = this.rooms.get(roomId)!; // Check if username is already in the room - if (roomData.usernames.has(username)) { + if (roomData.clients.has(username)) { logger.warn(`Username ${username} already taken in room ${roomId}`); return false; } @@ -69,7 +66,6 @@ export class ChatManager { // Add user to room roomData.clients.set(username, ws); - roomData.usernames.add(username); // Notify all users in the room that a new user has joined const message: JoinRoomMessage = { @@ -88,10 +84,10 @@ export class ChatManager { system: true, type: MessageType.USER_LIST, roomId: roomId, - users: Array.from(roomData.usernames) + users: Array.from(roomData.clients.keys()) }; this.broadcastMessage(roomId, userListMessage); - + return true; } @@ -111,7 +107,6 @@ export class ChatManager { } roomData.clients.delete(username); - roomData.usernames.delete(username); // If room is empty, delete it if (roomData.clients.size === 0) { @@ -133,7 +128,7 @@ export class ChatManager { system: true, type: MessageType.USER_LIST, roomId: roomId, - users: Array.from(roomData.usernames) + users: Array.from(roomData.clients.keys()) }; this.broadcastMessage(roomId, userListMessage); } From fef5178ea32163a8e42253f2d8bc814c72d5d894 Mon Sep 17 00:00:00 2001 From: Carlos Lugones Date: Wed, 4 Mar 2026 04:48:52 +0100 Subject: [PATCH 07/19] feat: validate room IDs and usernames --- src/chat-manager.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/chat-manager.ts b/src/chat-manager.ts index 89da8b7..9210b71 100644 --- a/src/chat-manager.ts +++ b/src/chat-manager.ts @@ -10,6 +10,13 @@ interface RoomData { clients: Map; } +const MAX_ID_LENGTH = 64; +const VALID_ID_RE = /^[a-zA-Z0-9_-]+$/; + +function isValidId(value: string): boolean { + return value.length > 0 && value.length <= MAX_ID_LENGTH && VALID_ID_RE.test(value); +} + /** * Manages chat rooms, users, and message broadcasting */ @@ -43,6 +50,11 @@ export class ChatManager { * @returns true if user successfully joined, false otherwise */ joinRoom(ws: ElysiaWS, roomId: string, username: string): boolean { + if (!isValidId(roomId) || !isValidId(username)) { + logger.warn(`Invalid roomId or username: "${roomId}", "${username}"`); + return false; + } + // Create room if it doesn't exist if (!this.rooms.has(roomId)) { this.createRoom(roomId); From 5a4243f9afa49800e0978eaf3d83f10b6843b1e2 Mon Sep 17 00:00:00 2001 From: Carlos Lugones Date: Wed, 4 Mar 2026 04:53:12 +0100 Subject: [PATCH 08/19] feat: added max payload size, improved port handling --- src/config.ts | 5 +++++ src/index.ts | 7 +++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index 2530541..39d60f1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,4 +16,9 @@ export const config = { * Log level */ logLevel: process.env.LOG_LEVEL || 'all', + + /** + * Maximum WebSocket payload size in bytes (default: 10 MB) + */ + maxPayloadBytes: parseInt(process.env.MAX_PAYLOAD_SIZE_MB || '10') * 1024 * 1024, }; diff --git a/src/index.ts b/src/index.ts index e19e22a..e924c1e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,9 +26,12 @@ function isBrowser(userAgent: string | null): boolean { * Initialize the chat application */ const chatManager = new ChatManager(); -const port = (config.isDevelopment && Bun.argv.slice(2).includes("--port")) ? Bun.argv.slice(2)[1] : config.port ; +const args = Bun.argv.slice(2); +const portFlagIndex = args.indexOf("--port"); +const port = (config.isDevelopment && portFlagIndex !== -1) ? args[portFlagIndex + 1] : config.port; const app = new Elysia() .ws('/ws', { + maxPayloadLength: config.maxPayloadBytes, /** * Handle new WebSocket connection */ @@ -60,7 +63,7 @@ const app = new Elysia() timestamp: Date.now(), }; chatManager.sendError(ws, errorMessage); - } + } break; case MessageType.LEAVE_ROOM: From ab23441cb125d754b3b8c601d86663ee8f34222a Mon Sep 17 00:00:00 2001 From: Carlos Lugones Date: Wed, 4 Mar 2026 04:55:33 +0100 Subject: [PATCH 09/19] perf: improved logger with hierarchy --- src/utils/logger.ts | 50 +++++++++++---------------------------------- 1 file changed, 12 insertions(+), 38 deletions(-) diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 3d4b57f..c887f4e 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,57 +1,31 @@ import { config } from '../config'; +const LEVELS = { debug: 0, info: 1, warn: 2, error: 3, all: 0 }; + +function isEnabled(level: 'debug' | 'info' | 'warn' | 'error'): boolean { + if (!config.isDevelopment) return false; + const configured = config.logLevel as keyof typeof LEVELS; + return LEVELS[level] >= LEVELS[configured ?? 'all']; +} + /** * Simple logger utility for consistent logging throughout the application */ class Logger { - - - /** - * Log debug message (only in development) - */ debug(message: string, ...args: any[]): void { - if (!config.isDevelopment) { - return; - } - if (config.logLevel === 'all' || config.logLevel === 'debug') { - console.debug(`[DEBUG] ${message}`, ...args); - } + if (isEnabled('debug')) console.debug(`[DEBUG] ${message}`, ...args); } - /** - * Log info message - */ info(message: string, ...args: any[]): void { - if (!config.isDevelopment) { - return; - } - if (config.logLevel === 'all' || config.logLevel === 'info') { - console.info(`[INFO] ${message}`, ...args); - } + if (isEnabled('info')) console.info(`[INFO] ${message}`, ...args); } - /** - * Log warning message - */ warn(message: string, ...args: any[]): void { - if (!config.isDevelopment) { - return; - } - if (config.logLevel === 'all' || config.logLevel === 'warn') { - console.warn(`[WARN] ${message}`, ...args); - } + if (isEnabled('warn')) console.warn(`[WARN] ${message}`, ...args); } - /** - * Log error message - */ error(message: string, ...args: any[]): void { - if (!config.isDevelopment) { - return; - } - if (config.logLevel === 'all' || config.logLevel === 'error') { - console.error(`[ERROR] ${message}`, ...args); - } + if (isEnabled('error')) console.error(`[ERROR] ${message}`, ...args); } } From 816a3708e474ecafc30e1592f733ebbd49023eea Mon Sep 17 00:00:00 2001 From: Carlos Lugones Date: Wed, 4 Mar 2026 05:00:10 +0100 Subject: [PATCH 10/19] fix: use public elysia/ws import, pin bun-types, set moduleResolution to bundler --- package.json | 2 +- src/chat-manager.ts | 2 +- tsconfig.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 038fa7e..11532ac 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "elysia": "^1.4.27" }, "devDependencies": { - "bun-types": "latest" + "bun-types": "1.2.4" }, "module": "src/index.js" } \ No newline at end of file diff --git a/src/chat-manager.ts b/src/chat-manager.ts index 9210b71..23bc935 100644 --- a/src/chat-manager.ts +++ b/src/chat-manager.ts @@ -1,4 +1,4 @@ -import { ElysiaWS } from "elysia/dist/ws"; +import { ElysiaWS } from "elysia/ws"; import { Message, MessageType, JoinRoomMessage, LeaveRoomMessage, ErrorMessage, UserListMessage } from "./types"; import { logger } from "./utils/logger"; diff --git a/tsconfig.json b/tsconfig.json index 1ca2350..74e21b4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,7 @@ /* Modules */ "module": "ES2022", /* Specify what module code is generated. */ // "rootDir": "./", /* Specify the root folder within your source files. */ - "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "bundler", /* Specify how TypeScript looks up a file from a given module specifier. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ From 41b1afa6a8538292a156079d1d6bb5359eeddd85 Mon Sep 17 00:00:00 2001 From: Carlos Lugones Date: Wed, 4 Mar 2026 05:06:35 +0100 Subject: [PATCH 11/19] feat: check if the client is a room member before allowing broadcast of image or text --- src/chat-manager.ts | 14 ++++++++++++++ src/index.ts | 14 +++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/chat-manager.ts b/src/chat-manager.ts index 23bc935..cb7bd8c 100644 --- a/src/chat-manager.ts +++ b/src/chat-manager.ts @@ -150,6 +150,20 @@ export class ChatManager { return true; } + /** + * Check if a WebSocket connection is a member of a room + * @param ws - ElysiaWS connection to check + * @param roomId - ID of the room + */ + isInRoom(ws: ElysiaWS, roomId: string): boolean { + const roomData = this.rooms.get(roomId); + if (!roomData) return false; + for (const connection of roomData.clients.values()) { + if (connection.id === ws.id) return true; + } + return false; + } + /** * Broadcast a message to all users in a room * @param roomId - ID of the room to broadcast to diff --git a/src/index.ts b/src/index.ts index e924c1e..b32a36e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -72,10 +72,18 @@ const app = new Elysia() break; case MessageType.CHAT_MESSAGE: - chatManager.broadcastMessage(parsedMessage.roomId, parsedMessage); - break; - case MessageType.IMAGE_MESSAGE: + if (!chatManager.isInRoom(ws, parsedMessage.roomId)) { + chatManager.sendError(ws, { + system: true, + type: MessageType.ERROR, + roomId: parsedMessage.roomId, + username: '', + content: 'Not a member of this room', + timestamp: Date.now(), + }); + break; + } chatManager.broadcastMessage(parsedMessage.roomId, parsedMessage); break; From a52384736649b93d398ea8cf86bcf0ea693ff1e7 Mon Sep 17 00:00:00 2001 From: Carlos Lugones Date: Wed, 4 Mar 2026 05:09:26 +0100 Subject: [PATCH 12/19] perf: username made optional for system-level error messages --- src/index.ts | 2 -- src/types.ts | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index b32a36e..a351325 100644 --- a/src/index.ts +++ b/src/index.ts @@ -78,7 +78,6 @@ const app = new Elysia() system: true, type: MessageType.ERROR, roomId: parsedMessage.roomId, - username: '', content: 'Not a member of this room', timestamp: Date.now(), }); @@ -97,7 +96,6 @@ const app = new Elysia() system: true, type: MessageType.ERROR, roomId: '', - username: '', content: 'Invalid message format', timestamp: Date.now(), }; diff --git a/src/types.ts b/src/types.ts index 5d42576..bee89fa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -85,8 +85,8 @@ export interface ErrorMessage extends BaseMessage { type: MessageType.ERROR; /** ID of the room where the error occurred */ roomId: string; - /** Username to whom the error relates */ - username: string; + /** Username to whom the error relates (omitted for system-level errors) */ + username?: string; /** Error message content */ content: string; /** Timestamp when the error occurred */ From 96cd19faba28f94a5d733d9449c35f93dcdf1f3b Mon Sep 17 00:00:00 2001 From: Carlos Lugones Date: Wed, 4 Mar 2026 05:10:42 +0100 Subject: [PATCH 13/19] fix: added missing type to the UserListMessage interface --- src/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/types.ts b/src/types.ts index bee89fa..61f2ce8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -97,6 +97,7 @@ export interface ErrorMessage extends BaseMessage { * Message containing a list of users in a room */ export interface UserListMessage extends BaseMessage { + type: MessageType.USER_LIST; /** ID of the room */ roomId: string; /** List of usernames in the room */ From 92c2c865b07c9e82d2ed14f05af5834b035b491a Mon Sep 17 00:00:00 2001 From: Carlos Lugones Date: Wed, 4 Mar 2026 05:13:09 +0100 Subject: [PATCH 14/19] perf: improved error handling when joining room --- src/chat-manager.ts | 12 ++++++------ src/index.ts | 11 +++++------ 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/chat-manager.ts b/src/chat-manager.ts index cb7bd8c..7120e3d 100644 --- a/src/chat-manager.ts +++ b/src/chat-manager.ts @@ -47,12 +47,12 @@ export class ChatManager { * @param ws - ElysiaWS connection of the user * @param roomId - ID of the room to join * @param username - Username of the user - * @returns true if user successfully joined, false otherwise + * @returns null on success, or an error string describing the failure */ - joinRoom(ws: ElysiaWS, roomId: string, username: string): boolean { + joinRoom(ws: ElysiaWS, roomId: string, username: string): string | null { if (!isValidId(roomId) || !isValidId(username)) { logger.warn(`Invalid roomId or username: "${roomId}", "${username}"`); - return false; + return 'Invalid room ID or username'; } // Create room if it doesn't exist @@ -65,14 +65,14 @@ export class ChatManager { // Check if username is already in the room if (roomData.clients.has(username)) { logger.warn(`Username ${username} already taken in room ${roomId}`); - return false; + return 'Username already taken'; } // Check if this connection is already in another username in the room for (const [_, connection] of roomData.clients.entries()) { if (connection === ws) { logger.warn(`Connection already in room ${roomId}`); - return false; + return 'Already joined this room'; } } @@ -100,7 +100,7 @@ export class ChatManager { }; this.broadcastMessage(roomId, userListMessage); - return true; + return null; } /** diff --git a/src/index.ts b/src/index.ts index a351325..63f94c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,17 +52,16 @@ const app = new Elysia() case MessageType.JOIN_ROOM: const joinRoomMessage = parsedMessage as JoinRoomMessage; - const success = chatManager.joinRoom(ws, joinRoomMessage.roomId, joinRoomMessage.username); - if (!success) { - const errorMessage: ErrorMessage = { + const joinError = chatManager.joinRoom(ws, joinRoomMessage.roomId, joinRoomMessage.username); + if (joinError) { + chatManager.sendError(ws, { system: true, type: MessageType.ERROR, roomId: joinRoomMessage.roomId, username: joinRoomMessage.username, - content: 'Username already taken', + content: joinError, timestamp: Date.now(), - }; - chatManager.sendError(ws, errorMessage); + }); } break; From e32935e92e69a9159bf3455c49468a92c7ad2525 Mon Sep 17 00:00:00 2001 From: Carlos Lugones Date: Wed, 4 Mar 2026 05:14:46 +0100 Subject: [PATCH 15/19] fix: replaced require() with ESM import and update tsconfig for bundler resolution --- src/index.ts | 2 +- tsconfig.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 63f94c6..9fae3e7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import { logger } from './utils/logger'; import { config } from './config'; import { renderIndexPage } from './templates/index.html'; -const VERSION = require('../package.json').version; +import { version as VERSION } from '../package.json'; /** * Determines if the user agent is a browser diff --git a/tsconfig.json b/tsconfig.json index 74e21b4..978744d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -35,7 +35,7 @@ "types": ["bun-types"], /* Specify type package names to be included without being referenced in a source file. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ - // "resolveJsonModule": true, /* Enable importing .json files. */ + "resolveJsonModule": true, /* Enable importing .json files. */ // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ /* JavaScript Support */ From 7aaf8e64b0cbd79411395bd9acaf7b45087c9899 Mon Sep 17 00:00:00 2001 From: Carlos Lugones Date: Wed, 4 Mar 2026 05:20:45 +0100 Subject: [PATCH 16/19] tests: added basic unit tests --- src/__tests__/chat-manager.test.ts | 322 +++++++++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 src/__tests__/chat-manager.test.ts diff --git a/src/__tests__/chat-manager.test.ts b/src/__tests__/chat-manager.test.ts new file mode 100644 index 0000000..818eddc --- /dev/null +++ b/src/__tests__/chat-manager.test.ts @@ -0,0 +1,322 @@ +import { describe, test, expect, beforeEach, jest } from "bun:test"; +import { ElysiaWS } from "elysia/ws"; +import { ChatManager } from "../chat-manager"; +import { MessageType } from "../types"; + +// Suppress logger output during tests +process.env.NODE_ENV = "production"; + +function createMockWs(id = "ws-1") { + return { id, send: jest.fn() } as unknown as ElysiaWS; +} + +function parseSent(ws: ReturnType, callIndex = 0) { + const raw = (ws.send as ReturnType).mock.calls[callIndex][0]; + return JSON.parse(raw as string); +} + +// --------------------------------------------------------------------------- + +describe("ChatManager.createRoom", () => { + let manager: ChatManager; + beforeEach(() => { manager = new ChatManager(); }); + + test("creates a new room and returns true", () => { + expect(manager.createRoom("room-1")).toBe(true); + }); + + test("returns false if room already exists", () => { + manager.createRoom("room-1"); + expect(manager.createRoom("room-1")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- + +describe("ChatManager.joinRoom", () => { + let manager: ChatManager; + beforeEach(() => { manager = new ChatManager(); }); + + test("returns null on successful join", () => { + const ws = createMockWs(); + expect(manager.joinRoom(ws, "room-1", "alice")).toBeNull(); + }); + + test("creates room automatically if it does not exist", () => { + const ws = createMockWs(); + manager.joinRoom(ws, "new-room", "alice"); + expect(manager.isInRoom(ws, "new-room")).toBe(true); + }); + + test("returns error for empty roomId", () => { + expect(manager.joinRoom(createMockWs(), "", "alice")).toBe("Invalid room ID or username"); + }); + + test("returns error for roomId that is too long", () => { + expect(manager.joinRoom(createMockWs(), "a".repeat(65), "alice")).toBe("Invalid room ID or username"); + }); + + test("returns error for roomId with special characters", () => { + expect(manager.joinRoom(createMockWs(), "room@1", "alice")).toBe("Invalid room ID or username"); + }); + + test("returns error for empty username", () => { + expect(manager.joinRoom(createMockWs(), "room-1", "")).toBe("Invalid room ID or username"); + }); + + test("returns error for username with special characters", () => { + expect(manager.joinRoom(createMockWs(), "room-1", "ali ce")).toBe("Invalid room ID or username"); + }); + + test("returns error for duplicate username in same room", () => { + manager.joinRoom(createMockWs("ws-1"), "room-1", "alice"); + expect(manager.joinRoom(createMockWs("ws-2"), "room-1", "alice")).toBe("Username already taken"); + }); + + test("returns error for duplicate connection", () => { + const ws = createMockWs(); + manager.joinRoom(ws, "room-1", "alice"); + expect(manager.joinRoom(ws, "room-1", "bob")).toBe("Already joined this room"); + }); + + test("broadcasts JOIN_ROOM to all members after join", () => { + const ws1 = createMockWs("ws-1"); + const ws2 = createMockWs("ws-2"); + manager.joinRoom(ws1, "room-1", "alice"); + manager.joinRoom(ws2, "room-1", "bob"); + + // ws1 should have received the JOIN_ROOM broadcast for bob + const calls = (ws1.send as ReturnType).mock.calls; + const joinMsg = calls.map((c: any[]) => JSON.parse(c[0] as string)) + .find((m: any) => m.type === MessageType.JOIN_ROOM && m.username === "bob"); + expect(joinMsg).toBeDefined(); + }); + + test("broadcasts USER_LIST after join", () => { + const ws1 = createMockWs("ws-1"); + const ws2 = createMockWs("ws-2"); + manager.joinRoom(ws1, "room-1", "alice"); + manager.joinRoom(ws2, "room-1", "bob"); + + const calls = (ws2.send as ReturnType).mock.calls; + const userListMsg = calls.map((c: any[]) => JSON.parse(c[0] as string)) + .find((m: any) => m.type === MessageType.USER_LIST); + expect(userListMsg?.users).toEqual(expect.arrayContaining(["alice", "bob"])); + }); + + test("accepts valid IDs with underscores and hyphens", () => { + const ws = createMockWs(); + expect(manager.joinRoom(ws, "my_room-1", "user_name-1")).toBeNull(); + }); + + test("accepts roomId and username exactly 64 chars long", () => { + const id = "a".repeat(64); + expect(manager.joinRoom(createMockWs(), id, id)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- + +describe("ChatManager.leaveRoom", () => { + let manager: ChatManager; + beforeEach(() => { manager = new ChatManager(); }); + + test("returns true on successful leave", () => { + const ws = createMockWs(); + manager.joinRoom(ws, "room-1", "alice"); + expect(manager.leaveRoom(ws, "room-1", "alice")).toBe(true); + }); + + test("returns false for non-existent room", () => { + expect(manager.leaveRoom(createMockWs(), "ghost-room", "alice")).toBe(false); + }); + + test("deletes room when last user leaves", () => { + const ws = createMockWs(); + manager.joinRoom(ws, "room-1", "alice"); + manager.leaveRoom(ws, "room-1", "alice"); + expect(manager.isInRoom(ws, "room-1")).toBe(false); + }); + + test("does not broadcast when last user leaves (room deleted)", () => { + const ws = createMockWs(); + manager.joinRoom(ws, "room-1", "alice"); + (ws.send as ReturnType).mockClear(); + manager.leaveRoom(ws, "room-1", "alice"); + expect((ws.send as ReturnType).mock.calls).toHaveLength(0); + }); + + test("broadcasts LEAVE_ROOM to remaining members", () => { + const ws1 = createMockWs("ws-1"); + const ws2 = createMockWs("ws-2"); + manager.joinRoom(ws1, "room-1", "alice"); + manager.joinRoom(ws2, "room-1", "bob"); + (ws2.send as ReturnType).mockClear(); + + manager.leaveRoom(ws1, "room-1", "alice"); + + const msgs = (ws2.send as ReturnType).mock.calls + .map((c: any[]) => JSON.parse(c[0] as string)); + expect(msgs.some((m: any) => m.type === MessageType.LEAVE_ROOM && m.username === "alice")).toBe(true); + }); + + test("broadcasts USER_LIST to remaining members after leave", () => { + const ws1 = createMockWs("ws-1"); + const ws2 = createMockWs("ws-2"); + manager.joinRoom(ws1, "room-1", "alice"); + manager.joinRoom(ws2, "room-1", "bob"); + (ws2.send as ReturnType).mockClear(); + + manager.leaveRoom(ws1, "room-1", "alice"); + + const msgs = (ws2.send as ReturnType).mock.calls + .map((c: any[]) => JSON.parse(c[0] as string)); + const userList = msgs.find((m: any) => m.type === MessageType.USER_LIST); + expect(userList?.users).toEqual(["bob"]); + }); +}); + +// --------------------------------------------------------------------------- + +describe("ChatManager.isInRoom", () => { + let manager: ChatManager; + beforeEach(() => { manager = new ChatManager(); }); + + test("returns true when ws is in room", () => { + const ws = createMockWs(); + manager.joinRoom(ws, "room-1", "alice"); + expect(manager.isInRoom(ws, "room-1")).toBe(true); + }); + + test("returns false when ws is not in room", () => { + const ws1 = createMockWs("ws-1"); + const ws2 = createMockWs("ws-2"); + manager.joinRoom(ws1, "room-1", "alice"); + expect(manager.isInRoom(ws2, "room-1")).toBe(false); + }); + + test("returns false for non-existent room", () => { + expect(manager.isInRoom(createMockWs(), "ghost-room")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- + +describe("ChatManager.broadcastMessage", () => { + let manager: ChatManager; + beforeEach(() => { manager = new ChatManager(); }); + + test("sends message to all clients in room", () => { + const ws1 = createMockWs("ws-1"); + const ws2 = createMockWs("ws-2"); + manager.joinRoom(ws1, "room-1", "alice"); + manager.joinRoom(ws2, "room-1", "bob"); + (ws1.send as ReturnType).mockClear(); + (ws2.send as ReturnType).mockClear(); + + manager.broadcastMessage("room-1", { + system: false, + type: MessageType.CHAT_MESSAGE, + roomId: "room-1", + username: "alice", + content: "hello", + timestamp: 0, + }); + + expect((ws1.send as ReturnType).mock.calls).toHaveLength(1); + expect((ws2.send as ReturnType).mock.calls).toHaveLength(1); + }); + + test("adds timestamp to the message", () => { + const ws = createMockWs(); + manager.joinRoom(ws, "room-1", "alice"); + (ws.send as ReturnType).mockClear(); + + manager.broadcastMessage("room-1", { + system: false, + type: MessageType.CHAT_MESSAGE, + roomId: "room-1", + username: "alice", + content: "hello", + timestamp: 0, + }); + + const sent = parseSent(ws, 0); + expect(typeof sent.timestamp).toBe("number"); + expect(sent.timestamp).toBeGreaterThan(0); + }); + + test("does not throw for non-existent room", () => { + expect(() => + manager.broadcastMessage("ghost-room", { + system: true, + type: MessageType.CHAT_MESSAGE, + roomId: "ghost-room", + username: "alice", + content: "hi", + timestamp: 0, + }) + ).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- + +describe("ChatManager.sendError", () => { + let manager: ChatManager; + beforeEach(() => { manager = new ChatManager(); }); + + test("sends error only to the target client", () => { + const ws1 = createMockWs("ws-1"); + const ws2 = createMockWs("ws-2"); + manager.joinRoom(ws1, "room-1", "alice"); + manager.joinRoom(ws2, "room-1", "bob"); + (ws1.send as ReturnType).mockClear(); + (ws2.send as ReturnType).mockClear(); + + manager.sendError(ws1, { + system: true, + type: MessageType.ERROR, + roomId: "room-1", + content: "something went wrong", + timestamp: Date.now(), + }); + + expect((ws1.send as ReturnType).mock.calls).toHaveLength(1); + expect((ws2.send as ReturnType).mock.calls).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- + +describe("ChatManager.handleDisconnect", () => { + let manager: ChatManager; + beforeEach(() => { manager = new ChatManager(); }); + + test("removes user from all rooms on disconnect", () => { + const ws = createMockWs(); + manager.joinRoom(ws, "room-1", "alice"); + manager.joinRoom(ws, "room-2", "alice"); + + // Can't join same connection twice in same room, but can join different rooms + // However joinRoom blocks same ws in same room, so we test two rooms + manager.handleDisconnect(ws); + + expect(manager.isInRoom(ws, "room-1")).toBe(false); + expect(manager.isInRoom(ws, "room-2")).toBe(false); + }); + + test("notifies remaining members when user disconnects from a room", () => { + const ws1 = createMockWs("ws-1"); + const ws2 = createMockWs("ws-2"); + manager.joinRoom(ws1, "room-1", "alice"); + manager.joinRoom(ws2, "room-1", "bob"); + (ws2.send as ReturnType).mockClear(); + + manager.handleDisconnect(ws1); + + const msgs = (ws2.send as ReturnType).mock.calls + .map((c: any[]) => JSON.parse(c[0] as string)); + expect(msgs.some((m: any) => m.type === MessageType.LEAVE_ROOM)).toBe(true); + }); +}); From e84393c27877afcf320d3c237e95992001c32962 Mon Sep 17 00:00:00 2001 From: Carlos Lugones Date: Wed, 4 Mar 2026 05:21:59 +0100 Subject: [PATCH 17/19] chore: added GitHub action to run unit tests on pull request and push --- .github/workflows/test.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..82df4fd --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,19 @@ +name: Test + +on: + pull_request: + push: + branches: + - main + - develop + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: bun install + - run: bun test From e8828bbcd38de46c79daf38b98bd8d7d9739efa8 Mon Sep 17 00:00:00 2001 From: Carlos Lugones Date: Wed, 4 Mar 2026 05:30:45 +0100 Subject: [PATCH 18/19] docs: updated CLAUDE.md --- CLAUDE.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bc6d989..e4ab7b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,10 +7,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ```bash bun install # Install dependencies bun run dev # Start dev server with hot reload (watches src/index.ts) +bun test # Run unit tests ``` -There are no tests configured (`test` script exits with error). - **Docker:** ```bash docker-compose up # Run production server on port 8000 @@ -23,6 +22,7 @@ docker-compose up # Run production server on port 8000 | `PORT` | `8000` | Server port | | `NODE_ENV` | (dev if unset) | Set to `production` to disable all logging | | `LOG_LEVEL` | `all` | One of: `all`, `debug`, `info`, `warn`, `error` | +| `MAX_PAYLOAD_SIZE_MB` | `10` | Maximum WebSocket message size in MB | ## Architecture @@ -41,7 +41,7 @@ Single-file entry point at `src/index.ts` bootstraps an **Elysia** (Bun-native) **Source layout:** - `src/index.ts` — Elysia app, WebSocket handlers, HTTP routes (`/`, `/health`) -- `src/chat-manager.ts` — `ChatManager` class: `joinRoom`, `leaveRoom`, `broadcastMessage`, `handleDisconnect` +- `src/chat-manager.ts` — `ChatManager` class: `joinRoom`, `leaveRoom`, `isInRoom`, `broadcastMessage`, `sendError`, `handleDisconnect` - `src/types.ts` — All message interfaces and `MessageType` enum - `src/config.ts` — Reads env vars into a typed `config` object - `src/utils/logger.ts` — Leveled logger, no-ops in production From c92ed7ad90c482498675b9773e87cc7f6d162b71 Mon Sep 17 00:00:00 2001 From: Carlos Lugones Date: Wed, 4 Mar 2026 05:31:10 +0100 Subject: [PATCH 19/19] repo: upgraded version to 1.2.1 --- bun.lockb | Bin 8833 -> 8833 bytes package.json | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bun.lockb b/bun.lockb index a9555c677f7292d3f5381360220e0f9f9ae7d13f..a23ccd34c9e9e3cd6e0db8a61c41ae4fb6a4fcc3 100755 GIT binary patch delta 20 bcmZp4ZFJqRP=cM&P|rxugkkdviJP1NL%Rk> delta 20 bcmZp4ZFJqRP=cK)C$S{8xMcGRiJP1NP{{}3 diff --git a/package.json b/package.json index 11532ac..df58c84 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "private-chat-server", - "version": "1.2.0", + "version": "1.2.1", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "bun test", "dev": "bun run --watch src/index.ts" }, "dependencies": {