UNPKG

@ably/cli

Version:

Ably CLI for Pub/Sub, Chat and Spaces

268 lines (267 loc) 12.5 kB
import { Args, Flags } from "@oclif/core"; import chalk from "chalk"; import { AblyBaseCommand } from "../../base-command.js"; import { formatJson, isJsonData } from "../../utils/json-formatter.js"; import { waitUntilInterruptedOrTimeout } from "../../utils/long-running.js"; export default class ChannelsSubscribe extends AblyBaseCommand { static args = { channels: Args.string({ description: "Channel name(s) to subscribe to", multiple: false, required: true, }), }; static description = "Subscribe to messages published on one or more Ably channels"; static examples = [ "$ ably channels subscribe my-channel", "$ ably channels subscribe my-channel another-channel", '$ ably channels subscribe --api-key "YOUR_API_KEY" my-channel', '$ ably channels subscribe --token "YOUR_ABLY_TOKEN" my-channel', "$ ably channels subscribe --rewind 10 my-channel", "$ ably channels subscribe --delta my-channel", "$ ably channels subscribe --cipher-key YOUR_CIPHER_KEY my-channel", "$ ably channels subscribe my-channel --json", "$ ably channels subscribe my-channel --pretty-json", "$ ably channels subscribe my-channel --duration 30", ]; static flags = { ...AblyBaseCommand.globalFlags, "cipher-algorithm": Flags.string({ default: "aes", description: "Encryption algorithm to use", }), "cipher-key": Flags.string({ description: "Encryption key for decrypting messages (hex-encoded)", }), "cipher-key-length": Flags.integer({ default: 256, description: "Length of encryption key in bits", }), "cipher-mode": Flags.string({ default: "cbc", description: "Cipher mode to use", }), delta: Flags.boolean({ default: false, description: "Enable delta compression for messages", }), duration: Flags.integer({ description: "Automatically exit after the given number of seconds (0 = run indefinitely)", char: "D", required: false, }), rewind: Flags.integer({ default: 0, description: "Number of messages to rewind when subscribing", }), }; static strict = false; cleanupInProgress = false; client = null; async properlyCloseAblyClient() { if (!this.client || this.client.connection.state === "closed" || this.client.connection.state === "failed") { return; } return new Promise((resolve) => { const timeout = setTimeout(() => { resolve(); }, 2000); const onClosedOrFailed = () => { clearTimeout(timeout); resolve(); }; this.client.connection.once("closed", onClosedOrFailed); this.client.connection.once("failed", onClosedOrFailed); this.client.close(); }); } // Override finally to ensure resources are cleaned up async finally(err) { await this.properlyCloseAblyClient(); return super.finally(err); } async run() { const { flags } = await this.parse(ChannelsSubscribe); const _args = await this.parse(ChannelsSubscribe); // Get all channel names from argv const channelNames = _args.argv; let channels = []; try { // Create the Ably client this.client = await this.createAblyRealtimeClient(flags); if (!this.client) return; const client = this.client; if (channelNames.length === 0) { const errorMsg = "At least one channel name is required"; this.logCliEvent(flags, "subscribe", "validationError", errorMsg, { error: errorMsg, }); if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput({ error: errorMsg, success: false }, flags)); } else { this.error(errorMsg); } return; } // Setup channels with appropriate options channels = channelNames.map((channelName) => { const channelOptions = {}; // Configure encryption if cipher key is provided if (flags["cipher-key"]) { channelOptions.cipher = { algorithm: flags["cipher-algorithm"], key: flags["cipher-key"], keyLength: flags["cipher-key-length"], mode: flags["cipher-mode"], }; this.logCliEvent(flags, "subscribe", "encryptionEnabled", `Encryption enabled for channel ${channelName}`, { algorithm: flags["cipher-algorithm"], channel: channelName }); } // Configure delta compression if (flags.delta) { channelOptions.params = { ...channelOptions.params, delta: "vcdiff", }; this.logCliEvent(flags, "subscribe", "deltaEnabled", `Delta compression enabled for channel ${channelName}`, { channel: channelName }); } // Configure rewind if (flags.rewind > 0) { channelOptions.params = { ...channelOptions.params, rewind: flags.rewind.toString(), }; this.logCliEvent(flags, "subscribe", "rewindEnabled", `Rewind enabled for channel ${channelName}`, { channel: channelName, count: flags.rewind }); } return client.channels.get(channelName, channelOptions); }); // Set up connection state logging this.setupConnectionStateLogging(client, flags, { includeUserFriendlyMessages: true, }); // Subscribe to messages on all channels const attachPromises = []; for (const channel of channels) { this.logCliEvent(flags, "subscribe", "subscribing", `Subscribing to channel: ${channel.name}`, { channel: channel.name }); if (!this.shouldOutputJson(flags)) { this.log(`Attaching to channel: ${chalk.cyan(channel.name)}...`); } // Set up channel state logging this.setupChannelStateLogging(channel, flags, { includeUserFriendlyMessages: true, }); // Track attachment promise const attachPromise = new Promise((resolve) => { const checkAttached = () => { if (channel.state === "attached") { resolve(); } }; channel.once("attached", checkAttached); checkAttached(); // Check if already attached }); attachPromises.push(attachPromise); channel.subscribe((message) => { const timestamp = message.timestamp ? new Date(message.timestamp).toISOString() : new Date().toISOString(); const messageEvent = { channel: channel.name, clientId: message.clientId, connectionId: message.connectionId, data: message.data, encoding: message.encoding, event: message.name || "(none)", id: message.id, timestamp, }; this.logCliEvent(flags, "subscribe", "messageReceived", `Received message on channel ${channel.name}`, messageEvent); if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput(messageEvent, flags)); } else { const name = message.name || "(none)"; // Message header with timestamp and channel info this.log(`${chalk.gray(`[${timestamp}]`)} ${chalk.cyan(`Channel: ${channel.name}`)} | ${chalk.yellow(`Event: ${name}`)}`); // Message data with consistent formatting if (isJsonData(message.data)) { this.log(chalk.blue("Data:")); this.log(formatJson(message.data)); } else { this.log(`${chalk.blue("Data:")} ${message.data}`); } this.log(""); // Empty line for better readability } }); } // Wait for all channels to attach await Promise.all(attachPromises); // Log the ready signal for E2E tests if (channelNames.length === 1) { this.log(`Successfully attached to channel ${channelNames[0]}`); } // Show success message once all channels are attached if (!this.shouldOutputJson(flags)) { if (channelNames.length === 1) { this.log(chalk.green(`✓ Subscribed to channel: ${chalk.cyan(channelNames[0])}. Listening for messages...`)); } else { this.log(chalk.green(`✓ Subscribed to ${channelNames.length} channels. Listening for messages...`)); } } this.logCliEvent(flags, "subscribe", "listening", "Listening for messages. Press Ctrl+C to exit."); // Wait until the user interrupts or the optional duration elapses const exitReason = await waitUntilInterruptedOrTimeout(flags.duration); this.logCliEvent(flags, "subscribe", "runComplete", "Exiting wait loop", { exitReason, }); this.cleanupInProgress = exitReason === "signal"; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); this.logCliEvent(flags, "subscribe", "fatalError", `Error during subscription: ${errorMsg}`, { channels: channelNames, error: errorMsg }); if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput({ channels: channelNames, error: errorMsg, success: false }, flags)); } else { this.error(`Error: ${errorMsg}`); } } finally { // Wrap all cleanup in a timeout to prevent hanging await Promise.race([ this.performCleanup(flags || {}, channels), new Promise((resolve) => { setTimeout(() => { this.logCliEvent(flags || {}, "subscribe", "cleanupTimeout", "Cleanup timed out after 5s, forcing completion"); resolve(); }, 5000); }), ]); // Don't show cleanup messages for minimal output } } async performCleanup(flags, channels) { // Unsubscribe from all channels with timeout for (const channel of channels) { try { await Promise.race([ Promise.resolve(channel.unsubscribe()), new Promise((resolve) => setTimeout(resolve, 1000)), ]); this.logCliEvent(flags, "subscribe", "unsubscribedChannel", `Unsubscribed from ${channel.name}`); } catch (error) { this.logCliEvent(flags, "subscribe", "unsubscribeError", `Error unsubscribing from ${channel.name}: ${error instanceof Error ? error.message : String(error)}`); } } // Close Ably client (already has internal timeout) this.logCliEvent(flags, "connection", "closingClientFinally", "Closing Ably client."); await this.properlyCloseAblyClient(); this.logCliEvent(flags, "connection", "clientClosedFinally", "Ably client close attempt finished."); } }