Skip to content

Commit 0da72ef

Browse files
authored
feat(fcm): Add sendEach and sendEachForMulticast for FCM batch send (#2138)
* Deprecate sendAll and sendMulticast (#2094) 1. Deprecate sendAll and sendMulticast 2. Add dummy implementation for sendEach and sendEachForMulticast to avoid errors reported by api-extractor * Implement `sendEach` and `sendEachForMulticast` (#2097) `sendEach` vs `sendAll` 1. `sendEach` sends one HTTP request to V1 Send endpoint for each message in the array. `sendAll` sends only one HTTP request to V1 Batch Send endpoint to send all messages in the array. 2. `sendEach` calls `Promise.allSettled` to wait for all `httpClient.send` calls to complete and construct a `BatchResponse`. An `httpClient.send` call to V1 Send endpoint either completes with a success or throws an error. So if an error is thrown out, the error will be caught in `sendEach` and turned into a `SendResponse` with an error. Therefore, unlike `sendAll`, `sendEach` does not always throw an error for a total failure. It can also return a `BatchResponse` with only errors in it. `sendEachForMulticast` calls `sendEach` under the hood. * Add integration tests for `sendEach` and `sendMulticast` (#2130) * Avoid using "-- i.e." in the function comment
1 parent 90426de commit 0da72ef

File tree

5 files changed

+747
-15
lines changed

5 files changed

+747
-15
lines changed

etc/firebase-admin.messaging.api.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,11 @@ export type Message = TokenMessage | TopicMessage | ConditionMessage;
184184
export class Messaging {
185185
get app(): App;
186186
send(message: Message, dryRun?: boolean): Promise<string>;
187+
// @deprecated
187188
sendAll(messages: Message[], dryRun?: boolean): Promise<BatchResponse>;
189+
sendEach(messages: Message[], dryRun?: boolean): Promise<BatchResponse>;
190+
sendEachForMulticast(message: MulticastMessage, dryRun?: boolean): Promise<BatchResponse>;
191+
// @deprecated
188192
sendMulticast(message: MulticastMessage, dryRun?: boolean): Promise<BatchResponse>;
189193
sendToCondition(condition: string, payload: MessagingPayload, options?: MessagingOptions): Promise<MessagingConditionResponse>;
190194
// @deprecated

src/messaging/messaging-api-request-internal.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,34 @@ export class FirebaseMessagingRequestHandler {
9696
});
9797
}
9898

99+
/**
100+
* Invokes the request handler with the provided request data.
101+
*
102+
* @param host - The host to which to send the request.
103+
* @param path - The path to which to send the request.
104+
* @param requestData - The request data.
105+
* @returns A promise that resolves with the {@link SendResponse}.
106+
*/
107+
public invokeRequestHandlerForSendResponse(host: string, path: string, requestData: object): Promise<SendResponse> {
108+
const request: HttpRequestConfig = {
109+
method: FIREBASE_MESSAGING_HTTP_METHOD,
110+
url: `https://${host}${path}`,
111+
data: requestData,
112+
headers: LEGACY_FIREBASE_MESSAGING_HEADERS,
113+
timeout: FIREBASE_MESSAGING_TIMEOUT,
114+
};
115+
return this.httpClient.send(request).then((response) => {
116+
return this.buildSendResponse(response);
117+
})
118+
.catch((err) => {
119+
if (err instanceof HttpError) {
120+
return this.buildSendResponseFromError(err);
121+
}
122+
// Re-throw the error if it already has the proper format.
123+
throw err;
124+
});
125+
}
126+
99127
/**
100128
* Sends the given array of sub requests as a single batch to FCM, and parses the result into
101129
* a BatchResponse object.
@@ -136,4 +164,11 @@ export class FirebaseMessagingRequestHandler {
136164
}
137165
return result;
138166
}
167+
168+
private buildSendResponseFromError(err: HttpError): SendResponse {
169+
return {
170+
success: false,
171+
error: createFirebaseError(err)
172+
};
173+
}
139174
}

src/messaging/messaging.ts

Lines changed: 128 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
MessagingConditionResponse,
4040
DataMessagePayload,
4141
NotificationMessagePayload,
42+
SendResponse,
4243
} from './messaging-api';
4344

4445
// FCM endpoints
@@ -250,6 +251,124 @@ export class Messaging {
250251
});
251252
}
252253

254+
/**
255+
* Sends each message in the given array via Firebase Cloud Messaging.
256+
*
257+
* Unlike {@link Messaging.sendAll}, this method makes a single RPC call for each message
258+
* in the given array.
259+
*
260+
* The responses list obtained from the return value corresponds to the order of `messages`.
261+
* An error from this method or a `BatchResponse` with all failures indicates a total failure,
262+
* meaning that none of the messages in the list could be sent. Partial failures or no
263+
* failures are only indicated by a `BatchResponse` return value.
264+
*
265+
* @param messages - A non-empty array
266+
* containing up to 500 messages.
267+
* @param dryRun - Whether to send the messages in the dry-run
268+
* (validation only) mode.
269+
* @returns A Promise fulfilled with an object representing the result of the
270+
* send operation.
271+
*/
272+
public sendEach(messages: Message[], dryRun?: boolean): Promise<BatchResponse> {
273+
if (validator.isArray(messages) && messages.constructor !== Array) {
274+
// In more recent JS specs, an array-like object might have a constructor that is not of
275+
// Array type. Our deepCopy() method doesn't handle them properly. Convert such objects to
276+
// a regular array here before calling deepCopy(). See issue #566 for details.
277+
messages = Array.from(messages);
278+
}
279+
280+
const copy: Message[] = deepCopy(messages);
281+
if (!validator.isNonEmptyArray(copy)) {
282+
throw new FirebaseMessagingError(
283+
MessagingClientErrorCode.INVALID_ARGUMENT, 'messages must be a non-empty array');
284+
}
285+
if (copy.length > FCM_MAX_BATCH_SIZE) {
286+
throw new FirebaseMessagingError(
287+
MessagingClientErrorCode.INVALID_ARGUMENT,
288+
`messages list must not contain more than ${FCM_MAX_BATCH_SIZE} items`);
289+
}
290+
if (typeof dryRun !== 'undefined' && !validator.isBoolean(dryRun)) {
291+
throw new FirebaseMessagingError(
292+
MessagingClientErrorCode.INVALID_ARGUMENT, 'dryRun must be a boolean');
293+
}
294+
295+
return this.getUrlPath()
296+
.then((urlPath) => {
297+
const requests: Promise<SendResponse>[] = copy.map((message) => {
298+
validateMessage(message);
299+
const request: { message: Message; validate_only?: boolean } = { message };
300+
if (dryRun) {
301+
request.validate_only = true;
302+
}
303+
return this.messagingRequestHandler.invokeRequestHandlerForSendResponse(FCM_SEND_HOST, urlPath, request);
304+
});
305+
return Promise.allSettled(requests);
306+
}).then((results) => {
307+
const responses: SendResponse[] = [];
308+
results.forEach(result => {
309+
if (result.status === 'fulfilled') {
310+
responses.push(result.value);
311+
} else { // rejected
312+
responses.push({ success: false, error: result.reason })
313+
}
314+
})
315+
const successCount: number = responses.filter((resp) => resp.success).length;
316+
return {
317+
responses,
318+
successCount,
319+
failureCount: responses.length - successCount,
320+
};
321+
});
322+
}
323+
324+
/**
325+
* Sends the given multicast message to all the FCM registration tokens
326+
* specified in it.
327+
*
328+
* This method uses the {@link Messaging.sendEach} API under the hood to send the given
329+
* message to all the target recipients. The responses list obtained from the
330+
* return value corresponds to the order of tokens in the `MulticastMessage`.
331+
* An error from this method or a `BatchResponse` with all failures indicates a total
332+
* failure, meaning that the messages in the list could be sent. Partial failures or
333+
* failures are only indicated by a `BatchResponse` return value.
334+
*
335+
* @param message - A multicast message
336+
* containing up to 500 tokens.
337+
* @param dryRun - Whether to send the message in the dry-run
338+
* (validation only) mode.
339+
* @returns A Promise fulfilled with an object representing the result of the
340+
* send operation.
341+
*/
342+
public sendEachForMulticast(message: MulticastMessage, dryRun?: boolean): Promise<BatchResponse> {
343+
const copy: MulticastMessage = deepCopy(message);
344+
if (!validator.isNonNullObject(copy)) {
345+
throw new FirebaseMessagingError(
346+
MessagingClientErrorCode.INVALID_ARGUMENT, 'MulticastMessage must be a non-null object');
347+
}
348+
if (!validator.isNonEmptyArray(copy.tokens)) {
349+
throw new FirebaseMessagingError(
350+
MessagingClientErrorCode.INVALID_ARGUMENT, 'tokens must be a non-empty array');
351+
}
352+
if (copy.tokens.length > FCM_MAX_BATCH_SIZE) {
353+
throw new FirebaseMessagingError(
354+
MessagingClientErrorCode.INVALID_ARGUMENT,
355+
`tokens list must not contain more than ${FCM_MAX_BATCH_SIZE} items`);
356+
}
357+
358+
const messages: Message[] = copy.tokens.map((token) => {
359+
return {
360+
token,
361+
android: copy.android,
362+
apns: copy.apns,
363+
data: copy.data,
364+
notification: copy.notification,
365+
webpush: copy.webpush,
366+
fcmOptions: copy.fcmOptions,
367+
};
368+
});
369+
return this.sendEach(messages, dryRun);
370+
}
371+
253372
/**
254373
* Sends all the messages in the given array via Firebase Cloud Messaging.
255374
* Employs batching to send the entire list as a single RPC call. Compared
@@ -258,8 +377,8 @@ export class Messaging {
258377
*
259378
* The responses list obtained from the return value
260379
* corresponds to the order of tokens in the `MulticastMessage`. An error
261-
* from this method indicates a total failure -- i.e. none of the messages in
262-
* the list could be sent. Partial failures are indicated by a `BatchResponse`
380+
* from this method indicates a total failure, meaning that none of the messages
381+
* in the list could be sent. Partial failures are indicated by a `BatchResponse`
263382
* return value.
264383
*
265384
* @param messages - A non-empty array
@@ -268,6 +387,8 @@ export class Messaging {
268387
* (validation only) mode.
269388
* @returns A Promise fulfilled with an object representing the result of the
270389
* send operation.
390+
*
391+
* @deprecated Use {@link Messaging.sendEach} instead.
271392
*/
272393
public sendAll(messages: Message[], dryRun?: boolean): Promise<BatchResponse> {
273394
if (validator.isArray(messages) && messages.constructor !== Array) {
@@ -316,16 +437,18 @@ export class Messaging {
316437
* This method uses the `sendAll()` API under the hood to send the given
317438
* message to all the target recipients. The responses list obtained from the
318439
* return value corresponds to the order of tokens in the `MulticastMessage`.
319-
* An error from this method indicates a total failure -- i.e. the message was
320-
* not sent to any of the tokens in the list. Partial failures are indicated by
321-
* a `BatchResponse` return value.
440+
* An error from this method indicates a total failure, meaning that the message
441+
* was not sent to any of the tokens in the list. Partial failures are indicated
442+
* by a `BatchResponse` return value.
322443
*
323444
* @param message - A multicast message
324445
* containing up to 500 tokens.
325446
* @param dryRun - Whether to send the message in the dry-run
326447
* (validation only) mode.
327448
* @returns A Promise fulfilled with an object representing the result of the
328449
* send operation.
450+
*
451+
* @deprecated Use {@link Messaging.sendEachForMulticast} instead.
329452
*/
330453
public sendMulticast(message: MulticastMessage, dryRun?: boolean): Promise<BatchResponse> {
331454
const copy: MulticastMessage = deepCopy(message);

test/integration/messaging.spec.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,37 @@ describe('admin.messaging', () => {
108108
});
109109
});
110110

111+
it('sendEach()', () => {
112+
const messages: Message[] = [message, message, message];
113+
return getMessaging().sendEach(messages, true)
114+
.then((response) => {
115+
expect(response.responses.length).to.equal(messages.length);
116+
expect(response.successCount).to.equal(messages.length);
117+
expect(response.failureCount).to.equal(0);
118+
response.responses.forEach((resp) => {
119+
expect(resp.success).to.be.true;
120+
expect(resp.messageId).matches(/^projects\/.*\/messages\/.*$/);
121+
});
122+
});
123+
});
124+
125+
it('sendEach(500)', () => {
126+
const messages: Message[] = [];
127+
for (let i = 0; i < 500; i++) {
128+
messages.push({ topic: `foo-bar-${i % 10}` });
129+
}
130+
return getMessaging().sendEach(messages, true)
131+
.then((response) => {
132+
expect(response.responses.length).to.equal(messages.length);
133+
expect(response.successCount).to.equal(messages.length);
134+
expect(response.failureCount).to.equal(0);
135+
response.responses.forEach((resp) => {
136+
expect(resp.success).to.be.true;
137+
expect(resp.messageId).matches(/^projects\/.*\/messages\/.*$/);
138+
});
139+
});
140+
});
141+
111142
it('sendAll()', () => {
112143
const messages: Message[] = [message, message, message];
113144
return getMessaging().sendAll(messages, true)
@@ -139,6 +170,25 @@ describe('admin.messaging', () => {
139170
});
140171
});
141172

173+
it('sendEachForMulticast()', () => {
174+
const multicastMessage: MulticastMessage = {
175+
data: message.data,
176+
android: message.android,
177+
tokens: ['not-a-token', 'also-not-a-token'],
178+
};
179+
return getMessaging().sendEachForMulticast(multicastMessage, true)
180+
.then((response) => {
181+
expect(response.responses.length).to.equal(2);
182+
expect(response.successCount).to.equal(0);
183+
expect(response.failureCount).to.equal(2);
184+
response.responses.forEach((resp) => {
185+
expect(resp.success).to.be.false;
186+
expect(resp.messageId).to.be.undefined;
187+
expect(resp.error).to.have.property('code', 'messaging/invalid-argument');
188+
});
189+
});
190+
});
191+
142192
it('sendMulticast()', () => {
143193
const multicastMessage: MulticastMessage = {
144194
data: message.data,

0 commit comments

Comments
 (0)