182 lines
6.4 KiB
TypeScript
182 lines
6.4 KiB
TypeScript
import {
|
|
NEVER,
|
|
Observable,
|
|
Subject,
|
|
combineLatest,
|
|
firstValueFrom,
|
|
map,
|
|
mergeWith,
|
|
of,
|
|
shareReplay,
|
|
switchMap,
|
|
tap,
|
|
} from "rxjs";
|
|
import { SemVer } from "semver";
|
|
|
|
import {
|
|
DefaultFeatureFlagValue,
|
|
FeatureFlag,
|
|
FeatureFlagValueType,
|
|
} from "../../../enums/feature-flag.enum";
|
|
import { UserId } from "../../../types/guid";
|
|
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
|
|
import { ConfigService } from "../../abstractions/config/config.service";
|
|
import { ServerConfig } from "../../abstractions/config/server-config";
|
|
import { EnvironmentService, Region } from "../../abstractions/environment.service";
|
|
import { LogService } from "../../abstractions/log.service";
|
|
import { ServerConfigData } from "../../models/data/server-config.data";
|
|
import { CONFIG_DISK, KeyDefinition, StateProvider, UserKeyDefinition } from "../../state";
|
|
|
|
export const RETRIEVAL_INTERVAL = 3_600_000; // 1 hour
|
|
|
|
export type ApiUrl = string;
|
|
|
|
export const USER_SERVER_CONFIG = new UserKeyDefinition<ServerConfig>(CONFIG_DISK, "serverConfig", {
|
|
deserializer: (data) => (data == null ? null : ServerConfig.fromJSON(data)),
|
|
clearOn: ["logout"],
|
|
});
|
|
|
|
export const GLOBAL_SERVER_CONFIGURATIONS = KeyDefinition.record<ServerConfig, ApiUrl>(
|
|
CONFIG_DISK,
|
|
"byServer",
|
|
{
|
|
deserializer: (data) => (data == null ? null : ServerConfig.fromJSON(data)),
|
|
},
|
|
);
|
|
|
|
// FIXME: currently we are limited to api requests for active users. Update to accept a UserId and APIUrl once ApiService supports it.
|
|
export class DefaultConfigService implements ConfigService {
|
|
private failedFetchFallbackSubject = new Subject<ServerConfig>();
|
|
|
|
serverConfig$: Observable<ServerConfig>;
|
|
|
|
cloudRegion$: Observable<Region>;
|
|
|
|
constructor(
|
|
private configApiService: ConfigApiServiceAbstraction,
|
|
private environmentService: EnvironmentService,
|
|
private logService: LogService,
|
|
private stateProvider: StateProvider,
|
|
) {
|
|
const apiUrl$ = this.environmentService.environment$.pipe(
|
|
map((environment) => environment.getApiUrl()),
|
|
);
|
|
|
|
this.serverConfig$ = combineLatest([this.stateProvider.activeUserId$, apiUrl$]).pipe(
|
|
switchMap(([userId, apiUrl]) => {
|
|
const config$ =
|
|
userId == null ? this.globalConfigFor$(apiUrl) : this.userConfigFor$(userId);
|
|
return config$.pipe(map((config) => [config, userId, apiUrl] as const));
|
|
}),
|
|
tap(async (rec) => {
|
|
const [existingConfig, userId, apiUrl] = rec;
|
|
// Grab new config if older retrieval interval
|
|
if (!existingConfig || this.olderThanRetrievalInterval(existingConfig.utcDate)) {
|
|
await this.renewConfig(existingConfig, userId, apiUrl);
|
|
}
|
|
}),
|
|
switchMap(([existingConfig]) => {
|
|
// If we needed to fetch, stop this emit, we'll get a new one after update
|
|
// This is split up with the above tap because we need to return an observable from a failed promise,
|
|
// which isn't very doable since promises are converted to observables in switchMap
|
|
if (!existingConfig || this.olderThanRetrievalInterval(existingConfig.utcDate)) {
|
|
return NEVER;
|
|
}
|
|
return of(existingConfig);
|
|
}),
|
|
// If fetch fails, we'll emit on this subject to fallback to the existing config
|
|
mergeWith(this.failedFetchFallbackSubject),
|
|
shareReplay({ refCount: true, bufferSize: 1 }),
|
|
);
|
|
|
|
this.cloudRegion$ = this.serverConfig$.pipe(
|
|
map((config) => config?.environment?.cloudRegion ?? Region.US),
|
|
);
|
|
}
|
|
|
|
getFeatureFlag$<Flag extends FeatureFlag>(key: Flag) {
|
|
return this.serverConfig$.pipe(
|
|
map((serverConfig) => {
|
|
if (serverConfig?.featureStates == null || serverConfig.featureStates[key] == null) {
|
|
return DefaultFeatureFlagValue[key];
|
|
}
|
|
|
|
return serverConfig.featureStates[key] as FeatureFlagValueType<Flag>;
|
|
}),
|
|
);
|
|
}
|
|
|
|
async getFeatureFlag<Flag extends FeatureFlag>(key: Flag) {
|
|
return await firstValueFrom(this.getFeatureFlag$(key));
|
|
}
|
|
|
|
checkServerMeetsVersionRequirement$(minimumRequiredServerVersion: SemVer) {
|
|
return this.serverConfig$.pipe(
|
|
map((serverConfig) => {
|
|
if (serverConfig == null) {
|
|
return false;
|
|
}
|
|
const serverVersion = new SemVer(serverConfig.version);
|
|
return serverVersion.compare(minimumRequiredServerVersion) >= 0;
|
|
}),
|
|
);
|
|
}
|
|
|
|
async ensureConfigFetched() {
|
|
// Triggering a retrieval for the given user ensures that the config is less than RETRIEVAL_INTERVAL old
|
|
await firstValueFrom(this.serverConfig$);
|
|
}
|
|
|
|
private olderThanRetrievalInterval(date: Date) {
|
|
return new Date().getTime() - date.getTime() > RETRIEVAL_INTERVAL;
|
|
}
|
|
|
|
// Updates the on-disk configuration with a newly retrieved configuration
|
|
private async renewConfig(
|
|
existingConfig: ServerConfig,
|
|
userId: UserId,
|
|
apiUrl: string,
|
|
): Promise<void> {
|
|
try {
|
|
const response = await this.configApiService.get(userId);
|
|
const newConfig = new ServerConfig(new ServerConfigData(response));
|
|
|
|
// Update the environment region
|
|
if (
|
|
newConfig?.environment?.cloudRegion != null &&
|
|
existingConfig?.environment?.cloudRegion != newConfig.environment.cloudRegion
|
|
) {
|
|
// Null userId sets global, otherwise sets to the given user
|
|
await this.environmentService.setCloudRegion(userId, newConfig?.environment?.cloudRegion);
|
|
}
|
|
|
|
if (userId == null) {
|
|
// update global state with new pulled config
|
|
await this.stateProvider.getGlobal(GLOBAL_SERVER_CONFIGURATIONS).update((configs) => {
|
|
return { ...configs, [apiUrl]: newConfig };
|
|
});
|
|
} else {
|
|
// update state with new pulled config
|
|
await this.stateProvider.setUserState(USER_SERVER_CONFIG, newConfig, userId);
|
|
}
|
|
} catch (e) {
|
|
// mutate error to be handled by catchError
|
|
this.logService.error(
|
|
`Unable to fetch ServerConfig from ${apiUrl}: ${(e as Error)?.message}`,
|
|
);
|
|
// Emit the existing config
|
|
this.failedFetchFallbackSubject.next(existingConfig);
|
|
}
|
|
}
|
|
|
|
private globalConfigFor$(apiUrl: string): Observable<ServerConfig> {
|
|
return this.stateProvider
|
|
.getGlobal(GLOBAL_SERVER_CONFIGURATIONS)
|
|
.state$.pipe(map((configs) => configs?.[apiUrl]));
|
|
}
|
|
|
|
private userConfigFor$(userId: UserId): Observable<ServerConfig> {
|
|
return this.stateProvider.getUser(userId, USER_SERVER_CONFIG).state$;
|
|
}
|
|
}
|