Pusher 示例

使用 Pusher API 的实时通信示例,包括频道、事件、存在感知和客户端认证

💻 Pusher 基础聊天应用 javascript

🟢 simple ⭐⭐

使用 Pusher 公共和私有频道的简单但功能强大的聊天应用,包含认证

⏱️ 20 min 🏷️ pusher, chat, client, real-time
Prerequisites: Pusher account, JavaScript ES6+, HTTP basics
// Basic Real-time Chat Application using Pusher
// Client-side implementation

// Initialize Pusher with your app credentials
const pusher = new Pusher('your-pusher-app-key', {
    cluster: 'your-pusher-cluster',
    forceTLS: true,
    authEndpoint: '/api/pusher/auth',
    auth: {
        headers: {
            'Authorization': 'Bearer ' + localStorage.getItem('token')
        }
    }
});

class ChatApp {
    constructor() {
        this.currentUser = null;
        this.currentChannel = null;
        this.messages = [];
        this.users = new Map();
        this.init();
    }

    async init() {
        // Authenticate user
        await this.authenticateUser();

        // Connect to Pusher
        this.connectToPusher();

        // Setup UI event listeners
        this.setupUIListeners();

        // Subscribe to notifications channel
        this.subscribeToNotifications();
    }

    async authenticateUser() {
        try {
            const response = await fetch('/api/user/me', {
                headers: {
                    'Authorization': 'Bearer ' + localStorage.getItem('token')
                }
            });

            if (response.ok) {
                this.currentUser = await response.json();
            } else {
                // Redirect to login
                window.location.href = '/login';
            }
        } catch (error) {
            console.error('Authentication failed:', error);
            window.location.href = '/login';
        }
    }

    connectToPusher() {
        // Bind to connection events
        pusher.connection.bind('connected', () => {
            console.log('Connected to Pusher');
            this.updateConnectionStatus(true);
        });

        pusher.connection.bind('disconnected', () => {
            console.log('Disconnected from Pusher');
            this.updateConnectionStatus(false);
        });

        pusher.connection.bind('error', (err) => {
            console.error('Pusher connection error:', err);
            this.showErrorMessage('Connection error. Please refresh.');
        });
    }

    // Subscribe to a chat room (private channel)
    joinRoom(roomId) {
        if (this.currentChannel) {
            this.leaveCurrentRoom();
        }

        // Subscribe to private channel for the room
        this.currentChannel = pusher.subscribe(`private-chat-${roomId}`);

        // Bind to chat events
        this.currentChannel.bind('pusher:subscription_succeeded', () => {
            console.log(`Successfully joined room: ${roomId}`);
            this.onRoomJoined(roomId);
        });

        this.currentChannel.bind('pusher:subscription_error', (err) => {
            console.error('Failed to join room:', err);
            this.showErrorMessage('Failed to join room');
        });

        // Listen for new messages
        this.currentChannel.bind('new_message', (data) => {
            this.addMessage(data);
        });

        // Listen for user events
        this.currentChannel.bind('user_joined', (data) => {
            this.onUserJoined(data);
        });

        this.currentChannel.bind('user_left', (data) => {
            this.onUserLeft(data);
        });

        // Listen for typing indicators
        this.currentChannel.bind('user_typing', (data) => {
            this.onUserTyping(data);
        });

        this.currentChannel.bind('user_stop_typing', (data) => {
            this.onUserStopTyping(data);
        });
    }

    // Subscribe to presence channel for user presence
    subscribeToPresence(roomId) {
        const presenceChannel = pusher.subscribe(`presence-room-${roomId}`);

        presenceChannel.bind('pusher:subscription_succeeded', (members) => {
            console.log('Presence channel joined');
            this.updateOnlineUsers(members.members);
        });

        presenceChannel.bind('pusher:member_added', (member) => {
            this.onUserPresenceJoined(member);
        });

        presenceChannel.bind('pusher:member_removed', (member) => {
            this.onUserPresenceLeft(member);
        });

        // Trigger presence event when joining
        presenceChannel.trigger('client-joined', {
            user: this.currentUser,
            timestamp: new Date()
        });
    }

    // Leave current room
    leaveCurrentRoom() {
        if (this.currentChannel) {
            // Notify others we're leaving
            this.currentChannel.trigger('client-leaving', {
                user: this.currentUser,
                timestamp: new Date()
            });

            pusher.unsubscribe(this.currentChannel.name);
            this.currentChannel = null;
        }
    }

    // Send a message
    sendMessage(message, type = 'text') {
        if (!this.currentChannel || !message.trim()) return;

        const messageData = {
            id: this.generateId(),
            user: this.currentUser,
            message: message.trim(),
            type,
            timestamp: new Date(),
            roomId: this.getRoomId()
        };

        // Trigger client event (will be received by server and broadcast)
        this.currentChannel.trigger('client-send_message', messageData);

        // Optimistically add message to UI
        this.addMessage(messageData);
    }

    // Send file
    sendFile(file) {
        const formData = new FormData();
        formData.append('file', file);
        formData.append('roomId', this.getRoomId());

        fetch('/api/upload', {
            method: 'POST',
            body: formData,
            headers: {
                'Authorization': 'Bearer ' + localStorage.getItem('token')
            }
        })
        .then(response => response.json())
        .then(data => {
            if (data.success) {
                this.sendMessage({
                    type: 'file',
                    url: data.fileUrl,
                    fileName: file.name,
                    fileSize: file.size
                }, 'file');
            } else {
                this.showErrorMessage('Failed to upload file');
            }
        })
        .catch(error => {
            console.error('File upload error:', error);
            this.showErrorMessage('Failed to upload file');
        });
    }

