@ably/cli
Version:
Ably CLI for Pub/Sub, Chat and Spaces
287 lines (286 loc) • 15.3 kB
JavaScript
import { RoomStatus, PresenceEventType } from "@ably/chat";
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 RoomsPresenceEnter extends ChatBaseCommand {
static args = {
roomId: Args.string({
description: "Room ID to enter presence on",
required: true,
}),
};
static description = "Enter presence in a chat room and remain present until terminated";
static examples = [
"$ ably rooms presence enter my-room",
`$ ably rooms presence enter my-room --profile-data '{"name":"User","status":"active"}'`,
"$ ably rooms presence enter my-room --duration 30",
];
static flags = {
...ChatBaseCommand.globalFlags,
"profile-data": Flags.string({
description: "Profile data to include with the member (JSON format)",
required: false,
}),
"show-others": Flags.boolean({
default: true,
description: "Show other presence events while present",
}),
duration: Flags.integer({
description: "Automatically exit after the given number of seconds (0 = run indefinitely)",
char: "D",
required: false,
}),
data: Flags.string({
required: false,
hidden: true,
deprecated: { message: "--data is deprecated, use --profile-data instead.", version: "0.6.0" }
}),
};
ablyClient = null;
chatClient = null;
room = null;
roomId = null;
profileData = null;
unsubscribeStatusFn = null;
unsubscribePresenceFn = null;
cleanupInProgress = false;
commandFlags = null;
async properlyCloseAblyClient() {
const flagsForLog = this.commandFlags || {};
if (!this.ablyClient || this.ablyClient.connection.state === 'closed' || this.ablyClient.connection.state === 'failed') {
this.logCliEvent(flagsForLog, "connection", "alreadyClosedOrFailed", "Ably client already closed or failed, skipping close.");
return;
}
this.logCliEvent(flagsForLog, "connection", "attemptingClose", "Attempting to close Ably client.");
return new Promise((resolve) => {
const timeout = setTimeout(() => {
this.logCliEvent(flagsForLog, "connection", "cleanupTimeout", "Ably client close TIMED OUT after 2s. Forcing resolve.");
resolve();
}, 2000);
const onClosedOrFailed = () => {
clearTimeout(timeout);
this.logCliEvent(flagsForLog, "connection", "closedOrFailedEventFired", `Ably client connection emitted: ${this.ablyClient?.connection.state}`);
resolve();
};
this.ablyClient.connection.once('closed', onClosedOrFailed);
this.ablyClient.connection.once('failed', onClosedOrFailed);
this.ablyClient.close();
});
}
async run() {
const { args, flags } = await this.parse(RoomsPresenceEnter);
this.commandFlags = flags;
this.roomId = args.roomId;
const rawProfileData = flags["profile-data"] || flags.data;
if (rawProfileData && rawProfileData !== "{}") {
try {
let trimmed = rawProfileData.trim();
// If the string is wrapped in single or double quotes (common when passed through a shell), remove them first.
if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"'))) {
trimmed = trimmed.slice(1, -1);
}
this.profileData = JSON.parse(trimmed);
}
catch (error) {
this.error(`Invalid profile-data or data JSON: ${error instanceof Error ? error.message : String(error)}`);
return; // Exit early if JSON is invalid
}
}
try {
// Always show the readiness signal first, before attempting auth
if (!this.shouldOutputJson(flags)) {
this.log(`${chalk.dim("Staying present. Press Ctrl+C to exit.")}`);
}
// Create clients
this.chatClient = await this.createChatClient(flags);
this.ablyClient = this._chatRealtimeClient;
if (!this.chatClient || !this.ablyClient || !this.roomId) {
this.error("Failed to initialize chat client or room ID");
return;
}
// Set up connection state logging
this.setupConnectionStateLogging(this.ablyClient, flags, {
includeUserFriendlyMessages: true
});
this.room = await this.chatClient.rooms.get(this.roomId);
const currentRoom = this.room;
if (flags["show-others"]) {
this.unsubscribeStatusFn = currentRoom.onStatusChange((statusChange) => {
let reasonToLog;
if (statusChange.current === RoomStatus.Failed) {
const roomError = this.room?.error;
reasonToLog = roomError instanceof Error ? roomError.message : String(roomError);
this.logCliEvent(flags, "room", `status-failed-detail`, `Room status is FAILED. Error: ${reasonToLog}`, { error: roomError });
if (!this.shouldOutputJson(flags)) {
this.error(`Room connection failed: ${reasonToLog || "Unknown error"}`);
}
}
else if (statusChange.current === RoomStatus.Attached && !this.shouldOutputJson(flags) && this.roomId) {
this.log(`${chalk.green("Successfully connected to room:")} ${chalk.cyan(this.roomId)}`);
}
else {
this.logCliEvent(flags, "room", `status-${statusChange.current}`, `Room status: ${statusChange.current}`);
}
});
this.unsubscribePresenceFn = currentRoom.presence.subscribe((event) => {
const member = event.member;
if (member.clientId !== this.chatClient?.clientId) {
const timestamp = new Date().toISOString();
const eventData = { type: event.type, member: { clientId: member.clientId, data: member.data }, roomId: this.roomId, timestamp };
this.logCliEvent(flags, "presence", event.type, `Presence event '${event.type}' received`, eventData);
if (this.shouldOutputJson(flags)) {
this.log(this.formatJsonOutput({ success: true, ...eventData }, flags));
}
else {
let actionSymbol = "•";
let actionColor = chalk.white;
if (event.type === PresenceEventType.Enter) {
actionSymbol = "✓";
actionColor = chalk.green;
}
if (event.type === PresenceEventType.Leave) {
actionSymbol = "✗";
actionColor = chalk.red;
}
if (event.type === PresenceEventType.Update) {
actionSymbol = "⟲";
actionColor = chalk.yellow;
}
this.log(`[${timestamp}] ${actionColor(actionSymbol)} ${chalk.blue(member.clientId || "Unknown")} ${actionColor(event.type)}`);
if (member.data && typeof member.data === 'object' && Object.keys(member.data).length > 0) {
const profile = member.data;
if (profile.name) {
this.log(` ${chalk.dim("Name:")} ${profile.name}`);
}
this.log(` ${chalk.dim("Full Profile Data:")} ${this.formatJsonOutput({ data: member.data }, flags)}`);
}
}
}
});
}
await currentRoom.attach();
this.logCliEvent(flags, "presence", "entering", "Entering presence", { profileData: this.profileData });
await currentRoom.presence.enter(this.profileData || {});
this.logCliEvent(flags, "presence", "entered", "Entered presence successfully");
if (!this.shouldOutputJson(flags) && this.roomId) {
// Output the exact signal that E2E tests expect (without ANSI codes)
this.log(`✓ Entered room ${this.roomId} as ${this.chatClient?.clientId || "Unknown"}`);
if (flags["show-others"]) {
this.log(`\n${chalk.dim("Listening for presence events. Press Ctrl+C to exit.")}`);
}
else {
this.log(`\n${chalk.dim("Staying present. Press Ctrl+C to exit.")}`);
}
}
// 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, "presence", "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, "presence", "runError", `Error during command execution: ${errorMsg}`, { errorDetails: error });
if (!this.shouldOutputJson(flags)) {
this.error(`Execution Error: ${errorMsg}`);
}
// Don't force exit on errors - let the command handle cleanup naturally
return;
}
finally {
const currentFlags = this.commandFlags || flags || {};
this.logCliEvent(currentFlags, "presence", "finallyBlockReached", "Reached finally block for cleanup.");
if (!this.cleanupInProgress && !this.shouldOutputJson(currentFlags)) {
this.logCliEvent(currentFlags, "presence", "implicitCleanupInFinally", "Performing cleanup in finally (no prior signal or natural end).");
}
else {
// Either cleanup is in progress or we're in JSON mode
this.logCliEvent(currentFlags, "presence", "explicitCleanupOrJsonMode", "Cleanup already in progress or JSON output mode");
}
// Wrap all cleanup in a timeout to prevent hanging
await Promise.race([
this.performCleanup(currentFlags),
new Promise((resolve) => {
setTimeout(() => {
this.logCliEvent(currentFlags, "presence", "cleanupTimeout", "Cleanup timed out after 5s, forcing completion");
resolve();
}, 5000);
})
]);
if (!this.shouldOutputJson(currentFlags)) {
if (this.cleanupInProgress) {
this.log(chalk.green("Graceful shutdown complete (user interrupt)."));
}
else {
// Normal completion without user interrupt
this.logCliEvent(currentFlags, "presence", "completedNormally", "Command completed normally");
}
}
}
}
async performCleanup(flags) {
// Unsubscribe from presence events with timeout
if (this.unsubscribePresenceFn) {
try {
await Promise.race([
Promise.resolve(this.unsubscribePresenceFn.unsubscribe()),
new Promise((resolve) => setTimeout(resolve, 1000))
]);
this.logCliEvent(flags, "presence", "unsubscribedEventsFinally", "Unsubscribed presence listener in finally.");
}
catch (error) {
this.logCliEvent(flags, "presence", "unsubscribeErrorFinally", `Error unsubscribing presenceFn: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Unsubscribe from status events with timeout
if (this.unsubscribeStatusFn) {
try {
await Promise.race([
Promise.resolve(this.unsubscribeStatusFn.off()),
new Promise((resolve) => setTimeout(resolve, 1000))
]);
this.logCliEvent(flags, "room", "unsubscribedStatusFinally", "Unsubscribed room status listener in finally.");
}
catch (error) {
this.logCliEvent(flags, "room", "unsubscribeStatusErrorFinally", `Error unsubscribing statusFn: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Leave presence with timeout
if (this.room) {
try {
this.logCliEvent(flags, "presence", "leavingFinally", "Attempting to leave presence in finally.");
await Promise.race([
this.room.presence.leave(),
new Promise((resolve) => setTimeout(resolve, 2000))
]);
this.logCliEvent(flags, "presence", "leftFinally", "Left room presence in finally.");
}
catch (error) {
this.logCliEvent(flags, "presence", "leaveErrorFinally", `Error leaving: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Release room with timeout
if (this.chatClient && this.roomId) {
try {
this.logCliEvent(flags, "room", "releasingFinally", `Releasing room ${this.roomId} in finally.`);
await Promise.race([
this.chatClient.rooms.release(this.roomId),
new Promise((resolve) => setTimeout(resolve, 2000))
]);
this.logCliEvent(flags, "room", "releasedInFinally", `Room ${this.roomId} released in finally.`);
}
catch (error) {
this.logCliEvent(flags, "room", "releaseErrorInFinally", `Error releasing room: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Close Ably client (already has internal timeout)
this.logCliEvent(flags, "connection", "BEFORE_properlyCloseAblyClient", "About to call properlyCloseAblyClient.");
await this.properlyCloseAblyClient();
this.logCliEvent(flags, "connection", "AFTER_properlyCloseAblyClient", "Finished awaiting properlyCloseAblyClient.");
}
}