Skip to content

Commit e2d83c8

Browse files
committedDec 16, 2019
New RC API
1 parent 3c1ad1f commit e2d83c8

File tree

2 files changed

+85
-97
lines changed

2 files changed

+85
-97
lines changed
 

‎src/remote-config/public_api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
export * from './remote-config';
2-
export * from './remote-config.module';
2+
export * from './remote-config.module';

‎src/remote-config/remote-config.ts

Lines changed: 84 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
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';
44
import { FirebaseAppConfig, FirebaseOptions, ɵlazySDKProxy, FIREBASE_OPTIONS, FIREBASE_APP_NAME } from '@angular/fire';
55
import { remoteConfig } from 'firebase/app';
66

@@ -50,48 +50,24 @@ export class Parameter extends Value {
5050
}
5151
}
5252

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());
8662

8763
@Injectable()
8864
export class AngularFireRemoteConfig {
8965

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>>;
9571

9672
constructor(
9773
@Inject(FIREBASE_OPTIONS) options:FirebaseOptions,
@@ -101,91 +77,103 @@ export class AngularFireRemoteConfig {
10177
@Inject(PLATFORM_ID) platformId:Object,
10278
private zone: NgZone
10379
) {
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;
11180

112-
const remoteConfig = of(undefined).pipe(
81+
const remoteConfig$ = of(undefined).pipe(
11382
// @ts-ignore zapping in the UMD in the build script
11483
switchMap(() => zone.runOutsideAngular(() => import('firebase/remote-config'))),
11584
map(() => _firebaseAppFactory(options, zone, nameOrConfig)),
11685
// SEMVER no need to cast once we drop older Firebase
11786
map(app => <remoteConfig.RemoteConfig>app.remoteConfig()),
11887
tap(rc => {
11988
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
12391
}),
92+
startWith(undefined),
12493
runOutsideAngular(zone),
125-
shareReplay(1)
94+
shareReplay({ bufferSize: 1, refCount: false })
12695
);
12796

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(
130106
switchMap(rc => rc.activate().then(() => rc.getAll()))
131107
);
132108

133-
let fresh = of(undefined).pipe(
134-
switchMap(() => remoteConfig),
109+
const fresh$ = loadedRemoteConfig$.pipe(
135110
switchMap(rc => zone.runOutsideAngular(() => rc.fetchAndActivate().then(() => rc.getAll())))
136111
);
137112

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$),
141115
shareReplay({ bufferSize: 1, refCount: true })
142116
);
143117

144-
this.changes = all.pipe(
145-
map(all => Object.values(all)),
118+
this.changes = this.parameters.pipe(
146119
switchMap(params => of(...params)),
147120
groupBy(param => param.key),
148121
mergeMap(group => group.pipe(
149-
distinctUntilChanged((a,b) => JSON.stringify(a) === JSON.stringify(b))
122+
distinctUntilChanged()
150123
))
151124
);
152125

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');
186129

187130
// TODO fix the proxy for server
188-
return isPlatformServer(platformId) ? this : ɵlazySDKProxy(this, remoteConfig, zone);
131+
return isPlatformServer(platformId) ? this : ɵlazySDKProxy(this, remoteConfig$, zone);
189132
}
190133

191134
}
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

Comments
 (0)