@ably/cli
Version:
Ably CLI for Pub/Sub, Chat and Spaces
289 lines (288 loc) • 13.2 kB
JavaScript
import { Args, Flags } from "@oclif/core";
import chalk from "chalk";
import { AblyBaseCommand } from "../../base-command.js";
export default class ChannelsPublish extends AblyBaseCommand {
static args = {
channel: Args.string({
description: "The channel name to publish to",
required: true,
}),
message: Args.string({
description: "The message to publish (JSON format or plain text)",
required: true,
}),
};
static description = "Publish a message to an Ably channel";
static examples = [
'$ ably channels publish my-channel \'{"name":"event","data":"Hello World"}\'',
'$ ably channels publish --api-key "YOUR_API_KEY" my-channel \'{"data":"Simple message"}\'',
'$ ably channels publish --token "YOUR_ABLY_TOKEN" my-channel \'{"data":"Using token auth"}\'',
'$ ably channels publish --name event my-channel \'{"text":"Hello World"}\'',
'$ ably channels publish my-channel "Hello World"',
'$ ably channels publish --name event my-channel "Plain text message"',
'$ ably channels publish --count 5 my-channel "Message number {{.Count}}"',
'$ ably channels publish --count 10 --delay 1000 my-channel "Message at {{.Timestamp}}"',
'$ ably channels publish --transport realtime my-channel "Using realtime transport"',
'$ ably channels publish my-channel "Hello World" --json',
'$ ably channels publish my-channel "Hello World" --pretty-json',
];
static flags = {
...AblyBaseCommand.globalFlags,
count: Flags.integer({
char: "c",
default: 1,
description: "Number of messages to publish",
}),
delay: Flags.integer({
char: "d",
default: 40,
description: "Delay between messages in milliseconds (default: 40ms, max 25 msgs/sec)",
}),
encoding: Flags.string({
char: "e",
description: "The encoding for the message",
}),
name: Flags.string({
char: "n",
description: "The event name (if not specified in the message JSON)",
}),
transport: Flags.string({
description: "Transport method to use for publishing (rest or realtime)",
options: ["rest", "realtime"],
}),
};
progressIntervalId = null;
realtime = null;
// Override finally to ensure resources are cleaned up
async finally(err) {
if (this.progressIntervalId) {
clearInterval(this.progressIntervalId);
this.progressIntervalId = null;
}
if (this.realtime &&
this.realtime.connection.state !== "closed" && // Check state before closing to avoid errors if already closed
this.realtime.connection.state !== "failed") {
this.realtime.close();
}
return super.finally(err);
}
// --- Refactored Publish Logic ---
async run() {
const { args, flags } = await this.parse(ChannelsPublish);
// Use Realtime transport by default when publishing multiple messages to ensure ordering
// If transport is not explicitly set and count > 1, use realtime
// If transport is explicitly set, respect that choice
const shouldUseRealtime = flags.transport === "realtime" || (!flags.transport && flags.count > 1);
await (shouldUseRealtime
? this.publishWithRealtime(args, flags)
: this.publishWithRest(args, flags));
}
clearProgressIndicator() {
if (this.progressIntervalId) {
clearInterval(this.progressIntervalId);
this.progressIntervalId = null;
}
}
handlePublishError(error, flags) {
const errorMsg = error instanceof Error ? error.message : String(error);
this.logCliEvent(flags, "publish", "fatalError", `Failed to publish message: ${errorMsg}`, { error: errorMsg });
this.logErrorAndExit(`Failed to publish message: ${errorMsg}`, flags);
}
// --- Original Methods (modified) ---
interpolateMessage(message, count) {
// Replace {{.Count}} with the current count
let result = message.replaceAll("{{.Count}}", count.toString());
// Replace {{.Timestamp}} with the current timestamp
result = result.replaceAll("{{.Timestamp}}", Date.now().toString());
return result;
}
logErrorAndExit(message, flags) {
if (this.shouldOutputJson(flags)) {
this.log(this.formatJsonOutput({ error: message, success: false }, flags));
process.exitCode = 1; // Set exit code for JSON output errors
}
else {
this.error(message); // Use oclif error which sets exit code
}
}
logFinalSummary(flags, total, published, errors, results, args) {
const finalResult = {
errors,
published,
results,
success: errors === 0 && published === total,
total,
channel: args.channel,
};
const eventType = total > 1 ? "multiPublishComplete" : "singlePublishComplete";
const eventMessage = total > 1
? `Finished publishing ${total} messages`
: "Finished publishing message";
this.logCliEvent(flags, "publish", eventType, eventMessage, finalResult);
if (!this.shouldSuppressOutput(flags)) {
if (this.shouldOutputJson(flags)) {
this.log(this.formatJsonOutput(finalResult, flags));
}
else if (total > 1) {
this.log(`${chalk.green("✓")} ${published}/${total} messages published successfully${errors > 0 ? ` (${chalk.red(errors)} errors)` : ""}.`);
}
else if (errors === 0) {
this.log(`${chalk.green("✓")} Message published successfully to channel "${args.channel}".`);
}
else {
// Error message already logged by publishMessages loop or prepareMessage
}
}
}
prepareMessage(rawMessage, flags, index) {
// Apply interpolation to the message
const interpolatedMessage = this.interpolateMessage(rawMessage, index);
// Parse the message
let messageData;
try {
messageData = JSON.parse(interpolatedMessage);
}
catch {
// If parsing fails, use the raw message as data
messageData = { data: interpolatedMessage };
}
// Prepare the message
const message = {};
// If name is provided in flags, use it. Otherwise, check if it's in the message data
if (flags.name) {
message.name = flags.name;
}
else if (messageData.name) {
message.name = messageData.name;
// Remove the name from the data to avoid duplication
delete messageData.name;
}
// If data is explicitly provided in the message, use it
if ("data" in messageData) {
message.data = messageData.data;
}
else {
// Otherwise use the entire messageData as the data
message.data = messageData;
}
// Add encoding if provided
if (flags.encoding) {
message.encoding = flags.encoding;
}
return message;
}
async publishMessages(args, flags, publisher) {
// Validate count and delay
const count = Math.max(1, flags.count);
let delay = flags.delay;
this.logCliEvent(flags, "publish", "startingPublish", `Publishing ${count} messages with ${delay}ms delay...`, { count, delay });
if (count > 1 && !this.shouldOutputJson(flags)) {
this.log(`Publishing ${count} messages with ${delay}ms delay...`);
}
let publishedCount = 0;
let errorCount = 0;
const results = [];
// Setup progress indicator
this.setupProgressIndicator(flags, count, () => publishedCount, () => errorCount);
for (let i = 0; i < count; i++) {
if (delay > 0 && i > 0) {
// Wait for the specified delay before publishing the next message
await new Promise((resolve) => setTimeout(resolve, delay));
}
const messageIndex = i + 1;
const message = this.prepareMessage(args.message, flags, messageIndex);
try {
await publisher(message);
publishedCount++;
const result = { index: messageIndex, message, success: true };
results.push(result);
this.logCliEvent(flags, "publish", "messagePublished", `Message ${messageIndex} published successfully to channel "${args.channel}"`, { index: messageIndex, message, channel: args.channel });
if (!this.shouldSuppressOutput(flags) &&
!this.shouldOutputJson(flags) &&
count > 1 // Only show individual success messages when publishing multiple messages
) {
this.log(`${chalk.green("✓")} Message ${messageIndex} published successfully to channel "${args.channel}".`);
}
}
catch (error) {
errorCount++;
const errorMsg = error instanceof Error ? error.message : String(error);
const result = { error: errorMsg, index: messageIndex, success: false };
results.push(result);
this.logCliEvent(flags, "publish", "publishError", `Error publishing message ${messageIndex}: ${errorMsg}`, { error: errorMsg, index: messageIndex });
if (!this.shouldSuppressOutput(flags) &&
!this.shouldOutputJson(flags)) {
this.log(`${chalk.red("✗")} Error publishing message ${messageIndex}: ${errorMsg}`);
}
}
}
this.clearProgressIndicator();
this.logFinalSummary(flags, count, publishedCount, errorCount, results, args);
}
async publishWithRealtime(args, flags) {
try {
this.realtime = await this.createAblyRealtimeClient(flags);
if (!this.realtime) {
const errorMsg = "Failed to create Ably client. Please check your API key and try again.";
this.logCliEvent(flags, "publish", "clientCreationFailed", errorMsg, {
error: errorMsg,
});
this.logErrorAndExit(errorMsg, flags);
return;
}
const client = this.realtime;
client.connection.on((stateChange) => {
this.logCliEvent(flags, "connection", stateChange.current, `Connection state changed to ${stateChange.current}`, { reason: stateChange.reason });
});
this.logCliEvent(flags, "publish", "transportSelected", "Using Realtime transport");
const channel = client.channels.get(args.channel);
channel.on((stateChange) => {
this.logCliEvent(flags, "channel", stateChange.current, `Channel '${args.channel}' state changed to ${stateChange.current}`, { reason: stateChange.reason });
});
await this.publishMessages(args, flags, (msg) => channel.publish(msg));
}
catch (error) {
this.handlePublishError(error, flags);
}
finally {
// Ensure connection is closed if it was opened
if (this.realtime) {
this.realtime.close();
this.logCliEvent(flags, "connection", "closed", "Realtime connection closed.");
}
}
}
async publishWithRest(args, flags) {
try {
// Create REST client
const rest = await this.createAblyRestClient(flags);
if (!rest) {
return;
}
const channel = rest.channels.get(args.channel);
this.logCliEvent(flags, "publish", "transportSelected", "Using REST transport");
await this.publishMessages(args, flags, (msg) => channel.publish(msg));
}
catch (error) {
this.handlePublishError(error, flags);
}
// No finally block needed here as REST client doesn't maintain a connection
}
setupProgressIndicator(flags, total, getPublishedCount, getErrorCount) {
if (total <= 1)
return; // No progress for single message
if (this.progressIntervalId)
clearInterval(this.progressIntervalId);
this.progressIntervalId = this.shouldOutputJson(flags)
? setInterval(() => {
this.logCliEvent(flags, "publish", "progress", "Publishing messages", {
errors: getErrorCount(),
published: getPublishedCount(),
total,
});
}, 2000)
: setInterval(() => {
this.log(`Progress: ${getPublishedCount()}/${total} messages published (${getErrorCount()} errors)`);
}, 1000);
}
}