    // Start typing indicator
    startTyping() {
        if (this.currentChannel && !this.isTyping) {
            this.isTyping = true;
            this.currentChannel.trigger('client-typing', {
                user: this.currentUser,
                timestamp: new Date()
            });

            // Auto-stop typing after 3 seconds
            clearTimeout(this.typingTimeout);
            this.typingTimeout = setTimeout(() => this.stopTyping(), 3000);
        }
    }

    // Stop typing indicator
    stopTyping() {
        if (this.currentChannel && this.isTyping) {
            this.isTyping = false;
            this.currentChannel.trigger('client-stop_typing', {
                user: this.currentUser,
                timestamp: new Date()
            });

            clearTimeout(this.typingTimeout);
        }
    }

    // Subscribe to notifications
    subscribeToNotifications() {
        const notificationChannel = pusher.subscribe(`private-notifications-${this.currentUser.id}`);

        notificationChannel.bind('new_notification', (data) => {
            this.showNotification(data);
        });

        notificationChannel.bind('message_mention', (data) => {
            this.showMentionNotification(data);
        });
    }

    // Event handlers
    onRoomJoined(roomId) {
        this.messages = [];
        this.updateMessagesList();

        // Subscribe to presence for this room
        this.subscribeToPresence(roomId);

        // Load message history
        this.loadMessageHistory(roomId);
    }

    onUserJoined(data) {
        this.addSystemMessage(`${data.user.name} joined the chat`);
    }

    onUserLeft(data) {
        this.addSystemMessage(`${data.user.name} left the chat`);
    }

    onUserTyping(data) {
        this.updateTypingIndicator(data.user, true);
    }

    onUserStopTyping(data) {
        this.updateTypingIndicator(data.user, false);
    }

    onUserPresenceJoined(member) {
        this.users.set(member.id, member.info);
        this.updateOnlineUsersList();
    }

    onUserPresenceLeft(member) {
        this.users.delete(member.id);
        this.updateOnlineUsersList();
    }

    // UI helpers
    addMessage(messageData) {
        this.messages.push(messageData);
        this.updateMessagesList();
        this.scrollToBottom();

        // Play notification sound if message is not from current user
        if (messageData.user.id !== this.currentUser.id) {
            this.playNotificationSound();
        }
    }

    addSystemMessage(text) {
        const systemMessage = {
            id: this.generateId(),
            type: 'system',
            message: text,
            timestamp: new Date()
        };

        this.messages.push(systemMessage);
        this.updateMessagesList();
        this.scrollToBottom();
    }

    updateMessagesList() {
        const container = document.getElementById('messages');
        if (!container) return;

        container.innerHTML = this.messages.map(msg => this.renderMessage(msg)).join('');
    }

    renderMessage(msg) {
        if (msg.type === 'system') {
            return `
                <div class="system-message">
                    <span class="system-text">${msg.message}</span>
                    <span class="timestamp">${this.formatTime(msg.timestamp)}</span>
                </div>
            `;
        }

        const isOwnMessage = msg.user.id === this.currentUser.id;
        const messageClass = isOwnMessage ? 'own-message' : 'other-message';

        if (msg.type === 'file') {
            return `
                <div class="message ${messageClass}">
                    <div class="message-header">
                        <span class="username">${msg.user.name}</span>
                        <span class="timestamp">${this.formatTime(msg.timestamp)}</span>
                    </div>
                    <div class="file-message">
                        📎 <a href="${msg.message.url}" target="_blank">
                            ${msg.message.fileName}
                        </a>
                        <span class="file-size">(${this.formatFileSize(msg.message.fileSize)})</span>
                    </div>
                </div>
            `;
        }

        return `
            <div class="message ${messageClass}">
                <div class="message-header">
                    <span class="username">${msg.user.name}</span>
                    <span class="timestamp">${this.formatTime(msg.timestamp)}</span>
                </div>
                <div class="message-content">${this.escapeHtml(msg.message)}</div>
            </div>
        `;
    }

    updateTypingIndicator(user, isTyping) {
        const typingUsers = this.typingUsers || new Set();

        if (isTyping) {
            typingUsers.add(user.id);
        } else {
            typingUsers.delete(user.id);
        }

        this.typingUsers = typingUsers;
        this.renderTypingIndicator();
    }

    renderTypingIndicator() {
        const container = document.getElementById('typing-indicator');
        if (!container) return;

        if (this.typingUsers.size === 0) {
            container.style.display = 'none';
            return;
        }

        const typingNames = Array.from(this.typingUsers)
            .map(userId => this.users.get(userId)?.name)
            .filter(Boolean);

        container.innerHTML = `
            <div class="typing-indicator-content">
                <span class="typing-dots"></span>
                ${typingNames.join(', ')} ${typingNames.length === 1 ? 'is' : 'are'} typing...
            </div>
        `;
        container.style.display = 'block';
    }

    updateOnlineUsers(members) {
        members.forEach((member, userId) => {
            this.users.set(userId, member);
        });
        this.updateOnlineUsersList();
    }

    updateOnlineUsersList() {
        const container = document.getElementById('online-users');
        if (!container) return;

        container.innerHTML = Array.from(this.users.values())
            .map(user => `
                <div class="online-user">
                    <div class="user-avatar" style="background-color: ${user.color}">
                        ${user.name[0].toUpperCase()}
                    </div>
                    <span class="user-name">${user.name}</span>
                </div>
            `)
            .join('');
    }

