feat: initialize Annual Gala Interactive System monorepo
- Set up pnpm workspace with 4 packages: shared, server, client-mobile, client-screen - Implement Redis atomic voting with Lua scripts (HINCRBY + distributed lock) - Add optimistic UI with IndexedDB queue for offline resilience - Configure Socket.io with auto-reconnection (infinite retries) - Separate mobile (Vant) and big screen (Pixi.js) dependencies Tech stack: - Frontend Mobile: Vue 3 + Vant + Socket.io-client - Frontend Screen: Vue 3 + Pixi.js + GSAP - Backend: Express + Socket.io + Redis + Prisma/MySQL Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
248
packages/client-mobile/src/stores/connection.ts
Normal file
248
packages/client-mobile/src/stores/connection.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed, shallowRef } from 'vue';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import type {
|
||||
ServerToClientEvents,
|
||||
ClientToServerEvents,
|
||||
ConnectionAckPayload,
|
||||
VoteCategory,
|
||||
SyncStatePayload,
|
||||
} from '@gala/shared/types';
|
||||
import { SOCKET_EVENTS } from '@gala/shared/constants';
|
||||
import { CONFIG } from '@gala/shared/constants';
|
||||
|
||||
type GalaSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
||||
|
||||
export const useConnectionStore = defineStore('connection', () => {
|
||||
// State - use shallowRef for socket to avoid deep reactivity issues
|
||||
const socket = shallowRef<GalaSocket | null>(null);
|
||||
const isConnected = ref(false);
|
||||
const isConnecting = ref(false);
|
||||
const sessionId = ref<string | null>(null);
|
||||
const lastPingTime = ref<number>(0);
|
||||
const latency = ref<number>(0);
|
||||
const reconnectAttempts = ref(0);
|
||||
const userId = ref<string | null>(null);
|
||||
const userName = ref<string | null>(null);
|
||||
const votedCategories = ref<VoteCategory[]>([]);
|
||||
|
||||
// Computed
|
||||
const connectionStatus = computed(() => {
|
||||
if (isConnected.value) return 'connected';
|
||||
if (isConnecting.value) return 'connecting';
|
||||
return 'disconnected';
|
||||
});
|
||||
|
||||
const remainingVotes = computed(() => {
|
||||
return CONFIG.MAX_VOTES_PER_USER - votedCategories.value.length;
|
||||
});
|
||||
|
||||
// Heartbeat interval
|
||||
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let pongTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/**
|
||||
* Connect to WebSocket server
|
||||
*/
|
||||
function connect() {
|
||||
if (socket.value?.connected || isConnecting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isConnecting.value = true;
|
||||
|
||||
const socketInstance = io(import.meta.env.VITE_SOCKET_URL || '', {
|
||||
reconnection: true,
|
||||
reconnectionAttempts: Infinity,
|
||||
reconnectionDelay: CONFIG.RECONNECTION_DELAY_MS,
|
||||
reconnectionDelayMax: CONFIG.RECONNECTION_DELAY_MAX_MS,
|
||||
timeout: CONFIG.HEARTBEAT_TIMEOUT_MS,
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
|
||||
// Connection events
|
||||
socketInstance.on('connect', () => {
|
||||
console.log('[Socket] Connected');
|
||||
isConnected.value = true;
|
||||
isConnecting.value = false;
|
||||
reconnectAttempts.value = 0;
|
||||
|
||||
// Join with user info
|
||||
joinRoom();
|
||||
|
||||
// Start heartbeat
|
||||
startHeartbeat();
|
||||
});
|
||||
|
||||
socketInstance.on('disconnect', (reason) => {
|
||||
console.log('[Socket] Disconnected:', reason);
|
||||
isConnected.value = false;
|
||||
stopHeartbeat();
|
||||
});
|
||||
|
||||
socketInstance.on('connect_error', (error) => {
|
||||
console.error('[Socket] Connection error:', error);
|
||||
isConnecting.value = false;
|
||||
reconnectAttempts.value++;
|
||||
});
|
||||
|
||||
// Custom pong handler
|
||||
socketInstance.on('connection:pong' as any, () => {
|
||||
if (pongTimeout) {
|
||||
clearTimeout(pongTimeout);
|
||||
pongTimeout = null;
|
||||
}
|
||||
latency.value = Date.now() - lastPingTime.value;
|
||||
});
|
||||
|
||||
// Connection acknowledgment
|
||||
socketInstance.on('connection:ack' as any, (data: ConnectionAckPayload) => {
|
||||
sessionId.value = data.sessionId;
|
||||
if (data.reconnected && data.missedEvents) {
|
||||
// Handle missed events
|
||||
console.log('[Socket] Reconnected, processing missed events');
|
||||
}
|
||||
});
|
||||
|
||||
// Sync state
|
||||
socketInstance.on('sync:state' as any, (data: SyncStatePayload) => {
|
||||
if (data.userVotedCategories) {
|
||||
votedCategories.value = data.userVotedCategories;
|
||||
}
|
||||
});
|
||||
|
||||
socket.value = socketInstance as GalaSocket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join room with user info
|
||||
*/
|
||||
function joinRoom() {
|
||||
if (!socket.value || !userId.value) return;
|
||||
|
||||
socket.value.emit(
|
||||
SOCKET_EVENTS.CONNECTION_JOIN as any,
|
||||
{
|
||||
userId: userId.value,
|
||||
userName: userName.value || 'Guest',
|
||||
role: 'user',
|
||||
},
|
||||
(response: any) => {
|
||||
if (response.success) {
|
||||
sessionId.value = response.data.sessionId;
|
||||
if (response.data.votedCategories) {
|
||||
votedCategories.value = response.data.votedCategories;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start heartbeat
|
||||
*/
|
||||
function startHeartbeat() {
|
||||
stopHeartbeat();
|
||||
|
||||
heartbeatInterval = setInterval(() => {
|
||||
if (socket.value?.connected) {
|
||||
lastPingTime.value = Date.now();
|
||||
socket.value.emit(SOCKET_EVENTS.CONNECTION_PING as any);
|
||||
|
||||
// Set pong timeout
|
||||
pongTimeout = setTimeout(() => {
|
||||
console.warn('[Socket] Pong timeout, connection may be unstable');
|
||||
}, CONFIG.HEARTBEAT_TIMEOUT_MS);
|
||||
}
|
||||
}, CONFIG.HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop heartbeat
|
||||
*/
|
||||
function stopHeartbeat() {
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = null;
|
||||
}
|
||||
if (pongTimeout) {
|
||||
clearTimeout(pongTimeout);
|
||||
pongTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from server
|
||||
*/
|
||||
function disconnect() {
|
||||
stopHeartbeat();
|
||||
if (socket.value) {
|
||||
socket.value.disconnect();
|
||||
socket.value = null;
|
||||
}
|
||||
isConnected.value = false;
|
||||
isConnecting.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user info
|
||||
*/
|
||||
function setUser(id: string, name: string) {
|
||||
userId.value = id;
|
||||
userName.value = name;
|
||||
|
||||
// Rejoin if already connected
|
||||
if (socket.value?.connected) {
|
||||
joinRoom();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add voted category
|
||||
*/
|
||||
function addVotedCategory(category: VoteCategory) {
|
||||
if (!votedCategories.value.includes(category)) {
|
||||
votedCategories.value.push(category);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request sync from server
|
||||
*/
|
||||
function requestSync() {
|
||||
if (socket.value?.connected) {
|
||||
socket.value.emit(SOCKET_EVENTS.SYNC_REQUEST as any, {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get socket instance (for advanced usage)
|
||||
*/
|
||||
function getSocket(): GalaSocket | null {
|
||||
return socket.value;
|
||||
}
|
||||
|
||||
return {
|
||||
// State (excluding socket to avoid type inference issues)
|
||||
isConnected,
|
||||
isConnecting,
|
||||
sessionId,
|
||||
latency,
|
||||
reconnectAttempts,
|
||||
userId,
|
||||
userName,
|
||||
votedCategories,
|
||||
|
||||
// Computed
|
||||
connectionStatus,
|
||||
remainingVotes,
|
||||
|
||||
// Actions
|
||||
connect,
|
||||
disconnect,
|
||||
setUser,
|
||||
addVotedCategory,
|
||||
requestSync,
|
||||
getSocket,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user