Skip to content

Commit 27361a7

Browse files
committed
feat(messaging): migrate topic subscription APIs to new REST endpoints via HTTP/2 multiplexing
1 parent bcae57a commit 27361a7

3 files changed

Lines changed: 1000 additions & 940 deletions

File tree

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,43 @@ export class FirebaseMessagingRequestHandler {
152152
});
153153
}
154154

155+
/**
156+
* Invokes the HTTP/2 request handler for generic operations (e.g., topic subscriptions).
157+
*
158+
* @param host - The host to which to send the request.
159+
* @param path - The path to which to send the request.
160+
* @param method - The HTTP method to use.
161+
* @param requestData - Optional request data.
162+
* @param http2SessionHandler - The HTTP/2 session handler.
163+
* @returns A promise that resolves with the response data.
164+
*/
165+
public invokeHttp2RequestHandler(
166+
host: string,
167+
path: string,
168+
method: HttpMethod,
169+
requestData: object | undefined,
170+
http2SessionHandler: Http2SessionHandler
171+
): Promise<object> {
172+
const request: Http2RequestConfig = {
173+
method,
174+
url: `https://${host}${path}`,
175+
data: requestData,
176+
headers: FIREBASE_MESSAGING_HEADERS,
177+
timeout: FIREBASE_MESSAGING_TIMEOUT,
178+
http2SessionHandler,
179+
};
180+
return this.http2Client.send(request).then((response) => {
181+
if (!response.isJson()) {
182+
throw new RequestResponseError(response);
183+
}
184+
const errorCode = getErrorCode(response.data);
185+
if (errorCode) {
186+
throw new RequestResponseError(response);
187+
}
188+
return response.data;
189+
});
190+
}
191+
155192
private buildSendResponse(response: RequestResponse): SendResponse {
156193
const result: SendResponse = {
157194
success: response.status === 200,

src/messaging/messaging.ts

Lines changed: 86 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { ErrorInfo } from '../utils/error';
2424
import * as utils from '../utils';
2525
import * as validator from '../utils/validator';
2626
import { validateMessage } from './messaging-internal';
27+
import { getErrorCode, createFirebaseError } from './messaging-errors-internal';
2728
import { FirebaseMessagingRequestHandler } from './messaging-api-request-internal';
2829

2930
import {
@@ -36,53 +37,14 @@ import {
3637
// Legacy API types
3738
SendResponse,
3839
} from './messaging-api';
39-
import { Http2SessionHandler } from '../utils/api-request';
40+
import { Http2SessionHandler, RequestResponseError } from '../utils/api-request';
4041

4142
// FCM endpoints
4243
const FCM_SEND_HOST = 'fcm.googleapis.com';
43-
const FCM_TOPIC_MANAGEMENT_HOST = 'iid.googleapis.com';
44-
const FCM_TOPIC_MANAGEMENT_ADD_PATH = '/iid/v1:batchAdd';
45-
const FCM_TOPIC_MANAGEMENT_REMOVE_PATH = '/iid/v1:batchRemove';
4644

4745
// Maximum messages that can be included in a batch request.
4846
const FCM_MAX_BATCH_SIZE = 500;
4947

50-
/**
51-
* Maps a raw FCM server response to a `MessagingTopicManagementResponse` object.
52-
*
53-
* @param {object} response The raw FCM server response to map.
54-
*
55-
* @returns {MessagingTopicManagementResponse} The mapped `MessagingTopicManagementResponse` object.
56-
*/
57-
function mapRawResponseToTopicManagementResponse(response: object): MessagingTopicManagementResponse {
58-
// Add the success and failure counts.
59-
const result: MessagingTopicManagementResponse = {
60-
successCount: 0,
61-
failureCount: 0,
62-
errors: [],
63-
};
64-
65-
if ('results' in response) {
66-
(response as any).results.forEach((tokenManagementResult: any, index: number) => {
67-
// Map the FCM server's error strings to actual error objects.
68-
if ('error' in tokenManagementResult) {
69-
result.failureCount += 1;
70-
const newError = FirebaseMessagingError.fromTopicManagementServerError(
71-
tokenManagementResult.error, /* message */ undefined, tokenManagementResult.error,
72-
);
73-
74-
result.errors.push({
75-
index,
76-
error: newError,
77-
});
78-
} else {
79-
result.successCount += 1;
80-
}
81-
});
82-
}
83-
return result;
84-
}
85-
8648

8749
/**
8850
* Messaging service bound to the provided app.
@@ -379,7 +341,7 @@ export class Messaging {
379341
registrationTokenOrTokens,
380342
topic,
381343
'subscribeToTopic',
382-
FCM_TOPIC_MANAGEMENT_ADD_PATH,
344+
'',
383345
);
384346
}
385347

@@ -406,7 +368,7 @@ export class Messaging {
406368
registrationTokenOrTokens,
407369
topic,
408370
'unsubscribeFromTopic',
409-
FCM_TOPIC_MANAGEMENT_REMOVE_PATH,
371+
'',
410372
);
411373
}
412374

@@ -448,7 +410,7 @@ export class Messaging {
448410
registrationTokenOrTokens: string | string[],
449411
topic: string,
450412
methodName: string,
451-
path: string,
413+
_path: string,
452414
): Promise<MessagingTopicManagementResponse> {
453415
this.validateRegistrationTokensType(registrationTokenOrTokens, methodName);
454416
this.validateTopicType(topic, methodName);
@@ -469,17 +431,88 @@ export class Messaging {
469431
registrationTokensArray = [registrationTokenOrTokens as string];
470432
}
471433

472-
const request = {
473-
to: topic,
474-
registration_tokens: registrationTokensArray,
475-
};
434+
return utils.findProjectId(this.app).then((projectId) => {
435+
if (!validator.isNonEmptyString(projectId)) {
436+
throw new FirebaseMessagingError(
437+
MessagingClientErrorCode.INVALID_ARGUMENT,
438+
'Failed to determine project ID for Messaging. Initialize the '
439+
+ 'SDK with service account credentials or set project ID as an app option. '
440+
+ 'Alternatively set the GOOGLE_CLOUD_PROJECT environment variable.',
441+
);
442+
}
476443

477-
return this.messagingRequestHandler.invokeRequestHandler(
478-
FCM_TOPIC_MANAGEMENT_HOST, path, request,
479-
);
480-
})
481-
.then((response) => {
482-
return mapRawResponseToTopicManagementResponse(response);
444+
const topicName = topic.replace(/^\/topics\//, '');
445+
const isSubscribe = methodName === 'subscribeToTopic';
446+
const httpMethod = isSubscribe ? 'POST' : 'DELETE';
447+
448+
const http2SessionHandler = new Http2SessionHandler('https://fcm.googleapis.com');
449+
450+
let settledPromise: Promise<PromiseSettledResult<any>[]>;
451+
return new Promise<MessagingTopicManagementResponse>((resolve, reject) => {
452+
http2SessionHandler.invoke().catch((error) => {
453+
reject(new FirebaseMessagingSessionError(error, undefined, undefined));
454+
});
455+
456+
const requests = registrationTokensArray.map(async (registrationId) => {
457+
let requestPath = `/v1/projects/${projectId}/registrations/${registrationId}/topicSubscriptions`;
458+
if (isSubscribe) {
459+
requestPath += `?topic_name=${topicName}`;
460+
} else {
461+
requestPath += `/${topicName}?allow_missing=true`;
462+
}
463+
return this.messagingRequestHandler.invokeHttp2RequestHandler(
464+
'fcm.googleapis.com',
465+
requestPath,
466+
httpMethod,
467+
isSubscribe ? {} : undefined,
468+
http2SessionHandler
469+
);
470+
});
471+
472+
settledPromise = Promise.allSettled(requests);
473+
settledPromise.then((results) => {
474+
if (results.length > 0 && results.every((r) => r.status === 'rejected')) {
475+
const firstReason = (results[0] as PromiseRejectedResult).reason;
476+
if (firstReason instanceof RequestResponseError) {
477+
reject(createFirebaseError(firstReason));
478+
} else {
479+
reject(firstReason);
480+
}
481+
return;
482+
}
483+
484+
const response: MessagingTopicManagementResponse = {
485+
successCount: 0,
486+
failureCount: 0,
487+
errors: [],
488+
};
489+
490+
results.forEach((result, index) => {
491+
if (result.status === 'fulfilled') {
492+
response.successCount += 1;
493+
} else {
494+
response.failureCount += 1;
495+
const err = result.reason;
496+
const errorCode = err.response?.isJson() ? getErrorCode(err.response.data) : null;
497+
const errorMessage = err.response?.isJson() ? err.response.data?.error?.message : err.message;
498+
const newError = FirebaseMessagingError.fromTopicManagementServerError(
499+
errorCode || 'UNKNOWN',
500+
errorMessage,
501+
err.response?.isJson() ? err.response.data : undefined
502+
);
503+
response.errors.push({
504+
index,
505+
error: newError,
506+
});
507+
}
508+
});
509+
510+
resolve(response);
511+
});
512+
}).finally(() => {
513+
http2SessionHandler.close();
514+
});
515+
});
483516
});
484517
}
485518

0 commit comments

Comments
 (0)