    setupUIListeners() {
        // Message input
        const messageInput = document.getElementById('message-input');
        const sendButton = document.getElementById('send-button');

        messageInput?.addEventListener('keypress', (e) => {
            if (e.key === 'Enter' && !e.shiftKey) {
                e.preventDefault();
                this.sendMessage(messageInput.value);
                messageInput.value = '';
            } else {
                this.startTyping();
            }
        });

        messageInput?.addEventListener('blur', () => this.stopTyping());

        sendButton?.addEventListener('click', () => {
            this.sendMessage(messageInput.value);
            messageInput.value = '';
        });

        // File upload
        const fileInput = document.getElementById('file-input');
        fileInput?.addEventListener('change', (e) => {
            const file = e.target.files[0];
            if (file) {
                this.sendFile(file);
            }
            e.target.value = ''; // Reset input
        });

        // Room switching
        const roomSelect = document.getElementById('room-select');
        roomSelect?.addEventListener('change', (e) => {
            this.joinRoom(e.target.value);
        });
    }

    // Utility functions
    generateId() {
        return Math.random().toString(36).substr(2, 9);
    }

    formatTime(date) {
        return new Date(date).toLocaleTimeString([], {
            hour: '2-digit',
            minute: '2-digit'
        });
    }

    formatFileSize(bytes) {
        if (bytes === 0) return '0 Bytes';
        const k = 1024;
        const sizes = ['Bytes', 'KB', 'MB', 'GB'];
        const i = Math.floor(Math.log(bytes) / Math.log(k));
        return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
    }

    escapeHtml(text) {
        const div = document.createElement('div');
        div.textContent = text;
        return div.innerHTML;
    }

    scrollToBottom() {
        const container = document.getElementById('messages-container');
        if (container) {
            container.scrollTop = container.scrollHeight;
        }
    }

    updateConnectionStatus(connected) {
        const indicator = document.getElementById('connection-status');
        if (indicator) {
            indicator.className = connected ? 'connected' : 'disconnected';
            indicator.textContent = connected ? 'Connected' : 'Disconnected';
        }
    }

    showNotification(data) {
        // Show browser notification if permitted
        if (Notification.permission === 'granted') {
            new Notification(data.title, {
                body: data.message,
                icon: '/notification-icon.png',
                tag: data.id
            });
        }

        // Show in-app notification
        this.showInAppNotification(data);
    }

    showMentionNotification(data) {
        this.showNotification({
            title: 'You were mentioned!',
            message: `${data.message} in ${data.roomName}`,
            type: 'mention',
            roomId: data.roomId
        });
    }

    showInAppNotification(data) {
        // Implementation depends on your UI framework
        console.log('Notification:', data);
    }

    showErrorMessage(message) {
        alert(message); // Replace with better UI
    }

    playNotificationSound() {
        // Play a subtle notification sound
        const audio = new Audio('/notification.mp3');
        audio.volume = 0.3;
        audio.play().catch(() => {});
    }

    getRoomId() {
        const roomSelect = document.getElementById('room-select');
        return roomSelect?.value || 'general';
    }

    async loadMessageHistory(roomId) {
        try {
            const response = await fetch(`/api/messages/${roomId}`, {
                headers: {
                    'Authorization': 'Bearer ' + localStorage.getItem('token')
                }
            });

            if (response.ok) {
                const history = await response.json();
                this.messages = history;
                this.updateMessagesList();
                this.scrollToBottom();
            }
        } catch (error) {
            console.error('Failed to load message history:', error);
        }
    }
}

// Initialize the chat app when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
    // Request notification permission
    if ('Notification' in window && Notification.permission === 'default') {
        Notification.requestPermission();
    }

    // Initialize chat app
    window.chatApp = new ChatApp();
});

export { ChatApp };

💻 Pusher 服务器端集成 javascript

🟡 intermediate ⭐⭐⭐⭐

带有 Pusher 集成的完整 Node.js 服务器,用于认证、频道管理和事件广播

⏱️ 30 min 🏷️ pusher, server, authentication, integration
Prerequisites: Node.js, Express, Pusher account
// Server-side Pusher Integration with Node.js/Express
// Complete implementation with authentication, channels, and event broadcasting

const express = require('express');
const cors = require('cors');
const jwt = require('jsonwebtoken');
const Pusher = require('pusher');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const rateLimit = require('express-rate-limit');
const helmet = require('helmet');

const app = express();
const PORT = process.env.PORT || 3001;

// Security middleware
app.use(helmet());
app.use(cors({
    origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'],
    credentials: true
}));

// Rate limiting
const limiter = rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 100 // limit each IP to 100 requests per windowMs
});

app.use(limiter);
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Initialize Pusher
const pusher = new Pusher({
    appId: process.env.PUSHER_APP_ID,
    key: process.env.PUSHER_KEY,
    secret: process.env.PUSHER_SECRET,
    cluster: process.env.PUSHER_CLUSTER,
    useTLS: true,
    encrypted: true
});

// JWT Secret
const JWT_SECRET = process.env.JWT_SECRET || 'your-super-secret-jwt-key';

// File upload configuration
const upload = multer({
    dest: 'uploads/',
    limits: {
        fileSize: 10 * 1024 * 1024 // 10MB limit
    },
    fileFilter: (req, file, cb) => {
        // Allow common file types
        const allowedTypes = [
            'image/jpeg', 'image/png', 'image/gif', 'image/webp',
            'application/pdf', 'text/plain',
            'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
        ];

        if (allowedTypes.includes(file.mimetype)) {
            cb(null, true);
        } else {
            cb(new Error('Invalid file type'), false);
        }
    }
});

// In-memory storage (replace with database in production)
const users = new Map();
const rooms = new Map();
const messages = new Map();
const userSessions = new Map();

// Sample user data (in production, use a proper database)
const sampleUsers = [
    { id: '1', name: 'Alice', email: '[email protected]', role: 'user' },
    { id: '2', name: 'Bob', email: '[email protected]', role: 'user' },
    { id: '3', name: 'Charlie', email: '[email protected]', role: 'admin' }
];

