Async-Redis
view release on metacpan or search on metacpan
examples/pagi-chat/public/js/app.js view on Meta::CPAN
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
elements.toastContainer.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideIn 0.3s ease reverse';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// ===== Exponential Backoff =====
function calculateReconnectDelay() {
// Formula: min(1000 * 2^attempts + random(0, 1000), 30000)
const baseDelay = 1000 * Math.pow(2, state.reconnectAttempts);
const jitter = Math.random() * 1000;
return Math.min(baseDelay + jitter, state.maxReconnectDelay);
}
// ===== Session Management =====
function getOrCreateSessionId() {
let sessionId = localStorage.getItem('chat-session-id');
if (!sessionId) {
sessionId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
localStorage.setItem('chat-session-id', sessionId);
}
return sessionId;
}
function clearSession() {
localStorage.removeItem('chat-session-id');
state.sessionId = '';
state.lastMsgId = 0;
}
// ===== WebSocket Connection =====
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// Get or create persistent session ID
state.sessionId = getOrCreateSessionId();
// Build connection URL with session info for resume support
const params = new URLSearchParams({
name: state.username,
session: state.sessionId,
lastMsgId: state.lastMsgId.toString()
});
const wsUrl = `${protocol}//${window.location.host}/ws/chat?${params}`;
setConnectionStatus(state.reconnectAttempts > 0 ? 'reconnecting' : 'connecting');
state.ws = new WebSocket(wsUrl);
state.ws.onopen = () => {
setConnectionStatus('connected');
state.reconnectAttempts = 0;
state.lastPongTime = Date.now();
// Start keepalive ping interval
if (state.pingInterval) {
clearInterval(state.pingInterval);
}
state.pingInterval = setInterval(() => {
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
sendMessage({ type: 'ping' });
}
}, state.pingIntervalMs);
// Start heartbeat timeout check
if (state.heartbeatCheckInterval) {
clearInterval(state.heartbeatCheckInterval);
}
state.heartbeatCheckInterval = setInterval(() => {
const timeSinceLastPong = Date.now() - state.lastPongTime;
if (timeSinceLastPong > state.heartbeatTimeoutMs) {
console.warn('Heartbeat timeout - connection appears dead, reconnecting...');
if (state.ws) {
state.ws.close();
}
}
}, 5000); // Check every 5 seconds
};
state.ws.onclose = (event) => {
setConnectionStatus('disconnected');
// Clear intervals
if (state.pingInterval) {
clearInterval(state.pingInterval);
state.pingInterval = null;
}
if (state.heartbeatCheckInterval) {
clearInterval(state.heartbeatCheckInterval);
state.heartbeatCheckInterval = null;
}
// Always try to reconnect with exponential backoff
state.reconnectAttempts++;
const delay = calculateReconnectDelay();
console.log(`Reconnecting in ${Math.round(delay)}ms (attempt ${state.reconnectAttempts})...`);
setConnectionStatus('reconnecting');
setTimeout(connectWebSocket, delay);
};
state.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
state.ws.onmessage = (event) => {
// Any message resets the heartbeat timer
state.lastPongTime = Date.now();
try {
const data = JSON.parse(event.data);
handleWebSocketMessage(data);
} catch (e) {
console.error('Failed to parse message:', e);
}
};
examples/pagi-chat/public/js/app.js view on Meta::CPAN
}
updateRoomsList();
break;
case 'user_left':
if (data.room === state.currentRoom) {
addSystemMessage(`${data.user} left the room`);
updateUsersList(data.users || []);
}
updateRoomsList();
break;
case 'typing':
if (data.room === state.currentRoom) {
updateTypingIndicator(data.user, data.typing);
}
break;
case 'pm':
addPrivateMessage(data, 'received');
showToast(`Private message from ${data.from}`, 'info');
break;
case 'pm_sent':
addPrivateMessage({ from: 'You', to: data.to, text: data.text, ts: data.ts }, 'sent');
break;
case 'nick_changed':
if (data.old_name === state.username) {
state.username = data.new_name;
updateUserInfo();
showToast(`You are now known as ${data.new_name}`, 'success');
} else if (data.room === state.currentRoom) {
addSystemMessage(`${data.old_name} is now known as ${data.new_name}`);
updateUsersList(data.users || []);
}
break;
case 'room_list':
updateRoomsList(data.rooms);
break;
case 'user_list':
if (data.room === state.currentRoom) {
updateUsersList(data.users);
}
break;
case 'history':
if (data.room === state.currentRoom) {
renderMessages(data.messages || []);
}
break;
case 'error':
showToast(data.message, 'error');
break;
case 'pong':
case 'server_ping':
// Keepalive messages - no action needed
break;
case 'stats':
updateStats(data);
break;
}
}
function sendMessage(data) {
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
state.ws.send(JSON.stringify(data));
}
}
// ===== SSE Connection =====
function connectSSE() {
state.sse = new EventSource('/events');
state.sse.addEventListener('stats', (event) => {
try {
const stats = JSON.parse(event.data);
updateStats(stats);
} catch (e) {
console.error('Failed to parse stats:', e);
}
});
state.sse.addEventListener('user_connected', (event) => {
try {
const data = JSON.parse(event.data);
elements.statUsers.textContent = data.count;
} catch (e) {}
});
state.sse.addEventListener('user_disconnected', (event) => {
try {
const data = JSON.parse(event.data);
elements.statUsers.textContent = data.count;
} catch (e) {}
});
state.sse.onerror = () => {
// SSE will auto-reconnect
};
}
function updateStats(stats) {
elements.statUsers.textContent = stats.users_online;
elements.statRooms.textContent = stats.rooms_count;
elements.statUptime.textContent = formatUptime(stats.uptime);
}
function formatUptime(seconds) {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
return `${Math.floor(seconds / 86400)}d`;
}
// ===== UI Updates =====
examples/pagi-chat/public/js/app.js view on Meta::CPAN
function formatMessageText(text) {
// Escape HTML first
text = escapeHtml(text);
// Convert newlines to <br> for multiline messages (like /help output)
text = text.replace(/\n/g, '<br>');
// Simple URL detection
text = text.replace(
/(https?:\/\/[^\s<]+)/g,
'<a href="$1" target="_blank" rel="noopener">$1</a>'
);
return text;
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ===== Typing Indicator =====
function handleTyping() {
if (!state.isTyping) {
state.isTyping = true;
sendMessage({
type: 'typing',
room: state.currentRoom,
typing: true
});
}
// Clear existing timeout
if (state.typingTimeout) {
clearTimeout(state.typingTimeout);
}
// Stop typing after 2 seconds of inactivity
state.typingTimeout = setTimeout(() => {
state.isTyping = false;
sendMessage({
type: 'typing',
room: state.currentRoom,
typing: false
});
}, 2000);
}
// ===== Event Handlers =====
function initEventHandlers() {
// Theme toggle
elements.themeToggle.addEventListener('click', toggleTheme);
// Visibility change - send ping when tab becomes visible
// This helps prevent disconnects from browser throttling background tabs
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
// Tab became visible - send immediate ping to keep connection alive
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
sendMessage({ type: 'ping' });
}
}
});
// Login form
elements.loginForm.addEventListener('submit', (e) => {
e.preventDefault();
const username = elements.usernameInput.value.trim();
if (username) {
state.username = username;
elements.loginScreen.classList.add('hidden');
elements.chatScreen.classList.remove('hidden');
connectWebSocket();
// connectSSE(); // SSE not implemented - stats update via WebSocket
}
});
// Message form
elements.messageForm.addEventListener('submit', (e) => {
e.preventDefault();
const text = elements.messageInput.value.trim();
if (text) {
sendMessage({
type: 'message',
room: state.currentRoom,
text: text
});
elements.messageInput.value = '';
// Stop typing indicator
if (state.typingTimeout) {
clearTimeout(state.typingTimeout);
}
state.isTyping = false;
}
});
// Typing detection
elements.messageInput.addEventListener('input', handleTyping);
// Leave room button
elements.leaveRoomBtn.addEventListener('click', () => {
sendMessage({ type: 'leave', room: state.currentRoom });
});
// Create room button
elements.createRoomBtn.addEventListener('click', () => {
elements.createRoomModal.classList.remove('hidden');
elements.roomNameInput.focus();
});
// Cancel create room
elements.cancelCreateRoom.addEventListener('click', () => {
elements.createRoomModal.classList.add('hidden');
elements.roomNameInput.value = '';
});
// Modal backdrop click
( run in 0.888 second using v1.01-cache-2.11-cpan-df04353d9ac )