apps/meteor/app/push/server/fcm.ts
import { serverFetch as fetch, type ExtendedFetchOptions } from '@rocket.chat/server-fetch';
import EJSON from 'ejson';
import type { Response } from 'node-fetch';
import type { PendingPushNotification } from './definition';
import { logger } from './logger';
import type { NativeNotificationParameters } from './push';
type FCMDataField = Record<string, any>;
type FCMNotificationField = {
title: string;
body: string;
image?: string;
};
type FCMMessage = {
notification?: FCMNotificationField;
data?: FCMDataField;
token?: string;
to?: string;
android?: {
collapseKey?: string;
priority?: 'HIGH' | 'NORMAL';
ttl?: string;
restrictedPackageName?: string;
data?: FCMDataField;
notification?: FCMNotificationField;
fcm_options?: {
analytics_label?: string;
};
direct_boot_ok?: boolean;
};
webpush?: {
headers?: FCMDataField;
data?: FCMDataField;
notification?: FCMNotificationField;
fcm_options?: {
link?: string;
analytics_label?: string;
};
};
fcm_options?: {
analytics_label?: string;
};
};
// https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode
type FCMError = {
error: {
code: number;
message: string;
status: string;
};
};
/**
* Send a push notification using Firebase Cloud Messaging (FCM).
* implements the Firebase Cloud Messaging HTTP v1 API, and all of its retry logic,
* see: https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode
*
* Errors:
* - For 400, 401, 403 errors: abort, and do not retry.
* - For 404 errors: remove the token from the database.
* - For 429 errors: retry after waiting for the duration set in the retry-after header. If no retry-after header is set, default to 60 seconds.
* - For 500 errors: retry with exponential backoff.
*/
async function fetchWithRetry(url: string, _removeToken: () => void, options: ExtendedFetchOptions, retries = 0): Promise<Response> {
const MAX_RETRIES = 5;
const response = await fetch(url, options);
if (response.ok) {
return response;
}
if (retries >= MAX_RETRIES) {
logger.error('sendFCM error: max retries reached');
return response;
}
const retryAfter = response.headers.get('retry-after');
const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : 60;
if (response.status === 404) {
_removeToken();
return response;
}
if (response.status === 429) {
await new Promise((resolve) => setTimeout(resolve, retryAfterSeconds * 1000));
return fetchWithRetry(url, _removeToken, options, retries + 1);
}
if (response.status >= 500 && response.status < 600) {
const backoff = Math.pow(2, retries) * 10000;
await new Promise((resolve) => setTimeout(resolve, backoff));
return fetchWithRetry(url, _removeToken, options, retries + 1);
}
const error: FCMError = await response.json();
logger.error('sendFCM error', error);
return response;
}
function getFCMMessagesFromPushData(userTokens: string[], notification: PendingPushNotification): { message: FCMMessage }[] {
// first we will get the `data` field from the notification
const data: FCMDataField = notification.payload ? { ejson: EJSON.stringify(notification.payload) } : {};
// Set image
if (notification.gcm?.image) {
data.image = notification.gcm?.image;
}
// Set extra details
if (notification.badge) {
data.msgcnt = notification.badge.toString();
}
if (notification.sound) {
data.soundname = notification.sound;
}
if (notification.notId) {
data.notId = notification.notId.toString();
}
if (notification.gcm?.style) {
data.style = notification.gcm?.style;
}
if (notification.contentAvailable) {
data['content-available'] = notification.contentAvailable.toString();
}
// then we will create the notification field
const notificationField: FCMNotificationField = {
title: notification.title,
body: notification.text,
};
// then we will create the message
const message: FCMMessage = {
notification: notificationField,
data,
android: {
priority: 'HIGH',
},
};
// then we will create the message for each token
return userTokens.map((token) => ({ message: { ...message, token } }));
}
export const sendFCM = function ({ userTokens, notification, _removeToken, options }: NativeNotificationParameters): void {
const tokens = typeof userTokens === 'string' ? [userTokens] : userTokens;
if (!tokens.length) {
logger.log('sendFCM no push tokens found');
return;
}
logger.debug('sendFCM', tokens, notification);
const messages = getFCMMessagesFromPushData(tokens, notification);
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${options.gcm.apiKey}`,
'access_token_auth': true,
} as Record<string, any>;
if (!options.gcm.projectNumber.trim()) {
logger.error('sendFCM error: GCM project number is missing');
return;
}
const url = `https://fcm.googleapis.com/v1/projects/${options.gcm.projectNumber}/messages:send`;
for (const fcmRequest of messages) {
logger.debug('sendFCM message', fcmRequest);
const removeToken = () => {
const { token } = fcmRequest.message;
token && _removeToken({ gcm: token });
};
const response = fetchWithRetry(url, removeToken, { method: 'POST', headers, body: JSON.stringify(fcmRequest) });
response.catch((err) => {
logger.error('sendFCM error', err);
});
}
};