sampleUsers.forEach(user => {
    users.set(user.id, user);
});

// Authentication middleware
const authenticateToken = (req, res, next) => {
    const authHeader = req.headers['authorization'];
    const token = authHeader && authHeader.split(' ')[1];

    if (!token) {
        return res.status(401).json({ error: 'Access token required' });
    }

    jwt.verify(token, JWT_SECRET, (err, user) => {
        if (err) {
            return res.status(403).json({ error: 'Invalid or expired token' });
        }
        req.user = user;
        next();
    });
};

// Authentication routes
app.post('/api/login', (req, res) => {
    const { email, password } = req.body;

    // In production, verify against database with hashed passwords
    const user = Array.from(users.values()).find(u => u.email === email);

    if (!user || password !== 'password') { // Simplified for demo
        return res.status(401).json({ error: 'Invalid credentials' });
    }

    const token = jwt.sign(
        { userId: user.id, name: user.name, role: user.role },
        JWT_SECRET,
        { expiresIn: '24h' }
    );

    // Generate a unique color for the user
    const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7'];
    const userColor = colors[user.id.charCodeAt(0) % colors.length];

    res.json({
        token,
        user: {
            ...user,
            color: userColor
        }
    });
});

app.get('/api/user/me', authenticateToken, (req, res) => {
    const user = users.get(req.user.userId);
    if (!user) {
        return res.status(404).json({ error: 'User not found' });
    }

    // Generate color if not present
    const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7'];
    if (!user.color) {
        user.color = colors[user.id.charCodeAt(0) % colors.length];
    }

    res.json(user);
});

// Pusher authentication endpoint
app.post('/api/pusher/auth', authenticateToken, (req, res) => {
    const socketId = req.body.socket_id;
    const channel = req.body.channel_name;

    // Check if user has permission to access this channel
    if (!hasChannelPermission(req.user, channel)) {
        return res.status(403).json({ error: 'Permission denied' });
    }

    // Get presence data for presence channels
    let presenceData = {};
    if (channel.startsWith('presence-')) {
        const user = users.get(req.user.userId);
        presenceData = {
            user_id: user.id,
            user_info: {
                name: user.name,
                email: user.email,
                role: user.role,
                color: user.color
            }
        };
    }

    const auth = pusher.authenticate(socketId, channel, presenceData);
    res.send(auth);
});

// Check channel permissions
function hasChannelPermission(user, channelName) {
    // Public channels - everyone can access
    if (channelName.startsWith('public-')) {
        return true;
    }

    // Private channels - authenticated users can access
    if (channelName.startsWith('private-')) {
        return true;
    }

    // Presence channels - authenticated users can access
    if (channelName.startsWith('presence-')) {
        return true;
    }

    // Admin channels - only admin users can access
    if (channelName.startsWith('private-admin-')) {
        return user.role === 'admin';
    }

    // Notification channels - user can only access their own
    if (channelName.startsWith('private-notifications-')) {
        const userId = channelName.split('-')[2];
        return userId === user.userId;
    }

    return false;
}

// Handle client events and broadcast them
app.post('/api/client-events', authenticateToken, (req, res) => {
    const { channel_name, event_name, data } = req.body;

    // Validate event name (must start with 'client-')
    if (!event_name.startsWith('client-')) {
        return res.status(400).json({ error: 'Invalid event name' });
    }

    // Check if user has permission to trigger event on this channel
    if (!hasChannelPermission(req.user, channel_name)) {
        return res.status(403).json({ error: 'Permission denied' });
    }

    try {
        // Add user info to event data
        const enrichedData = {
            ...data,
            user: {
                id: req.user.userId,
                name: req.user.name,
                role: req.user.role
            },
            server_timestamp: new Date().toISOString()
        };

        // Broadcast to channel (remove 'client-' prefix for actual event name)
        const actualEventName = event_name.replace('client-', '');
        pusher.trigger(channel_name, actualEventName, enrichedData);

        // Handle specific events
        if (actualEventName === 'send_message') {
            saveMessage(enrichedData);
        } else if (actualEventName === 'typing') {
            handleTypingIndicator(enrichedData, true);
        } else if (actualEventName === 'stop_typing') {
            handleTypingIndicator(enrichedData, false);
        }

        res.json({ success: true });
    } catch (error) {
        console.error('Error broadcasting event:', error);
        res.status(500).json({ error: 'Failed to broadcast event' });
    }
});

// Save message to storage
function saveMessage(messageData) {
    const roomId = messageData.roomId;
    if (!messages.has(roomId)) {
        messages.set(roomId, []);
    }

    const roomMessages = messages.get(roomId);
    roomMessages.push(messageData);

    // Limit message history
    if (roomMessages.length > 1000) {
        roomMessages.splice(0, roomMessages.length - 1000);
    }

    // Check for mentions and send notifications
    const mentions = extractMentions(messageData.message);
    if (mentions.length > 0) {
        mentions.forEach(userId => {
            sendMentionNotification(userId, messageData);
        });
    }
}

// Handle typing indicators
function handleTypingIndicator(data, isTyping) {
    const typingData = {
        user: data.user,
        isTyping,
        timestamp: data.timestamp
    };

    // Broadcast typing event to the channel
    const eventName = isTyping ? 'user_typing' : 'user_stop_typing';
    pusher.trigger(data.channel_name, eventName, typingData);
}

// Extract user mentions from message
function extractMentions(message) {
    const mentionRegex = /@(\w+)/g;
    const mentions = [];
    let match;

    while ((match = mentionRegex.exec(message)) !== null) {
        const username = match[1];
        const user = Array.from(users.values()).find(u =>
            u.name.toLowerCase() === username.toLowerCase()
        );

        if (user) {
            mentions.push(user.id);
        }
    }

    return mentions;
}

