feat(infra): framework

This commit is contained in:
EYHN
2024-04-17 14:12:29 +08:00
parent ab17a05df3
commit 06fda3b62c
467 changed files with 9996 additions and 8697 deletions

View File

@@ -0,0 +1,70 @@
import type {
OauthProvidersQuery,
ServerConfigQuery,
ServerFeature,
} from '@affine/graphql';
import {
backoffRetry,
effect,
Entity,
fromPromise,
LiveData,
} from '@toeverything/infra';
import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
import type { ServerConfigStore } from '../stores/server-config';
type LowercaseServerFeature = Lowercase<ServerFeature>;
type ServerFeatureRecord = {
[key in LowercaseServerFeature]: boolean;
};
export type ServerConfigType = ServerConfigQuery['serverConfig'] &
OauthProvidersQuery['serverConfig'];
export class ServerConfig extends Entity {
readonly config$ = new LiveData<ServerConfigType | null>(null);
readonly features$ = this.config$.map(config => {
return config
? Array.from(new Set(config.features)).reduce((acc, cur) => {
acc[cur.toLowerCase() as LowercaseServerFeature] = true;
return acc;
}, {} as ServerFeatureRecord)
: null;
});
readonly credentialsRequirement$ = this.config$.map(config => {
return config ? config.credentialsRequirement : null;
});
constructor(private readonly store: ServerConfigStore) {
super();
}
revalidate = effect(
exhaustMap(() => {
return fromPromise<ServerConfigType>(signal =>
this.store.fetchServerConfig(signal)
).pipe(
backoffRetry({
count: Infinity,
}),
mergeMap(config => {
this.config$.next(config);
return EMPTY;
})
);
})
);
revalidateIfNeeded = () => {
if (!this.config$.value) {
this.revalidate();
}
};
override dispose(): void {
this.revalidate.unsubscribe();
}
}

View File

@@ -0,0 +1,134 @@
import {
backoffRetry,
effect,
Entity,
fromPromise,
LiveData,
onComplete,
onStart,
} from '@toeverything/infra';
import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
import { validateAndReduceImage } from '../../../utils/reduce-image';
import type { AccountProfile, AuthStore } from '../stores/auth';
export interface AuthSessionInfo {
account: AuthAccountInfo;
}
export interface AuthAccountInfo {
id: string;
label: string;
email?: string;
info?: AccountProfile | null;
avatar?: string | null;
}
export interface AuthSessionUnauthenticated {
status: 'unauthenticated';
}
export interface AuthSessionAuthenticated {
status: 'authenticated';
session: AuthSessionInfo;
}
export class AuthSession extends Entity {
id = 'affine-cloud' as const;
session$: LiveData<AuthSessionUnauthenticated | AuthSessionAuthenticated> =
LiveData.from(this.store.watchCachedAuthSession(), null).map(session =>
session
? {
status: 'authenticated',
session: session as AuthSessionInfo,
}
: {
status: 'unauthenticated',
}
);
status$ = this.session$.map(session => session.status);
account$ = this.session$.map(session =>
session.status === 'authenticated' ? session.session.account : null
);
waitForAuthenticated = (signal?: AbortSignal) =>
this.session$.waitFor(
session => session.status === 'authenticated',
signal
) as Promise<AuthSessionAuthenticated>;
isRevalidating$ = new LiveData(false);
constructor(private readonly store: AuthStore) {
super();
}
revalidate = effect(
exhaustMap(() =>
fromPromise(this.getSession()).pipe(
backoffRetry({
count: Infinity,
}),
mergeMap(sessionInfo => {
this.store.setCachedAuthSession(sessionInfo);
return EMPTY;
}),
onStart(() => {
this.isRevalidating$.next(true);
}),
onComplete(() => {
this.isRevalidating$.next(false);
})
)
)
);
private async getSession(): Promise<AuthSessionInfo | null> {
const session = await this.store.fetchSession();
if (session?.user) {
const account = {
id: session.user.id,
email: session.user.email,
label: session.user.name,
avatar: session.user.avatarUrl,
info: session.user,
};
const result = {
account,
};
return result;
} else {
return null;
}
}
async waitForRevalidation() {
this.revalidate();
await this.isRevalidating$.waitFor(isRevalidating => !isRevalidating);
}
async removeAvatar() {
await this.store.removeAvatar();
await this.waitForRevalidation();
}
async uploadAvatar(file: File) {
const reducedFile = await validateAndReduceImage(file);
await this.store.uploadAvatar(reducedFile);
await this.waitForRevalidation();
}
async updateLabel(label: string) {
await this.store.updateLabel(label);
console.log('updateLabel');
await this.waitForRevalidation();
}
override dispose(): void {
this.revalidate.unsubscribe();
}
}

