Skip to content

Commit 53d07fd

Browse files
committed
adding the realtime notification handling logic.
1 parent a98f140 commit 53d07fd

File tree

7 files changed

+312
-10
lines changed

7 files changed

+312
-10
lines changed

common/api-review/remote-config.api.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,15 @@ export interface FetchResponse {
4242
config?: FirebaseRemoteConfigObject;
4343
eTag?: string;
4444
status: number;
45+
templateVersionNumber?: number;
4546
}
4647

4748
// @public
4849
export type FetchStatus = 'no-fetch-yet' | 'success' | 'failure' | 'throttle';
4950

51+
// @public
52+
export type FetchType = 'BASE' | 'REALTIME';
53+
5054
// @public
5155
export interface FirebaseRemoteConfigObject {
5256
// (undocumented)

packages/remote-config/src/client/realtime_handler.ts

Lines changed: 253 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,30 @@
1717

1818
import { _FirebaseInstallationsInternal } from '@firebase/installations';
1919
import { Logger } from '@firebase/logger';
20-
import { ConfigUpdateObserver } from '../public_types';
20+
import {
21+
ConfigUpdate,
22+
ConfigUpdateObserver,
23+
FetchResponse,
24+
FirebaseRemoteConfigObject
25+
} from '../public_types';
2126
import { calculateBackoffMillis, FirebaseError } from '@firebase/util';
2227
import { ERROR_FACTORY, ErrorCode } from '../errors';
2328
import { Storage } from '../storage/storage';
2429
import { VisibilityMonitor } from './visibility_monitor';
30+
import { StorageCache } from '../storage/storage_cache';
31+
import {
32+
FetchRequest,
33+
RemoteConfigAbortSignal
34+
} from './remote_config_fetch_client';
35+
import { RestClient } from './rest_client';
2536

2637
const API_KEY_HEADER = 'X-Goog-Api-Key';
2738
const INSTALLATIONS_AUTH_TOKEN_HEADER = 'X-Goog-Firebase-Installations-Auth';
2839
const ORIGINAL_RETRIES = 8;
40+
const MAXIMUM_FETCH_ATTEMPTS = 3;
2941
const NO_BACKOFF_TIME_IN_MILLIS = -1;
3042
const NO_FAILED_REALTIME_STREAMS = 0;
43+
const TEMPLATE_VERSION_KEY = 'latestTemplateVersionNumber';
3144

3245
export class RealtimeHandler {
3346
constructor(
@@ -38,7 +51,9 @@ export class RealtimeHandler {
3851
private readonly projectId: string,
3952
private readonly apiKey: string,
4053
private readonly appId: string,
41-
private readonly logger: Logger
54+
private readonly logger: Logger,
55+
private readonly storageCache: StorageCache,
56+
private readonly restClient: RestClient
4257
) {
4358
void this.setRetriesRemaining();
4459
void VisibilityMonitor.getInstance().on(
@@ -56,6 +71,7 @@ export class RealtimeHandler {
5671
private reader: ReadableStreamDefaultReader | undefined;
5772
private httpRetriesRemaining: number = ORIGINAL_RETRIES;
5873
private isInBackground: boolean = false;
74+
private readonly decoder = new TextDecoder('utf-8');
5975

6076
private async setRetriesRemaining(): Promise<void> {
6177
// Retrieve number of remaining retries from last session. The minimum retry count being one.
@@ -229,6 +245,239 @@ export class RealtimeHandler {
229245
return canMakeConnection;
230246
}
231247

248+
private fetchResponseIsUpToDate(
249+
fetchResponse: FetchResponse,
250+
lastKnownVersion: number
251+
): boolean {
252+
if (fetchResponse.config != null && fetchResponse.templateVersionNumber) {
253+
return fetchResponse.templateVersionNumber >= lastKnownVersion;
254+
}
255+
return false;
256+
}
257+
258+
private parseAndValidateConfigUpdateMessage(message: string): string {
259+
const left = message.indexOf('{');
260+
const right = message.indexOf('}', left);
261+
262+
if (left < 0 || right < 0) {
263+
return '';
264+
}
265+
return left >= right ? '' : message.substring(left, right + 1);
266+
}
267+
268+
private isEventListenersEmpty(): boolean {
269+
return this.observers.size === 0;
270+
}
271+
272+
private getRandomInt(max: number): number {
273+
return Math.floor(Math.random() * max);
274+
}
275+
276+
private executeAllListenerCallbacks(configUpdate: ConfigUpdate): void {
277+
this.observers.forEach(observer => observer.next(configUpdate));
278+
}
279+
280+
private getChangedParams(
281+
newConfig: FirebaseRemoteConfigObject,
282+
oldConfig: FirebaseRemoteConfigObject
283+
): Set<string> {
284+
const changed = new Set<string>();
285+
const newKeys = new Set(Object.keys(newConfig || {}));
286+
const oldKeys = new Set(Object.keys(oldConfig || {}));
287+
288+
for (const key of newKeys) {
289+
if (!oldKeys.has(key)) {
290+
changed.add(key);
291+
continue;
292+
}
293+
if (
294+
JSON.stringify((newConfig as any)[key]) !==
295+
JSON.stringify((oldConfig as any)[key])
296+
) {
297+
changed.add(key);
298+
continue;
299+
}
300+
}
301+
302+
for (const key of oldKeys) {
303+
if (!newKeys.has(key)) {
304+
changed.add(key);
305+
}
306+
}
307+
return changed;
308+
}
309+
310+
private async fetchLatestConfig(
311+
remainingAttempts: number,
312+
targetVersion: number
313+
): Promise<void> {
314+
const remainingAttemptsAfterFetch = remainingAttempts - 1;
315+
const currentAttempt = MAXIMUM_FETCH_ATTEMPTS - remainingAttemptsAfterFetch;
316+
const customSignals = this.storageCache.getCustomSignals();
317+
if (customSignals) {
318+
this.logger.debug(
319+
`Fetching config with custom signals: ${JSON.stringify(customSignals)}`
320+
);
321+
}
322+
try {
323+
const fetchRequest: FetchRequest = {
324+
cacheMaxAgeMillis: 0,
325+
signal: new RemoteConfigAbortSignal(),
326+
customSignals: customSignals,
327+
fetchType: 'REALTIME',
328+
fetchAttempt: currentAttempt
329+
};
330+
331+
const fetchResponse: FetchResponse = await this.restClient.fetch(
332+
fetchRequest
333+
);
334+
let activatedConfigs = await this.storage.getActiveConfig();
335+
336+
if (!this.fetchResponseIsUpToDate(fetchResponse, targetVersion)) {
337+
this.logger.debug(
338+
"Fetched template version is the same as SDK's current version." +
339+
' Retrying fetch.'
340+
);
341+
// Continue fetching until template version number is greater than current.
342+
await this.autoFetch(remainingAttemptsAfterFetch, targetVersion);
343+
return;
344+
}
345+
346+
if (fetchResponse.config == null) {
347+
this.logger.debug(
348+
'The fetch succeeded, but the backend had no updates.'
349+
);
350+
return;
351+
}
352+
353+
if (activatedConfigs == null) {
354+
activatedConfigs = {};
355+
}
356+
357+
const updatedKeys = this.getChangedParams(
358+
fetchResponse.config,
359+
activatedConfigs
360+
);
361+
362+
if (updatedKeys.size === 0) {
363+
this.logger.debug('Config was fetched, but no params changed.');
364+
return;
365+
}
366+
367+
const configUpdate: ConfigUpdate = {
368+
getUpdatedKeys(): Set<string> {
369+
return new Set(updatedKeys);
370+
}
371+
};
372+
this.executeAllListenerCallbacks(configUpdate);
373+
} catch (e: unknown) {
374+
const errorMessage = e instanceof Error ? e.message : String(e);
375+
const error = ERROR_FACTORY.create(ErrorCode.CONFIG_UPDATE_NOT_FETCHED, {
376+
originalErrorMessage: `Failed to auto-fetch config update: ${errorMessage}`
377+
});
378+
this.propagateError(error);
379+
}
380+
}
381+
382+
private async autoFetch(
383+
remainingAttempts: number,
384+
targetVersion: number
385+
): Promise<void> {
386+
if (remainingAttempts === 0) {
387+
const error = ERROR_FACTORY.create(ErrorCode.CONFIG_UPDATE_NOT_FETCHED, {
388+
originalErrorMessage:
389+
'Unable to fetch the latest version of the template.'
390+
});
391+
this.propagateError(error);
392+
return;
393+
}
394+
395+
const timeTillFetch = this.getRandomInt(4);
396+
setTimeout(async () => {
397+
await this.fetchLatestConfig(remainingAttempts, targetVersion);
398+
}, timeTillFetch);
399+
}
400+
401+
private async handleNotifications(
402+
reader: ReadableStreamDefaultReader
403+
): Promise<void> {
404+
let partialConfigUpdateMessage: string;
405+
let currentConfigUpdateMessage = '';
406+
407+
while (true) {
408+
const { done, value } = await reader.read();
409+
if (done) {
410+
break;
411+
}
412+
413+
partialConfigUpdateMessage = this.decoder.decode(value, { stream: true });
414+
currentConfigUpdateMessage += partialConfigUpdateMessage;
415+
416+
if (partialConfigUpdateMessage.includes('}')) {
417+
currentConfigUpdateMessage = this.parseAndValidateConfigUpdateMessage(
418+
currentConfigUpdateMessage
419+
);
420+
421+
if (currentConfigUpdateMessage.length === 0) {
422+
continue;
423+
}
424+
try {
425+
const jsonObject = JSON.parse(currentConfigUpdateMessage);
426+
427+
if (this.isEventListenersEmpty()) {
428+
break;
429+
}
430+
431+
if (TEMPLATE_VERSION_KEY in jsonObject) {
432+
const oldTemplateVersion =
433+
await this.storage.getLastKnownTemplateVersion();
434+
let targetTemplateVersion = Number(
435+
jsonObject[TEMPLATE_VERSION_KEY]
436+
);
437+
438+
if (
439+
oldTemplateVersion &&
440+
targetTemplateVersion > oldTemplateVersion
441+
) {
442+
await this.autoFetch(
443+
MAXIMUM_FETCH_ATTEMPTS,
444+
targetTemplateVersion
445+
);
446+
}
447+
}
448+
} catch (e: any) {
449+
this.logger.error('Unable to parse latest config update message.', e);
450+
this.propagateError(
451+
ERROR_FACTORY.create(ErrorCode.CONFIG_UPDATE_MESSAGE_INVALID, {
452+
originalErrorMessage: e
453+
})
454+
);
455+
}
456+
currentConfigUpdateMessage = '';
457+
}
458+
}
459+
}
460+
461+
public async listenForNotifications(
462+
reader: ReadableStreamDefaultReader
463+
): Promise<void> {
464+
try {
465+
await this.handleNotifications(reader);
466+
} catch (e) {
467+
if (!this.isInBackground) {
468+
this.logger.debug(
469+
'Real-time connection was closed due to an exception.',
470+
e
471+
);
472+
}
473+
} finally {
474+
if (this.reader) {
475+
this.reader.cancel();
476+
this.reader = undefined;
477+
}
478+
}
479+
}
480+
232481
/**
233482
* Open the real-time connection, begin listening for updates, and auto-fetch when an update is
234483
* received.
@@ -263,8 +512,8 @@ export class RealtimeHandler {
263512
if (response.ok && response.body) {
264513
this.resetRetryCount();
265514
await this.resetRealtimeBackoff();
266-
//const configAutoFetch = this.startAutoFetch(reader);
267-
//await configAutoFetch.listenForNotifications();
515+
const redaer = response.body.getReader();
516+
this.listenForNotifications(redaer);
268517
}
269518
} catch (error) {
270519
if (this.isInBackground) {

packages/remote-config/src/client/remote_config_fetch_client.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
* limitations under the License.
1616
*/
1717

18-
import { CustomSignals, FetchResponse } from '../public_types';
18+
import { CustomSignals, FetchResponse, FetchType } from '../public_types';
1919

2020
/**
2121
* Defines a client, as in https://en.wikipedia.org/wiki/Client%E2%80%93server_model, for the
@@ -100,4 +100,18 @@ export interface FetchRequest {
100100
* <p>Optional in case no custom signals are set for the instance.
101101
*/
102102
customSignals?: CustomSignals;
103+
104+
/**
105+
* The type of fetch to perform, such as a regular fetch or a real-time fetch.
106+
*
107+
* <p>Optional as not all fetch requests need to be distinguished.
108+
*/
109+
fetchType?: FetchType;
110+
111+
/**
112+
* The number of fetch attempts made so far for this request.
113+
*
114+
* <p>Optional as not all fetch requests are part of a retry series.
115+
*/
116+
fetchAttempt?: number;
103117
}

packages/remote-config/src/client/rest_client.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,16 @@ export class RestClient implements RemoteConfigFetchClient {
8282

8383
const url = `${urlBase}/v1/projects/${this.projectId}/namespaces/${this.namespace}:fetch?key=${this.apiKey}`;
8484

85+
const fetchType = request.fetchType || 'BASE';
86+
const fetchAttempt = request.fetchAttempt || 0;
87+
8588
const headers = {
8689
'Content-Type': 'application/json',
8790
'Content-Encoding': 'gzip',
8891
// Deviates from pure decorator by not passing max-age header since we don't currently have
8992
// service behavior using that header.
90-
'If-None-Match': request.eTag || '*'
93+
'If-None-Match': request.eTag || '*',
94+
'X_FIREBASE_RC_FETCH_TYPE': `${fetchType}/${fetchAttempt}`
9195
};
9296

9397
const requestBody: FetchRequestBody = {
@@ -140,6 +144,7 @@ export class RestClient implements RemoteConfigFetchClient {
140144

141145
let config: FirebaseRemoteConfigObject | undefined;
142146
let state: string | undefined;
147+
let templateVersionNumber: number | undefined;
143148

144149
// JSON parsing throws SyntaxError if the response body isn't a JSON string.
145150
// Requesting application/json and checking for a 200 ensures there's JSON data.
@@ -154,6 +159,7 @@ export class RestClient implements RemoteConfigFetchClient {
154159
}
155160
config = responseBody['entries'];
156161
state = responseBody['state'];
162+
templateVersionNumber = responseBody['templateVersionNumber'];
157163
}
158164

159165
// Normalizes based on legacy state.
@@ -176,6 +182,6 @@ export class RestClient implements RemoteConfigFetchClient {
176182
});
177183
}
178184

179-
return { status, eTag: responseEtag, config };
185+
return { status, eTag: responseEtag, config, templateVersionNumber };
180186
}
181187
}

0 commit comments

Comments
 (0)