// Send mention notification
function sendMentionNotification(userId, messageData) {
    const notificationData = {
        type: 'mention',
        title: 'You were mentioned in a message',
        message: `${messageData.user.name} mentioned you in a chat`,
        roomId: messageData.roomId,
        messageId: messageData.id,
        timestamp: new Date().toISOString()
    };

    pusher.trigger(`private-notifications-${userId}`, 'message_mention', notificationData);
}

// Send general notification
function sendNotification(userId, notificationData) {
    pusher.trigger(`private-notifications-${userId}`, 'new_notification', notificationData);
}

// File upload endpoint
app.post('/api/upload', authenticateToken, upload.single('file'), (req, res) => {
    if (!req.file) {
        return res.status(400).json({ error: 'No file uploaded' });
    }

    try {
        const file = req.file;
        const roomId = req.body.roomId;

        // Generate unique filename
        const fileExtension = path.extname(file.originalname);
        const fileName = `${Date.now()}-${file.originalname}`;
        const filePath = path.join(__dirname, 'uploads', fileName);

        // Move file to uploads directory
        fs.renameSync(file.path, filePath);

        // Create file URL
        const fileUrl = `${req.protocol}://${req.get('host')}/uploads/${fileName}`;

        // Create file message data
        const fileMessage = {
            id: Date.now().toString(),
            type: 'file',
            url: fileUrl,
            fileName: file.originalname,
            fileSize: file.size,
            mimeType: file.mimetype
        };

        // Broadcast file message to room
        const channelName = `private-chat-${roomId}`;
        pusher.trigger(channelName, 'new_message', {
            ...fileMessage,
            user: {
                id: req.user.userId,
                name: req.user.name,
                role: req.user.role
            },
            timestamp: new Date().toISOString(),
            roomId
        });

        res.json({
            success: true,
            fileUrl,
            fileName: file.originalname,
            fileSize: file.size
        });

    } catch (error) {
        console.error('File upload error:', error);
        res.status(500).json({ error: 'Failed to upload file' });
    }
});

// Serve uploaded files
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));

// Get message history for a room
app.get('/api/messages/:roomId', authenticateToken, (req, res) => {
    const roomId = req.params.roomId;
    const roomMessages = messages.get(roomId) || [];

    // Return last 50 messages
    const recentMessages = roomMessages.slice(-50);

    res.json(recentMessages);
});

// Get available rooms
app.get('/api/rooms', authenticateToken, (req, res) => {
    const roomList = Array.from(rooms.values()).map(room => ({
        id: room.id,
        name: room.name,
        description: room.description,
        memberCount: room.memberCount || 0,
        isPrivate: room.isPrivate || false
    }));

    res.json(roomList);
});

// Create new room
app.post('/api/rooms', authenticateToken, (req, res) => {
    const { name, description, isPrivate = false } = req.body;

    if (!name) {
        return res.status(400).json({ error: 'Room name is required' });
    }

    const roomId = generateId();
    const newRoom = {
        id: roomId,
        name,
        description,
        isPrivate,
        createdBy: req.user.userId,
        createdAt: new Date(),
        memberCount: 0
    };

    rooms.set(roomId, newRoom);
    messages.set(roomId, []);

    // Broadcast room creation (for admin channels)
    pusher.trigger('private-admin-rooms', 'room_created', newRoom);

    res.json(newRoom);
});

// Get online users in a room
app.get('/api/rooms/:roomId/users', authenticateToken, (req, res) => {
    const roomId = req.params.roomId;

    // This would typically come from Pusher's presence channels
    // For now, return mock data
    res.json([]);
});

// Send system announcement (admin only)
app.post('/api/announce', authenticateToken, (req, res) => {
    if (req.user.role !== 'admin') {
        return res.status(403).json({ error: 'Admin access required' });
    }

    const { message, targetChannel = 'public-announcements' } = req.body;

    if (!message) {
        return res.status(400).json({ error: 'Message is required' });
    }

    const announcement = {
        type: 'announcement',
        message,
        from: {
            id: req.user.userId,
            name: req.user.name
        },
        timestamp: new Date().toISOString()
    };

    pusher.trigger(targetChannel, 'announcement', announcement);

    // Also send to all private notification channels for important announcements
    if (targetChannel === 'public-announcements') {
        Array.from(users.values()).forEach(user => {
            sendNotification(user.id, {
                type: 'announcement',
                title: 'System Announcement',
                message,
                timestamp: announcement.timestamp
            });
        });
    }

    res.json({ success: true });
});

// Get Pusher stats
app.get('/api/stats', authenticateToken, (req, res) => {
    if (req.user.role !== 'admin') {
        return res.status(403).json({ error: 'Admin access required' });
    }

    const stats = {
        totalUsers: users.size,
        totalRooms: rooms.size,
        totalMessages: Array.from(messages.values())
            .reduce((total, roomMessages) => total + roomMessages.length, 0),
        uptime: process.uptime(),
        memory: process.memoryUsage(),
        timestamp: new Date().toISOString()
    };

    res.json(stats);
});

// Health check
app.get('/health', (req, res) => {
    res.json({
        status: 'healthy',
        timestamp: new Date().toISOString(),
        uptime: process.uptime()
    });
});

// Utility functions
function generateId() {
    return Math.random().toString(36).substr(2, 9);
}

// Error handling middleware
app.use((error, req, res, next) => {
    console.error('Server error:', error);
    res.status(500).json({ error: 'Internal server error' });
});

// Start server
app.listen(PORT, () => {
    console.log(`Pusher server running on port ${PORT}`);
    console.log(`Pusher app ID: ${process.env.PUSHER_APP_ID}`);
    console.log(`Pusher cluster: ${process.env.PUSHER_CLUSTER}`);
});