View File

@@ -0,0 +1,69 @@
import type { PricesQuery } from '@affine/graphql';
import {
backoffRetry,
catchErrorInto,
effect,
Entity,
fromPromise,
LiveData,
mapInto,
onComplete,
onStart,
} from '@toeverything/infra';
import { exhaustMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../error';
import type { ServerConfigService } from '../services/server-config';
import type { SubscriptionStore } from '../stores/subscription';
export class SubscriptionPrices extends Entity {
prices$ = new LiveData<PricesQuery['prices'] | null>(null);
isRevalidating$ = new LiveData(false);
error$ = new LiveData<any | null>(null);
proPrice$ = this.prices$.map(prices =>
prices ? prices.find(price => price.plan === 'Pro') : null
);
aiPrice$ = this.prices$.map(prices =>
prices ? prices.find(price => price.plan === 'AI') : null
);
constructor(
private readonly serverConfigService: ServerConfigService,
private readonly store: SubscriptionStore
) {
super();
}
revalidate = effect(
exhaustMap(() => {
return fromPromise(async signal => {
// ensure server config is loaded
this.serverConfigService.serverConfig.revalidateIfNeeded();
const serverConfig =
await this.serverConfigService.serverConfig.features$.waitForNonNull(
signal
);
if (!serverConfig.payment) {
// No payment feature, no subscription
return [];
}
return this.store.fetchSubscriptionPrices(signal);
}).pipe(
backoffRetry({
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
mapInto(this.prices$),
catchErrorInto(this.error$),
onStart(() => this.isRevalidating$.next(true)),
onComplete(() => this.isRevalidating$.next(false))
);
})
);
}

View File

@@ -0,0 +1,176 @@
import type { SubscriptionQuery, SubscriptionRecurring } from '@affine/graphql';
import { SubscriptionPlan } from '@affine/graphql';
import {
backoffRetry,
catchErrorInto,
effect,
Entity,
exhaustMapSwitchUntilChanged,
fromPromise,
LiveData,
onComplete,
onStart,
} from '@toeverything/infra';
import { EMPTY, map, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../error';
import type { AuthService } from '../services/auth';
import type { ServerConfigService } from '../services/server-config';
import type { SubscriptionStore } from '../stores/subscription';
export type SubscriptionType = NonNullable<
SubscriptionQuery['currentUser']
>['subscriptions'][number];
export class Subscription extends Entity {
// undefined means no user, null means loading
subscription$ = new LiveData<SubscriptionType[] | null | undefined>(null);
isRevalidating$ = new LiveData(false);
error$ = new LiveData<any | null>(null);
/**
* Primary subscription is the subscription that is not AI.
*/
primary$ = this.subscription$.map(subscriptions =>
subscriptions
? subscriptions.find(sub => sub.plan !== SubscriptionPlan.AI)
: null
);
isFree$ = this.subscription$.map(subscriptions =>
subscriptions
? subscriptions.some(sub => sub.plan === SubscriptionPlan.Free)
: null
);
isPro$ = this.subscription$.map(subscriptions =>
subscriptions
? subscriptions.some(sub => sub.plan === SubscriptionPlan.Pro)
: null
);
isSelfHosted$ = this.subscription$.map(subscriptions =>
subscriptions
? subscriptions.some(sub => sub.plan === SubscriptionPlan.SelfHosted)
: null
);
ai$ = this.subscription$.map(subscriptions =>
subscriptions
? subscriptions.find(sub => sub.plan === SubscriptionPlan.AI)
: null
);
constructor(
private readonly authService: AuthService,
private readonly serverConfigService: ServerConfigService,
private readonly store: SubscriptionStore
) {
super();
}
async resumeSubscription(idempotencyKey: string, plan?: SubscriptionPlan) {
await this.store.mutateResumeSubscription(idempotencyKey, plan);
await this.waitForRevalidation();
}
async cancelSubscription(idempotencyKey: string, plan?: SubscriptionPlan) {
await this.store.mutateCancelSubscription(idempotencyKey, plan);
await this.waitForRevalidation();
}
async setSubscriptionRecurring(
idempotencyKey: string,
recurring: SubscriptionRecurring,
plan?: SubscriptionPlan
) {
await this.store.setSubscriptionRecurring(idempotencyKey, recurring, plan);
await this.waitForRevalidation();
}
async waitForRevalidation() {
this.revalidate();
await this.isRevalidating$.waitFor(isRevalidating => !isRevalidating);
}
revalidate = effect(
map(() => ({
accountId: this.authService.session.account$.value?.id,
})),
exhaustMapSwitchUntilChanged(
(a, b) => a.accountId === b.accountId,
({ accountId }) => {
return fromPromise(async signal => {
if (!accountId) {
return undefined; // no subscription if no user
}
// ensure server config is loaded
this.serverConfigService.serverConfig.revalidateIfNeeded();
const serverConfig =
await this.serverConfigService.serverConfig.features$.waitForNonNull(
signal
);
if (!serverConfig.payment) {
// No payment feature, no subscription
return {
userId: accountId,
subscriptions: [],
};
}
const { userId, subscriptions } =
await this.store.fetchSubscriptions(signal);
if (userId !== accountId) {
// The user has changed, ignore the result
this.authService.session.revalidate();
await this.authService.session.waitForRevalidation();
return null;
}
return {
userId: userId,
subscriptions: subscriptions,
};
}).pipe(
backoffRetry({
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
mergeMap(data => {
if (data) {
this.store.setCachedSubscriptions(
data.userId,
data.subscriptions
);
this.subscription$.next(data.subscriptions);
} else {
this.subscription$.next(undefined);
}
return EMPTY;
}),
catchErrorInto(this.error$),
onStart(() => this.isRevalidating$.next(true)),
onComplete(() => this.isRevalidating$.next(false))
);
},
({ accountId }) => {
this.reset();
if (!accountId) {
this.subscription$.next(null);
} else {
this.subscription$.next(this.store.getCachedSubscriptions(accountId));
}
}
)
);
reset() {
this.subscription$.next(null);
this.isRevalidating$.next(false);
this.error$.next(null);
}
override dispose(): void {
this.revalidate.unsubscribe();
}
}

View File

@@ -0,0 +1,94 @@
import { FeatureType } from '@affine/graphql';
import {
backoffRetry,
catchErrorInto,
effect,
Entity,
exhaustMapSwitchUntilChanged,
fromPromise,
LiveData,
onComplete,
onStart,
} from '@toeverything/infra';
import { EMPTY, map, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../error';
import type { AuthService } from '../services/auth';
import type { UserFeatureStore } from '../stores/user-feature';
export class UserFeature extends Entity {
// undefined means no user, null means loading
features$ = new LiveData<FeatureType[] | null | undefined>(null);
isEarlyAccess$ = this.features$.map(features =>
features === null
? null
: features?.some(f => f === FeatureType.EarlyAccess)
);
isRevalidating$ = new LiveData(false);
error$ = new LiveData<any | null>(null);
constructor(
private readonly authService: AuthService,
private readonly store: UserFeatureStore
) {
super();
}
revalidate = effect(
map(() => ({
accountId: this.authService.session.account$.value?.id,
})),
exhaustMapSwitchUntilChanged(
(a, b) => a.accountId === b.accountId,
({ accountId }) => {
return fromPromise(async signal => {
if (!accountId) {
return; // no feature if no user
}
const { userId, features } = await this.store.getUserFeatures(signal);
if (userId !== accountId) {
// The user has changed, ignore the result
this.authService.session.revalidate();
await this.authService.session.waitForRevalidation();
return;
}
return {
userId: userId,
features: features,
};
}).pipe(
backoffRetry({
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
mergeMap(data => {
if (data) {
this.features$.next(data.features);
} else {
this.features$.next(null);
}
return EMPTY;
}),
catchErrorInto(this.error$),
onStart(() => this.isRevalidating$.next(true)),
onComplete(() => this.isRevalidating$.next(false))
);
},
() => {
// Reset the state when the user is changed
this.reset();
}
)
);
reset() {
this.features$.next(null);
this.error$.next(null);
this.isRevalidating$.next(false);
}
}

View File

@@ -0,0 +1,131 @@
import type { QuotaQuery } from '@affine/graphql';
import {
backoffRetry,
catchErrorInto,
effect,
Entity,
exhaustMapSwitchUntilChanged,
fromPromise,
LiveData,
onComplete,
onStart,
} from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import bytes from 'bytes';
import { EMPTY, map, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../error';
import type { AuthService } from '../services/auth';
import type { UserQuotaStore } from '../stores/user-quota';
export class UserQuota extends Entity {
quota$ = new LiveData<NonNullable<QuotaQuery['currentUser']>['quota']>(null);
/** Used storage in bytes */
used$ = new LiveData<number | null>(null);
/** Formatted used storage */
usedFormatted$ = this.used$.map(used =>
used !== null ? bytes.format(used) : null
);
/** Maximum storage limit in bytes */
max$ = this.quota$.map(quota => (quota ? quota.storageQuota : null));
/** Maximum storage limit formatted */
maxFormatted$ = this.max$.map(max => (max ? bytes.format(max) : null));
aiActionLimit$ = new LiveData<number | 'unlimited' | null>(null);
aiActionUsed$ = new LiveData<number | null>(null);
/** Percentage of storage used */
percent$ = LiveData.computed(get => {
const max = get(this.max$);
const used = get(this.used$);
if (max === null || used === null) {
return null;
}
return Math.min(
100,
Math.max(0.5, Number(((used / max) * 100).toFixed(4)))
);
});
color$ = this.percent$.map(percent =>
percent !== null
? percent > 80
? cssVar('errorColor')
: cssVar('processingColor')
: null
);
isRevalidating$ = new LiveData(false);
error$ = new LiveData<any | null>(null);
constructor(
private readonly authService: AuthService,
private readonly store: UserQuotaStore
) {
super();
}
revalidate = effect(
map(() => ({
accountId: this.authService.session.account$.value?.id,
})),
exhaustMapSwitchUntilChanged(
(a, b) => a.accountId === b.accountId,
({ accountId }) =>
fromPromise(async signal => {
if (!accountId) {
return; // no quota if no user
}
const { quota, aiQuota, used } =
await this.store.fetchUserQuota(signal);
return { quota, aiQuota, used };
}).pipe(
backoffRetry({
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
}),
mergeMap(data => {
if (data) {
const { aiQuota, quota, used } = data;
this.quota$.next(quota);
this.used$.next(used);
this.aiActionUsed$.next(aiQuota.used);
this.aiActionLimit$.next(
aiQuota.limit === null ? 'unlimited' : aiQuota.limit
); // fix me: unlimited status
} else {
this.quota$.next(null);
this.used$.next(null);
this.aiActionUsed$.next(null);
this.aiActionLimit$.next(null);
}
return EMPTY;
}),
catchErrorInto(this.error$),
onStart(() => this.isRevalidating$.next(true)),
onComplete(() => this.isRevalidating$.next(false))
),
() => {
// Reset the state when the user is changed
this.reset();
}
)
);
reset() {
this.quota$.next(null);
this.used$.next(null);
this.aiActionUsed$.next(null);
this.aiActionLimit$.next(null);
this.error$.next(null);
this.isRevalidating$.next(false);
}
override dispose(): void {
this.revalidate.unsubscribe();
}
}

View File

@@ -0,0 +1,21 @@
export class NetworkError extends Error {
constructor(public readonly originError: Error) {
super(`Network error: ${originError.message}`);
this.stack = originError.stack;
}
}
export function isNetworkError(error: Error): error is NetworkError {
return error instanceof NetworkError;
}
export class BackendError extends Error {
constructor(public readonly originError: Error) {
super(`Server error: ${originError.message}`);
this.stack = originError.stack;
}
}
export function isBackendError(error: Error): error is BackendError {
return error instanceof BackendError;
}

View File

@@ -0,0 +1,64 @@
export type { AuthAccountInfo } from './entities/session';
export {
BackendError,
isBackendError,
isNetworkError,
NetworkError,
} from './error';
export { AccountChanged, AuthService } from './services/auth';
export { FetchService } from './services/fetch';
export { GraphQLService } from './services/graphql';
export { ServerConfigService } from './services/server-config';
export { SubscriptionService } from './services/subscription';
export { UserFeatureService } from './services/user-feature';
export { UserQuotaService } from './services/user-quota';
export { WebSocketService } from './services/websocket';
import {
type Framework,
GlobalCacheService,
GlobalStateService,
} from '@toeverything/infra';
import { ServerConfig } from './entities/server-config';
import { AuthSession } from './entities/session';
import { Subscription } from './entities/subscription';
import { SubscriptionPrices } from './entities/subscription-prices';
import { UserFeature } from './entities/user-feature';
import { UserQuota } from './entities/user-quota';
import { AuthService } from './services/auth';
import { FetchService } from './services/fetch';
import { GraphQLService } from './services/graphql';
import { ServerConfigService } from './services/server-config';
import { SubscriptionService } from './services/subscription';
import { UserFeatureService } from './services/user-feature';
import { UserQuotaService } from './services/user-quota';
import { WebSocketService } from './services/websocket';
import { AuthStore } from './stores/auth';
import { ServerConfigStore } from './stores/server-config';
import { SubscriptionStore } from './stores/subscription';
import { UserFeatureStore } from './stores/user-feature';
import { UserQuotaStore } from './stores/user-quota';
export function configureCloudModule(framework: Framework) {
framework
.service(FetchService)
.service(GraphQLService, [FetchService])
.service(WebSocketService)
.service(ServerConfigService)
.entity(ServerConfig, [ServerConfigStore])
.store(ServerConfigStore, [GraphQLService])
.service(AuthService, [FetchService, AuthStore])
.store(AuthStore, [FetchService, GraphQLService, GlobalStateService])
.entity(AuthSession, [AuthStore])
.service(SubscriptionService, [SubscriptionStore])
.store(SubscriptionStore, [GraphQLService, GlobalCacheService])
.entity(Subscription, [AuthService, ServerConfigService, SubscriptionStore])
.entity(SubscriptionPrices, [ServerConfigService, SubscriptionStore])
.service(UserQuotaService)
.store(UserQuotaStore, [GraphQLService])
.entity(UserQuota, [AuthService, UserQuotaStore])
.service(UserFeatureService)
.entity(UserFeature, [AuthService, UserFeatureStore])
.store(UserFeatureStore, [GraphQLService]);
}

View File

@@ -0,0 +1,161 @@
import { apis } from '@affine/electron-api';
import type { OAuthProviderType } from '@affine/graphql';
import {
ApplicationFocused,
ApplicationStarted,
createEvent,
OnEvent,
Service,
} from '@toeverything/infra';
import { distinctUntilChanged, map, skip } from 'rxjs';
import { type AuthAccountInfo, AuthSession } from '../entities/session';
import type { AuthStore } from '../stores/auth';
import type { FetchService } from './fetch';
// Emit when account changed
export const AccountChanged = createEvent<AuthAccountInfo | null>(
'AccountChanged'
);
export const AccountLoggedIn = createEvent<AuthAccountInfo>('AccountLoggedIn');
export const AccountLoggedOut =
createEvent<AuthAccountInfo>('AccountLoggedOut');
@OnEvent(ApplicationStarted, e => e.onApplicationStart)
@OnEvent(ApplicationFocused, e => e.onApplicationFocused)
export class AuthService extends Service {
session = this.framework.createEntity(AuthSession);
constructor(
private readonly fetchService: FetchService,
private readonly store: AuthStore
) {
super();
this.session.account$
.pipe(
map(a => ({
id: a?.id,
account: a,
})),
distinctUntilChanged((a, b) => a.id === b.id), // only emit when the value changes
skip(1) // skip the initial value
)
.subscribe(({ account }) => {
if (account === null) {
this.eventBus.emit(AccountLoggedOut, account);
} else {
this.eventBus.emit(AccountLoggedIn, account);
}
this.eventBus.emit(AccountChanged, account);
});
}
private onApplicationStart() {
this.session.revalidate();
}
private onApplicationFocused() {
this.session.revalidate();
}
async sendEmailMagicLink(
email: string,
verifyToken: string,
challenge?: string
) {
const searchParams = new URLSearchParams();
if (challenge) {
searchParams.set('challenge', challenge);
}
searchParams.set('token', verifyToken);
const redirectUri = new URL(location.href);
if (environment.isDesktop) {
redirectUri.pathname = this.buildRedirectUri('/open-app/signin-redirect');
}
searchParams.set('redirect_uri', redirectUri.toString());
const res = await this.fetchService.fetch(
'/api/auth/sign-in?' + searchParams.toString(),
{
method: 'POST',
body: JSON.stringify({ email }),
headers: {
'content-type': 'application/json',
},
}
);
if (!res?.ok) {
throw new Error('Failed to send email');
}
}
async signInOauth(provider: OAuthProviderType) {
if (environment.isDesktop) {
await apis?.ui.openExternal(
`${
runtimeConfig.serverUrlPrefix
}/desktop-signin?provider=${provider}&redirect_uri=${this.buildRedirectUri(
'/open-app/signin-redirect'
)}`
);
} else {
location.href = `${
runtimeConfig.serverUrlPrefix
}/oauth/login?provider=${provider}&redirect_uri=${encodeURIComponent(
location.pathname
)}`;
}
return;
}
async signInPassword(credential: { email: string; password: string }) {
const searchParams = new URLSearchParams();
const redirectUri = new URL(location.href);
if (environment.isDesktop) {
redirectUri.pathname = this.buildRedirectUri('/open-app/signin-redirect');
}
searchParams.set('redirect_uri', redirectUri.toString());
const res = await this.fetchService.fetch(
'/api/auth/sign-in?' + searchParams.toString(),
{
method: 'POST',
body: JSON.stringify(credential),
headers: {
'content-type': 'application/json',
},
}
);
if (!res.ok) {
throw new Error('Failed to sign in');
}
this.session.revalidate();
}
async signOut() {
await this.fetchService.fetch('/api/auth/sign-out');
this.store.setCachedAuthSession(null);
this.session.revalidate();
}
private buildRedirectUri(callbackUrl: string) {
const params: string[][] = [];
if (environment.isDesktop && window.appInfo.schema) {
params.push(['schema', window.appInfo.schema]);
}
const query =
params.length > 0
? '?' +
params.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')
: '';
return callbackUrl + query;
}
checkUserByEmail(email: string) {
return this.store.checkUserByEmail(email);
}
}

View File

@@ -0,0 +1,84 @@
import { DebugLogger } from '@affine/debug';
import { fromPromise, Service } from '@toeverything/infra';
import { BackendError, NetworkError } from '../error';
export function getAffineCloudBaseUrl(): string {
if (environment.isDesktop) {
return runtimeConfig.serverUrlPrefix;
}
const { protocol, hostname, port } = window.location;
return `${protocol}//${hostname}${port ? `:${port}` : ''}`;
}
const logger = new DebugLogger('affine:fetch');
export type FetchInit = RequestInit & { timeout?: number };
export class FetchService extends Service {
rxFetch = (
input: string,
init?: RequestInit & {
// https://github.com/microsoft/TypeScript/issues/54472
priority?: 'auto' | 'low' | 'high';
} & {
traceEvent?: string;
}
) => {
return fromPromise(signal => {
return this.fetch(input, { signal, ...init });
});
};
/**
* fetch with custom custom timeout and error handling.
*/
fetch = async (input: string, init?: FetchInit): Promise<Response> => {
logger.debug('fetch', input);
const externalSignal = init?.signal;
if (externalSignal?.aborted) {
throw externalSignal.reason;
}
const abortController = new AbortController();
externalSignal?.addEventListener('abort', () => {
abortController.abort();
});
const timeout = init?.timeout ?? 15000;
const timeoutId = setTimeout(() => {
abortController.abort('timeout');
}, timeout);
const res = await fetch(new URL(input, getAffineCloudBaseUrl()), {
...init,
signal: abortController.signal,
}).catch(err => {
logger.debug('network error', err);
throw new NetworkError(err);
});
clearTimeout(timeoutId);
if (res.status === 504) {
const error = new Error('Gateway Timeout');
logger.debug('network error', error);
throw new NetworkError(error);
}
if (!res.ok) {
logger.warn(
'backend error',
new Error(`${res.status} ${res.statusText}`)
);
let reason: string | any = '';
if (res.headers.get('Content-Type')?.includes('application/json')) {
try {
reason = await res.json();
} catch (err) {
// ignore
}
}
throw new BackendError(
new Error(`${res.status} ${res.statusText}`, reason)
);
}
return res;
};
}

View File

@@ -0,0 +1,53 @@
import {
gqlFetcherFactory,
GraphQLError,
type GraphQLQuery,
type QueryOptions,
type QueryResponse,
} from '@affine/graphql';
import { fromPromise, Service } from '@toeverything/infra';
import type { Observable } from 'rxjs';
import { BackendError } from '../error';
import { AuthService } from './auth';
import type { FetchService } from './fetch';
export class GraphQLService extends Service {
constructor(private readonly fetcher: FetchService) {
super();
}
private readonly rawGql = gqlFetcherFactory('/graphql', this.fetcher.fetch);
rxGql = <Query extends GraphQLQuery>(
options: QueryOptions<Query>
): Observable<QueryResponse<Query>> => {
return fromPromise(signal => {
return this.gql({
...options,
context: {
signal,
...options.context,
},
} as any);
});
};
gql = async <Query extends GraphQLQuery>(
options: QueryOptions<Query>
): Promise<QueryResponse<Query>> => {
try {
return await this.rawGql(options);
} catch (err) {
if (err instanceof Array) {
for (const error of err) {
if (error instanceof GraphQLError && error.extensions?.code === 403) {
this.framework.get(AuthService).session.revalidate();
}
}
throw new BackendError(new Error('Graphql Error'));
}
throw err;
}
};
}

View File

@@ -0,0 +1,12 @@
import { ApplicationStarted, OnEvent, Service } from '@toeverything/infra';
import { ServerConfig } from '../entities/server-config';
@OnEvent(ApplicationStarted, e => e.onApplicationStart)
export class ServerConfigService extends Service {
serverConfig = this.framework.createEntity(ServerConfig);
private onApplicationStart() {
this.serverConfig.revalidate();
}
}

View File

@@ -0,0 +1,25 @@
import { type CreateCheckoutSessionInput } from '@affine/graphql';
import { OnEvent, Service } from '@toeverything/infra';
import { Subscription } from '../entities/subscription';
import { SubscriptionPrices } from '../entities/subscription-prices';
import type { SubscriptionStore } from '../stores/subscription';
import { AccountChanged } from './auth';
@OnEvent(AccountChanged, e => e.onAccountChanged)
export class SubscriptionService extends Service {
subscription = this.framework.createEntity(Subscription);
prices = this.framework.createEntity(SubscriptionPrices);
constructor(private readonly store: SubscriptionStore) {
super();
}
async createCheckoutSession(input: CreateCheckoutSessionInput) {
return await this.store.createCheckoutSession(input);
}
private onAccountChanged() {
this.subscription.revalidate();
}
}

View File

@@ -0,0 +1,13 @@
import { OnEvent, Service } from '@toeverything/infra';
import { UserFeature } from '../entities/user-feature';
import { AccountChanged } from './auth';
@OnEvent(AccountChanged, e => e.onAccountChanged)
export class UserFeatureService extends Service {
userFeature = this.framework.createEntity(UserFeature);
private onAccountChanged() {
this.userFeature.revalidate();
}
}

View File

@@ -0,0 +1,13 @@
import { OnEvent, Service } from '@toeverything/infra';
import { UserQuota } from '../entities/user-quota';
import { AccountChanged } from './auth';
@OnEvent(AccountChanged, e => e.onAccountChanged)
export class UserQuotaService extends Service {
quota = this.framework.createEntity(UserQuota);
private onAccountChanged() {
this.quota.revalidate();
}
}

View File

@@ -0,0 +1,37 @@
import { OnEvent, Service } from '@toeverything/infra';
import type { Socket } from 'socket.io-client';
import { Manager } from 'socket.io-client';
import { getAffineCloudBaseUrl } from '../services/fetch';
import { AccountChanged } from './auth';
@OnEvent(AccountChanged, e => e.reconnect)
export class WebSocketService extends Service {
ioManager: Manager = new Manager(`${getAffineCloudBaseUrl()}/`, {
autoConnect: false,
transports: ['websocket'],
secure: location.protocol === 'https:',
});
sockets: Set<Socket> = new Set();
constructor() {
super();
}
newSocket(): Socket {
const socket = this.ioManager.socket('/');
this.sockets.add(socket);
return socket;
}
reconnect(): void {
for (const socket of this.sockets) {
socket.disconnect();
}
for (const socket of this.sockets) {
socket.connect();
}
}
}

View File

@@ -0,0 +1,97 @@
import {
getUserQuery,
removeAvatarMutation,
updateUserProfileMutation,
uploadAvatarMutation,
} from '@affine/graphql';
import type { GlobalStateService } from '@toeverything/infra';
import { Store } from '@toeverything/infra';
import type { AuthSessionInfo } from '../entities/session';
import type { FetchService } from '../services/fetch';
import type { GraphQLService } from '../services/graphql';
export interface AccountProfile {
id: string;
email: string;
name: string;
hasPassword: boolean;
avatarUrl: string | null;
emailVerified: string | null;
}
export class AuthStore extends Store {
constructor(
private readonly fetchService: FetchService,
private readonly gqlService: GraphQLService,
private readonly globalStateService: GlobalStateService
) {
super();
}
watchCachedAuthSession() {
return this.globalStateService.globalState.watch<AuthSessionInfo>(
'affine-cloud-auth'
);
}
setCachedAuthSession(session: AuthSessionInfo | null) {
this.globalStateService.globalState.set('affine-cloud-auth', session);
}
async fetchSession() {
const url = `/api/auth/session`;
const options: RequestInit = {
headers: {
'Content-Type': 'application/json',
},
};
const res = await this.fetchService.fetch(url, options);
const data = (await res.json()) as {
user?: AccountProfile | null;
};
if (!res.ok)
throw new Error('Get session fetch error: ' + JSON.stringify(data));
return data; // Return null if data empty
}
async uploadAvatar(file: File) {
await this.gqlService.gql({
query: uploadAvatarMutation,
variables: {
avatar: file,
},
});
}
async removeAvatar() {
await this.gqlService.gql({
query: removeAvatarMutation,
});
}
async updateLabel(label: string) {
await this.gqlService.gql({
query: updateUserProfileMutation,
variables: {
input: {
name: label,
},
},
});
}
async checkUserByEmail(email: string) {
const data = await this.gqlService.gql({
query: getUserQuery,
variables: {
email,
},
});
return {
isExist: !!data.user,
hasPassword: !!data.user?.hasPassword,
};
}
}

View File

@@ -0,0 +1,39 @@
import {
oauthProvidersQuery,
serverConfigQuery,
ServerFeature,
} from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { ServerConfigType } from '../entities/server-config';
import type { GraphQLService } from '../services/graphql';
export class ServerConfigStore extends Store {
constructor(private readonly gqlService: GraphQLService) {
super();
}
async fetchServerConfig(
abortSignal?: AbortSignal
): Promise<ServerConfigType> {
const serverConfigData = await this.gqlService.gql({
query: serverConfigQuery,
context: {
signal: abortSignal,
},
});
if (serverConfigData.serverConfig.features.includes(ServerFeature.OAuth)) {
const oauthProvidersData = await this.gqlService.gql({
query: oauthProvidersQuery,
context: {
signal: abortSignal,
},
});
return {
...serverConfigData.serverConfig,
...oauthProvidersData.serverConfig,
};
}
return { ...serverConfigData.serverConfig, oauthProviders: [] };
}
}

View File

@@ -0,0 +1,130 @@
import type {
CreateCheckoutSessionInput,
SubscriptionPlan,
SubscriptionRecurring,
} from '@affine/graphql';
import {
cancelSubscriptionMutation,
createCheckoutSessionMutation,
pricesQuery,
resumeSubscriptionMutation,
subscriptionQuery,
updateSubscriptionMutation,
} from '@affine/graphql';
import type { GlobalCacheService } from '@toeverything/infra';
import { Store } from '@toeverything/infra';
import type { SubscriptionType } from '../entities/subscription';
import type { GraphQLService } from '../services/graphql';
const SUBSCRIPTION_CACHE_KEY = 'subscription:';
export class SubscriptionStore extends Store {
constructor(
private readonly gqlService: GraphQLService,
private readonly globalCacheService: GlobalCacheService
) {
super();
}
async fetchSubscriptions(abortSignal?: AbortSignal) {
const data = await this.gqlService.gql({
query: subscriptionQuery,
context: {
signal: abortSignal,
},
});
if (!data.currentUser) {
throw new Error('No logged in');
}
return {
userId: data.currentUser?.id,
subscriptions: data.currentUser?.subscriptions,
};
}
async mutateResumeSubscription(
idempotencyKey: string,
plan?: SubscriptionPlan,
abortSignal?: AbortSignal
) {
const data = await this.gqlService.gql({
query: resumeSubscriptionMutation,
variables: {
idempotencyKey,
plan,
},
context: {
signal: abortSignal,
},
});
return data.resumeSubscription;
}
async mutateCancelSubscription(
idempotencyKey: string,
plan?: SubscriptionPlan,
abortSignal?: AbortSignal
) {
const data = await this.gqlService.gql({
query: cancelSubscriptionMutation,
variables: {
idempotencyKey,
plan,
},
context: {
signal: abortSignal,
},
});
return data.cancelSubscription;
}
getCachedSubscriptions(userId: string) {
return this.globalCacheService.globalCache.get<SubscriptionType[]>(
SUBSCRIPTION_CACHE_KEY + userId
);
}
setCachedSubscriptions(userId: string, subscriptions: SubscriptionType[]) {
return this.globalCacheService.globalCache.set(
SUBSCRIPTION_CACHE_KEY + userId,
subscriptions
);
}
setSubscriptionRecurring(
idempotencyKey: string,
recurring: SubscriptionRecurring,
plan?: SubscriptionPlan
) {
return this.gqlService.gql({
query: updateSubscriptionMutation,
variables: {
idempotencyKey,
plan,
recurring,
},
});
}
async createCheckoutSession(input: CreateCheckoutSessionInput) {
const data = await this.gqlService.gql({
query: createCheckoutSessionMutation,
variables: { input },
});
return data.createCheckoutSession;
}
async fetchSubscriptionPrices(abortSignal?: AbortSignal) {
const data = await this.gqlService.gql({
query: pricesQuery,
context: {
signal: abortSignal,
},
});
return data.prices;
}
}

View File

@@ -0,0 +1,23 @@
import { getUserFeaturesQuery } from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { GraphQLService } from '../services/graphql';
export class UserFeatureStore extends Store {
constructor(private readonly gqlService: GraphQLService) {
super();
}
async getUserFeatures(signal: AbortSignal) {
const data = await this.gqlService.gql({
query: getUserFeaturesQuery,
context: {
signal,
},
});
return {
userId: data.currentUser?.id,
features: data.currentUser?.features,
};
}
}

View File

@@ -0,0 +1,30 @@
import { quotaQuery } from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { GraphQLService } from '../services/graphql';
export class UserQuotaStore extends Store {
constructor(private readonly graphqlService: GraphQLService) {
super();
}
async fetchUserQuota(abortSignal?: AbortSignal) {
const data = await this.graphqlService.gql({
query: quotaQuery,
context: {
signal: abortSignal,
},
});
if (!data.currentUser) {
throw new Error('No logged in');
}
return {
userId: data.currentUser.id,
aiQuota: data.currentUser.copilot.quota,
quota: data.currentUser.quota,
used: data.collectAllBlobSizes.size,
};
}
}