Skip to content

Commit 22ba366

Browse files
committed
added the api-definition, interfaces, realtime connection and backoffLogic
1 parent e25317f commit 22ba366

File tree

7 files changed

+400
-11
lines changed

7 files changed

+400
-11
lines changed

packages/remote-config/src/api.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ import {
2222
LogLevel as RemoteConfigLogLevel,
2323
RemoteConfig,
2424
Value,
25-
RemoteConfigOptions
25+
RemoteConfigOptions,
26+
ConfigUpdateObserver,
27+
Unsubscribe
2628
} from './public_types';
2729
import { RemoteConfigAbortSignal } from './client/remote_config_fetch_client';
2830
import {
@@ -256,7 +258,7 @@ export function getValue(remoteConfig: RemoteConfig, key: string): Value {
256258
if (!rc._isInitializationComplete) {
257259
rc._logger.debug(
258260
`A value was requested for key "${key}" before SDK initialization completed.` +
259-
' Await on ensureInitialized if the intent was to get a previously activated value.'
261+
' Await on ensureInitialized if the intent was to get a previously activated value.'
260262
);
261263
}
262264
const activeConfig = rc._storageCache.getActiveConfig();
@@ -267,7 +269,7 @@ export function getValue(remoteConfig: RemoteConfig, key: string): Value {
267269
}
268270
rc._logger.debug(
269271
`Returning static value for key "${key}".` +
270-
' Define a default or remote value if this is unintentional.'
272+
' Define a default or remote value if this is unintentional.'
271273
);
272274
return new ValueImpl('static');
273275
}
@@ -351,3 +353,23 @@ export async function setCustomSignals(
351353
);
352354
}
353355
}
356+
357+
/**
358+
* Registers a real-time listener for Remote Config updates.
359+
*
360+
* @param remoteConfig - The {@link RemoteConfig} instance.
361+
* @param observer - The {@link ConfigUpdateObserver} to be notified of config updates.
362+
* @returns An {@link Unsubscribe} function to remove the listener.
363+
*
364+
* @public
365+
*/
366+
export function onConfigUpdate(
367+
remoteConfig: RemoteConfig,
368+
observer: ConfigUpdateObserver
369+
): Unsubscribe {
370+
const rc = getModularInstance(remoteConfig) as RemoteConfigImpl;
371+
rc._realtimeHandler.addObserver(observer);
372+
return () => {
373+
rc._realtimeHandler.removeObserver(observer);
374+
};
375+
}
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { _FirebaseInstallationsInternal } from "@firebase/installations";
19+
import { ConfigUpdate, ConfigUpdateObserver } from "../public_types";
20+
import { calculateBackoffMillis, FirebaseError } from "@firebase/util";
21+
import { ERROR_FACTORY, ErrorCode } from "../errors";
22+
import { Storage } from "../storage/storage";
23+
const ORIGINAL_RETRIES = 8;
24+
const API_KEY_HEADER = 'X-Goog-Api-Key';
25+
const INSTALLATIONS_AUTH_TOKEN_HEADER = 'X-Goog-Firebase-Installations-Auth';
26+
export class RealtimeHandler {
27+
constructor(
28+
private readonly firebaseInstallations: _FirebaseInstallationsInternal,
29+
private readonly storage: Storage,
30+
private readonly sdkVersion: string,
31+
private readonly namespace: string,
32+
private readonly projectId: string,
33+
private readonly apiKey: string,
34+
private readonly appId: string,
35+
) { }
36+
37+
private observers: Set<ConfigUpdateObserver> = new Set<ConfigUpdateObserver>();
38+
private isConnectionActive: boolean = false;
39+
private retriesRemaining: number = ORIGINAL_RETRIES;
40+
private isRealtimeDisabled: boolean = false;
41+
private scheduledConnectionTimeoutId?: ReturnType<typeof setTimeout>;
42+
private controller?: AbortController;
43+
private reader: ReadableStreamDefaultReader | undefined;
44+
45+
/**
46+
* Adds an observer to the realtime updates.
47+
* @param observer The observer to add.
48+
*/
49+
async addObserver(observer: ConfigUpdateObserver): Promise<void> {
50+
this.observers.add(observer);
51+
await this.beginRealtime();
52+
}
53+
54+
/**
55+
* Removes an observer from the realtime updates.
56+
* @param observer The observer to remove.
57+
*/
58+
removeObserver(observer: ConfigUpdateObserver): void {
59+
if (this.observers.has(observer)) {
60+
this.observers.delete(observer);
61+
}
62+
if (this.observers.size === 0) {
63+
// this.stopRealtime();
64+
}
65+
}
66+
67+
/**
68+
* Checks whether connection can be made or not based on some conditions
69+
* @returns booelean
70+
*/
71+
private canEstablishStreamConnection(): boolean {
72+
const hasActiveListeners = this.observers.size > 0;
73+
const isNotDisabled = !this.isRealtimeDisabled;
74+
const isNoConnectionActive = !this.isConnectionActive;
75+
return hasActiveListeners && isNotDisabled && isNoConnectionActive;
76+
}
77+
78+
private async beginRealtime(): Promise<void> {
79+
if (this.observers.size > 0) {
80+
await this.makeRealtimeHttpConnection(0);
81+
}
82+
}
83+
84+
private async makeRealtimeHttpConnection(delayMillis: number): Promise<void> {
85+
if (!this.canEstablishStreamConnection()) {
86+
return;
87+
}
88+
if (this.retriesRemaining > 0) {
89+
this.retriesRemaining--;
90+
console.log(this.retriesRemaining);
91+
this.scheduledConnectionTimeoutId = setTimeout(async () => {
92+
await this.beginRealtimeHttpStream();
93+
}, delayMillis);
94+
}
95+
}
96+
97+
private propagateError = (e: FirebaseError) => this.observers.forEach(o => o.error?.(e));
98+
99+
private checkAndSetHttpConnectionFlagIfNotRunning(): boolean {
100+
let canMakeConnection: boolean;
101+
canMakeConnection = this.canEstablishStreamConnection();
102+
if (canMakeConnection) {
103+
this.setIsHttpConnectionRunning(true);
104+
}
105+
return canMakeConnection;
106+
}
107+
108+
private setIsHttpConnectionRunning(connectionRunning: boolean): void {
109+
this.isConnectionActive = connectionRunning;
110+
}
111+
112+
private async beginRealtimeHttpStream(): Promise<void> {
113+
if (!this.checkAndSetHttpConnectionFlagIfNotRunning()) {
114+
return;
115+
}
116+
const metadataFromStorage = await this.storage.getRealtimeBackoffMetadata();
117+
let metadata;
118+
if (metadataFromStorage) {
119+
metadata = metadataFromStorage;
120+
} else {
121+
metadata = {
122+
backoffEndTimeMillis: new Date(-1),
123+
numFailedStreams: 0
124+
}
125+
await this.storage.setRealtimeBackoffMetadata(metadata);
126+
}
127+
const backoffEndTime = metadata.backoffEndTimeMillis.getTime();
128+
129+
if (Date.now() < backoffEndTime) {
130+
await this.retryHttpConnectionWhenBackoffEnds();
131+
return;
132+
}
133+
134+
let response: Response | undefined;
135+
let responseCode: number | undefined;
136+
137+
try {
138+
response = await this.createRealtimeConnection();
139+
responseCode = response.status;
140+
141+
if (response.ok && response.body) {
142+
this.resetRetryCount();
143+
await this.resetRealtimeBackoff();
144+
//const configAutoFetch = this.startAutoFetch(reader);
145+
//await configAutoFetch.listenForNotifications();
146+
}
147+
}
148+
catch (error) {
149+
console.error('Exception connecting to real-time RC backend. Retrying the connection...:', error);
150+
}
151+
finally {
152+
this.closeRealtimeHttpConnection();
153+
this.setIsHttpConnectionRunning(false);
154+
const connectionFailed = responseCode == null || this.isStatusCodeRetryable(responseCode);
155+
156+
if (connectionFailed) {
157+
await this.updateBackoffMetadataWithLastFailedStreamConnectionTime(new Date());
158+
}
159+
160+
if (connectionFailed || response?.ok) {
161+
await this.retryHttpConnectionWhenBackoffEnds();
162+
} else {
163+
let errorMessage = `Unable to connect to the server. HTTP status code: ${responseCode}`;
164+
if (responseCode === 403) {
165+
if (response) {
166+
errorMessage = await this.parseForbiddenErrorResponseMessage(response);
167+
}
168+
}
169+
const firebaseError = ERROR_FACTORY.create(ErrorCode.CONFIG_UPDATE_STREAM_ERROR, {
170+
httpStatus: responseCode,
171+
originalErrorMessage: errorMessage
172+
});
173+
this.propagateError(firebaseError);
174+
}
175+
}
176+
}
177+
178+
private async retryHttpConnectionWhenBackoffEnds(): Promise<void> {
179+
const metadataFromStorage = await this.storage.getRealtimeBackoffMetadata();
180+
let metadata;
181+
if (metadataFromStorage) {
182+
metadata = metadataFromStorage;
183+
} else {
184+
metadata = {
185+
backoffEndTimeMillis: new Date(-1),
186+
numFailedStreams: 0
187+
}
188+
await this.storage.setRealtimeBackoffMetadata(metadata);
189+
}
190+
const backoffEndTime = new Date(metadata.backoffEndTimeMillis).getTime();
191+
const currentTime = Date.now();
192+
const retryMillis = Math.max(0, backoffEndTime - currentTime);
193+
this.makeRealtimeHttpConnection(retryMillis);
194+
}
195+
196+
private async resetRealtimeBackoff(): Promise<void> {
197+
await this.storage.setRealtimeBackoffMetadata({
198+
backoffEndTimeMillis: new Date(-1),
199+
numFailedStreams: 0
200+
});
201+
}
202+
203+
private resetRetryCount(): void {
204+
this.retriesRemaining = ORIGINAL_RETRIES;
205+
}
206+
207+
private isStatusCodeRetryable = (sc?: number) => !sc || [408, 429, 502, 503, 504].includes(sc);
208+
209+
private async updateBackoffMetadataWithLastFailedStreamConnectionTime(lastFailedStreamTime: Date): Promise<void> {
210+
const numFailedStreams = ((await this.storage.getRealtimeBackoffMetadata())?.numFailedStreams || 0) + 1;
211+
const backoffMillis = calculateBackoffMillis(numFailedStreams);
212+
await this.storage.setRealtimeBackoffMetadata({
213+
backoffEndTimeMillis: new Date(lastFailedStreamTime.getTime() + backoffMillis),
214+
numFailedStreams
215+
});
216+
}
217+
218+
private async createRealtimeConnection(): Promise<Response> {
219+
this.controller = new AbortController();
220+
const [installationId, installationTokenResult] = await Promise.all([
221+
this.firebaseInstallations.getId(),
222+
this.firebaseInstallations.getToken(false)
223+
]);
224+
let response: Response;
225+
const url = this.getRealtimeUrl();
226+
response = await this.setRequestParams(url, installationId, installationTokenResult, this.controller.signal);
227+
return response;
228+
}
229+
230+
private getRealtimeUrl(): URL {
231+
const urlBase =
232+
window.FIREBASE_REMOTE_CONFIG_URL_BASE ||
233+
'https://firebaseremoteconfigrealtime.googleapis.com';
234+
235+
const urlString = `${urlBase}/v1/projects/${this.projectId}/namespaces/${this.namespace}:streamFetchInvalidations?key=${this.apiKey}`;
236+
return new URL(urlString);
237+
}
238+
239+
private async setRequestParams(url: URL, installationId: string, installationTokenResult: string, signal: AbortSignal): Promise<Response> {
240+
const eTagValue = await this.storage.getActiveConfigEtag();
241+
const headers = {
242+
[API_KEY_HEADER]: this.apiKey,
243+
[INSTALLATIONS_AUTH_TOKEN_HEADER]: installationTokenResult,
244+
'Content-Type': 'application/json',
245+
'Accept': 'application/json',
246+
'If-None-Match': eTagValue || '*',
247+
'Content-Encoding': 'gzip',
248+
};
249+
const requestBody = {
250+
project: this.projectId,
251+
namespace: this.namespace,
252+
lastKnownVersionNumber: await this.storage.getLastKnownTemplateVersion(),
253+
appId: this.appId,
254+
sdkVersion: this.sdkVersion,
255+
appInstanceId: installationId
256+
};
257+
258+
const response = await fetch(url, {
259+
method: 'POST',
260+
headers,
261+
body: JSON.stringify(requestBody),
262+
signal: signal
263+
});
264+
return response;
265+
}
266+
267+
private parseForbiddenErrorResponseMessage(response: Response): Promise<string> {
268+
const error = response.text();
269+
return error;
270+
}
271+
272+
private closeRealtimeHttpConnection(): void {
273+
if (this.controller) {
274+
this.controller.abort();
275+
this.controller = undefined;
276+
}
277+
278+
if (this.reader) {
279+
this.reader.cancel();
280+
this.reader = undefined;
281+
}
282+
}
283+
}

