Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2907,7 +2907,7 @@ COMMANDS
ably push channels Manage push notification channel subscriptions
ably push config Manage push notification configuration (APNs, FCM)
ably push devices Manage push notification device registrations
ably push publish Publish a push notification to a device or client
ably push publish Publish a push notification to a device, client, or channel
```

_See code: [src/commands/push/index.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/push/index.ts)_
Expand Down Expand Up @@ -3470,19 +3470,22 @@ _See code: [src/commands/push/devices/save.ts](https://github.com/ably/ably-cli/

## `ably push publish`

Publish a push notification to a device or client
Publish a push notification to a device, client, or channel

```
USAGE
$ ably push publish [-v] [--json | --pretty-json] [--device-id <value> | --client-id <value> | --recipient
<value>] [--title <value>] [--body <value>] [--sound <value>] [--icon <value>] [--badge <value>] [--data <value>]
[--collapse-key <value>] [--ttl <value>] [--payload <value>] [--apns <value>] [--fcm <value>] [--web <value>]
<value>] [--channel <value>] [--title <value>] [--body <value>] [--sound <value>] [--icon <value>] [--badge <value>]
[--data <value>] [--collapse-key <value>] [--ttl <value>] [--payload <value>] [--apns <value>] [--fcm <value>]
[--web <value>]

FLAGS
-v, --verbose Output verbose logs
--apns=<value> APNs-specific override as JSON
--badge=<value> Notification badge count
--body=<value> Notification body
--channel=<value> Target channel name (publishes push notification via the channel using extras.push;
ignored if --device-id, --client-id, or --recipient is also provided)
--client-id=<value> Target client ID
--collapse-key=<value> Collapse key for notification grouping
--data=<value> Custom data payload as JSON
Expand All @@ -3499,13 +3502,15 @@ FLAGS
--web=<value> Web push-specific override as JSON
Comment on lines 3476 to 3502
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

push publish now includes a --force flag (and channel publishing prompts for confirmation when not using JSON), but the README section for ably push publish doesn’t list --force in USAGE/FLAGS. Please update the generated command docs so help/README stay in sync with the command’s actual flags and behavior.

Copilot uses AI. Check for mistakes.

DESCRIPTION
Publish a push notification to a device or client
Publish a push notification to a device, client, or channel

EXAMPLES
$ ably push publish --device-id device-123 --title Hello --body World

$ ably push publish --client-id client-1 --title Hello --body World

$ ably push publish --channel my-channel --title Hello --body World

$ ably push publish --device-id device-123 --payload '{"notification":{"title":"Hello","body":"World"}}'

$ ably push publish --recipient '{"transportType":"apns","deviceToken":"token123"}' --title Hello --body World
Expand Down
151 changes: 123 additions & 28 deletions src/commands/push/batch-publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,19 @@ import { BaseFlags } from "../../types/cli.js";
import {
formatCountLabel,
formatProgress,
formatResource,
formatSuccess,
formatWarning,
} from "../../utils/output.js";
import { promptForConfirmation } from "../../utils/prompt-confirmation.js";

export default class PushBatchPublish extends AblyBaseCommand {
static override description =
"Publish push notifications to multiple recipients in a batch";

static override examples = [
'<%= config.bin %> <%= command.id %> --payload \'[{"recipient":{"deviceId":"dev1"},"payload":{"notification":{"title":"Hello","body":"World"}}}]\'',
'<%= config.bin %> <%= command.id %> --payload \'[{"channel":"my-channel","payload":{"notification":{"title":"Hello","body":"World"}}}]\'',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm.. not sure what format does those examples follow, can we make them similar to examples format in other commands. e.g. ably push

Copy link
Contributor

@sacOO7 sacOO7 Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make command to accept space separated channel names, so internally it gets accepted as channels string array. If payload is same for all channels, then this would make sense right?
You can check channels subscribe command for the same 👍

"<%= config.bin %> <%= command.id %> --payload @batch.json",
"cat batch.json | <%= config.bin %> <%= command.id %> --payload -",
"<%= config.bin %> <%= command.id %> --payload @batch.json --json",
Expand All @@ -28,6 +32,10 @@ export default class PushBatchPublish extends AblyBaseCommand {
description: "Batch payload as JSON array, @filepath, or - for stdin",
required: true,
}),
force: Flags.boolean({
char: "f",
description: "Skip confirmation prompt when publishing to channels",
}),
Comment on lines 21 to +38
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR introduces channel-targeted batch publishing (payload items with a channel field) and a new --force flag for push:batch-publish, but the PR description/release notes only mention changes to push publish. Consider updating the PR description and/or release notes to reflect the additional user-facing behavior added in this command too.

Copilot uses AI. Check for mistakes.
};