// Graceful shutdown
process.on('SIGTERM', () => {
    console.log('SIGTERM received, shutting down gracefully');
    process.exit(0);
});

module.exports = app;

💻 Pusher React Hooks typescript

🔴 complex ⭐⭐⭐⭐

用于无缝 Pusher 集成的现代 React hooks,支持 TypeScript 和自定义 hooks

⏱️ 35 min 🏷️ pusher, react, hooks, typescript
Prerequisites: React, TypeScript, Pusher basics
// React Hooks for Pusher Integration
// Modern TypeScript hooks with state management and error handling

import { useState, useEffect, useCallback, useRef } from 'react';
import Pusher, { Channel, Members } from 'pusher-js';

// Type definitions
interface User {
    id: string;
    name: string;
    email?: string;
    role?: string;
    color?: string;
    avatar?: string;
}

interface Message {
    id: string;
    user: User;
    message: string;
    type: 'text' | 'file' | 'system';
    timestamp: Date;
    roomId?: string;
    metadata?: Record<string, any>;
}

interface TypingUser {
    id: string;
    name: string;
    color?: string;
    lastSeen: Date;
}

interface NotificationSettings {
    mentions: boolean;
    replies: boolean;
    systemMessages: boolean;
    soundEnabled: boolean;
}

interface PusherConfig {
    key: string;
    cluster: string;
    authEndpoint?: string;
    forceTLS?: boolean;
}

// Custom hook for Pusher connection management
export const usePusher = (config: PusherConfig) => {
    const [isConnected, setIsConnected] = useState(false);
    const [connectionState, setConnectionState] = useState<string>('disconnected');
    const [error, setError] = useState<string | null>(null);

    const pusherRef = useRef<Pusher | null>(null);

    useEffect(() => {
        // Initialize Pusher
        pusherRef.current = new Pusher(config.key, {
            cluster: config.cluster,
            authEndpoint: config.authEndpoint,
            forceTLS: config.forceTLS ?? true,
            auth: {
                headers: {
                    'Authorization': 'Bearer ' + localStorage.getItem('token')
                }
            }
        });

        // Connection event handlers
        const handleConnected = () => {
            setIsConnected(true);
            setConnectionState('connected');
            setError(null);
            console.log('Connected to Pusher');
        };

        const handleDisconnected = () => {
            setIsConnected(false);
            setConnectionState('disconnected');
            console.log('Disconnected from Pusher');
        };

        const handleError = (err: any) => {
            setError(err.message || 'Connection error');
            setConnectionState('error');
            console.error('Pusher connection error:', err);
        };

        const handleConnecting = () => {
            setConnectionState('connecting');
        };

        pusherRef.current.connection.bind('connected', handleConnected);
        pusherRef.current.connection.bind('disconnected', handleDisconnected);
        pusherRef.current.connection.bind('error', handleError);
        pusherRef.current.connection.bind('connecting', handleConnecting);

        return () => {
            if (pusherRef.current) {
                pusherRef.current.connection.unbind('connected', handleConnected);
                pusherRef.current.connection.unbind('disconnected', handleDisconnected);
                pusherRef.current.connection.unbind('error', handleError);
                pusherRef.current.connection.unbind('connecting', handleConnecting);
                pusherRef.current.disconnect();
            }
        };
    }, [config]);

    return {
        pusher: pusherRef.current,
        isConnected,
        connectionState,
        error
    };
};

// Custom hook for channel subscription
export const useChannel = (
    pusher: Pusher | null,
    channelName: string,
    options?: {
        type?: 'public' | 'private' | 'presence';
        onSubscribed?: () => void;
        onSubscriptionError?: (error: any) => void;
    }
) => {
    const [channel, setChannel] = useState<Channel | null>(null);
    const [isSubscribed, setIsSubscribed] = useState(false);
    const [subscriptionError, setSubscriptionError] = useState<string | null>(null);

    const channelRef = useRef<Channel | null>(null);

    useEffect(() => {
        if (!pusher || !channelName) return;

        // Subscribe to channel
        const fullChannelName = getFullChannelName(channelName, options?.type);
        channelRef.current = pusher.subscribe(fullChannelName);
        setChannel(channelRef.current);

        // Bind to subscription events
        const handleSubscribed = () => {
            setIsSubscribed(true);
            setSubscriptionError(null);
            options?.onSubscribed?.();
        };

        const handleSubscriptionError = (err: any) => {
            setSubscriptionError(err.message || 'Subscription error');
            options?.onSubscriptionError?.(err);
        };

        channelRef.current.bind('pusher:subscription_succeeded', handleSubscribed);
        channelRef.current.bind('pusher:subscription_error', handleSubscriptionError);

        return () => {
            if (channelRef.current) {
                channelRef.current.unbind('pusher:subscription_succeeded', handleSubscribed);
                channelRef.current.unbind('pusher:subscription_error', handleSubscriptionError);
                pusher.unsubscribe(fullChannelName);
            }
            setChannel(null);
            setIsSubscribed(false);
        };
    }, [pusher, channelName, options]);

    return {
        channel,
        isSubscribed,
        subscriptionError
    };
};

