UNPKG

@ably/cli

Version:

Ably CLI for Pub/Sub, Chat and Spaces

279 lines (278 loc) 13.8 kB
import { Args, Flags } from "@oclif/core"; import chalk from "chalk"; import { ChatBaseCommand } from "../../../chat-base-command.js"; import { waitUntilInterruptedOrTimeout } from "../../../utils/long-running.js"; export default class MessagesSubscribe extends ChatBaseCommand { static args = { roomId: Args.string({ description: "The room ID to subscribe to messages from", required: true, }), }; static description = "Subscribe to messages in an Ably Chat room"; static examples = [ "$ ably rooms messages subscribe my-room", '$ ably rooms messages subscribe --api-key "YOUR_API_KEY" my-room', "$ ably rooms messages subscribe --show-metadata my-room", "$ ably rooms messages subscribe my-room --duration 30", "$ ably rooms messages subscribe my-room --json", "$ ably rooms messages subscribe my-room --pretty-json", ]; static flags = { ...ChatBaseCommand.globalFlags, "show-metadata": Flags.boolean({ default: false, description: "Display message metadata if available", }), duration: Flags.integer({ description: "Automatically exit after the given number of seconds (0 = run indefinitely)", char: "D", required: false, }), }; ablyClient = null; // Store Ably client for cleanup messageSubscription = null; unsubscribeStatusFn = null; chatClient = null; roomId = null; cleanupInProgress = false; async properlyCloseAblyClient() { if (!this.ablyClient || this.ablyClient.connection.state === 'closed') { return; } return new Promise((resolve) => { const timeout = setTimeout(() => { console.warn('Ably client cleanup timed out after 2 seconds'); resolve(); }, 2000); // Reduced from 3000 to 2000 const onClosed = () => { clearTimeout(timeout); resolve(); }; // Listen for both closed and failed states this.ablyClient.connection.once('closed', onClosed); this.ablyClient.connection.once('failed', onClosed); this.ablyClient.close(); }); } // Override finally to ensure resources are cleaned up async finally(err) { // Proper cleanup sequence try { // Release room if we haven't already if (this.chatClient && this.roomId) { await this.chatClient.rooms.release(this.roomId); } } catch { // Ignore release errors in cleanup } if (this.messageSubscription) { try { this.messageSubscription.unsubscribe(); } catch { /* ignore */ } } if (this.unsubscribeStatusFn) { try { this.unsubscribeStatusFn.off(); } catch { /* ignore */ } } // Close Ably client properly with timeout await this.properlyCloseAblyClient(); // Ensure the process does not linger due to any stray async handles await super.finally(err); // Force a graceful exit shortly after cleanup to avoid hanging (skip in tests) if (process.env.NODE_ENV !== 'test') { setTimeout(() => { process.exit(0); }, 100); } } async run() { const { args, flags } = await this.parse(MessagesSubscribe); this.roomId = args.roomId; // Store for cleanup this.logCliEvent(flags, "subscribe.run", "start", `Starting rooms messages subscribe for room: ${this.roomId}`); try { // Create clients this.logCliEvent(flags, "subscribe.auth", "attemptingClientCreation", "Attempting to create Chat and Ably clients."); // Create Chat client (which also creates the Ably client internally) this.chatClient = await this.createChatClient(flags); // Get the underlying Ably client for cleanup and state listeners this.ablyClient = this._chatRealtimeClient; this.logCliEvent(flags, "subscribe.auth", "clientCreationSuccess", "Chat and Ably clients created."); if (!this.shouldOutputJson(flags)) { this.log(`Attaching to room: ${chalk.cyan(this.roomId)}...`); } if (!this.chatClient || !this.ablyClient) { throw new Error("Failed to create Chat or Ably client"); } // Set up connection state logging this.setupConnectionStateLogging(this.ablyClient, flags, { includeUserFriendlyMessages: true }); // Get the room this.logCliEvent(flags, "room", "gettingRoom", `Getting room handle for ${this.roomId}`); const room = await this.chatClient.rooms.get(this.roomId, {}); this.logCliEvent(flags, "room", "gotRoom", `Got room handle for ${this.roomId}`); // Setup message handler this.logCliEvent(flags, "room", "subscribingToMessages", `Subscribing to messages in room ${this.roomId}`); this.messageSubscription = room.messages.subscribe((messageEvent) => { const { message } = messageEvent; const messageLog = { clientId: message.clientId, text: message.text, timestamp: message.timestamp, ...(message.metadata ? { metadata: message.metadata } : {}), }; this.logCliEvent(flags, "message", "received", "Message received", { message: messageLog, roomId: this.roomId, }); if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput({ message: messageLog, roomId: this.roomId, success: true, }, flags)); } else { // Format message with timestamp, author and content const timestamp = new Date(message.timestamp).toLocaleTimeString(); const author = message.clientId || "Unknown"; // Message content with consistent formatting this.log(`${chalk.gray(`[${timestamp}]`)} ${chalk.cyan(`${author}:`)} ${message.text}`); // Show metadata if enabled and available if (flags["show-metadata"] && message.metadata) { this.log(`${chalk.blue(" Metadata:")} ${chalk.yellow(this.formatJsonOutput(message.metadata, flags))}`); } this.log(""); // Empty line for better readability } }); this.logCliEvent(flags, "room", "subscribedToMessages", `Successfully subscribed to messages in room ${this.roomId}`); // Subscribe to room status changes this.logCliEvent(flags, "room", "subscribingToStatus", `Subscribing to status changes for room ${this.roomId}`); this.unsubscribeStatusFn = room.onStatusChange((statusChange) => { const change = statusChange; this.logCliEvent(flags, "room", `status-${change.current}`, `Room status changed to ${change.current}`, { reason: change.reason, roomId: this.roomId }); if (change.current === "attached") { this.logCliEvent(flags, "room", "statusAttached", "Room status is ATTACHED."); // Log the ready signal for E2E tests this.log(`Connected to room: ${this.roomId}`); if (!this.shouldOutputJson(flags)) { this.log(chalk.green(`✓ Subscribed to room: ${chalk.cyan(this.roomId)}. Listening for messages...`)); } // If we want to suppress output, we just don't log anything } else if (change.current === "failed") { const errorMsg = room.error?.message || "Unknown error"; if (this.shouldOutputJson(flags)) { // Logged via logCliEvent } else { this.error(`Failed to attach to room: ${errorMsg}`); } } }); this.logCliEvent(flags, "room", "subscribedToStatus", `Successfully subscribed to status changes for room ${this.roomId}`); // Attach to the room this.logCliEvent(flags, "room", "attaching", `Attaching to room ${this.roomId}`); await room.attach(); this.logCliEvent(flags, "room", "attachCallComplete", `room.attach() call complete for ${this.roomId}. Waiting for status change to 'attached'.`); // Note: successful attach logged by onStatusChange handler this.logCliEvent(flags, "subscribe", "listening", "Now listening for messages and status changes"); // Wait until the user interrupts or the optional duration elapses const effectiveDuration = typeof flags.duration === "number" && flags.duration > 0 ? flags.duration : process.env.ABLY_CLI_DEFAULT_DURATION ? Number(process.env.ABLY_CLI_DEFAULT_DURATION) : undefined; const exitReason = await waitUntilInterruptedOrTimeout(effectiveDuration); this.logCliEvent(flags, "subscribe", "runComplete", "Exiting wait loop", { exitReason }); this.cleanupInProgress = exitReason === "signal"; // mark if signal so finally knows } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); this.logCliEvent(flags, "subscribe", "fatalError", `Failed to subscribe to messages: ${errorMsg}`, { error: errorMsg, roomId: this.roomId }); // Close the connection in case of error if (this.ablyClient) { this.ablyClient.close(); } if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput({ error: errorMsg, roomId: this.roomId, success: false }, flags)); } else { this.error(`Failed to subscribe to messages: ${errorMsg}`); } } finally { // Wrap all cleanup in a timeout to prevent hanging await Promise.race([ this.performCleanup(flags || {}), new Promise((resolve) => { setTimeout(() => { this.logCliEvent(flags || {}, "subscribe", "cleanupTimeout", "Cleanup timed out after 5s, forcing completion"); resolve(); }, 5000); }) ]); this.logCliEvent(flags || {}, "subscribe", "cleanupComplete", "Cleanup complete"); // Don't show cleanup messages for minimal output } } async performCleanup(flags) { // Unsubscribe from messages with timeout if (this.messageSubscription) { try { this.logCliEvent(flags, "room", "unsubscribingMessages", "Unsubscribing from messages"); await Promise.race([ Promise.resolve(this.messageSubscription.unsubscribe()), new Promise((resolve) => setTimeout(resolve, 1000)) ]); this.logCliEvent(flags, "room", "unsubscribedMessages", "Unsubscribed from messages"); } catch (error) { this.logCliEvent(flags, "room", "unsubscribeMessagesError", "Error unsubscribing messages", { error: error instanceof Error ? error.message : String(error), }); } } // Unsubscribe from status with timeout if (this.unsubscribeStatusFn) { try { this.logCliEvent(flags, "room", "unsubscribingStatus", "Unsubscribing from status changes"); await Promise.race([ Promise.resolve(this.unsubscribeStatusFn.off()), new Promise((resolve) => setTimeout(resolve, 1000)) ]); this.logCliEvent(flags, "room", "unsubscribedStatus", "Unsubscribed from status changes"); } catch (error) { this.logCliEvent(flags, "room", "unsubscribeStatusError", "Error unsubscribing status", { error: error instanceof Error ? error.message : String(error), }); } } // Release the room with timeout try { if (this.chatClient && this.roomId) { this.logCliEvent(flags, "room", "releasing", `Releasing room ${this.roomId}`); await Promise.race([ this.chatClient.rooms.release(this.roomId), new Promise((resolve) => setTimeout(resolve, 2000)) ]); this.logCliEvent(flags, "room", "released", `Room ${this.roomId} released`); } } catch (error) { this.logCliEvent(flags, "room", "releaseError", `Error releasing room: ${error instanceof Error ? error.message : String(error)}`, { error: error instanceof Error ? error.message : String(error) }); } // Close Ably client properly with timeout (already has internal timeout) await this.properlyCloseAblyClient(); } }