async run(): Promise<void> {
Expand Down Expand Up @@ -97,11 +105,31 @@ export default class PushBatchPublish extends AblyBaseCommand {
);
}

type RecipientItem = {
recipient: Record<string, unknown>;
payload: Record<string, unknown>;
};
type ChannelItem = { channel: string; payload: Record<string, unknown> };

const recipientItems: RecipientItem[] = [];
const channelItemsList: ChannelItem[] = [];

for (const [index, item] of batchPayload.entries()) {
const entry = item as Record<string, unknown>;
if (!entry.recipient) {

if (entry.recipient && entry.channel) {
// Both present — channel is ignored, warn the user
const msg = `Item at index ${index}: "channel" is ignored when "recipient" is also provided.`;
if (this.shouldOutputJson(flags)) {
this.logJsonStatus("warning", msg, flags);
} else {
this.log(formatWarning(msg));
}
}

if (!entry.recipient && !entry.channel) {
this.fail(
`Item at index ${index} is missing required "recipient" field`,
`Item at index ${index} is missing required "recipient" or "channel" field`,
flags as BaseFlags,
"pushBatchPublish",
);
Expand All @@ -117,6 +145,39 @@ export default class PushBatchPublish extends AblyBaseCommand {
"pushBatchPublish",
);
}

if (entry.recipient) {
recipientItems.push({
recipient: entry.recipient as Record<string, unknown>,
payload: itemPayload!,
});
} else {
channelItemsList.push({
channel: entry.channel as string,
Comment on lines +155 to +156
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

entry.channel is cast to string without validating its type/value. Since the batch payload is user-provided JSON, channel could be a non-string (object/number/null), which would produce confusing behavior (e.g., "[object Object]") or downstream SDK errors. Consider explicitly validating typeof entry.channel === "string" and that it’s non-empty when entry.recipient is not provided.

Suggested change
channelItemsList.push({
channel: entry.channel as string,
const channel = entry.channel;
if (typeof channel !== "string" || channel.trim().length === 0) {
this.fail(
`Item at index ${index} has an invalid "channel" field; expected a non-empty string when "recipient" is not provided`,
flags as BaseFlags,
"pushBatchPublish",
);
}
channelItemsList.push({
channel,

Copilot uses AI. Check for mistakes.
payload: itemPayload!,
});
}
}

// Prompt for confirmation when publishing to channels (non-JSON, non-force mode only)
if (
channelItemsList.length > 0 &&
!this.shouldOutputJson(flags) &&
!flags.force
) {
const uniqueChannels = [
...new Set(channelItemsList.map((i) => i.channel)),
];
const channelList = uniqueChannels
.map((c) => formatResource(c))
.join(", ");
const confirmed = await promptForConfirmation(
`This will publish push notifications to ${formatCountLabel(channelItemsList.length, "item")} targeting ${channelList}. Continue?`,
);
if (!confirmed) {
this.log("Batch publish cancelled.");
return;
}
}

if (!this.shouldOutputJson(flags)) {
Expand All @@ -127,50 +188,84 @@ export default class PushBatchPublish extends AblyBaseCommand {
);
}

const response = await rest.request(
"post",
"/push/batch/publish",
2,
null,
batchPayload,
);

// Parse response items for success/failure counts
const items = (response.items ?? []) as Record<string, unknown>[];
const failed = items.filter(
(item) => item.error || (item.statusCode && item.statusCode !== 200),
);
const succeeded =
items.length > 0 ? items.length - failed.length : batchPayload.length;
let succeeded = 0;
const failedItems: { index: number; error: string }[] = [];

// Publish recipient-based items via /push/batch/publish
if (recipientItems.length > 0) {
const response = await rest.request(
"post",
"/push/batch/publish",
2,
null,
recipientItems,
);
const responseItems = (response.items ?? []) as Record<
string,
unknown
>[];
for (const [i, result] of responseItems.entries()) {
if (
result.error ||
(result.statusCode && result.statusCode !== 200)
) {
const err = result.error as Record<string, unknown> | undefined;
failedItems.push({
index: i,
error: String(err?.message ?? "Unknown error"),
});
} else {
succeeded++;
}
}
if (responseItems.length === 0) {
succeeded += recipientItems.length;
}
}

// Publish channel-based items via channel extras.push
for (const [i, item] of channelItemsList.entries()) {
try {
await rest.channels
.get(item.channel)
.publish({ extras: { push: item.payload } });
succeeded++;
} catch (error) {
failedItems.push({
index: recipientItems.length + i,
error: error instanceof Error ? error.message : String(error),
});
Comment on lines +207 to +237
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In mixed recipient/channel batches, the failedItems.index values don’t correspond to the original batchPayload indices: recipient failures use the index within responseItems (subset order), and channel failures use recipientItems.length + i (also subset-based). This makes it hard/impossible for users to map failures back to their input array. Consider storing the original input index alongside each queued item and using that original index for both success/failure reporting.

Copilot uses AI. Check for mistakes.
Comment on lines +227 to +237
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Channel-targeted items are published one-by-one with an await inside the loop. With the current max batch size (10,000), this can turn into thousands of sequential network requests and be extremely slow / prone to rate limits. Consider grouping items by channel and publishing arrays per channel (or running publishes with a concurrency limit) to reduce request count and wall-clock time.

Suggested change
for (const [i, item] of channelItemsList.entries()) {
try {
await rest.channels
.get(item.channel)
.publish({ extras: { push: item.payload } });
succeeded++;
} catch (error) {
failedItems.push({
index: recipientItems.length + i,
error: error instanceof Error ? error.message : String(error),
});
if (channelItemsList.length > 0) {
// Group items by channel so we can publish multiple messages per channel in one request
const channelGroups = new Map<
string,
{ messages: { extras: { push: unknown } }[]; indices: number[] }
>();
channelItemsList.forEach((item, i) => {
const globalIndex = recipientItems.length + i;
const message = { extras: { push: item.payload as unknown } };
const existing = channelGroups.get(item.channel);
if (existing) {
existing.messages.push(message);
existing.indices.push(globalIndex);
} else {
channelGroups.set(item.channel, {
messages: [message],
indices: [globalIndex],
});
}
});
for (const [channel, group] of channelGroups.entries()) {
try {
await rest.channels.get(channel).publish(group.messages);
succeeded += group.messages.length;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
for (const index of group.indices) {
failedItems.push({
index,
error: errorMessage,
});
}
}

Copilot uses AI. Check for mistakes.
}
}

const total = batchPayload.length;
const failedCount = failedItems.length;

if (this.shouldOutputJson(flags)) {
this.logJsonResult(
{
published: true,
total: batchPayload.length,
total,
succeeded,
failed: failed.length,
...(failed.length > 0 ? { failedItems: failed } : {}),
failed: failedCount,
...(failedCount > 0 ? { failedItems } : {}),
},
Comment on lines 244 to 252
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSON output schema for failures changes here: previously failedItems contained the raw response items from /push/batch/publish, but it’s now an array of { index, error }. If consumers rely on the old structure (e.g., statusCode, error.code), this is a breaking change. Consider preserving the prior structure (and adding index), or documenting/versioning the schema change.

Copilot uses AI. Check for mistakes.
flags,
);
} else {
if (failed.length > 0) {
if (failedCount > 0) {
this.log(
formatSuccess(
`Batch published: ${succeeded} succeeded, ${failed.length} failed out of ${formatCountLabel(batchPayload.length, "notification")}.`,
`Batch published: ${succeeded} succeeded, ${failedCount} failed out of ${formatCountLabel(total, "notification")}.`,
),
);
for (const item of failed) {
const error = item.error as Record<string, unknown> | undefined;
const message = error?.message ?? "Unknown error";
const code = error?.code ? ` (code: ${error.code})` : "";
this.logToStderr(` Failed: ${message}${code}`);
for (const item of failedItems) {
this.logToStderr(` Failed (index ${item.index}): ${item.error}`);
}
} else {
this.log(
formatSuccess(
`Batch of ${formatCountLabel(batchPayload.length, "notification")} published.`,
`Batch of ${formatCountLabel(total, "notification")} published.`,
),
);
}
Expand Down
Loading
Loading