UNPKG

pump-chat-client

Version:

WebSocket client for connecting to pump.fun token chat rooms

649 lines 25.7 kB
"use strict"; /** * @fileoverview PumpChatClient - A WebSocket client for connecting to pump.fun token chat rooms. * This client handles the socket.io protocol communication with pump.fun's chat servers, * providing an easy-to-use interface for reading and sending chat messages. * * @module pump-chat-client * @author codingbutter * @license MIT */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.PumpChatClient = void 0; const websocket_1 = __importDefault(require("websocket")); const events_1 = require("events"); /** * Event definitions for PumpChatClient * @event PumpChatClient#connected - Emitted when successfully connected to the chat room * @event PumpChatClient#disconnected - Emitted when disconnected from the chat room * @event PumpChatClient#message - Emitted when a new message is received * @event PumpChatClient#messageHistory - Emitted when message history is received * @event PumpChatClient#error - Emitted when a connection or protocol error occurs * @event PumpChatClient#serverError - Emitted when the server returns an error (e.g., authentication required) * @event PumpChatClient#userLeft - Emitted when a user leaves the chat room * @event PumpChatClient#maxReconnectAttemptsReached - Emitted after exhausting all reconnection attempts */ /** * WebSocket client for connecting to pump.fun token chat rooms. * Extends EventEmitter to provide event-driven communication. * * @class PumpChatClient * @extends {EventEmitter} * @example * ```typescript * const client = new PumpChatClient({ * roomId: 'YOUR_TOKEN_ADDRESS', * username: 'myUsername', * messageHistoryLimit: 50 * }); * * client.on('message', (msg) => { * console.log(`${msg.username}: ${msg.message}`); * }); * * client.connect(); * ``` */ class PumpChatClient extends events_1.EventEmitter { /** * Creates a new PumpChatClient instance. * @param {PumpChatClientOptions} options - Configuration options * @param {string} options.roomId - The token address to connect to * @param {string} [options.username="anonymous"] - Username for chat messages * @param {number} [options.messageHistoryLimit=100] - Max messages to store * @constructor */ constructor(options) { super(); /** Active WebSocket connection, null when disconnected */ this.connection = null; /** In-memory storage of chat messages */ this.messageHistory = []; /** Current connection state */ this.isConnected = false; /** Interval timer for sending ping messages to keep connection alive */ this.pingInterval = null; /** Counter for reconnection attempts */ this.reconnectAttempts = 0; /** Maximum number of times to attempt reconnection before giving up */ this.maxReconnectAttempts = 5; /** * Current acknowledgment ID for socket.io protocol. * Cycles from 0-9 to match request/response pairs. */ this.ackId = 0; /** * Map of pending acknowledgments waiting for server responses. * Key is the ack ID, value contains the event name and timestamp. */ this.pendingAcks = new Map(); // Store configuration this.roomId = options.roomId; this.username = options.username || "anonymous"; this.messageHistoryLimit = options.messageHistoryLimit || 100; // Initialize WebSocket client this.client = new websocket_1.default.client(); // Set up WebSocket event handlers this.setupClientHandlers(); } /** * Sets up event handlers for the WebSocket client. * These handlers manage the initial connection establishment. * @private */ setupClientHandlers() { /** * Handle successful WebSocket connection. * This is called when the WebSocket upgrade is successful. */ this.client.on("connect", (connection) => { // Store the connection reference this.connection = connection; this.isConnected = true; // Reset reconnection counter on successful connection this.reconnectAttempts = 0; console.error("WebSocket Client Connected"); // Emit connected event for consumers this.emit("connected"); // Set up handlers for this specific connection this.setupConnectionHandlers(connection); // Initialize the socket.io protocol handshake this.initializeConnection(); }); /** * Handle connection failures. * This is called when the WebSocket connection cannot be established. */ this.client.on("connectFailed", (error) => { console.error("Connection Failed:", error.toString()); // Emit error event for consumers this.emit("error", error); // Attempt to reconnect with exponential backoff this.attemptReconnect(); }); } /** * Sets up event handlers for an active WebSocket connection. * These handlers manage the ongoing communication and lifecycle. * @param {WebSocket.connection} connection - The active WebSocket connection * @private */ setupConnectionHandlers(connection) { /** * Handle connection errors during active connection. * These are different from connection establishment errors. */ connection.on("error", (error) => { console.error("Connection Error:", error.toString()); this.emit("error", error); }); /** * Handle connection closure. * This can happen due to network issues, server shutdown, or explicit disconnection. */ connection.on("close", () => { console.error("WebSocket Connection Closed"); // Update connection state this.isConnected = false; this.connection = null; // Notify consumers this.emit("disconnected"); // Stop sending ping messages this.stopPing(); // Attempt to reconnect unless explicitly disconnected this.attemptReconnect(); }); /** * Handle incoming WebSocket messages. * All messages from pump.fun come through this handler. */ connection.on("message", (message) => { // pump.fun sends UTF-8 encoded text messages if (message.type === "utf8" && message.utf8Data) { this.handleMessage(message.utf8Data); } }); /** * Set up periodic cleanup of stale acknowledgments. * This prevents memory leaks from acknowledgments that never receive responses. */ setInterval(() => { this.cleanupStaleAcks(); }, 10000); // Run cleanup every 10 seconds } /** * Main message handler that routes messages based on socket.io protocol type. * Socket.io uses numeric prefixes to identify different message types. * @param {string} data - Raw message data from the WebSocket * @private */ handleMessage(data) { var _a; // Extract the numeric message type prefix using regex // Socket.io messages start with a number (e.g., "42[...]", "430[...]") const messageType = (_a = data.match(/^(\d+)/)) === null || _a === void 0 ? void 0 : _a[1]; // Route to appropriate handler based on message type switch (messageType) { case "0": // Connect message - Server is ready this.handleConnect(data); break; case "40": // Connected acknowledgment - Handshake accepted this.handleConnectedAck(data); break; case "42": // Event message - Regular events without acknowledgment this.handleEvent(data); break; case "43": // Event with acknowledgment - Generic acknowledgment this.handleEventWithAck(data); break; // Numbered acknowledgments (430-439) correspond to requests (420-429) case "430": // Response to 420 (usually joinRoom) case "431": // Response to 421 (usually getMessageHistory) case "432": // Response to 422 case "433": // Response to 423 case "434": // Response to 424 case "435": // Response to 425 case "436": // Response to 426 case "437": // Response to 427 case "438": // Response to 428 (usually sendMessage errors) case "439": // Response to 429 this.handleNumberedAck(data); break; case "2": // Ping from server - Keep-alive mechanism this.sendPong(); break; case "3": // Pong from server - Response to our ping // No action needed, connection is alive break; default: // Log unknown message types for debugging console.error(`Unknown message type: ${messageType}`); } } /** * Handles the initial connection message from the server. * This message contains configuration like ping interval. * @param {string} data - Raw message data starting with "0" * @private */ handleConnect(data) { // Remove the "0" prefix and parse the JSON const jsonData = data.substring(1); const connectData = JSON.parse(jsonData); // Set up ping interval if specified by server if (connectData.pingInterval) { this.startPing(connectData.pingInterval); } // Send socket.io handshake with origin and timestamp // The "40" prefix indicates this is a handshake message this.send(`40{"origin":"https://pump.fun","timestamp":${Date.now()},"token":null}`); } /** * Handles the server's acknowledgment of our handshake. * After this, we can join the specific chat room. * @param {string} data - Raw message data starting with "40" * @private */ handleConnectedAck(data) { // Get the next acknowledgment ID (0-9) const joinAckId = this.getNextAckId(); // Track this pending acknowledgment this.pendingAcks.set(joinAckId, { event: "joinRoom", timestamp: Date.now() }); // Send joinRoom request with acknowledgment ID // Format: 42X["joinRoom",{...}] where X is the ack ID this.send(`42${joinAckId}["joinRoom",{"roomId":"${this.roomId}","username":"${this.username}"}]`); // Note: Message history request will be sent after successful join } /** * Handles regular event messages that don't expect acknowledgments. * These are typically server-initiated events. * @param {string} data - Raw message data starting with "42" * @private */ handleEvent(data) { try { // Remove "42" prefix and parse the JSON array const eventData = JSON.parse(data.substring(2)); const [eventName, payload] = eventData; // Handle different event types switch (eventName) { case "setCookie": // Server wants us to store a cookie (we don't actually use cookies) // After this, we can request message history this.requestMessageHistory(); break; case "newMessage": // A new chat message was posted this.handleNewMessage(payload); break; case "userLeft": // A user left the chat room this.emit("userLeft", payload); break; default: console.error(`Unknown event: ${eventName}`); } } catch (error) { console.error("Error parsing event:", error); } } /** * Handles acknowledgment messages without specific IDs. * These are typically responses to requests without acknowledgment IDs. * @param {string} data - Raw message data starting with "43" * @private */ handleEventWithAck(data) { try { // Remove "43" prefix and parse the response const ackData = JSON.parse(data.substring(2)); const eventData = ackData[0]; // Handle different response formats for message history if (eventData && eventData.messages) { // Response includes a messages array in an object this.messageHistory = eventData.messages; this.emit("messageHistory", this.messageHistory); } else if (Array.isArray(eventData)) { // Response is directly an array of messages this.messageHistory = eventData; this.emit("messageHistory", this.messageHistory); } else if (Array.isArray(ackData) && ackData.length > 0) { // Response is wrapped in another array this.messageHistory = ackData[0]; this.emit("messageHistory", this.messageHistory); } } catch (error) { console.error("Error parsing acknowledgment:", error); } } /** * Handles numbered acknowledgment messages (430-439). * These correspond to our numbered requests (420-429). * @param {string} data - Raw message data starting with "43X" * @private */ handleNumberedAck(data) { var _a; try { // Extract the message type (e.g., "431" from "431[...]") const messageType = (_a = data.match(/^(\d+)/)) === null || _a === void 0 ? void 0 : _a[1]; if (!messageType) return; // Get the acknowledgment ID (last digit: 0-9) const ackId = parseInt(messageType.substring(2)); // Look up the pending acknowledgment const pendingAck = this.pendingAcks.get(ackId); if (pendingAck) { // Remove from pending list this.pendingAcks.delete(ackId); console.error(`Received ack ${messageType} for ${pendingAck.event}`); } // Parse the response data (remove the 3-digit prefix) const ackData = JSON.parse(data.substring(3)); // Handle response based on the original request type if ((pendingAck === null || pendingAck === void 0 ? void 0 : pendingAck.event) === "joinRoom") { // Successfully joined the room, now request message history this.requestMessageHistory(); } else if ((pendingAck === null || pendingAck === void 0 ? void 0 : pendingAck.event) === "getMessageHistory") { // Received message history const messages = ackData[0]; if (Array.isArray(messages)) { this.messageHistory = messages; this.emit("messageHistory", this.messageHistory); } } else if ((pendingAck === null || pendingAck === void 0 ? void 0 : pendingAck.event) === "sendMessage") { // Handle send message response (usually errors) if (ackData[0] && ackData[0].error) { console.error("Server error:", ackData[0]); this.emit("serverError", ackData[0]); } } } catch (error) { console.error("Error parsing numbered acknowledgment:", error); } } /** * Handles new chat messages from the server. * Adds the message to history and emits an event. * @param {IMessage} message - The new message object * @private */ handleNewMessage(message) { // Add to message history this.messageHistory.push(message); // Maintain message history limit by removing oldest messages if (this.messageHistory.length > this.messageHistoryLimit) { this.messageHistory.shift(); // Remove the oldest message } // Emit event for consumers this.emit("message", message); } /** * Initializes the connection sequence. * Currently a placeholder as the handshake is handled by message handlers. * @private */ initializeConnection() { // The connection sequence is event-driven: // 1. We send handshake (40) in handleConnect // 2. Server responds with acknowledgment (40) // 3. We join room in handleConnectedAck // 4. Server confirms join (430) // 5. We request message history } /** * Requests the chat message history from the server. * Uses an acknowledgment ID to match the response. * @private */ requestMessageHistory() { // Get next acknowledgment ID const historyAckId = this.getNextAckId(); // Track this pending request this.pendingAcks.set(historyAckId, { event: "getMessageHistory", timestamp: Date.now() }); // Send request with acknowledgment ID // Format: 42X["getMessageHistory",{...}] where X is the ack ID this.send(`42${historyAckId}["getMessageHistory",{"roomId":"${this.roomId}","before":null,"limit":${this.messageHistoryLimit}}]`); } /** * Sends raw data through the WebSocket connection. * Checks connection state before sending. * @param {string} data - The data to send * @private */ send(data) { if (this.connection && this.isConnected) { this.connection.sendUTF(data); } else { console.error("Cannot send data: not connected"); } } /** * Starts sending periodic ping messages to keep the connection alive. * The interval is usually specified by the server. * @param {number} interval - Milliseconds between ping messages * @private */ startPing(interval) { // Clear any existing ping interval this.stopPing(); // Set up new ping interval this.pingInterval = setInterval(() => { this.send("2"); // "2" is the ping message in socket.io }, interval); } /** * Stops sending ping messages. * Called when disconnecting or before setting a new interval. * @private */ stopPing() { if (this.pingInterval) { clearInterval(this.pingInterval); this.pingInterval = null; } } /** * Sends a pong message in response to a server ping. * This is part of the keep-alive mechanism. * @private */ sendPong() { this.send("3"); // "3" is the pong message in socket.io } /** * Attempts to reconnect after a connection failure. * Uses exponential backoff to avoid overwhelming the server. * @private */ attemptReconnect() { // Check if we've exceeded max attempts if (this.reconnectAttempts < this.maxReconnectAttempts) { this.reconnectAttempts++; // Calculate exponential backoff delay // Starts at 2 seconds, doubles each time, max 30 seconds const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000); console.error(`Attempting to reconnect in ${delay}ms...`); // Schedule reconnection attempt setTimeout(() => { this.connect(); }, delay); } else { // Max attempts reached, notify consumers this.emit("maxReconnectAttemptsReached"); } } /** * Connects to the pump.fun chat room. * Sets up all required headers for the WebSocket handshake. * @public * @example * ```typescript * const client = new PumpChatClient({ roomId: 'token123' }); * client.connect(); * ``` */ connect() { // Headers required for successful WebSocket connection to pump.fun const headers = { // Standard WebSocket headers "Host": "livechat.pump.fun", "Connection": "Upgrade", "Pragma": "no-cache", "Cache-Control": "no-cache", // Browser identification "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", // WebSocket specific headers "Upgrade": "websocket", "Origin": "https://pump.fun", "Sec-WebSocket-Version": "13", // Compression and language preferences "Accept-Encoding": "gzip, deflate, br, zstd", "Accept-Language": "en-US,en;q=0.9", "Sec-WebSocket-Extensions": "permessage-deflate; client_max_window_bits" }; // Initiate WebSocket connection // EIO=4 specifies Engine.IO protocol version 4 this.client.connect("wss://livechat.pump.fun/socket.io/?EIO=4&transport=websocket", undefined, // No specific protocol undefined, // Use default origin headers); } /** * Disconnects from the chat room. * Stops all timers and closes the WebSocket connection. * @public * @example * ```typescript * client.disconnect(); * ``` */ disconnect() { // Stop sending ping messages this.stopPing(); // Close the WebSocket connection if active if (this.connection) { this.connection.close(); } } /** * Retrieves stored chat messages. * @param {number} [limit] - Maximum number of messages to return (most recent) * @returns {IMessage[]} Array of chat messages * @public * @example * ```typescript * // Get all stored messages * const allMessages = client.getMessages(); * * // Get last 10 messages * const recentMessages = client.getMessages(10); * ``` */ getMessages(limit) { if (limit) { // Return the most recent messages up to the limit return this.messageHistory.slice(-limit); } // Return a copy of all messages to prevent external modifications return [...this.messageHistory]; } /** * Gets the most recent message from the chat. * @returns {IMessage | null} The latest message or null if no messages * @public * @example * ```typescript * const latest = client.getLatestMessage(); * if (latest) { * console.log(`Latest: ${latest.username}: ${latest.message}`); * } * ``` */ getLatestMessage() { return this.messageHistory[this.messageHistory.length - 1] || null; } /** * Sends a message to the chat room. * Note: Requires authentication with pump.fun to work. * @param {string} message - The message text to send * @public * @example * ```typescript * client.sendMessage('Hello everyone!'); * ``` * @remarks * Sending messages requires being logged into pump.fun with valid session cookies. * Without authentication, you'll receive a "Authentication required" error. */ sendMessage(message) { if (this.isConnected) { // Get acknowledgment ID for this request const sendAckId = this.getNextAckId(); // Track pending acknowledgment this.pendingAcks.set(sendAckId, { event: "sendMessage", timestamp: Date.now() }); // Send message with acknowledgment ID // Format: 42X["sendMessage",{...}] where X is the ack ID this.send(`42${sendAckId}["sendMessage",{"roomId":"${this.roomId}","message":"${message}","username":"${this.username}"}]`); } else { console.error("Cannot send message: not connected"); } } /** * Gets the next acknowledgment ID for socket.io protocol. * IDs cycle from 0 to 9 to match requests with responses. * @returns {number} The next acknowledgment ID (0-9) * @private */ getNextAckId() { const currentId = this.ackId; // Increment and wrap around at 10 this.ackId = (this.ackId + 1) % 10; return currentId; } /** * Cleans up acknowledgments that never received responses. * This prevents memory leaks from accumulating pending acknowledgments. * @private */ cleanupStaleAcks() { const now = Date.now(); const timeout = 30000; // 30 seconds timeout for acknowledgments // Iterate through pending acknowledgments for (const [id, ack] of this.pendingAcks.entries()) { // Check if acknowledgment has timed out if (now - ack.timestamp > timeout) { this.pendingAcks.delete(id); console.error(`Cleaned up stale ack ${id} for ${ack.event}`); } } } /** * Checks if the client is currently connected to the chat room. * @returns {boolean} True if connected, false otherwise * @public * @example * ```typescript * if (client.isActive()) { * client.sendMessage('Hello!'); * } * ``` */ isActive() { return this.isConnected; } } exports.PumpChatClient = PumpChatClient; //# sourceMappingURL=index.js.map