1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 13:23:34 +00:00

[PM-23062] Fix extra signalr connections (#15432)

* Add `globalEnvironment$` property to `EnvironmentService`

* Update `ConfigService` to emit less and have higher quality fallbacks when no user config is available

* Remove debug code

* Fix strict null problems
This commit is contained in:
Justin Baur
2025-07-14 09:11:56 -04:00
committed by GitHub
parent e51c159505
commit ec7a2613cc
6 changed files with 89 additions and 54 deletions

View File

@@ -5,5 +5,5 @@ export abstract class ConfigApiServiceAbstraction {
/** /**
* Fetches the server configuration for the given user. If no user is provided, the configuration will not contain user-specific context. * Fetches the server configuration for the given user. If no user is provided, the configuration will not contain user-specific context.
*/ */
abstract get(userId: UserId | undefined): Promise<ServerConfigResponse>; abstract get(userId: UserId | null): Promise<ServerConfigResponse>;
} }

View File

@@ -95,6 +95,13 @@ export interface Environment {
*/ */
export abstract class EnvironmentService { export abstract class EnvironmentService {
abstract environment$: Observable<Environment>; abstract environment$: Observable<Environment>;
/**
* The environment stored in global state, when a user signs in the state stored here will become
* their user environment.
*/
abstract globalEnvironment$: Observable<Environment>;
abstract cloudWebVaultUrl$: Observable<string>; abstract cloudWebVaultUrl$: Observable<string>;
/** /**
@@ -125,12 +132,12 @@ export abstract class EnvironmentService {
* @param userId - The user id to set the cloud web vault app URL for. If null or undefined the global environment is set. * @param userId - The user id to set the cloud web vault app URL for. If null or undefined the global environment is set.
* @param region - The region of the cloud web vault app. * @param region - The region of the cloud web vault app.
*/ */
abstract setCloudRegion(userId: UserId, region: Region): Promise<void>; abstract setCloudRegion(userId: UserId | null, region: Region): Promise<void>;
/** /**
* Get the environment from state. Useful if you need to get the environment for another user. * Get the environment from state. Useful if you need to get the environment for another user.
*/ */
abstract getEnvironment$(userId: UserId): Observable<Environment | undefined>; abstract getEnvironment$(userId: UserId): Observable<Environment>;
/** /**
* @deprecated Use {@link getEnvironment$} instead. * @deprecated Use {@link getEnvironment$} instead.

View File

@@ -10,7 +10,7 @@ export class ConfigApiService implements ConfigApiServiceAbstraction {
private tokenService: TokenService, private tokenService: TokenService,
) {} ) {}
async get(userId: UserId | undefined): Promise<ServerConfigResponse> { async get(userId: UserId | null): Promise<ServerConfigResponse> {
// Authentication adds extra context to config responses, if the user has an access token, we want to use it // Authentication adds extra context to config responses, if the user has an access token, we want to use it
// We don't particularly care about ensuring the token is valid and not expired, just that it exists // We don't particularly care about ensuring the token is valid and not expired, just that it exists
const authed: boolean = const authed: boolean =

View File

@@ -10,9 +10,9 @@ import {
FakeGlobalState, FakeGlobalState,
FakeSingleUserState, FakeSingleUserState,
FakeStateProvider, FakeStateProvider,
awaitAsync,
mockAccountServiceWith, mockAccountServiceWith,
} from "../../../../spec"; } from "../../../../spec";
import { Matrix } from "../../../../spec/matrix";
import { subscribeTo } from "../../../../spec/observable-tracker"; import { subscribeTo } from "../../../../spec/observable-tracker";
import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
@@ -74,7 +74,8 @@ describe("ConfigService", () => {
}); });
beforeEach(() => { beforeEach(() => {
environmentService.environment$ = environmentSubject; Matrix.autoMockMethod(environmentService.getEnvironment$, () => environmentSubject);
environmentService.globalEnvironment$ = environmentSubject;
sut = new DefaultConfigService( sut = new DefaultConfigService(
configApiService, configApiService,
environmentService, environmentService,
@@ -98,9 +99,12 @@ describe("ConfigService", () => {
: serverConfigFactory(activeApiUrl + userId, tooOld); : serverConfigFactory(activeApiUrl + userId, tooOld);
const globalStored = const globalStored =
configStateDescription === "missing" configStateDescription === "missing"
? {} ? {
[activeApiUrl]: null,
}
: { : {
[activeApiUrl]: serverConfigFactory(activeApiUrl, tooOld), [activeApiUrl]: serverConfigFactory(activeApiUrl, tooOld),
[activeApiUrl + "0"]: serverConfigFactory(activeApiUrl + userId, tooOld),
}; };
beforeEach(() => { beforeEach(() => {
@@ -108,11 +112,6 @@ describe("ConfigService", () => {
userState.nextState(userStored); userState.nextState(userStored);
}); });
// sanity check
test("authed and unauthorized state are different", () => {
expect(globalStored[activeApiUrl]).not.toEqual(userStored);
});
describe("fail to fetch", () => { describe("fail to fetch", () => {
beforeEach(() => { beforeEach(() => {
configApiService.get.mockRejectedValue(new Error("Unable to fetch")); configApiService.get.mockRejectedValue(new Error("Unable to fetch"));
@@ -178,6 +177,7 @@ describe("ConfigService", () => {
beforeEach(() => { beforeEach(() => {
globalState.stateSubject.next(globalStored); globalState.stateSubject.next(globalStored);
userState.nextState(userStored); userState.nextState(userStored);
Matrix.autoMockMethod(environmentService.getEnvironment$, () => environmentSubject);
}); });
it("does not fetch from server", async () => { it("does not fetch from server", async () => {
await firstValueFrom(sut.serverConfig$); await firstValueFrom(sut.serverConfig$);
@@ -189,21 +189,13 @@ describe("ConfigService", () => {
const actual = await firstValueFrom(sut.serverConfig$); const actual = await firstValueFrom(sut.serverConfig$);
expect(actual).toEqual(activeUserId ? userStored : globalStored[activeApiUrl]); expect(actual).toEqual(activeUserId ? userStored : globalStored[activeApiUrl]);
}); });
it("does not complete after emit", async () => {
const emissions = [];
const subscription = sut.serverConfig$.subscribe((v) => emissions.push(v));
await awaitAsync();
expect(emissions.length).toBe(1);
expect(subscription.closed).toBe(false);
});
}); });
}); });
}); });
it("gets global config when there is an locked active user", async () => { it("gets global config when there is an locked active user", async () => {
await accountService.switchAccount(userId); await accountService.switchAccount(userId);
environmentService.environment$ = of(environmentFactory(activeApiUrl)); environmentService.globalEnvironment$ = of(environmentFactory(activeApiUrl));
globalState.stateSubject.next({ globalState.stateSubject.next({
[activeApiUrl]: serverConfigFactory(activeApiUrl + "global"), [activeApiUrl]: serverConfigFactory(activeApiUrl + "global"),
@@ -236,7 +228,8 @@ describe("ConfigService", () => {
beforeEach(() => { beforeEach(() => {
environmentSubject = new Subject<Environment>(); environmentSubject = new Subject<Environment>();
environmentService.environment$ = environmentSubject; environmentService.globalEnvironment$ = environmentSubject;
Matrix.autoMockMethod(environmentService.getEnvironment$, () => environmentSubject);
sut = new DefaultConfigService( sut = new DefaultConfigService(
configApiService, configApiService,
environmentService, environmentService,
@@ -327,7 +320,8 @@ describe("ConfigService", () => {
beforeEach(async () => { beforeEach(async () => {
const config = serverConfigFactory("existing-data", tooOld); const config = serverConfigFactory("existing-data", tooOld);
environmentService.environment$ = environmentSubject; environmentService.globalEnvironment$ = environmentSubject;
Matrix.autoMockMethod(environmentService.getEnvironment$, () => environmentSubject);
globalState.stateSubject.next({ [apiUrl(0)]: config }); globalState.stateSubject.next({ [apiUrl(0)]: config });
userState.stateSubject.next({ userState.stateSubject.next({

View File

@@ -1,17 +1,18 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { import {
combineLatest, combineLatest,
distinctUntilChanged,
firstValueFrom, firstValueFrom,
map, map,
mergeWith, mergeWith,
NEVER, NEVER,
Observable, Observable,
of, of,
shareReplay, ReplaySubject,
share,
Subject, Subject,
switchMap, switchMap,
tap, tap,
timer,
} from "rxjs"; } from "rxjs";
import { SemVer } from "semver"; import { SemVer } from "semver";
@@ -50,11 +51,15 @@ export const GLOBAL_SERVER_CONFIGURATIONS = KeyDefinition.record<ServerConfig, A
}, },
); );
const environmentComparer = (previous: Environment, current: Environment) => {
return previous.getApiUrl() === current.getApiUrl();
};
// FIXME: currently we are limited to api requests for active users. Update to accept a UserId and APIUrl once ApiService supports it. // 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 { export class DefaultConfigService implements ConfigService {
private failedFetchFallbackSubject = new Subject<ServerConfig>(); private failedFetchFallbackSubject = new Subject<ServerConfig | null>();
serverConfig$: Observable<ServerConfig>; serverConfig$: Observable<ServerConfig | null>;
serverSettings$: Observable<ServerSettings>; serverSettings$: Observable<ServerSettings>;
@@ -67,25 +72,51 @@ export class DefaultConfigService implements ConfigService {
private stateProvider: StateProvider, private stateProvider: StateProvider,
private authService: AuthService, private authService: AuthService,
) { ) {
const userId$ = this.stateProvider.activeUserId$; const globalConfig$ = this.environmentService.globalEnvironment$.pipe(
const authStatus$ = userId$.pipe( distinctUntilChanged(environmentComparer),
switchMap((userId) => (userId == null ? of(null) : this.authService.authStatusFor$(userId))), switchMap((environment) =>
this.globalConfigFor$(environment.getApiUrl()).pipe(
map((config) => {
return [config, null as UserId | null, environment] as const;
}),
),
),
); );
this.serverConfig$ = combineLatest([ this.serverConfig$ = this.stateProvider.activeUserId$.pipe(
userId$, distinctUntilChanged(),
this.environmentService.environment$, switchMap((userId) => {
authStatus$, if (userId == null) {
]).pipe( // Global
switchMap(([userId, environment, authStatus]) => { return globalConfig$;
if (userId == null || authStatus !== AuthenticationStatus.Unlocked) {
return this.globalConfigFor$(environment.getApiUrl()).pipe(
map((config) => [config, null, environment] as const),
);
} }
return this.userConfigFor$(userId).pipe( return this.authService.authStatusFor$(userId).pipe(
map((config) => [config, userId, environment] as const), map((authStatus) => authStatus === AuthenticationStatus.Unlocked),
distinctUntilChanged(),
switchMap((isUnlocked) => {
if (!isUnlocked) {
return globalConfig$;
}
return combineLatest([
this.environmentService
.getEnvironment$(userId)
.pipe(distinctUntilChanged(environmentComparer)),
this.userConfigFor$(userId),
]).pipe(
switchMap(([environment, config]) => {
if (config == null) {
// If the user doesn't have any config yet, use the global config for that url as the fallback
return this.globalConfigFor$(environment.getApiUrl()).pipe(
map((globalConfig) => [globalConfig, userId, environment] as const),
);
}
return of([config, userId, environment] as const);
}),
);
}),
); );
}), }),
tap(async (rec) => { tap(async (rec) => {
@@ -106,7 +137,7 @@ export class DefaultConfigService implements ConfigService {
}), }),
// If fetch fails, we'll emit on this subject to fallback to the existing config // If fetch fails, we'll emit on this subject to fallback to the existing config
mergeWith(this.failedFetchFallbackSubject), mergeWith(this.failedFetchFallbackSubject),
shareReplay({ refCount: true, bufferSize: 1 }), share({ connector: () => new ReplaySubject(1), resetOnRefCountZero: () => timer(1000) }),
); );
this.cloudRegion$ = this.serverConfig$.pipe( this.cloudRegion$ = this.serverConfig$.pipe(
@@ -155,8 +186,8 @@ export class DefaultConfigService implements ConfigService {
// Updates the on-disk configuration with a newly retrieved configuration // Updates the on-disk configuration with a newly retrieved configuration
private async renewConfig( private async renewConfig(
existingConfig: ServerConfig, existingConfig: ServerConfig | null,
userId: UserId, userId: UserId | null,
environment: Environment, environment: Environment,
): Promise<void> { ): Promise<void> {
try { try {
@@ -164,9 +195,7 @@ export class DefaultConfigService implements ConfigService {
// somewhat quickly even though it may not be accurate, we won't cancel the HTTP request // somewhat quickly even though it may not be accurate, we won't cancel the HTTP request
// though so that hopefully it can have finished and hydrated a more accurate value. // though so that hopefully it can have finished and hydrated a more accurate value.
const handle = setTimeout(() => { const handle = setTimeout(() => {
this.logService.info( this.logService.info("Environment did not respond in time, emitting previous config.");
"Self-host environment did not respond in time, emitting previous config.",
);
this.failedFetchFallbackSubject.next(existingConfig); this.failedFetchFallbackSubject.next(existingConfig);
}, SLOW_EMISSION_GUARD); }, SLOW_EMISSION_GUARD);
const response = await this.configApiService.get(userId); const response = await this.configApiService.get(userId);
@@ -199,13 +228,13 @@ export class DefaultConfigService implements ConfigService {
} }
} }
private globalConfigFor$(apiUrl: string): Observable<ServerConfig> { private globalConfigFor$(apiUrl: string): Observable<ServerConfig | null> {
return this.stateProvider return this.stateProvider
.getGlobal(GLOBAL_SERVER_CONFIGURATIONS) .getGlobal(GLOBAL_SERVER_CONFIGURATIONS)
.state$.pipe(map((configs) => configs?.[apiUrl])); .state$.pipe(map((configs) => configs?.[apiUrl] ?? null));
} }
private userConfigFor$(userId: UserId): Observable<ServerConfig> { private userConfigFor$(userId: UserId): Observable<ServerConfig | null> {
return this.stateProvider.getUser(userId, USER_SERVER_CONFIG).state$; return this.stateProvider.getUser(userId, USER_SERVER_CONFIG).state$;
} }
} }

View File

@@ -133,6 +133,7 @@ export class DefaultEnvironmentService implements EnvironmentService {
); );
environment$: Observable<Environment>; environment$: Observable<Environment>;
globalEnvironment$: Observable<Environment>;
cloudWebVaultUrl$: Observable<string>; cloudWebVaultUrl$: Observable<string>;
constructor( constructor(
@@ -148,6 +149,10 @@ export class DefaultEnvironmentService implements EnvironmentService {
distinctUntilChanged((oldUserId: UserId, newUserId: UserId) => oldUserId == newUserId), distinctUntilChanged((oldUserId: UserId, newUserId: UserId) => oldUserId == newUserId),
); );
this.globalEnvironment$ = this.stateProvider
.getGlobal(GLOBAL_ENVIRONMENT_KEY)
.state$.pipe(map((state) => this.buildEnvironment(state?.region, state?.urls)));
this.environment$ = account$.pipe( this.environment$ = account$.pipe(
switchMap((userId) => { switchMap((userId) => {
const t = userId const t = userId
@@ -263,7 +268,7 @@ export class DefaultEnvironmentService implements EnvironmentService {
return new SelfHostedEnvironment(urls); return new SelfHostedEnvironment(urls);
} }
async setCloudRegion(userId: UserId, region: CloudRegion) { async setCloudRegion(userId: UserId | null, region: CloudRegion) {
if (userId == null) { if (userId == null) {
await this.globalCloudRegionState.update(() => region); await this.globalCloudRegionState.update(() => region);
} else { } else {
@@ -271,7 +276,7 @@ export class DefaultEnvironmentService implements EnvironmentService {
} }
} }
getEnvironment$(userId: UserId): Observable<Environment | undefined> { getEnvironment$(userId: UserId): Observable<Environment> {
return this.stateProvider.getUser(userId, USER_ENVIRONMENT_KEY).state$.pipe( return this.stateProvider.getUser(userId, USER_ENVIRONMENT_KEY).state$.pipe(
map((state) => { map((state) => {
return this.buildEnvironment(state?.region, state?.urls); return this.buildEnvironment(state?.region, state?.urls);