@ably/cli
Version:
Ably CLI for Pub/Sub, Chat and Spaces
352 lines (351 loc) • 17.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 SpacesLocationsSubscribe extends SpacesBaseCommand {
static args = {
spaceId: Args.string({
description: "Space ID to subscribe to locations for",
required: true,
}),
};
static description = "Subscribe to location updates for members in a space";
static examples = [
"$ ably spaces locations subscribe my-space",
"$ ably spaces locations subscribe my-space --json",
"$ ably spaces locations subscribe my-space --pretty-json",
"$ ably spaces locations 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;
subscription = null;
locationHandler = 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) {
this.unsubscribeFromLocation();
if (!this.cleanupInProgress && this.space) {
try {
await this.space.leave();
}
catch {
/* ignore */
} // Best effort
}
await this.properlyCloseAblyClient();
return super.finally(err);
}
unsubscribeFromLocation() {
if (this.locationHandler && this.space) {
try {
this.space.locations.unsubscribe("update", this.locationHandler);
this.locationHandler = null;
}
catch {
// Ignore unsubscribe errors during cleanup
}
}
if (this.subscription) {
try {
this.subscription.unsubscribe();
this.subscription = null;
}
catch {
// Ignore unsubscribe errors during cleanup
}
}
}
async run() {
const { args, flags } = await this.parse(SpacesLocationsSubscribe);
const { spaceId } = args;
this.logCliEvent(flags, "subscribe.run", "start", `Starting spaces locations subscribe for space: ${spaceId}`);
try {
// Always show the readiness signal first, before attempting auth
if (!this.shouldOutputJson(flags)) {
this.log("Subscribing to location updates");
}
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.");
// 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}...`);
if (!this.shouldOutputJson(flags)) {
this.log(`Connecting to space: ${chalk.cyan(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 });
// Get current locations
this.logCliEvent(flags, "location", "gettingInitial", `Fetching initial locations for space ${spaceId}`);
if (!this.shouldOutputJson(flags)) {
this.log(`Fetching current locations for space ${chalk.cyan(spaceId)}...`);
}
let locations = [];
try {
const result = await this.space.locations.getAll();
this.logCliEvent(flags, "location", "gotInitial", `Fetched initial locations`, { locations: result });
if (result && typeof result === "object") {
if (Array.isArray(result)) {
// Unlikely based on current docs, but handle if API changes
// Need to map Array result to LocationItem[] if structure differs
this.logCliEvent(flags, "location", "initialFormatWarning", "Received array format for initial locations, expected object");
// Assuming array elements match expected structure for now:
locations = result.map((item) => ({
location: item.location,
member: item.member,
}));
}
else if (Object.keys(result).length > 0) {
// Standard case: result is an object { connectionId: locationData }
locations = Object.entries(result).map(([connectionId, locationData]) => ({
location: locationData,
member: {
// Construct a partial SpaceMember as SDK doesn't provide full details here
clientId: "unknown", // clientId not directly available in getAll response
connectionId,
isConnected: true, // Assume connected for initial state
profileData: null,
},
}));
}
}
if (this.shouldOutputJson(flags)) {
this.log(this.formatJsonOutput({
locations: locations.map((item) => ({
// Map to a simpler structure for output if needed
connectionId: item.member.connectionId,
location: item.location,
})),
spaceId,
success: true,
type: "locations_snapshot",
}, flags));
}
else if (locations.length === 0) {
this.log(chalk.yellow("No locations are currently set in this space."));
}
else {
this.log(`\n${chalk.cyan("Current locations")} (${chalk.bold(locations.length.toString())}):\n`);
for (const item of locations) {
this.log(`- Connection ID: ${chalk.blue(item.member.connectionId || "Unknown")}`); // Use connectionId as key
this.log(` ${chalk.dim("Location:")} ${JSON.stringify(item.location)}`);
}
}
}
catch (error) {
const errorMsg = `Error fetching locations: ${error instanceof Error ? error.message : String(error)}`;
this.logCliEvent(flags, "location", "getInitialError", 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(errorMsg));
}
}
this.logCliEvent(flags, "location", "subscribing", "Subscribing to location updates");
if (!this.shouldOutputJson(flags)) {
this.log(`\n${chalk.dim("Subscribing to location updates. Press Ctrl+C to exit.")}\n`);
}
this.logCliEvent(flags, "location.subscribe", "readySignalLogged", "Final readiness signal 'Subscribing to location updates' logged.");
try {
// Define the location update handler
this.locationHandler = (update) => {
try {
const timestamp = new Date().toISOString();
const eventData = {
action: "update",
location: update.currentLocation,
member: {
clientId: update.member.clientId,
connectionId: update.member.connectionId,
},
previousLocation: update.previousLocation,
timestamp,
};
this.logCliEvent(flags, "location", "updateReceived", "Location update received", { spaceId, ...eventData });
if (this.shouldOutputJson(flags)) {
this.log(this.formatJsonOutput({
spaceId,
success: true,
type: "location_update",
...eventData,
}, flags));
}
else {
this.log(`[${timestamp}] ${chalk.blue(update.member.clientId)} ${chalk.yellow("updated")} location:`);
this.log(` ${chalk.dim("Current:")} ${JSON.stringify(update.currentLocation)}`);
this.log(` ${chalk.dim("Previous:")} ${JSON.stringify(update.previousLocation)}`);
}
}
catch (error) {
const errorMsg = `Error processing location update: ${error instanceof Error ? error.message : String(error)}`;
this.logCliEvent(flags, "location", "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));
}
}
};
// Subscribe to location updates
this.space.locations.subscribe("update", this.locationHandler);
// Create our subscription object for cleanup
this.subscription = {
unsubscribe: () => {
if (this.locationHandler && this.space) {
this.space.locations.unsubscribe("update", this.locationHandler);
this.locationHandler = null;
}
},
};
this.logCliEvent(flags, "location", "subscribed", "Successfully subscribed to location updates");
}
catch (error) {
const errorMsg = `Error subscribing to location updates: ${error instanceof Error ? error.message : String(error)}`;
this.logCliEvent(flags, "location", "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.red(errorMsg));
}
}
this.logCliEvent(flags, "location", "listening", "Listening for location updates...");
// 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, "location", "runComplete", "Exiting wait loop", { exitReason });
this.cleanupInProgress = exitReason === "signal";
}
catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
this.logCliEvent(flags, "location", "fatalError", `Failed to subscribe to location updates: ${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 location updates: ${errorMsg}`);
}
}
finally {
// Wrap all cleanup in a timeout to prevent hanging
await Promise.race([
this.performCleanup(flags || {}),
new Promise((resolve) => {
setTimeout(() => {
this.logCliEvent(flags || {}, "location", "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 location events with timeout
this.unsubscribeFromLocation();
// 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.");
}
}