UNPKG

@ably/cli

Version:

Ably CLI for Pub/Sub, Chat and Spaces

409 lines (408 loc) 19.1 kB
import { Args } from "@oclif/core"; import chalk from "chalk"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import isTestMode from "../../../utils/test-mode.js"; export default class SpacesCursorsGetAll extends SpacesBaseCommand { static args = { space: Args.string({ description: "Space to get cursors from", required: true, }), }; static description = "Get all current cursors in a space"; static examples = [ "$ ably spaces cursors get-all my-space", "$ ably spaces cursors get-all my-space --json", "$ ably spaces cursors get-all my-space --pretty-json", ]; static flags = { ...SpacesBaseCommand.globalFlags, }; // Declare class properties for clients and space realtimeClient = null; spacesClient = null; space = null; parsedFlags = {}; async finally(error) { // Always clean up connections try { if (this.space !== null) { await this.space.leave(); // Wait a bit after leaving space await new Promise((resolve) => setTimeout(resolve, 200)); // Spaces maintains an internal map of members which have timeouts. This keeps node alive. // This is a workaround to hold off until those timeouts are cleared by the client, as otherwise // we'll get unhandled presence rejections as the connection closes. await new Promise((resolve) => { let intervalId; const maxWaitMs = 10000; // 10 second timeout const startTime = Date.now(); const getAll = async () => { // Avoid waiting forever if (Date.now() - startTime > maxWaitMs) { clearInterval(intervalId); this.debug("Timed out waiting for space members to clear"); resolve(); return; } const members = await this.space.members.getAll(); if (members.filter((member) => !member.isConnected).length === 0) { clearInterval(intervalId); this.debug("space members cleared"); resolve(); } else { this.debug(`waiting for spaces members to clear, ${members.length} remaining`); } }; intervalId = setInterval(() => { getAll(); }, 1000); }); } } catch (error) { // Log but don't throw cleanup errors if (!this.shouldOutputJson(this.parsedFlags)) { this.debug(`Space leave error: ${error}`); } } try { if (this.realtimeClient && this.realtimeClient.connection.state !== "closed") { // Ensure we're not in the middle of any operations if (this.realtimeClient.connection.state === "connecting" || this.realtimeClient.connection.state === "disconnected") { // Wait for connection to stabilize before closing await new Promise((resolve) => setTimeout(resolve, 500)); } this.realtimeClient.close(); // Give the connection a moment to close await new Promise((resolve) => setTimeout(resolve, 100)); } } catch (error) { // Log but don't throw cleanup errors if (!this.shouldOutputJson(this.parsedFlags)) { this.debug(`Realtime close error: ${error}`); } } super.finally(error); } async run() { const { args, flags } = await this.parse(SpacesCursorsGetAll); this.parsedFlags = flags; let cleanupInProgress = false; const { space: spaceName } = args; // Handle process termination gracefully const cleanup = async () => { if (!cleanupInProgress) { cleanupInProgress = true; try { if (this.space) { await this.space.leave(); } if (this.realtimeClient) { this.realtimeClient.close(); } } catch { // Ignore cleanup errors during signal handling } } }; process.on("SIGINT", cleanup); process.on("SIGTERM", cleanup); try { // Create Spaces client using setupSpacesClient const setupResult = await this.setupSpacesClient(flags, spaceName); 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; } // Make sure we have a connection before proceeding await new Promise((resolve, reject) => { const checkConnection = () => { const { state } = this.realtimeClient.connection; if (state === "connected") { resolve(); } else if (state === "failed" || state === "closed" || state === "suspended") { reject(new Error(`Connection failed with state: ${state}`)); } else { // Still connecting, check again shortly setTimeout(checkConnection, 100); } }; checkConnection(); }); // Get the space if (!this.shouldOutputJson(flags)) { this.log(`Connecting to space: ${chalk.cyan(spaceName)}...`); } // Enter the space await this.space.enter(); // Wait for space to be properly entered before fetching cursors await new Promise((resolve, reject) => { // Set a reasonable timeout to avoid hanging indefinitely const timeout = setTimeout(() => { reject(new Error("Timed out waiting for space connection")); }, 5000); const checkSpaceStatus = () => { try { // Check realtime client state if (this.realtimeClient.connection.state === "connected") { clearTimeout(timeout); if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput({ connectionId: this.realtimeClient.connection.id, spaceName, status: "connected", success: true, }, flags)); } else { this.log(`${chalk.green("Successfully entered space:")} ${chalk.cyan(spaceName)}`); } resolve(); } else if (this.realtimeClient.connection.state === "failed" || this.realtimeClient.connection.state === "closed" || this.realtimeClient.connection.state === "suspended") { clearTimeout(timeout); reject(new Error(`Space connection failed with state: ${this.realtimeClient.connection.state}`)); } else { // Still connecting, check again shortly setTimeout(checkSpaceStatus, 100); } } catch (error) { clearTimeout(timeout); reject(error); } }; checkSpaceStatus(); }); // Subscribe to cursor updates to ensure we receive remote cursors let cursorUpdateReceived = false; const cursorMap = new Map(); // Show initial message if (!this.shouldOutputJson(flags)) { const waitSeconds = isTestMode() ? "0.5" : "5"; this.log(`Collecting cursor positions for ${waitSeconds} seconds...`); this.log(chalk.dim("─".repeat(60))); } const cursorUpdateHandler = (cursor) => { cursorUpdateReceived = true; // Update the cursor map if (cursor.connectionId) { cursorMap.set(cursor.connectionId, cursor); // Show live update on one line if (!this.shouldOutputJson(flags) && this.shouldUseTerminalUpdates()) { const clientDisplay = cursor.clientId || "Unknown"; const x = cursor.position.x; const y = cursor.position.y; // Clear the line and write the update process.stdout.write(`\r${chalk.gray("►")} ${chalk.blue(clientDisplay)}: (${chalk.yellow(x)}, ${chalk.yellow(y)})${" ".repeat(30)}`); } } }; try { await this.space.cursors.subscribe("update", cursorUpdateHandler); } catch (error) { // If subscription fails, continue anyway if (!this.shouldOutputJson(flags)) { this.debug(`Cursor subscription error: ${error}`); } } // Wait for 5 seconds (or shorter in test mode) const waitTime = isTestMode() ? 500 : 5000; await new Promise((resolve) => { setTimeout(() => { if (!this.shouldOutputJson(flags) && this.shouldUseTerminalUpdates()) { // Clear the last update line process.stdout.write("\r" + " ".repeat(60) + "\r"); } resolve(); }, waitTime); }); // Unsubscribe from cursor updates this.space.cursors.unsubscribe("update", cursorUpdateHandler); // Ensure connection is stable before calling getAll() if (this.realtimeClient.connection.state !== "connected") { await new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error("Timed out waiting for connection to stabilize")); }, 5000); this.realtimeClient.connection.once("connected", () => { clearTimeout(timeout); resolve(); }); if (this.realtimeClient.connection.state === "connected") { clearTimeout(timeout); resolve(); } }); } // Now get all cursors (including locally cached ones) and merge with live updates try { const allCursors = await this.space.cursors.getAll(); // Add any cached cursors that we didn't see in live updates if (Array.isArray(allCursors)) { allCursors.forEach((cursor) => { if (cursor && cursor.connectionId && !cursorMap.has(cursor.connectionId)) { cursorMap.set(cursor.connectionId, cursor); } }); } else if (allCursors && typeof allCursors === "object") { // Handle object return type Object.values(allCursors).forEach((cursor) => { if (cursor && cursor.connectionId && !cursorMap.has(cursor.connectionId)) { cursorMap.set(cursor.connectionId, cursor); } }); } } catch { // If getAll fails due to connection issues, use only the live updates we collected if (!this.shouldOutputJson(flags)) { this.log(chalk.yellow("Warning: Could not fetch all cursors, showing only live updates")); } } const cursors = [...cursorMap.values()]; if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput({ cursors: cursors.map((cursor) => ({ clientId: cursor.clientId, connectionId: cursor.connectionId, data: cursor.data, position: cursor.position, })), spaceName, success: true, cursorUpdateReceived, }, flags)); } else { if (!cursorUpdateReceived && cursors.length === 0) { this.log(chalk.dim("─".repeat(60))); this.log(chalk.yellow("No cursor updates are being sent in this space. Make sure other clients are actively setting cursor positions.")); cleanupInProgress = true; return; } if (cursors.length === 0) { this.log(chalk.dim("─".repeat(60))); this.log(chalk.yellow("No active cursors found in space.")); cleanupInProgress = true; return; } // Show summary table this.log(chalk.dim("─".repeat(60))); this.log(chalk.bold(`\nCursor Summary - ${cursors.length} cursor${cursors.length === 1 ? "" : "s"} found:\n`)); // Table header const colWidths = { client: 20, x: 8, y: 8, connection: 20 }; this.log(chalk.gray("┌" + "─".repeat(colWidths.client + 2) + "┬" + "─".repeat(colWidths.x + 2) + "┬" + "─".repeat(colWidths.y + 2) + "┬" + "─".repeat(colWidths.connection + 2) + "┐")); this.log(chalk.gray("│ ") + chalk.bold("Client ID".padEnd(colWidths.client)) + chalk.gray(" │ ") + chalk.bold("X".padEnd(colWidths.x)) + chalk.gray(" │ ") + chalk.bold("Y".padEnd(colWidths.y)) + chalk.gray(" │ ") + chalk.bold("Connection".padEnd(colWidths.connection)) + chalk.gray(" │")); this.log(chalk.gray("├" + "─".repeat(colWidths.client + 2) + "┼" + "─".repeat(colWidths.x + 2) + "┼" + "─".repeat(colWidths.y + 2) + "┼" + "─".repeat(colWidths.connection + 2) + "┤")); // Table rows cursors.forEach((cursor) => { const clientId = (cursor.clientId || "Unknown").slice(0, colWidths.client); const x = cursor.position.x.toString().slice(0, colWidths.x); const y = cursor.position.y.toString().slice(0, colWidths.y); const connectionId = (cursor.connectionId || "Unknown").slice(0, colWidths.connection); this.log(chalk.gray("│ ") + chalk.blue(clientId.padEnd(colWidths.client)) + chalk.gray(" │ ") + chalk.yellow(x.padEnd(colWidths.x)) + chalk.gray(" │ ") + chalk.yellow(y.padEnd(colWidths.y)) + chalk.gray(" │ ") + chalk.dim(connectionId.padEnd(colWidths.connection)) + chalk.gray(" │")); }); this.log(chalk.gray("└" + "─".repeat(colWidths.client + 2) + "┴" + "─".repeat(colWidths.x + 2) + "┴" + "─".repeat(colWidths.y + 2) + "┴" + "─".repeat(colWidths.connection + 2) + "┘")); // Show additional data if any cursor has it const cursorsWithData = cursors.filter((c) => c.data); if (cursorsWithData.length > 0) { this.log(`\n${chalk.bold("Additional Data:")}`); cursorsWithData.forEach((cursor) => { this.log(` ${chalk.blue(cursor.clientId || "Unknown")}: ${JSON.stringify(cursor.data)}`); }); } } // Mark that we're done cleanupInProgress = true; } catch (error) { // Check if this is a connection closed error const errorMessage = error instanceof Error ? error.message : String(error); const isConnectionError = errorMessage.includes("Connection closed") || errorMessage.includes("connection") || error?.code === 80017; if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput({ error: isConnectionError ? "Connection was closed before operation completed. Please try again." : `Error getting cursors: ${errorMessage}`, spaceName, status: "error", success: false, connectionError: isConnectionError, }, flags)); } else { const message = isConnectionError ? "Connection was closed before operation completed. Please try again." : `Error getting cursors: ${errorMessage}`; this.log(chalk.red(message)); } } } }