Skip to content

Commit 7364d23

Browse files
Herklosclaude
andcommitted
feat(firebase): support FCM condition addressing in FirebaseDestination
The format callback may now return a non-empty `condition` (an FCM condition expression over up to 5 topics, e.g. "'A' in topics && !('B' in topics)"). When present the message is sent with `condition` and WITHOUT `topic` (FCM accepts one or the other, never both); an absent/empty condition falls back to the normal topic send, so existing formatters are unaffected. Enables targeting a combination of topics — e.g. a topic's subscribers minus those also on another topic. Bump to 0.7.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f2283e1 commit 7364d23

5 files changed

Lines changed: 69 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

7+
## [0.7.0] — 2026-05-29
8+
9+
### Added
10+
- **FCM condition addressing in `FirebaseDestination`** — the `format` callback may now return a non-empty `condition` (an [FCM condition expression](https://firebase.google.com/docs/cloud-messaging/send-message#send_messages_to_topics), boolean over up to 5 topics, e.g. `"'A' in topics && !('B' in topics)"`). When present, the message is sent with `condition` and **without** `topic` (FCM accepts one or the other, never both); an absent or empty-string `condition` falls back to the normal topic send, so existing formatters are unaffected. This enables targeting a *combination* of topics — e.g. delivering to a topic's subscribers while excluding those also subscribed to another topic. `topic` still cannot be set by `format` (it is stripped, as before).
11+
12+
### Changed
13+
- `FirebaseDestination.send` strips both `topic` and `condition` from the formatted body before re-applying exactly one: `condition` when non-empty, otherwise the subscription's `topic`.
14+
715
## [0.6.0] — 2026-05-23
816

917
### Added

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,28 @@ new FirebaseDestination({
226226
})
227227
```
228228

229-
`topic` is always set from the subscription config and cannot be overridden by `format`.
229+
`topic` is set from the subscription config and cannot be overridden by `format`.
230+
231+
To target a **combination of topics** instead of a single one, return a non-empty
232+
`condition` from `format` — an [FCM condition expression](https://firebase.google.com/docs/cloud-messaging/send-message#send_messages_to_topics)
233+
(boolean over up to 5 topics). When present it is sent as `condition` and the
234+
`topic` is omitted (FCM accepts one or the other, never both); an absent/empty
235+
`condition` falls back to the normal topic send:
236+
237+
```typescript
238+
// deliver to everyone subscribed to the space topic EXCEPT the author's own devices
239+
new FirebaseDestination({
240+
format: (n) => {
241+
const authorId = (n.rawPayload as { identity?: string }).identity
242+
return {
243+
notification: { title: "New message" },
244+
...(authorId
245+
? { condition: `'${n.topic}' in topics && !('user-${authorId}' in topics)` }
246+
: {}), // no identity → plain topic send (backward-compatible)
247+
}
248+
},
249+
})
250+
```
230251

231252
```
232253
pnpm add firebase-admin

packages/ts/whistlers/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@drakkar.software/whistlers",
3-
"version": "0.6.0",
3+
"version": "0.7.0",
44
"description": "Message-queue to destination bridge — Firebase, ClickHouse, PostgreSQL, S3, SSE",
55
"type": "module",
66
"main": "dist/index.js",

packages/ts/whistlers/src/destination/firebase.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,19 @@ export interface FirebaseDestinationOptions {
88
app?: import("firebase-admin/app").App
99
/**
1010
* Format the FCM message body. Receives the outgoing notification and returns an object
11-
* that is spread into the FCM message alongside `topic` (which is always set from
12-
* `notification.topic` and cannot be overridden — a `topic` key in the return value is
13-
* silently ignored).
11+
* that is spread into the FCM message alongside `topic` (which is set from
12+
* `notification.topic` — a `topic` key in the return value is silently ignored).
1413
*
1514
* Standard keys: `notification` (`{title?, body?}`), `data` (`Record<string, string>` —
1615
* FCM requires string values). Additional FCM-specific keys (android, apns, webpush,
1716
* fcmOptions, etc.) are also accepted.
1817
*
18+
* Returning a non-empty string `condition` switches addressing from topic to an FCM
19+
* condition expression (e.g. `"'A' in topics && !('B' in topics)"`, up to 5 topics):
20+
* the message is then sent with `condition` and WITHOUT `topic` (FCM rejects both at
21+
* once). An absent/empty `condition` falls back to the default topic send, so a
22+
* formatter can opt into exclusion per-message and otherwise behave as before.
23+
*
1924
* When omitted, `notification` and `data` are forwarded from the incoming notification.
2025
*/
2126
format?: (notification: OutgoingNotification) => Record<string, unknown>
@@ -49,6 +54,15 @@ export class FirebaseDestination implements DestinationAdapter {
4954
: {}),
5055
}
5156

52-
await messaging.send({ ...body, topic: notification.topic })
57+
// A formatter may return a `condition` (FCM topic-combination expression) to
58+
// exclude/combine topics. FCM accepts EITHER `topic` OR `condition`, never both,
59+
// so a non-empty condition replaces the topic; otherwise we send by topic. The
60+
// formatter can never set `topic` directly (stripped here, as documented).
61+
const { condition, topic: _ignoredTopic, ...rest } = body
62+
await messaging.send(
63+
typeof condition === "string" && condition.length > 0
64+
? { ...rest, condition }
65+
: { ...rest, topic: notification.topic },
66+
)
5367
}
5468
}

packages/ts/whistlers/tests/destination/firebase.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,26 @@ describe("FirebaseDestination", () => {
111111
expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({ topic: "orders" }))
112112
})
113113

114+
it("sends with condition (and no topic) when format returns a condition", async () => {
115+
const condition = "'orders' in topics && !('user-7' in topics)"
116+
const format = vi.fn().mockReturnValue({ data: { id: "1" }, condition })
117+
const dest = new FirebaseDestination({ format })
118+
await dest.send(makeNotification({ topic: "orders" }))
119+
const call = mockSend.mock.calls[0]?.[0] as Record<string, unknown>
120+
expect(call?.["condition"]).toBe(condition)
121+
expect(call?.["topic"]).toBeUndefined()
122+
expect(call?.["data"]).toEqual({ id: "1" })
123+
})
124+
125+
it("falls back to topic send when condition is empty", async () => {
126+
const format = vi.fn().mockReturnValue({ data: {}, condition: "" })
127+
const dest = new FirebaseDestination({ format })
128+
await dest.send(makeNotification({ topic: "orders" }))
129+
const call = mockSend.mock.calls[0]?.[0] as Record<string, unknown>
130+
expect(call?.["topic"]).toBe("orders")
131+
expect(call?.["condition"]).toBeUndefined()
132+
})
133+
114134
it("propagates errors thrown by format callback", async () => {
115135
const format = vi.fn().mockImplementation(() => {
116136
throw new Error("formatter crashed")

0 commit comments

Comments
 (0)