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
+ }
0 commit comments