packages/remote-config/src/errors.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ export const enum ErrorCode {
3333
FETCH_PARSE = 'fetch-client-parse',
3434
FETCH_STATUS = 'fetch-status',
3535
INDEXED_DB_UNAVAILABLE = 'indexed-db-unavailable',
36-
CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS = 'custom-signal-max-allowed-signals'
36+
CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS = 'custom-signal-max-allowed-signals',
37+
CONFIG_UPDATE_STREAM_ERROR = 'stream-error'
3738
}
3839

3940
const ERROR_DESCRIPTION_MAP: { readonly [key in ErrorCode]: string } = {
@@ -72,7 +73,9 @@ const ERROR_DESCRIPTION_MAP: { readonly [key in ErrorCode]: string } = {
7273
[ErrorCode.INDEXED_DB_UNAVAILABLE]:
7374
'Indexed DB is not supported by current browser',
7475
[ErrorCode.CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS]:
75-
'Setting more than {$maxSignals} custom signals is not supported.'
76+
'Setting more than {$maxSignals} custom signals is not supported.',
77+
[ErrorCode.CONFIG_UPDATE_STREAM_ERROR]:
78+
'The stream was not able to connect to the backend.',
7679
};
7780

7881
// Note this is effectively a type system binding a code to params. This approach overlaps with the
@@ -92,6 +95,7 @@ interface ErrorParams {
9295
[ErrorCode.FETCH_PARSE]: { originalErrorMessage: string };
9396
[ErrorCode.FETCH_STATUS]: { httpStatus: number };
9497
[ErrorCode.CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS]: { maxSignals: number };
98+
[ErrorCode.CONFIG_UPDATE_STREAM_ERROR]: { httpStatus?: number; originalErrorMessage?: string };
9599
}
96100

97101
export const ERROR_FACTORY = new ErrorFactory<ErrorCode, ErrorParams>(

0 commit comments

Comments
 (0)