// Custom hook for presence channel management
export const usePresenceChannel = (
    pusher: Pusher | null,
    channelName: string,
    options?: {
        onMemberJoined?: (member: any) => void;
        onMemberLeft?: (member: any) => void;
    }
) => {
    const [members, setMembers] = useState<Map<string, any>>(new Map());
    const [count, setCount] = useState(0);

    const channelHook = useChannel(pusher, channelName, { type: 'presence' });

    useEffect(() => {
        if (!channelHook.channel) return;

        const channel = channelHook.channel;

        // Handle presence events
        const handleSubscriptionSucceeded = (data: any) => {
            const membersMap = new Map<string, any>();
            data.members.forEach((member: any) => {
                membersMap.set(member.id, member.info);
            });
            setMembers(membersMap);
            setCount(data.count);
        };

        const handleMemberAdded = (member: any) => {
            setMembers(prev => {
                const newMembers = new Map(prev);
                newMembers.set(member.id, member.info);
                return newMembers;
            });
            setCount(prev => prev + 1);
            options?.onMemberJoined?.(member);
        };

        const handleMemberRemoved = (member: any) => {
            setMembers(prev => {
                const newMembers = new Map(prev);
                newMembers.delete(member.id);
                return newMembers;
            });
            setCount(prev => prev - 1);
            options?.onMemberLeft?.(member);
        };

        channel.bind('pusher:subscription_succeeded', handleSubscriptionSucceeded);
        channel.bind('pusher:member_added', handleMemberAdded);
        channel.bind('pusher:member_removed', handleMemberRemoved);

        return () => {
            channel.unbind('pusher:subscription_succeeded', handleSubscriptionSucceeded);
            channel.unbind('pusher:member_added', handleMemberAdded);
            channel.unbind('pusher:member_removed', handleMemberRemoved);
        };
    }, [channelHook.channel, options]);

    return {
        ...channelHook,
        members: Array.from(members.values()),
        count,
        getMember: (id: string) => members.get(id)
    };
};

// Custom hook for real-time messaging
export const useMessaging = (
    pusher: Pusher | null,
    channelId: string,
    options?: {
        onNewMessage?: (message: Message) => void;
        onTypingStart?: (user: TypingUser) => void;
        onTypingStop?: (userId: string) => void;
        onError?: (error: string) => void;
    }
) => {
    const [messages, setMessages] = useState<Message[]>([]);
    const [typingUsers, setTypingUsers] = useState<Map<string, TypingUser>>(new Map());

    const channelHook = useChannel(pusher, channelId, { type: 'private' });

    // Send message function
    const sendMessage = useCallback(async (content: string, type: 'text' | 'file' = 'text') => {
        if (!channelHook.channel || !content.trim()) {
            return false;
        }

        try {
            const response = await fetch('/api/client-events', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': 'Bearer ' + localStorage.getItem('token')
                },
                body: JSON.stringify({
                    channel_name: channelHook.channel.name,
                    event_name: 'client-send_message',
                    data: {
                        message: content.trim(),
                        type,
                        timestamp: new Date().toISOString()
                    }
                })
            });

            if (!response.ok) {
                throw new Error('Failed to send message');
            }

            return true;
        } catch (error) {
            options?.onError?.(error instanceof Error ? error.message : 'Unknown error');
            return false;
        }
    }, [channelHook.channel, options]);

    // Send typing indicator
    const sendTypingIndicator = useCallback((isTyping: boolean) => {
        if (!channelHook.channel) return;

        channelHook.channel.trigger(`client-${isTyping ? 'typing' : 'stop_typing'}`, {
            timestamp: new Date().toISOString()
        });
    }, [channelHook.channel]);

    // Handle channel events
    useEffect(() => {
        if (!channelHook.channel) return;

        const channel = channelHook.channel;

        // Listen for new messages
        const handleNewMessage = (data: any) => {
            const message: Message = {
                id: data.id,
                user: data.user,
                message: data.message,
                type: data.type || 'text',
                timestamp: new Date(data.timestamp),
                metadata: data.metadata
            };

            setMessages(prev => [...prev, message]);
            options?.onNewMessage?.(message);
        };

        // Listen for typing indicators
        const handleTypingStart = (data: any) => {
            const typingUser: TypingUser = {
                id: data.user.id,
                name: data.user.name,
                color: data.user.color,
                lastSeen: new Date()
            };

            setTypingUsers(prev => {
                const newMap = new Map(prev);
                newMap.set(typingUser.id, typingUser);
                return newMap;
            });

            options?.onTypingStart?.(typingUser);
        };

        const handleTypingStop = (data: any) => {
            setTypingUsers(prev => {
                const newMap = new Map(prev);
                newMap.delete(data.user.id);
                return newMap;
            });

            options?.onTypingStop?.(data.user.id);
        };

        channel.bind('new_message', handleNewMessage);
        channel.bind('user_typing', handleTypingStart);
        channel.bind('user_stop_typing', handleTypingStop);

        return () => {
            channel.unbind('new_message', handleNewMessage);
            channel.unbind('user_typing', handleTypingStart);
            channel.unbind('user_stop_typing', handleTypingStop);
        };
    }, [channelHook.channel, options]);

    // Clean up old typing indicators
    useEffect(() => {
        const interval = setInterval(() => {
            const now = new Date();
            setTypingUsers(prev => {
                const newMap = new Map(prev);
                prev.forEach((user, id) => {
                    // Remove typing indicator if user hasn't typed in 3 seconds
                    if (now.getTime() - user.lastSeen.getTime() > 3000) {
                        newMap.delete(id);
                    }
                });
                return newMap;
            });
        }, 1000);

        return () => clearInterval(interval);
    }, []);

    return {
        ...channelHook,
        messages,
        typingUsers: Array.from(typingUsers.values()),
        sendMessage,
        sendTypingIndicator
    };
};

