diff --git a/src/services/socket/index.ts b/src/services/socket/index.ts index a4635bc..d01c46c 100644 --- a/src/services/socket/index.ts +++ b/src/services/socket/index.ts @@ -4,6 +4,8 @@ import { io, Socket } from "socket.io-client"; import { createStore, useStore } from "zustand"; import { subscribeWithSelector } from "zustand/middleware"; +import { devLogger } from "@/shared/utils/logger"; + import type { ClientToServerEvents, ServerToClientEvents } from "./events"; export enum ESocketStatus { @@ -90,21 +92,31 @@ export const useSocketStore = createStore()( socket.removeAllListeners(); }; + // Universal incoming event logger + socket.onAny((event: string, ...args: unknown[]) => { + // Skip logging built-in socket.io events to avoid noise + const systemEvents = ['connect', 'disconnect', 'connect_error', 'reconnect']; + if (!systemEvents.includes(event)) { + devLogger.socketIncoming(event, ...args); + } + }); + socket.on("connect", () => { + devLogger.socketConnected(); set({ status: ESocketStatus.CONNECTED, reconnectAttempt: 0, socket }); get().clearReconnectTimer(); // get().flushQueue(); }); - socket.on("disconnect", _reason => { + socket.on("disconnect", reason => { + devLogger.socketDisconnected(reason); set({ status: ESocketStatus.DISCONNECTED }); cleanListeners(); scheduleReconnect(); }); socket.on("connect_error", err => { - // eslint-disable-next-line no-console - console.error("Socket connect_error:", err); + devLogger.socketError(err); set({ status: ESocketStatus.ERROR, error: err.message }); scheduleReconnect(); }); @@ -138,6 +150,7 @@ export const useSocketStore = createStore()( emit: (event, ...args) => { const { socket, status } = get(); if (status === ESocketStatus.CONNECTED && socket && socket.connected) { + devLogger.socketOutgoing(event as string, ...args); socket.emit(event, ...args); } else { // get().enqueue(event, ...args); diff --git a/src/shared/api/httpClient.ts b/src/shared/api/httpClient.ts index bf969d1..5b04334 100644 --- a/src/shared/api/httpClient.ts +++ b/src/shared/api/httpClient.ts @@ -1,7 +1,9 @@ +/* eslint-disable no-console */ import { redirect } from "next/navigation"; import { z } from "zod"; import { getServerAccessToken } from "../auth/token"; +import { devLogger } from "../utils/logger"; export class ApiError extends Error { constructor( @@ -55,12 +57,35 @@ class HttpClient { ...rest } = opts; + const fullUrl = this.buildUrl(rootUrl, path, query); + const startTime = Date.now(); + + // Log API request (both client and server with ENV control) + if (typeof window !== 'undefined') { + // Client-side logging + devLogger.apiRequest(fullUrl, method, body); + } else { + // Server-side logging (requires ENV variable) + if (typeof devLogger.serverApiRequest === 'function') { + devLogger.serverApiRequest(fullUrl, method, body); + } else { + // Fallback server logging + if (process.env.DEV_LOGGER_SERVER_ENABLED === 'true') { + console.group(`\nšŸš€ [SERVER] API REQUEST: ${method} ${fullUrl}`); + if (body !== undefined) { + console.log('šŸ“¦ Request Body:', JSON.stringify(body, null, 2)); + } + console.groupEnd(); + } + } + } + const headers = new Headers(); const accessToken = await getServerAccessToken(); if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`); headers.set("Content-Type", "application/json"); - const res = await fetch(this.buildUrl(rootUrl, path, query), { + const res = await fetch(fullUrl, { method, body: body ? JSON.stringify(body) : undefined, headers, @@ -69,8 +94,30 @@ class HttpClient { }); const payload = await res.json().catch(() => null); + const duration = Date.now() - startTime; if (!res.ok) { + // Log API error response (both client and server) + if (typeof window !== 'undefined') { + devLogger.apiResponse(fullUrl, method, res.status, payload, duration); + } else { + if (typeof devLogger.serverApiResponse === 'function') { + devLogger.serverApiResponse(fullUrl, method, res.status, payload, duration); + } else { + // Fallback server logging + if (process.env.DEV_LOGGER_SERVER_ENABLED === 'true') { + const emoji = res.status >= 200 && res.status < 300 ? 'āœ…' : 'āŒ'; + console.group(`\n${emoji} [SERVER] API ERROR: ${method} ${fullUrl}`); + console.log(`šŸ“Š Status: ${res.status}`); + console.log(`ā±ļø Duration: ${duration}ms`); + if (payload !== undefined) { + console.log('šŸ“¦ Error Response:', payload); + } + console.groupEnd(); + } + } + } + if (res.status === 401 && !skipAuthRedirect) { redirect(process.env.NEXT_PUBLIC_AUTH_REDIRECT_URL || ""); } @@ -78,7 +125,34 @@ class HttpClient { } const data = payload as T; - return schema ? schema.parse(data) : data; + const validatedData = schema ? schema.parse(data) : data; + + // Log successful API response (both client and server) + if (typeof window !== 'undefined') { + devLogger.apiResponse(fullUrl, method, res.status, validatedData, duration); + } else { + if (typeof devLogger.serverApiResponse === 'function') { + devLogger.serverApiResponse(fullUrl, method, res.status, validatedData, duration); + } else { + // Fallback server logging + if (process.env.DEV_LOGGER_SERVER_ENABLED === 'true') { + console.group(`\nāœ… [SERVER] API SUCCESS: ${method} ${fullUrl}`); + console.log(`šŸ“Š Status: ${res.status}`); + console.log(`ā±ļø Duration: ${duration}ms`); + if (validatedData !== undefined) { + const responsePreview = typeof validatedData === 'object' && validatedData !== null + ? Array.isArray(validatedData) + ? `Array[${validatedData.length}]` + : `Object{${Object.keys(validatedData).slice(0, 5).join(', ')}${Object.keys(validatedData).length > 5 ? '...' : ''}}` + : validatedData; + console.log('šŸ“¦ Response Preview:', responsePreview); + } + console.groupEnd(); + } + } + } + + return validatedData; } get = (p: string, o?: RequestOpts, u?: string) => diff --git a/src/shared/utils/logger.ts b/src/shared/utils/logger.ts new file mode 100644 index 0000000..0b60cf9 --- /dev/null +++ b/src/shared/utils/logger.ts @@ -0,0 +1,325 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +"use client"; +/* eslint-disable no-console */ + +export enum LogType { + API = 'API', + SOCKET = 'SOCKET', + ERROR = 'ERROR', + INFO = 'INFO' +} + +export enum LogDirection { + REQUEST = 'REQUEST', + RESPONSE = 'RESPONSE', + INCOMING = 'INCOMING', + OUTGOING = 'OUTGOING' +} + +interface LogEntry { + type: LogType; + direction?: LogDirection; + event: string; + data?: unknown; + url?: string; + method?: string; + status?: number; + timestamp: Date; + duration?: number; +} + +class DevLogger { + private enabled = false; + private enabledTypes = new Set(Object.values(LogType)); + private envEnabled = false; + private serverLoggingEnabled = false; + + constructor() { + // Check ENV variables first + if (typeof window !== 'undefined') { + this.envEnabled = process.env.NEXT_PUBLIC_DEV_LOGGER_ENABLED !== 'false'; + } else { + // Server side - check server env + this.serverLoggingEnabled = process.env.DEV_LOGGER_SERVER_ENABLED === 'true'; + this.envEnabled = process.env.DEV_LOGGER_ENABLED !== 'false'; + } + + // Check localStorage for logging preferences (client-side only) + if (typeof window !== 'undefined') { + const stored = localStorage.getItem('dev-logger-enabled'); + this.enabled = stored ? JSON.parse(stored) : this.envEnabled; + + const storedTypes = localStorage.getItem('dev-logger-types'); + if (storedTypes) { + this.enabledTypes = new Set(JSON.parse(storedTypes)); + } + } else { + this.enabled = this.envEnabled; + } + } + + private shouldLog(type: LogType): boolean { + // Check ENV first, then user preferences + return this.envEnabled && this.enabled && this.enabledTypes.has(type); + } + + private shouldLogServer(type: LogType): boolean { + // Server logging requires explicit ENV enable + return this.serverLoggingEnabled && this.envEnabled && this.enabled && this.enabledTypes.has(type); + } + + private getLogStyle(type: LogType, direction?: LogDirection): { emoji: string; color: string; bgColor?: string } { + const styles: Record = { + [LogType.API]: { + [LogDirection.REQUEST]: { emoji: 'šŸš€', color: '#3b82f6', bgColor: '#eff6ff' }, + [LogDirection.RESPONSE]: { emoji: 'šŸ“Ø', color: '#10b981', bgColor: '#f0fdf4' }, + }, + [LogType.SOCKET]: { + [LogDirection.OUTGOING]: { emoji: '🟢', color: '#16a34a' }, + [LogDirection.INCOMING]: { emoji: 'šŸ”µ', color: '#2563eb' }, + }, + [LogType.ERROR]: { emoji: 'āŒ', color: '#ef4444' }, + [LogType.INFO]: { emoji: 'ā„¹ļø', color: '#6366f1' } + }; + + const typeStyles = styles[type]; + if (direction && typeof typeStyles === 'object' && direction in typeStyles) { + return typeStyles[direction]; + } + return typeof typeStyles === 'object' ? { emoji: 'šŸ“', color: '#6b7280' } : typeStyles; + } + + private formatTime(date: Date): string { + return date.toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3 + }); + } + + log(entry: Omit) { + if (!this.shouldLog(entry.type)) return; + + const timestamp = new Date(); + const { emoji, color, bgColor } = this.getLogStyle(entry.type, entry.direction); + const timeStr = this.formatTime(timestamp); + const baseStyle = `color: ${color}; font-weight: bold;`; + const groupStyle = bgColor ? `${baseStyle} background: ${bgColor}; padding: 2px 6px; border-radius: 3px;` : baseStyle; + + // Create compact collapsible group + const groupTitle = `${emoji} ${entry.type}${entry.direction ? ` ${entry.direction}` : ''}: ${entry.event}`; + + // Always use groupCollapsed for cleaner output + console.groupCollapsed( + `%c${groupTitle} [${timeStr}]`, + groupStyle + ); + + // Compact one-line summary with key info + const summaryParts = []; + if (entry.method) summaryParts.push(`${entry.method}`); + if (entry.status) { + const statusColor = entry.status >= 200 && entry.status < 300 ? 'āœ…' : 'āŒ'; + summaryParts.push(`${statusColor} ${entry.status}`); + } + if (entry.duration !== undefined) summaryParts.push(`ā±ļø ${entry.duration}ms`); + + if (summaryParts.length > 0) { + console.log(`%c${summaryParts.join(' • ')}`, 'color: #6b7280; font-size: 11px;'); + } + + if (entry.data !== undefined) { + // Show preview for objects/arrays, full value for primitives + if (typeof entry.data === 'object' && entry.data !== null) { + const preview = Array.isArray(entry.data) + ? `Array[${entry.data.length}]` + : `Object{${Object.keys(entry.data).slice(0, 3).join(', ')}${Object.keys(entry.data).length > 3 ? '...' : ''}}`; + console.log(`%cšŸ“¦ Data:`, 'color: #6b7280; font-size: 11px;', preview); + console.log(entry.data); + } else { + console.log(`%cšŸ“¦ Data:`, 'color: #6b7280; font-size: 11px;', entry.data); + } + } + + console.groupEnd(); + } + + // API logging methods + apiRequest(url: string, method: string, data?: unknown) { + this.log({ + type: LogType.API, + direction: LogDirection.REQUEST, + event: `${method.toUpperCase()} ${url.split('?')[0]}`, + url, + method, + data + }); + } + + apiResponse(url: string, method: string, status: number, data?: unknown, duration?: number) { + this.log({ + type: LogType.API, + direction: LogDirection.RESPONSE, + event: `${method.toUpperCase()} ${url.split('?')[0]}`, + url, + method, + status, + data, + duration + }); + } + + // Socket logging methods + socketOutgoing(event: string, data?: unknown) { + this.log({ + type: LogType.SOCKET, + direction: LogDirection.OUTGOING, + event, + data + }); + } + + socketIncoming(event: string, data?: unknown) { + this.log({ + type: LogType.SOCKET, + direction: LogDirection.INCOMING, + event, + data + }); + } + + // Connection state logging + socketConnected() { + console.log( + `%cāœ… SOCKET CONNECTED`, + 'color: #10b981; font-weight: bold; background: #f0fdf4; padding: 2px 6px; border-radius: 3px;' + ); + } + + socketDisconnected(reason?: string) { + console.log( + `%cāŒ SOCKET DISCONNECTED`, + 'color: #ef4444; font-weight: bold; background: #fef2f2; padding: 2px 6px; border-radius: 3px;', + reason || '' + ); + } + + socketError(error?: unknown) { + console.log( + `%cāš ļø SOCKET ERROR`, + 'color: #f59e0b; font-weight: bold; background: #fffbeb; padding: 2px 6px; border-radius: 3px;', + error || '' + ); + } + + // Server-side logging methods + serverApiRequest(url: string, method: string, body?: unknown) { + if (!this.shouldLogServer(LogType.API)) return; + + console.group(`\nšŸš€ [SERVER] API REQUEST: ${method} ${url}`); + if (body !== undefined) { + console.log('šŸ“¦ Request Body:', JSON.stringify(body, null, 2)); + } + console.groupEnd(); + } + + serverApiResponse(url: string, method: string, status: number, data?: unknown, duration?: number) { + if (!this.shouldLogServer(LogType.API)) return; + + const emoji = status >= 200 && status < 300 ? 'āœ…' : 'āŒ'; + console.group(`\n${emoji} [SERVER] API ${status >= 200 && status < 300 ? 'SUCCESS' : 'ERROR'}: ${method} ${url}`); + console.log(`šŸ“Š Status: ${status}`); + if (duration !== undefined) { + console.log(`ā±ļø Duration: ${duration}ms`); + } + if (data !== undefined) { + // Limit response data display to avoid overwhelming logs + const responsePreview = typeof data === 'object' && data !== null + ? Array.isArray(data) + ? `Array[${data.length}]` + : `Object{${Object.keys(data).slice(0, 5).join(', ')}${Object.keys(data).length > 5 ? '...' : ''}}` + : data; + console.log('šŸ“¦ Response Preview:', responsePreview); + // Full response data (collapsed) + console.groupCollapsed('šŸ“„ Full Response Data:'); + console.log(data); + console.groupEnd(); + } + console.groupEnd(); + } + + // Control methods + enable() { + this.enabled = true; + if (typeof window !== 'undefined') { + localStorage.setItem('dev-logger-enabled', 'true'); + } + console.log('%cšŸ“ Dev Logger ENABLED', 'color: #10b981; font-weight: bold;'); + } + + disable() { + this.enabled = false; + if (typeof window !== 'undefined') { + localStorage.setItem('dev-logger-enabled', 'false'); + } + console.log('%cšŸ“ Dev Logger DISABLED', 'color: #ef4444; font-weight: bold;'); + } + + enableType(type: LogType) { + this.enabledTypes.add(type); + this.saveEnabledTypes(); + console.log(`%cšŸ“ ${type} logging ENABLED`, 'color: #10b981; font-weight: bold;'); + } + + disableType(type: LogType) { + this.enabledTypes.delete(type); + this.saveEnabledTypes(); + console.log(`%cšŸ“ ${type} logging DISABLED`, 'color: #ef4444; font-weight: bold;'); + } + + private saveEnabledTypes() { + if (typeof window !== 'undefined') { + localStorage.setItem('dev-logger-types', JSON.stringify(Array.from(this.enabledTypes))); + } + } + + // Helper method to show current settings + status() { + console.group('%cšŸ”§ Dev Logger Status', 'color: #6366f1; font-weight: bold;'); + console.log('Enabled:', this.enabled); + console.log('Active Types:', Array.from(this.enabledTypes)); + console.groupEnd(); + } +} + +// Create singleton instance +export const devLogger = new DevLogger(); + +// Make it available globally for easy console access +if (typeof window !== 'undefined') { + (window as any).devLogger = devLogger; +} + +// Export convenience methods for quick filtering +export const filterAPI = () => { + console.clear(); + devLogger.disableType(LogType.SOCKET); + devLogger.disableType(LogType.ERROR); + devLogger.disableType(LogType.INFO); + devLogger.enableType(LogType.API); +}; + +export const filterSocket = () => { + console.clear(); + devLogger.disableType(LogType.API); + devLogger.disableType(LogType.ERROR); + devLogger.disableType(LogType.INFO); + devLogger.enableType(LogType.SOCKET); +}; + +export const showAll = () => { + console.clear(); + Object.values(LogType).forEach(type => devLogger.enableType(type)); +};