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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e4ab7b5 --- /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) +bun test # Run unit tests +``` + +**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` | +| `MAX_PAYLOAD_SIZE_MB` | `10` | Maximum WebSocket message size in MB | + +## 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`, `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 +- `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 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 diff --git a/bun.lockb b/bun.lockb index 7843a71..a23ccd3 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 8a1d9fa..df58c84 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,15 @@ { "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": { - "elysia": "latest" + "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/__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); + }); +}); diff --git a/src/chat-manager.ts b/src/chat-manager.ts index 128b002..7120e3d 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"; @@ -8,8 +8,13 @@ import { logger } from "./utils/logger"; interface RoomData { /** Map of usernames to ElysiaWS connections */ clients: Map; - /** Set of all usernames in the room */ - usernames: Set; +} + +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); } /** @@ -31,8 +36,7 @@ export class ChatManager { } this.rooms.set(roomId, { - clients: new Map(), - usernames: new Set() + clients: new Map() }); logger.info(`Room ${roomId} created`); return true; @@ -43,9 +47,14 @@ 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 'Invalid room ID or username'; + } + // Create room if it doesn't exist if (!this.rooms.has(roomId)) { this.createRoom(roomId); @@ -54,22 +63,21 @@ 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; + 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'; } } // 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,11 +96,11 @@ 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; + + return null; } /** @@ -111,7 +119,6 @@ export class ChatManager { } roomData.clients.delete(username); - roomData.usernames.delete(username); // If room is empty, delete it if (roomData.clients.size === 0) { @@ -127,22 +134,36 @@ 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.clients.keys()) + }; + 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; } + /** + * 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/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..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 @@ -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 */ @@ -49,18 +52,17 @@ 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; case MessageType.LEAVE_ROOM: @@ -69,10 +71,17 @@ 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, + content: 'Not a member of this room', + timestamp: Date.now(), + }); + break; + } chatManager.broadcastMessage(parsedMessage.roomId, parsedMessage); break; @@ -86,7 +95,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 2def401..61f2ce8 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 */ @@ -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 */ @@ -106,4 +107,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; 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); } } diff --git a/tsconfig.json b/tsconfig.json index 1ca2350..978744d 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. */ @@ -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 */