UNPKG

@ably/cli

Version:

Ably CLI for Pub/Sub, Chat and Spaces

303 lines (302 loc) 16 kB
import { Args, Flags as _Flags } from "@oclif/core"; import chalk from "chalk"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { waitUntilInterruptedOrTimeout } from "../../../utils/long-running.js"; export default class SpacesCursorsSubscribe extends SpacesBaseCommand { static args = { spaceId: Args.string({ description: "Space ID to subscribe to cursors for", required: true, }), }; static description = "Subscribe to cursor movements in a space"; static examples = [ "$ ably spaces cursors subscribe my-space", "$ ably spaces cursors subscribe my-space --json", "$ ably spaces cursors subscribe my-space --pretty-json", "$ ably spaces cursors subscribe my-space --duration 30", ]; static flags = { ...SpacesBaseCommand.globalFlags, duration: _Flags.integer({ description: "Automatically exit after the given number of seconds (0 = run indefinitely)", char: "D", required: false, }), }; cleanupInProgress = false; realtimeClient = null; spacesClient = null; space = null; listener = null; async properlyCloseAblyClient() { if (!this.realtimeClient || this.realtimeClient.connection.state === 'closed' || this.realtimeClient.connection.state === 'failed') { return; } return new Promise((resolve) => { const timeout = setTimeout(() => { resolve(); }, 2000); const onClosedOrFailed = () => { clearTimeout(timeout); resolve(); }; this.realtimeClient.connection.once('closed', onClosedOrFailed); this.realtimeClient.connection.once('failed', onClosedOrFailed); this.realtimeClient.close(); }); } // Override finally to ensure resources are cleaned up async finally(err) { // Cleanup is already handled in the run method's finally block // Just ensure the Ably client is closed if (this.realtimeClient && this.realtimeClient.connection.state !== 'closed' && this.realtimeClient.connection.state !== 'failed') { await this.properlyCloseAblyClient(); } return super.finally(err); } async run() { const { args, flags } = await this.parse(SpacesCursorsSubscribe); const { spaceId } = args; try { // Create Spaces client using setupSpacesClient const setupResult = await this.setupSpacesClient(flags, spaceId); this.realtimeClient = setupResult.realtimeClient; this.spacesClient = setupResult.spacesClient; this.space = setupResult.space; if (!this.realtimeClient || !this.spacesClient || !this.space) { this.error("Failed to initialize clients or space"); return; } // Set up connection state logging this.setupConnectionStateLogging(this.realtimeClient, flags, { includeUserFriendlyMessages: true }); // Make sure we have a connection before proceeding this.logCliEvent(flags, "connection", "waiting", "Waiting for connection to establish..."); await new Promise((resolve, reject) => { const checkConnection = () => { const state = this.realtimeClient.connection.state; if (state === "connected") { this.logCliEvent(flags, "connection", "connected", "Realtime connection established."); resolve(); } else if (state === "failed" || state === "closed" || state === "suspended") { const errorMsg = `Connection failed with state: ${state}`; this.logCliEvent(flags, "connection", "failed", errorMsg, { state, }); reject(new Error(errorMsg)); } else { // Still connecting, check again shortly setTimeout(checkConnection, 100); } }; checkConnection(); }); // Get the space this.logCliEvent(flags, "spaces", "gettingSpace", `Getting space: ${spaceId}...`); this.logCliEvent(flags, "spaces", "gotSpace", `Successfully got space handle: ${spaceId}`); // Enter the space this.logCliEvent(flags, "spaces", "entering", "Entering space..."); await this.space.enter(); const clientId = this.realtimeClient.auth.clientId ?? "unknown-client"; this.logCliEvent(flags, "spaces", "entered", `Entered space ${spaceId} with clientId ${clientId}`); // Subscribe to cursor updates this.logCliEvent(flags, "cursor", "subscribing", "Subscribing to cursor updates"); try { // Define the listener function this.listener = (cursorUpdate) => { try { const timestamp = new Date().toISOString(); const eventData = { member: { clientId: cursorUpdate.clientId, connectionId: cursorUpdate.connectionId, }, position: cursorUpdate.position, data: cursorUpdate.data, spaceId, timestamp, type: "cursor_update", }; this.logCliEvent(flags, "cursor", "updateReceived", "Cursor update received", eventData); if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput({ success: true, ...eventData }, flags)); } else { // Include data field in the output if present const dataString = cursorUpdate.data ? ` data: ${JSON.stringify(cursorUpdate.data)}` : ''; this.log(`[${timestamp}] ${chalk.blue(cursorUpdate.clientId)} ${chalk.dim("position:")} ${JSON.stringify(cursorUpdate.position)}${dataString}`); } } catch (error) { const errorMsg = `Error processing cursor update: ${error instanceof Error ? error.message : String(error)}`; this.logCliEvent(flags, "cursor", "updateProcessError", errorMsg, { error: errorMsg, spaceId, }); if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput({ error: errorMsg, spaceId, status: "error", success: false }, flags)); } else { this.log(chalk.red(errorMsg)); } } }; // Workaround for known SDK issue: cursors.subscribe() fails if the underlying ::$cursors channel is not attached // This will be fixed upstream in the Spaces SDK - see https://github.com/ably/spaces/issues/XXX this.logCliEvent(flags, "cursor", "waitingForChannelAttachment", "Waiting for cursors channel to attach before subscribing"); // First, trigger channel creation by accessing the cursors API // This ensures the channel exists before we try to wait for it to attach try { await this.space.cursors.getAll(); this.logCliEvent(flags, "cursor", "channelCreated", "Cursors channel created via getAll()"); } catch (error) { // getAll() might fail if no cursors exist yet, but it should still create the channel this.logCliEvent(flags, "cursor", "channelCreationAttempted", "Attempted to create cursors channel", { error: error instanceof Error ? error.message : String(error) }); } // Now wait for the channel to be attached if (this.space.cursors.channel) { await new Promise((resolve, reject) => { const channel = this.space.cursors.channel; if (!channel) { reject(new Error("Cursors channel is not available")); return; } if (channel.state === "attached") { this.logCliEvent(flags, "cursor", "channelAlreadyAttached", "Cursors channel already attached"); resolve(); return; } const timeout = setTimeout(() => { channel.off("attached", onAttached); channel.off("failed", onFailed); reject(new Error("Timeout waiting for cursors channel to attach")); }, 10000); // 10 second timeout const onAttached = () => { clearTimeout(timeout); channel.off("attached", onAttached); channel.off("failed", onFailed); this.logCliEvent(flags, "cursor", "channelAttached", "Cursors channel attached successfully"); resolve(); }; const onFailed = (stateChange) => { clearTimeout(timeout); channel.off("attached", onAttached); channel.off("failed", onFailed); reject(new Error(`Cursors channel failed to attach: ${stateChange.reason?.message || 'Unknown error'}`)); }; channel.on("attached", onAttached); channel.on("failed", onFailed); this.logCliEvent(flags, "cursor", "waitingForAttachment", `Cursors channel state: ${channel.state}, waiting for attachment`); }); } else { // If channel still doesn't exist after getAll(), log a warning but continue this.logCliEvent(flags, "cursor", "channelNotAvailable", "Warning: cursors channel not available after creation attempt"); } // Subscribe using the listener await this.space.cursors.subscribe("update", this.listener); this.logCliEvent(flags, "cursor", "subscribed", "Successfully subscribed to cursor updates"); } catch (error) { const errorMsg = `Error subscribing to cursor updates: ${error instanceof Error ? error.message : String(error)}`; this.logCliEvent(flags, "cursor", "subscribeError", errorMsg, { error: errorMsg, spaceId, }); if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput({ error: errorMsg, spaceId, status: "error", success: false }, flags)); } else { this.log(chalk.yellow("Will continue running, but may not receive cursor updates.")); } } this.logCliEvent(flags, "cursor", "listening", "Listening for cursor updates..."); // Log the ready signal for E2E tests this.log("Subscribing to cursor movements"); // Print success message if (!this.shouldOutputJson(flags)) { this.log(chalk.green(`✓ Subscribed to space: ${chalk.cyan(spaceId)}. Listening for cursor movements...`)); } // 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, "cursor", "runComplete", "Exiting wait loop", { exitReason }); this.cleanupInProgress = exitReason === "signal"; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); this.logCliEvent(flags, "cursor", "fatalError", `Failed to subscribe to cursors: ${errorMsg}`, { error: errorMsg, spaceId }); if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput({ error: errorMsg, spaceId, status: "error", success: false }, flags)); } else { this.error(`Failed to subscribe to cursors: ${errorMsg}`); } } finally { // Only perform cleanup once if (!this.cleanupInProgress) { this.cleanupInProgress = true; // Wrap all cleanup in a timeout to prevent hanging await Promise.race([ this.performCleanup(flags || {}), new Promise((resolve) => { setTimeout(() => { this.logCliEvent(flags || {}, "cursor", "cleanupTimeout", "Cleanup timed out after 5s, forcing completion"); resolve(); }, 5000); }) ]); } // Don't show cleanup messages for minimal output // Ensure process exits cleanly so user doesn't need to press Ctrl+C twice if (process.env.NODE_ENV !== 'test') { process.exit(0); } } } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic flags type for logCliEvent utility async performCleanup(flags) { if (this.listener && this.space) { try { this.space.cursors.unsubscribe("update", this.listener); this.listener = null; this.logCliEvent(flags, "cursor", "unsubscribedEventsFinally", "Unsubscribed cursor listener."); } catch (error) { this.logCliEvent(flags, "cursor", "unsubscribeErrorFinally", `Error unsubscribing: ${error instanceof Error ? error.message : String(error)}`); } } // Leave space with timeout if (this.space) { try { this.logCliEvent(flags, "spaces", "leavingFinally", "Leaving space."); await Promise.race([ this.space.leave(), new Promise((resolve) => setTimeout(resolve, 2000)) ]); this.logCliEvent(flags, "spaces", "leftFinally", "Successfully left space."); } catch (error) { this.logCliEvent(flags, "spaces", "leaveErrorFinally", `Error leaving space: ${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."); } }