goldenChat base source add
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* Market WebSocket Service
|
||||
* Spring Boot Backend STOMP WebSocket 연결 및 실시간 캔들 데이터 수신
|
||||
*/
|
||||
import SockJS from 'sockjs-client';
|
||||
import { Client, IMessage, StompSubscription } from '@stomp/stompjs';
|
||||
import { getApiBaseUrl } from './apiConfig';
|
||||
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
|
||||
// SockJS는 HTTP/HTTPS URL을 받아서 내부적으로 WebSocket으로 변환
|
||||
// 따라서 http:// 또는 https:// 프로토콜을 사용해야 함
|
||||
const getWebSocketUrl = (): string => {
|
||||
// API_BASE_URL에서 /api를 제거하고 /ws 추가
|
||||
// 예: http://localhost:8083/api → http://localhost:8083/ws
|
||||
const baseUrl = API_BASE_URL.replace('/api', '');
|
||||
return `${baseUrl}/ws`;
|
||||
};
|
||||
|
||||
const WS_URL = getWebSocketUrl();
|
||||
|
||||
export interface RealtimeCandleUpdate {
|
||||
market: string;
|
||||
interval: string;
|
||||
candles: CandleUpdateData[];
|
||||
timestamp: number;
|
||||
type: 'snapshot' | 'update';
|
||||
}
|
||||
|
||||
export interface CandleUpdateData {
|
||||
time: string; // "2026-03-07T01:12"
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
isBuyPoint?: boolean;
|
||||
isSellPoint?: boolean;
|
||||
crossState?: string | null;
|
||||
}
|
||||
|
||||
type CandleUpdateCallback = (update: RealtimeCandleUpdate) => void;
|
||||
type ConnectionStatusCallback = (connected: boolean) => void;
|
||||
type ErrorCallback = (error: Error) => void;
|
||||
|
||||
interface Subscription {
|
||||
market: string;
|
||||
interval: string;
|
||||
callback: CandleUpdateCallback;
|
||||
stompSubscription?: StompSubscription;
|
||||
}
|
||||
|
||||
/**
|
||||
* Market WebSocket Client
|
||||
* STOMP over SockJS를 사용한 실시간 캔들 데이터 구독
|
||||
*/
|
||||
class MarketSocketClient {
|
||||
private client: Client | null = null;
|
||||
private subscriptions: Map<string, Subscription> = new Map();
|
||||
private connectionStatusCallbacks: Set<ConnectionStatusCallback> = new Set();
|
||||
private errorCallbacks: Set<ErrorCallback> = new Set();
|
||||
private isConnecting = false;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 10;
|
||||
private reconnectDelay = 3000;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor() {
|
||||
console.log('[MarketSocket] Client initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket 연결
|
||||
*/
|
||||
connect(): Promise<void> {
|
||||
if (this.client?.connected) {
|
||||
console.log('[MarketSocket] Already connected');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (this.isConnecting) {
|
||||
console.log('[MarketSocket] Connection in progress');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.isConnecting = true;
|
||||
|
||||
// SockJS 소켓 생성
|
||||
const socket = new SockJS(WS_URL);
|
||||
|
||||
// STOMP 클라이언트 생성
|
||||
this.client = new Client({
|
||||
webSocketFactory: () => socket as any,
|
||||
debug: (str) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[STOMP Debug]', str);
|
||||
}
|
||||
},
|
||||
reconnectDelay: this.reconnectDelay,
|
||||
heartbeatIncoming: 10000,
|
||||
heartbeatOutgoing: 10000,
|
||||
onConnect: () => {
|
||||
console.log('✅ [MarketSocket] Connected to WebSocket');
|
||||
this.isConnecting = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.notifyConnectionStatus(true);
|
||||
this.resubscribeAll();
|
||||
resolve();
|
||||
},
|
||||
onDisconnect: () => {
|
||||
console.log('🔌 [MarketSocket] Disconnected from WebSocket');
|
||||
this.isConnecting = false;
|
||||
this.notifyConnectionStatus(false);
|
||||
},
|
||||
onStompError: (frame) => {
|
||||
console.error('❌ [MarketSocket] STOMP error:', frame.headers['message']);
|
||||
this.isConnecting = false;
|
||||
this.notifyError(new Error(frame.headers['message'] || 'STOMP error'));
|
||||
this.handleReconnect();
|
||||
reject(new Error(frame.headers['message']));
|
||||
},
|
||||
onWebSocketError: (event) => {
|
||||
console.error('❌ [MarketSocket] WebSocket error:', event);
|
||||
this.isConnecting = false;
|
||||
this.notifyError(new Error('WebSocket connection error'));
|
||||
this.handleReconnect();
|
||||
reject(new Error('WebSocket connection error'));
|
||||
},
|
||||
});
|
||||
|
||||
// 연결 시작
|
||||
this.client.activate();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [MarketSocket] Failed to connect:', error);
|
||||
this.isConnecting = false;
|
||||
this.notifyError(error as Error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket 연결 해제
|
||||
*/
|
||||
disconnect(): void {
|
||||
console.log('[MarketSocket] Disconnecting...');
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
this.subscriptions.clear();
|
||||
|
||||
if (this.client?.connected) {
|
||||
this.client.deactivate();
|
||||
}
|
||||
|
||||
this.client = null;
|
||||
this.notifyConnectionStatus(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 자동 재연결 처리
|
||||
*/
|
||||
private handleReconnect(): void {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('[MarketSocket] Max reconnect attempts reached');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
const delay = this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1);
|
||||
|
||||
console.log(`[MarketSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connect().catch((error) => {
|
||||
console.error('[MarketSocket] Reconnect failed:', error);
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 마켓/타임프레임 구독
|
||||
*
|
||||
* @param market 마켓 코드 (예: KRW-BTC)
|
||||
* @param interval 타임프레임 (예: 1m, 5m, 10m)
|
||||
* @param callback 캔들 업데이트 콜백
|
||||
* @returns 구독 해제 함수
|
||||
*/
|
||||
subscribe(
|
||||
market: string,
|
||||
interval: string,
|
||||
callback: CandleUpdateCallback
|
||||
): () => void {
|
||||
const key = `${market}:${interval}`;
|
||||
|
||||
console.log(`[MarketSocket] Subscribing to ${key}`);
|
||||
|
||||
// 기존 구독 해제
|
||||
if (this.subscriptions.has(key)) {
|
||||
this.unsubscribe(market, interval);
|
||||
}
|
||||
|
||||
// 구독 정보 저장
|
||||
const subscription: Subscription = {
|
||||
market,
|
||||
interval,
|
||||
callback,
|
||||
};
|
||||
|
||||
this.subscriptions.set(key, subscription);
|
||||
|
||||
// 연결되어 있으면 즉시 구독
|
||||
if (this.client?.connected) {
|
||||
this.subscribeToTopic(subscription);
|
||||
} else {
|
||||
// 연결되지 않았으면 연결 시도
|
||||
console.log('[MarketSocket] Not connected, connecting...');
|
||||
this.connect().catch((error) => {
|
||||
console.error('[MarketSocket] Failed to connect for subscription:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// 구독 해제 함수 반환
|
||||
return () => this.unsubscribe(market, interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* STOMP 토픽 구독
|
||||
*/
|
||||
private subscribeToTopic(subscription: Subscription): void {
|
||||
if (!this.client?.connected) {
|
||||
console.warn('[MarketSocket] Cannot subscribe: not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
const { market, interval, callback } = subscription;
|
||||
const topic = `/topic/candles/${market}/${interval}`;
|
||||
const key = `${market}:${interval}`;
|
||||
|
||||
try {
|
||||
const stompSubscription = this.client.subscribe(topic, (message: IMessage) => {
|
||||
try {
|
||||
const update: RealtimeCandleUpdate = JSON.parse(message.body);
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).__lastRealtimeUpdate = { raw: message.body, parsed: update, receivedAt: Date.now() };
|
||||
}
|
||||
console.log(`📊 [MarketSocket] Received update for ${market} ${interval}:`, {
|
||||
type: update.type,
|
||||
candleCount: update.candles?.length || 0,
|
||||
timestamp: update.timestamp
|
||||
});
|
||||
|
||||
callback(update);
|
||||
} catch (error) {
|
||||
console.error('[MarketSocket] Failed to parse message:', error);
|
||||
this.notifyError(error as Error);
|
||||
}
|
||||
});
|
||||
|
||||
// STOMP 구독 객체 저장
|
||||
const sub = this.subscriptions.get(key);
|
||||
if (sub) {
|
||||
sub.stompSubscription = stompSubscription;
|
||||
}
|
||||
|
||||
console.log(`✅ [MarketSocket] Subscribed to ${topic}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[MarketSocket] Failed to subscribe to ${topic}:`, error);
|
||||
this.notifyError(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 구독 해제
|
||||
*/
|
||||
unsubscribe(market: string, interval: string): void {
|
||||
const key = `${market}:${interval}`;
|
||||
const subscription = this.subscriptions.get(key);
|
||||
|
||||
if (!subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[MarketSocket] Unsubscribing from ${key}`);
|
||||
|
||||
// STOMP 구독 해제
|
||||
if (subscription.stompSubscription) {
|
||||
try {
|
||||
subscription.stompSubscription.unsubscribe();
|
||||
} catch (error) {
|
||||
console.error('[MarketSocket] Failed to unsubscribe:', error);
|
||||
}
|
||||
}
|
||||
|
||||
this.subscriptions.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 구독 재구독 (재연결 시)
|
||||
*/
|
||||
private resubscribeAll(): void {
|
||||
console.log(`[MarketSocket] Resubscribing to ${this.subscriptions.size} subscriptions`);
|
||||
|
||||
this.subscriptions.forEach((subscription) => {
|
||||
this.subscribeToTopic(subscription);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 상태 콜백 등록
|
||||
*/
|
||||
onConnectionStatusChange(callback: ConnectionStatusCallback): () => void {
|
||||
this.connectionStatusCallbacks.add(callback);
|
||||
|
||||
// 현재 상태 즉시 전달
|
||||
if (this.client?.connected) {
|
||||
callback(true);
|
||||
}
|
||||
|
||||
return () => this.connectionStatusCallbacks.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 콜백 등록
|
||||
*/
|
||||
onError(callback: ErrorCallback): () => void {
|
||||
this.errorCallbacks.add(callback);
|
||||
return () => this.errorCallbacks.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 상태 알림
|
||||
*/
|
||||
private notifyConnectionStatus(connected: boolean): void {
|
||||
this.connectionStatusCallbacks.forEach((callback) => {
|
||||
try {
|
||||
callback(connected);
|
||||
} catch (error) {
|
||||
console.error('[MarketSocket] Error in connection status callback:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 알림
|
||||
*/
|
||||
private notifyError(error: Error): void {
|
||||
this.errorCallbacks.forEach((callback) => {
|
||||
try {
|
||||
callback(error);
|
||||
} catch (err) {
|
||||
console.error('[MarketSocket] Error in error callback:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 상태 확인
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.client?.connected || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 구독 수
|
||||
*/
|
||||
getSubscriptionCount(): number {
|
||||
return this.subscriptions.size;
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 싱글톤 인스턴스
|
||||
export const marketSocket = new MarketSocketClient();
|
||||
|
||||
// 개발 환경에서 디버깅용
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
(window as any).marketSocket = marketSocket;
|
||||
}
|
||||
Reference in New Issue
Block a user