@ably/cli
Version:
Ably CLI for Pub/Sub, Chat and Spaces
276 lines (275 loc) • 13.3 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 SpacesLocksSubscribe extends SpacesBaseCommand {
static args = {
spaceId: Args.string({
description: "Space ID to subscribe to locks for",
required: true,
}),
};
static description = "Subscribe to lock events in a space";
static examples = [
"$ ably spaces locks subscribe my-space",
"$ ably spaces locks subscribe my-space --json",
"$ ably spaces locks subscribe my-space --pretty-json",
"$ ably spaces locks 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) {
if (this.listener && this.space) {
try {
await this.space.locks.unsubscribe(this.listener);
}
catch {
/* ignore */
}
}
if (!this.cleanupInProgress && this.space) {
try {
await this.space.leave();
}
catch {
/* ignore */
} // Best effort
}
await this.properlyCloseAblyClient();
return super.finally(err);
}
async run() {
const { args, flags } = await this.parse(SpacesLocksSubscribe);
const { spaceId } = args;
this.logCliEvent(flags, "subscribe.run", "start", `Starting spaces locks subscribe for space: ${spaceId}`);
try {
// Always show the readiness signal first, before attempting auth
if (!this.shouldOutputJson(flags)) {
this.log("Subscribing to lock events");
}
this.logCliEvent(flags, "subscribe.run", "initialSignalLogged", "Initial readiness signal logged.");
// Create Spaces client using setupSpacesClient
this.logCliEvent(flags, "subscribe.clientSetup", "attemptingClientCreation", "Attempting to create Spaces and Ably clients.");
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.logCliEvent(flags, "subscribe.clientSetup", "clientCreationFailed", "Client or space setup failed.");
this.error("Failed to initialize clients or space");
return;
}
this.logCliEvent(flags, "subscribe.clientSetup", "clientCreationSuccess", "Spaces and Ably clients created.");
// Add listeners for connection state changes
// 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;
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();
this.logCliEvent(flags, "spaces", "entered", "Successfully entered space", { clientId: this.realtimeClient.auth.clientId });
if (!this.shouldOutputJson(flags)) {
this.log(`Connecting to space: ${chalk.cyan(spaceId)}...`);
}
// Get current locks
this.logCliEvent(flags, "lock", "gettingInitial", "Fetching initial locks");
if (!this.shouldOutputJson(flags)) {
this.log(`Fetching current locks for space ${chalk.cyan(spaceId)}...`);
}
const locks = await this.space.locks.getAll();
this.logCliEvent(flags, "lock", "gotInitial", `Fetched ${locks.length} initial locks`, { count: locks.length, locks });
// Output current locks
if (locks.length === 0) {
if (!this.shouldOutputJson(flags)) {
this.log(chalk.yellow("No locks are currently active in this space."));
}
}
else if (this.shouldOutputJson(flags)) {
this.log(this.formatJsonOutput({
locks: locks.map((lock) => ({
id: lock.id,
member: lock.member,
status: lock.status,
})),
spaceId,
status: "connected",
success: true,
}, flags));
}
else {
this.log(`\n${chalk.cyan("Current locks")} (${chalk.bold(locks.length.toString())}):\n`);
for (const lock of locks) {
this.log(`- Lock ID: ${chalk.blue(lock.id)}`);
this.log(` ${chalk.dim("Status:")} ${lock.status}`);
this.log(` ${chalk.dim("Member:")} ${lock.member?.clientId || "Unknown"}`);
if (lock.member?.connectionId) {
this.log(` ${chalk.dim("Connection ID:")} ${lock.member.connectionId}`);
}
}
}
// Subscribe to lock events
this.logCliEvent(flags, "lock", "subscribing", "Subscribing to lock events");
if (!this.shouldOutputJson(flags)) {
this.log(`\n${chalk.dim("Subscribing to lock events. Press Ctrl+C to exit.")}\n`);
}
this.logCliEvent(flags, "lock.subscribe", "readySignalLogged", "Final readiness signal 'Subscribing to lock events' logged.");
// Define the listener function
this.listener = (lock) => {
const timestamp = new Date().toISOString();
const eventData = {
lock: {
id: lock.id,
member: lock.member,
status: lock.status,
},
spaceId,
timestamp,
type: "lock_event",
};
this.logCliEvent(flags, "lock", "event-update", "Lock event received", eventData);
if (this.shouldOutputJson(flags)) {
this.log(this.formatJsonOutput({ success: true, ...eventData }, flags));
}
else {
this.log(`[${timestamp}] 🔒 Lock ${chalk.blue(lock.id)} updated`);
this.log(` ${chalk.dim("Status:")} ${lock.status}`);
this.log(` ${chalk.dim("Member:")} ${lock.member?.clientId || "Unknown"}`);
if (lock.member?.connectionId) {
this.log(` ${chalk.dim("Connection ID:")} ${lock.member.connectionId}`);
}
}
};
// Subscribe using the stored listener
await this.space.locks.subscribe(this.listener);
this.logCliEvent(flags, "lock", "subscribed", "Successfully subscribed to lock events");
this.logCliEvent(flags, "lock", "listening", "Listening for lock events...");
// 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, "lock", "runComplete", "Exiting wait loop", { exitReason });
this.cleanupInProgress = exitReason === "signal";
}
catch (error) {
const errorMsg = `Error during execution: ${error instanceof Error ? error.message : String(error)}`;
this.logCliEvent(flags, "lock", "executionError", errorMsg, { error: errorMsg });
if (!this.shouldOutputJson(flags)) {
this.log(chalk.red(errorMsg));
}
}
finally {
// Wrap all cleanup in a timeout to prevent hanging
await Promise.race([
this.performCleanup(flags || {}),
new Promise((resolve) => {
setTimeout(() => {
this.logCliEvent(flags || {}, "lock", "cleanupTimeout", "Cleanup timed out after 5s, forcing completion");
resolve();
}, 5000);
})
]);
if (!this.shouldOutputJson(flags || {})) {
if (this.cleanupInProgress) {
this.log(chalk.green("Graceful shutdown complete (user interrupt)."));
}
else {
this.log(chalk.green("Duration elapsed – command finished cleanly."));
}
}
}
}
async performCleanup(flags) {
// Unsubscribe from lock events with timeout
if (this.listener && this.space) {
try {
await Promise.race([
this.space.locks.unsubscribe(this.listener),
new Promise((resolve) => setTimeout(resolve, 1000))
]);
this.logCliEvent(flags, "lock", "unsubscribedEventsFinally", "Unsubscribed lock listener.");
}
catch (error) {
this.logCliEvent(flags, "lock", "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.");
}
}