// Custom hook for notifications
export const useNotifications = (
    pusher: Pusher | null,
    userId: string,
    settings: NotificationSettings = {
        mentions: true,
        replies: true,
        systemMessages: true,
        soundEnabled: true
    }
) => {
    const [notifications, setNotifications] = useState<any[]>([]);
    const [unreadCount, setUnreadCount] = useState(0);

    const channelHook = useChannel(pusher, `notifications-${userId}`, { type: 'private' });

    // Mark notification as read
    const markAsRead = useCallback((notificationId: string) => {
        setNotifications(prev =>
            prev.map(notif =>
                notif.id === notificationId
                    ? { ...notif, read: true }
                    : notif
            )
        );

        // Update unread count
        setUnreadCount(prev => Math.max(0, prev - 1));
    }, []);

    // Mark all as read
    const markAllAsRead = useCallback(() => {
        setNotifications(prev =>
            prev.map(notif => ({ ...notif, read: true }))
        );
        setUnreadCount(0);
    }, []);

    // Clear notifications
    const clearNotifications = useCallback(() => {
        setNotifications([]);
        setUnreadCount(0);
    }, []);

    // Listen for notifications
    useEffect(() => {
        if (!channelHook.channel) return;

        const channel = channelHook.channel;

        const handleNewNotification = (data: any) => {
            // Check user preferences
            if (data.type === 'mention' && !settings.mentions) return;
            if (data.type === 'reply' && !settings.replies) return;
            if (data.type === 'system' && !settings.systemMessages) return;

            const notification = {
                ...data,
                id: data.id || Date.now().toString(),
                timestamp: new Date(data.timestamp),
                read: false
            };

            setNotifications(prev => [notification, ...prev]);
            setUnreadCount(prev => prev + 1);

            // Play notification sound if enabled
            if (settings.soundEnabled) {
                playNotificationSound();
            }

            // Show browser notification
            if ('Notification' in window && Notification.permission === 'granted') {
                new Notification(data.title, {
                    body: data.message,
                    icon: '/notification-icon.png',
                    tag: notification.id
                });
            }
        };

        channel.bind('new_notification', handleNewNotification);
        channel.bind('message_mention', handleNewNotification);

        return () => {
            channel.unbind('new_notification', handleNewNotification);
            channel.unbind('message_mention', handleNewNotification);
        };
    }, [channelHook.channel, settings]);

    // Request notification permission
    useEffect(() => {
        if ('Notification' in window && Notification.permission === 'default') {
            Notification.requestPermission();
        }
    }, []);

    return {
        ...channelHook,
        notifications,
        unreadCount,
        markAsRead,
        markAllAsRead,
        clearNotifications
    };
};

// Utility functions
function getFullChannelName(channelName: string, type?: string): string {
    switch (type) {
        case 'private':
            return `private-${channelName}`;
        case 'presence':
            return `presence-${channelName}`;
        case 'public':
        default:
            return `public-${channelName}`;
    }
}

function playNotificationSound() {
    try {
        const audio = new Audio('/notification.mp3');
        audio.volume = 0.3;
        audio.play().catch(() => {});
    } catch (error) {
        // Ignore audio errors
    }
}

// Example usage component
export const ChatRoom: React.FC<{
    roomId: string;
    currentUser: User;
}> = ({ roomId, currentUser }) => {
    const config: PusherConfig = {
        key: process.env.REACT_APP_PUSHER_KEY!,
        cluster: process.env.REACT_APP_PUSHER_CLUSTER!,
        authEndpoint: '/api/pusher/auth'
    };

    const { pusher, isConnected } = usePusher(config);

    const {
        messages,
        typingUsers,
        sendMessage,
        sendTypingIndicator
    } = useMessaging(pusher, roomId, {
        onNewMessage: (message) => {
            console.log('New message:', message);
        },
        onError: (error) => {
            console.error('Message error:', error);
        }
    });

    const { members, count } = usePresenceChannel(pusher, roomId, {
        onMemberJoined: (member) => {
            console.log('Member joined:', member);
        }
    });

    const [inputValue, setInputValue] = useState('');
    const [isTyping, setIsTyping] = useState(false);

    const handleSend = useCallback(() => {
        if (sendMessage(inputValue)) {
            setInputValue('');
            setIsTyping(false);
        }
    }, [inputValue, sendMessage]);

    const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
        setInputValue(e.target.value);

        if (!isTyping && e.target.value.trim()) {
            setIsTyping(true);
            sendTypingIndicator(true);
        } else if (isTyping && !e.target.value.trim()) {
            setIsTyping(false);
            sendTypingIndicator(false);
        }
    }, [isTyping, sendTypingIndicator]);

    return (
        <div className="chat-room">
            <div className="chat-header">
                <h3>Room: {roomId}</h3>
                <div className="connection-status">
                    {isConnected ? '🟢 Connected' : '🔴 Disconnected'}
                </div>
                <div className="online-count">
                    {count} users online
                </div>
            </div>

            <div className="messages">
                {messages.map(message => (
                    <div key={message.id} className="message">
                        <strong>{message.user.name}: </strong>
                        {message.message}
                        <small>{new Date(message.timestamp).toLocaleTimeString()}</small>
                    </div>
                ))}
            </div>

            {typingUsers.length > 0 && (
                <div className="typing-indicator">
                    {typingUsers.map(user => user.name).join(', ')} is typing...
                </div>
            )}

            <div className="message-input">
                <input
                    type="text"
                    value={inputValue}
                    onChange={handleInputChange}
                    onKeyPress={(e) => e.key === 'Enter' && handleSend()}
                    placeholder="Type a message..."
                    disabled={!isConnected}
                />
                <button onClick={handleSend} disabled={!isConnected || !inputValue.trim()}>
                    Send
                </button>
            </div>

            <div className="online-users">
                <h4>Online Users:</h4>
                {members.map(member => (
                    <div key={member.id} className="user">
                        {member.name}
                    </div>
                ))}
            </div>
        </div>
    );
};

export default {
    usePusher,
    useChannel,
    usePresenceChannel,
    useMessaging,
    useNotifications,
    ChatRoom
};