1
1
import { Injectable , Inject , Optional , NgZone , InjectionToken , PLATFORM_ID } from '@angular/core' ;
2
- import { Observable , concat , of , empty , pipe , OperatorFunction } from 'rxjs' ;
3
- import { map , switchMap , tap , shareReplay , distinctUntilChanged , filter , groupBy , mergeMap } from 'rxjs/operators' ;
2
+ import { Observable , concat , of , pipe , OperatorFunction , UnaryFunction } from 'rxjs' ;
3
+ import { map , switchMap , tap , shareReplay , distinctUntilChanged , filter , groupBy , mergeMap , scan , withLatestFrom , startWith } from 'rxjs/operators' ;
4
4
import { FirebaseAppConfig , FirebaseOptions , ɵlazySDKProxy , FIREBASE_OPTIONS , FIREBASE_APP_NAME } from '@angular/fire' ;
5
5
import { remoteConfig } from 'firebase/app' ;
6
6
@@ -50,48 +50,24 @@ export class Parameter extends Value {
50
50
}
51
51
}
52
52
53
- type Filter < T , K = { } , M = any > = T extends { [ key :string ] : M } ?
54
- OperatorFunction < T , { [ key :string ] : M & K } > :
55
- OperatorFunction < T , T & K > ;
56
-
57
- const filterKey = ( attribute : any , test : ( param :any ) => boolean ) => pipe (
58
- map ( ( value :Parameter | Record < string , Parameter > ) => {
59
- const param = value [ attribute ] ;
60
- if ( param ) {
61
- if ( test ( param ) ) {
62
- return value ;
63
- } else {
64
- return undefined ;
65
- }
66
- } else {
67
- const filtered = Object . keys ( value ) . reduce ( ( c , k ) => {
68
- if ( test ( value [ k ] [ attribute ] ) ) {
69
- return { ...c , [ k ] : value [ k ] } ;
70
- } else {
71
- return c ;
72
- }
73
- } , { } ) ;
74
- return Object . keys ( filtered ) . length > 0 ? filtered : undefined
75
- }
76
- } ) ,
77
- filter ( a => ! ! a )
78
- ) as any ; // TODO figure out the typing here
79
-
80
- export const filterStatic = < T > ( ) : Filter < T , { _source : 'static' , getSource : ( ) => 'static' } > => filterKey ( '_source' , s => s === 'static' ) ;
81
- export const filterRemote = < T > ( ) : Filter < T , { _source : 'remote' , getSource : ( ) => 'remote' } > => filterKey ( '_source' , s => s === 'remote' ) ;
82
- export const filterDefault = < T > ( ) : Filter < T , { _source : 'default' , getSource : ( ) => 'default' } > => filterKey ( '_source' , s => s === 'default' ) ;
83
-
84
- const DEFAULT_INTERVAL = 60 * 60 * 1000 ; // 1 hour
85
- export const filterFresh = < T > ( howRecentInMillis : number = DEFAULT_INTERVAL ) : OperatorFunction < T , T > => filterKey ( 'fetchTimeMillis' , f => f + howRecentInMillis >= new Date ( ) . getTime ( ) ) ;
53
+ // If it's a Parameter array, test any, else test the individual Parameter
54
+ const filterTest = ( fn : ( param :Parameter ) => boolean ) => filter < Parameter | Parameter [ ] > ( it => Array . isArray ( it ) ? it . some ( fn ) : fn ( it ) )
55
+
56
+ // Allow the user to bypass the default values and wait till they get something from the server, even if it's a cached copy;
57
+ // if used in conjuntion with first() it will only fetch RC values from the server if they aren't cached locally
58
+ export const filterRemote = ( ) => filterTest ( p => p . getSource ( ) === 'remote' ) ;
59
+
60
+ // filterFresh allows the developer to effectively set up a maximum cache time
61
+ export const filterFresh = ( howRecentInMillis : number ) => filterTest ( p => p . fetchTimeMillis + howRecentInMillis >= new Date ( ) . getTime ( ) ) ;
86
62
87
63
@Injectable ( )
88
64
export class AngularFireRemoteConfig {
89
65
90
- readonly changes : Observable < Parameter > ;
91
- readonly values : Observable < Record < string , Parameter > > & Record < string , Observable < Parameter > > ;
92
- readonly numbers : Observable < Record < string , number > > & Record < string , Observable < number > > ;
93
- readonly booleans : Observable < Record < string , boolean > > & Record < string , Observable < boolean > > ;
94
- readonly strings : Observable < Record < string , string > > & Record < string , Observable < string > > ;
66
+ readonly changes : Observable < Parameter > ;
67
+ readonly parameters : Observable < Parameter [ ] > ;
68
+ readonly numbers : Observable < Record < string , number > > & Record < string , Observable < number > > ;
69
+ readonly booleans : Observable < Record < string , boolean > > & Record < string , Observable < boolean > > ;
70
+ readonly strings : Observable < Record < string , string > > & Record < string , Observable < string | undefined > > ;
95
71
96
72
constructor (
97
73
@Inject ( FIREBASE_OPTIONS ) options :FirebaseOptions ,
@@ -101,91 +77,103 @@ export class AngularFireRemoteConfig {
101
77
@Inject ( PLATFORM_ID ) platformId :Object ,
102
78
private zone : NgZone
103
79
) {
104
-
105
- let default$ : Observable < { [ key :string ] : remoteConfig . Value } > = of ( Object . keys ( defaultConfig || { } ) . reduce (
106
- ( c , k ) => ( { ...c , [ k ] : new Value ( "default" , defaultConfig ! [ k ] . toString ( ) ) } ) , { }
107
- ) ) ;
108
-
109
- let _remoteConfig : remoteConfig . RemoteConfig | undefined = undefined ;
110
- const fetchTimeMillis = ( ) => _remoteConfig && _remoteConfig . fetchTimeMillis || - 1 ;
111
80
112
- const remoteConfig = of ( undefined ) . pipe (
81
+ const remoteConfig$ = of ( undefined ) . pipe (
113
82
// @ts -ignore zapping in the UMD in the build script
114
83
switchMap ( ( ) => zone . runOutsideAngular ( ( ) => import ( 'firebase/remote-config' ) ) ) ,
115
84
map ( ( ) => _firebaseAppFactory ( options , zone , nameOrConfig ) ) ,
116
85
// SEMVER no need to cast once we drop older Firebase
117
86
map ( app => < remoteConfig . RemoteConfig > app . remoteConfig ( ) ) ,
118
87
tap ( rc => {
119
88
if ( settings ) { rc . settings = settings }
120
- if ( defaultConfig ) { rc . defaultConfig = defaultConfig }
121
- default$ = empty ( ) ; // once the SDK is loaded, we don't need our defaults anylonger
122
- _remoteConfig = rc ; // hack, keep the state around for easy injection of fetchTimeMillis
89
+ // FYI we don't load the defaults into remote config, since we have our own implementation
90
+ // see the comment on scanToParametersArray
123
91
} ) ,
92
+ startWith ( undefined ) ,
124
93
runOutsideAngular ( zone ) ,
125
- shareReplay ( 1 )
94
+ shareReplay ( { bufferSize : 1 , refCount : false } )
126
95
) ;
127
96
128
- const existing = of ( undefined ) . pipe (
129
- switchMap ( ( ) => remoteConfig ) ,
97
+ const loadedRemoteConfig$ = remoteConfig$ . pipe (
98
+ filter < remoteConfig . RemoteConfig > ( rc => ! ! rc )
99
+ ) ;
100
+
101
+ let default$ : Observable < { [ key :string ] : remoteConfig . Value } > = of ( Object . keys ( defaultConfig || { } ) . reduce (
102
+ ( c , k ) => ( { ...c , [ k ] : new Value ( "default" , defaultConfig ! [ k ] . toString ( ) ) } ) , { }
103
+ ) ) ;
104
+
105
+ const existing$ = loadedRemoteConfig$ . pipe (
130
106
switchMap ( rc => rc . activate ( ) . then ( ( ) => rc . getAll ( ) ) )
131
107
) ;
132
108
133
- let fresh = of ( undefined ) . pipe (
134
- switchMap ( ( ) => remoteConfig ) ,
109
+ const fresh$ = loadedRemoteConfig$ . pipe (
135
110
switchMap ( rc => zone . runOutsideAngular ( ( ) => rc . fetchAndActivate ( ) . then ( ( ) => rc . getAll ( ) ) ) )
136
111
) ;
137
112
138
- const all = concat ( default$ , existing , fresh ) . pipe (
139
- distinctUntilChanged ( ( a , b ) => JSON . stringify ( a ) === JSON . stringify ( b ) ) ,
140
- map ( all => Object . keys ( all ) . reduce ( ( c , k ) => ( { ...c , [ k ] : new Parameter ( k , fetchTimeMillis ( ) , all [ k ] . getSource ( ) , all [ k ] . asString ( ) ) } ) , { } as Record < string , Parameter > ) ) ,
113
+ this . parameters = concat ( default$ , existing$ , fresh$ ) . pipe (
114
+ scanToParametersArray ( remoteConfig$ ) ,
141
115
shareReplay ( { bufferSize : 1 , refCount : true } )
142
116
) ;
143
117
144
- this . changes = all . pipe (
145
- map ( all => Object . values ( all ) ) ,
118
+ this . changes = this . parameters . pipe (
146
119
switchMap ( params => of ( ...params ) ) ,
147
120
groupBy ( param => param . key ) ,
148
121
mergeMap ( group => group . pipe (
149
- distinctUntilChanged ( ( a , b ) => JSON . stringify ( a ) === JSON . stringify ( b ) )
122
+ distinctUntilChanged ( )
150
123
) )
151
124
) ;
152
125
153
- this . values = new Proxy ( all , {
154
- get : ( self , name :string ) => self [ name ] || all . pipe (
155
- map ( rc => rc [ name ] ? rc [ name ] : undefined ) ,
156
- distinctUntilChanged ( ( a , b ) => JSON . stringify ( a ) === JSON . stringify ( b ) )
157
- )
158
- } ) as any ; // TODO types
159
-
160
- // TODO change the any, once i figure out how to type the proxies better
161
- const allAs = ( type : 'Number' | 'Boolean' | 'String' ) => all . pipe (
162
- map ( all => Object . values ( all ) . reduce ( ( c , p ) => ( { ...c , [ p . key ] : p [ `as${ type } ` ] ( ) } ) , { } ) ) ,
163
- distinctUntilChanged ( ( a , b ) => JSON . stringify ( a ) === JSON . stringify ( b ) )
164
- ) as any ;
165
-
166
- this . strings = new Proxy ( allAs ( 'String' ) , {
167
- get : ( self , name :string ) => self [ name ] || all . pipe (
168
- map ( rc => rc [ name ] ? rc [ name ] . asString ( ) : undefined ) ,
169
- distinctUntilChanged ( )
170
- )
171
- } ) ;
172
-
173
- this . booleans = new Proxy ( allAs ( 'Boolean' ) , {
174
- get : ( self , name :string ) => self [ name ] || all . pipe (
175
- map ( rc => rc [ name ] ? rc [ name ] . asBoolean ( ) : false ) ,
176
- distinctUntilChanged ( )
177
- )
178
- } ) ;
179
-
180
- this . numbers = new Proxy ( allAs ( 'Number' ) , {
181
- get : ( self , name :string ) => self [ name ] || all . pipe (
182
- map ( rc => rc [ name ] ? rc [ name ] . asNumber ( ) : 0 ) ,
183
- distinctUntilChanged ( )
184
- )
185
- } ) ;
126
+ this . strings = proxyAll ( this . parameters , 'asString' ) ;
127
+ this . booleans = proxyAll ( this . parameters , 'asBoolean' ) ;
128
+ this . numbers = proxyAll ( this . parameters , 'asNumber' ) ;
186
129
187
130
// TODO fix the proxy for server
188
- return isPlatformServer ( platformId ) ? this : ɵlazySDKProxy ( this , remoteConfig , zone ) ;
131
+ return isPlatformServer ( platformId ) ? this : ɵlazySDKProxy ( this , remoteConfig$ , zone ) ;
189
132
}
190
133
191
134
}
135
+
136
+ // I ditched loading the defaults into RC and a simple map for scan since we already have our own defaults implementation.
137
+ // The idea here being that if they have a default that never loads from the server, they will be able to tell via fetchTimeMillis on the Parameter.
138
+ // Also if it doesn't come from the server it won't emit again in .changes, due to the distinctUntilChanged, which we can simplify to === rather than deep comparison
139
+ const scanToParametersArray = ( remoteConfig : Observable < remoteConfig . RemoteConfig | undefined > ) : OperatorFunction < Record < string , remoteConfig . Value > , Parameter [ ] > => pipe (
140
+ withLatestFrom ( remoteConfig ) ,
141
+ scan ( ( existing , [ all , rc ] ) => {
142
+ // SEMVER use "new Set" to unique once we're only targeting es6
143
+ // at the scale we expect remote config to be at, we probably won't see a performance hit from this unoptimized uniqueness implementation
144
+ // const allKeys = [...new Set([...existing.map(p => p.key), ...Object.keys(all)])];
145
+ const allKeys = [ ...existing . map ( p => p . key ) , ...Object . keys ( all ) ] . filter ( ( v , i , a ) => a . indexOf ( v ) === i ) ;
146
+ return allKeys . map ( key => {
147
+ const updatedValue = all [ key ] ;
148
+ return updatedValue ? new Parameter ( key , rc ? rc . fetchTimeMillis : - 1 , updatedValue . getSource ( ) , updatedValue . asString ( ) )
149
+ : existing . find ( p => p . key === key ) !
150
+ } ) ;
151
+ } , [ ] as Array < Parameter > )
152
+ ) ;
153
+
154
+ const PROXY_DEFAULTS = { 'asNumber' : 0 , 'asBoolean' : false , 'asString' : undefined } ;
155
+
156
+
157
+ function mapToObject ( fn : 'asNumber' ) : UnaryFunction < Observable < Parameter [ ] > , Observable < Record < string , number > > > ;
158
+ function mapToObject ( fn : 'asBoolean' ) : UnaryFunction < Observable < Parameter [ ] > , Observable < Record < string , boolean > > > ;
159
+ function mapToObject ( fn : 'asString' ) : UnaryFunction < Observable < Parameter [ ] > , Observable < Record < string , string | undefined > > > ;
160
+ function mapToObject ( fn : 'asNumber' | 'asBoolean' | 'asString' ) {
161
+ return pipe (
162
+ map ( ( params : Parameter [ ] ) => params . reduce ( ( c , p ) => ( { ...c , [ p . key ] : p [ fn ] ( ) } ) , { } as Record < string , number | boolean | string | undefined > ) ) ,
163
+ distinctUntilChanged ( ( a , b ) => JSON . stringify ( a ) === JSON . stringify ( b ) )
164
+ ) ;
165
+ } ;
166
+
167
+ export const mapAsStrings = ( ) => mapToObject ( 'asString' ) ;
168
+ export const mapAsBooleans = ( ) => mapToObject ( 'asBoolean' ) ;
169
+ export const mapAsNumbers = ( ) => mapToObject ( 'asNumber' ) ;
170
+
171
+ // TODO look into the types here, I don't like the anys
172
+ const proxyAll = ( observable : Observable < Parameter [ ] > , fn : 'asNumber' | 'asBoolean' | 'asString' ) => new Proxy (
173
+ observable . pipe ( mapToObject ( fn as any ) ) , {
174
+ get : ( self , name :string ) => self [ name ] || self . pipe (
175
+ map ( all => all [ name ] || PROXY_DEFAULTS [ fn ] ) ,
176
+ distinctUntilChanged ( )
177
+ )
178
+ }
179
+ ) as any ;
0 commit comments