///
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
import { createLogger } from '../utils/Logger';
// Pusher를 window 객체에 등록 (Laravel Echo 필수)
declare global {
interface Window {
Pusher: typeof Pusher;
Echo: Echo<'reverb'>;
}
}
/**
* WebSocket 채널 타입
*/
export type ChannelType = 'public' | 'private' | 'presence';
/**
* WebSocket 구독 옵션
*/
export interface SubscriptionOptions {
/** 채널 타입 (기본값: 'private') */
channelType?: ChannelType;
}
/**
* WebSocket 설정 인터페이스 (Blade에서 동적으로 전달)
*/
export interface WebSocketConfig {
/** Reverb 앱 키 */
appKey: string;
/** WebSocket 호스트 (기본값: localhost) */
host?: string;
/** WebSocket 포트 (기본값: 80) */
port?: number;
/** 스키마 (http 또는 https, 기본값: https) */
scheme?: 'http' | 'https';
/** Private 채널 인증 엔드포인트 (기본값: /broadcasting/auth) */
authEndpoint?: string;
}
/**
* WebSocket 연결을 관리하는 싱글톤 매니저
*
* Laravel Reverb와 Laravel Echo를 사용하여 WebSocket 연결을 관리합니다.
* 설정은 Blade 템플릿에서 동적으로 전달받습니다.
*/
const logger = createLogger('WebSocketManager');
class WebSocketManager {
private echo: Echo<'reverb'> | null = null;
private subscriptions: Map['channel']>> = new Map();
private initialized = false;
private config: WebSocketConfig | null = null;
/**
* WebSocket 설정을 지정합니다.
* TemplateApp 초기화 시 호출됩니다.
*
* @param config WebSocket 설정
*/
configure(config: WebSocketConfig): void {
if (this.initialized) {
logger.warn('[WebSocketManager] 이미 초기화되어 설정을 변경할 수 없습니다.');
return;
}
this.config = config;
logger.log('[WebSocketManager] 설정 완료:', {
appKey: config.appKey ? '***' : '(없음)',
host: config.host,
port: config.port,
scheme: config.scheme,
});
}
/**
* Echo 인스턴스를 초기화합니다.
*/
initialize(): void {
if (this.initialized) {
return;
}
// 설정 확인
if (!this.config || !this.config.appKey) {
logger.warn('[WebSocketManager] WebSocket 설정이 없습니다. initTemplateApp에서 websocket 옵션을 전달해주세요.');
return;
}
const { appKey, host = 'localhost', port = 80, scheme = 'https', authEndpoint = '/api/broadcasting/auth' } = this.config;
const numPort = Number(port) || 80;
const useTLS = scheme === 'https';
logger.log('[WebSocketManager] 연결 설정:', { host, port: numPort, scheme, useTLS, authEndpoint });
window.Pusher = Pusher;
// Pusher 클라이언트를 직접 생성하여 전달
// Pusher 8.x에서는 enabledTransports/disabledTransports 설정 시 충돌 가능
// Sanctum 토큰 가져오기
const authToken = localStorage.getItem('auth_token');
const pusherOptions = {
wsHost: host,
wsPort: numPort,
wssPort: numPort,
forceTLS: useTLS,
disableStats: true,
enabledTransports: ['ws', 'wss'] as const,
cluster: 'mt1', // 필수 옵션이지만 Reverb에서는 무시됨
// Private 채널 인증 설정
authEndpoint: authEndpoint,
auth: {
headers: {
'Authorization': authToken ? `Bearer ${authToken}` : '',
'Accept': 'application/json',
},
},
};
logger.log('[WebSocketManager] Pusher 옵션:', pusherOptions);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pusherClient = new Pusher(appKey, pusherOptions as any);
// 연결 상태 디버깅을 위한 이벤트 리스너
pusherClient.connection.bind('connecting', () => {
logger.log('[WebSocketManager] 연결 시도 중...');
});
pusherClient.connection.bind('connected', () => {
logger.log('[WebSocketManager] 연결 성공! Socket ID:', pusherClient.connection.socket_id);
});
pusherClient.connection.bind('failed', () => {
logger.error('[WebSocketManager] 연결 실패 - WebSocket을 사용할 수 없습니다.');
});
pusherClient.connection.bind('error', (error: unknown) => {
logger.error('[WebSocketManager] 연결 오류:', error);
});
pusherClient.connection.bind('state_change', (states: { previous: string; current: string }) => {
logger.log(`[WebSocketManager] 상태 변경: ${states.previous} → ${states.current}`);
});
pusherClient.connection.bind('unavailable', () => {
logger.error('[WebSocketManager] WebSocket 사용 불가 - 연결할 수 없습니다.');
});
pusherClient.connection.bind('disconnected', () => {
logger.warn('[WebSocketManager] 연결이 끊어졌습니다.');
});
// 초기 연결 상태 로깅
logger.log('[WebSocketManager] 초기 연결 상태:', pusherClient.connection.state);
this.echo = new Echo({
broadcaster: 'reverb',
client: pusherClient,
});
// 명시적으로 연결 시작
logger.log('[WebSocketManager] 연결 시작 시도...');
pusherClient.connect();
window.Echo = this.echo;
this.initialized = true;
logger.log('[WebSocketManager] Echo 초기화 완료');
}
/**
* 채널을 구독하고 이벤트를 리스닝합니다.
*
* @param channel 채널명 (예: 'admin.dashboard')
* @param event 이벤트명 (예: 'dashboard.stats.updated')
* @param callback 이벤트 수신 시 호출될 콜백
* @param options 구독 옵션
* @returns 구독 키
*/
subscribe(
channel: string,
event: string,
callback: (data: unknown) => void,
options: SubscriptionOptions = {}
): string {
this.initialize();
if (!this.echo) {
logger.warn('[WebSocketManager] Echo가 초기화되지 않았습니다.');
return '';
}
const { channelType = 'private' } = options;
const subscriptionKey = `${channel}:${event}`;
if (this.subscriptions.has(subscriptionKey)) {
logger.log(`[WebSocketManager] 이미 구독 중: ${subscriptionKey}`);
return subscriptionKey;
}
let channelInstance: ReturnType['channel']>;
switch (channelType) {
case 'public':
channelInstance = this.echo.channel(channel);
break;
case 'presence':
channelInstance = this.echo.join(channel);
break;
default:
channelInstance = this.echo.private(channel);
}
// Reverb 이벤트는 .으로 시작해야 함
channelInstance.listen(`.${event}`, callback);
this.subscriptions.set(subscriptionKey, channelInstance);
logger.log(`[WebSocketManager] 구독 완료: ${subscriptionKey} (${channelType})`);
return subscriptionKey;
}
/**
* 구독을 해제합니다.
*
* Map 엔트리 제거 + Echo 채널의 listener 명시 해제.
* Echo는 동일 채널을 재사용하므로 stopListening() 누락 시
* 재subscribe 시 listener가 중복 누적되어 콜백이 여러 번 실행됩니다.
*
* @param subscriptionKey 구독 키 (형식: "channel:event")
*/
unsubscribe(subscriptionKey: string): void {
const channelInstance = this.subscriptions.get(subscriptionKey);
if (!channelInstance) {
return;
}
// subscriptionKey 형식: "channelName:eventName"
// channelName이 ':'를 포함할 수 있으므로 마지막 ':' 기준 분리
const lastColon = subscriptionKey.lastIndexOf(':');
const eventName = lastColon >= 0 ? subscriptionKey.substring(lastColon + 1) : '';
if (eventName) {
try {
// Reverb 이벤트는 '.'으로 시작해야 함 (subscribe와 동일)
channelInstance.stopListening(`.${eventName}`);
} catch (e) {
logger.warn(`[WebSocketManager] stopListening 실패: ${subscriptionKey}`, e);
}
}
this.subscriptions.delete(subscriptionKey);
logger.log(`[WebSocketManager] 구독 해제: ${subscriptionKey}`);
}
/**
* 특정 채널의 모든 구독을 해제합니다.
*
* @param channel 채널명
*/
leaveChannel(channel: string): void {
if (!this.echo) {
return;
}
this.echo.leave(channel);
// 해당 채널의 모든 구독 키 제거
const keysToDelete: string[] = [];
this.subscriptions.forEach((_, key) => {
if (key.startsWith(`${channel}:`)) {
keysToDelete.push(key);
}
});
keysToDelete.forEach((key) => this.subscriptions.delete(key));
logger.log(`[WebSocketManager] 채널 구독 해제: ${channel}`);
}
/**
* 모든 구독을 해제하고 연결을 종료합니다.
*/
disconnect(): void {
if (this.echo) {
this.echo.disconnect();
this.subscriptions.clear();
this.initialized = false;
logger.log('[WebSocketManager] 연결 종료');
}
}
/**
* Echo 인스턴스를 반환합니다.
*
* @returns Echo 인스턴스 또는 null
*/
getEcho(): Echo<'reverb'> | null {
return this.echo;
}
/**
* 초기화 여부를 반환합니다.
*
* @returns 초기화 여부
*/
isInitialized(): boolean {
return this.initialized;
}
/**
* 설정 여부를 반환합니다.
*
* @returns 설정 여부
*/
isConfigured(): boolean {
return this.config !== null && !!this.config.appKey;
}
/**
* 현재 활성 구독 수를 반환합니다.
*
* @returns 구독 수
*/
getSubscriptionCount(): number {
return this.subscriptions.size;
}
}
export const webSocketManager = new WebSocketManager();
export { WebSocketManager };