@ably/cli
Version:
Ably CLI for Pub/Sub, Chat and Spaces
306 lines (305 loc) • 15.8 kB
JavaScript
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 = {
space: Args.string({
description: "Space 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 { space: spaceName } = args;
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;
}
// 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: ${spaceName}...`);
this.logCliEvent(flags, "spaces", "gotSpace", `Successfully got space handle: ${spaceName}`);
// 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 ${spaceName} 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,
spaceName,
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,
spaceName,
});
if (this.shouldOutputJson(flags)) {
this.log(this.formatJsonOutput({
error: errorMsg,
spaceName,
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,
spaceName,
});
if (this.shouldOutputJson(flags)) {
this.log(this.formatJsonOutput({ error: errorMsg, spaceName, 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(spaceName)}. Listening for cursor movements...`));
}
// Wait until the user interrupts or the optional duration elapses
const exitReason = await waitUntilInterruptedOrTimeout(flags.duration);
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, spaceName });
if (this.shouldOutputJson(flags)) {
this.log(this.formatJsonOutput({ error: errorMsg, spaceName, 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);
}),
]);
}
}
}
// 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.");
}
}