refactor(core): initial multiple servers infra (#8745)

This is the initial refactoring of affine to support multiple servers, but many more changes are needed to make multi-server actually work.
This commit is contained in:
EYHN
2024-11-27 06:44:46 +00:00
parent 3f4cb5be40
commit 6b4a1aa917
80 changed files with 1141 additions and 519 deletions
@@ -1,7 +1,7 @@
import { Button, ErrorMessage, Skeleton } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import {
ServerConfigService,
ServerService,
SubscriptionService,
UserCopilotQuotaService,
} from '@affine/core/modules/cloud';
@@ -22,9 +22,9 @@ export const AIUsagePanel = ({
onChangeSettingState?: (settingState: SettingState) => void;
}) => {
const t = useI18n();
const serverConfigService = useService(ServerConfigService);
const serverService = useService(ServerService);
const hasPaymentFeature = useLiveData(
serverConfigService.serverConfig.features$.map(f => f?.payment)
serverService.server.features$.map(f => f?.payment)
);
const subscriptionService = useService(SubscriptionService);
const aiSubscription = useLiveData(subscriptionService.subscription.ai$);
@@ -23,7 +23,7 @@ import {
import { useSetAtom } from 'jotai';
import { useCallback, useEffect, useState } from 'react';
import { AuthService, ServerConfigService } from '../../../../modules/cloud';
import { AuthService, ServerService } from '../../../../modules/cloud';
import type { SettingState } from '../types';
import { AIUsagePanel } from './ai-usage-panel';
import { StorageProgress } from './storage-progress';
@@ -178,13 +178,11 @@ export const AccountSetting = ({
}: {
onChangeSettingState?: (settingState: SettingState) => void;
}) => {
const { authService, serverConfigService } = useServices({
const { authService, serverService } = useServices({
AuthService,
ServerConfigService,
ServerService,
});
const serverFeatures = useLiveData(
serverConfigService.serverConfig.features$
);
const serverFeatures = useLiveData(serverService.server.features$);
const t = useI18n();
const session = authService.session;
useEffect(() => {
@@ -5,7 +5,7 @@ import { cssVar } from '@toeverything/theme';
import { useEffect, useMemo } from 'react';
import {
ServerConfigService,
ServerService,
SubscriptionService,
UserQuotaService,
} from '../../../../modules/cloud';
@@ -34,9 +34,9 @@ export const StorageProgress = ({ onUpgrade }: StorageProgressProgress) => {
const maxFormatted = useLiveData(quota.maxFormatted$);
const percent = useLiveData(quota.percent$);
const serverConfigService = useService(ServerConfigService);
const serverService = useService(ServerService);
const hasPaymentFeature = useLiveData(
serverConfigService.serverConfig.features$.map(f => f?.payment)
serverService.server.features$.map(f => f?.payment)
);
const subscription = useService(SubscriptionService).subscription;
useEffect(() => {
@@ -16,7 +16,7 @@ import {
SettingWrapper,
} from '@affine/component/setting-components';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { ServerConfigService } from '@affine/core/modules/cloud';
import { ServerService } from '@affine/core/modules/cloud';
import { DesktopApiService } from '@affine/core/modules/desktop-api';
import {
EditorSettingService,
@@ -358,13 +358,11 @@ const NewDocDefaultModeSettings = () => {
const AISettings = () => {
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const { featureFlagService, serverConfigService } = useServices({
const { featureFlagService, serverService } = useServices({
FeatureFlagService,
ServerConfigService,
ServerService,
});
const serverFeatures = useLiveData(
serverConfigService.serverConfig.features$
);
const serverFeatures = useLiveData(serverService.server.features$);
const enableAI = useLiveData(featureFlagService.flags.enable_ai.$);
const onAIChange = useCallback(
@@ -16,7 +16,7 @@ import {
import type { ReactElement, SVGProps } from 'react';
import { useEffect } from 'react';
import { AuthService, ServerConfigService } from '../../../../modules/cloud';
import { AuthService, ServerService } from '../../../../modules/cloud';
import type { SettingState } from '../types';
import { AboutAffine } from './about';
import { AppearanceSettings } from './appearance';
@@ -38,20 +38,16 @@ export type GeneralSettingList = GeneralSettingListItem[];
export const useGeneralSettingList = (): GeneralSettingList => {
const t = useI18n();
const {
authService,
serverConfigService,
userFeatureService,
featureFlagService,
} = useServices({
AuthService,
ServerConfigService,
UserFeatureService,
FeatureFlagService,
});
const { authService, serverService, userFeatureService, featureFlagService } =
useServices({
AuthService,
ServerService,
UserFeatureService,
FeatureFlagService,
});
const status = useLiveData(authService.session.status$);
const hasPaymentFeature = useLiveData(
serverConfigService.serverConfig.features$.map(f => f?.payment)
serverService.server.features$.map(f => f?.payment)
);
const enableEditorSettings = useLiveData(
featureFlagService.flags.enable_editor_settings.$
@@ -40,7 +40,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import {
type AuthAccountInfo,
AuthService,
ServerConfigService,
ServerService,
SubscriptionService,
} from '../../../../../modules/cloud';
import type { SettingState } from '../../types';
@@ -65,9 +65,9 @@ export const CloudWorkspaceMembersPanel = ({
}: {
onChangeSettingState: (settingState: SettingState) => void;
}) => {
const serverConfig = useService(ServerConfigService).serverConfig;
const serverService = useService(ServerService);
const hasPaymentFeature = useLiveData(
serverConfig.features$.map(f => f?.payment)
serverService.server.features$.map(f => f?.payment)
);
const workspace = useService(WorkspaceService).workspace;
@@ -2,19 +2,14 @@ import { notify } from '@affine/component';
import {
ChangeEmailPage,
ChangePasswordPage,
ConfirmChangeEmail,
ConfirmVerifiedEmail,
OnboardingPage,
SetPasswordPage,
SignInSuccessPage,
SignUpPage,
} from '@affine/component/auth-components';
import {
changeEmailMutation,
changePasswordMutation,
fetcher,
sendVerifyChangeEmailMutation,
verifyEmailMutation,
} from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
@@ -28,7 +23,10 @@ import {
RouteLogic,
useNavigateHelper,
} from '../../../components/hooks/use-navigate-helper';
import { AuthService, ServerConfigService } from '../../../modules/cloud';
import { AuthService, ServerService } from '../../../modules/cloud';
import { AppContainer } from '../../components/app-container';
import { ConfirmChangeEmail } from './confirm-change-email';
import { ConfirmVerifiedEmail } from './email-verified-email';
const authTypeSchema = z.enum([
'onboarding',
@@ -46,9 +44,9 @@ export const Component = () => {
const authService = useService(AuthService);
const account = useLiveData(authService.session.account$);
const t = useI18n();
const serverConfig = useService(ServerConfigService).serverConfig;
const serverService = useService(ServerService);
const passwordLimits = useLiveData(
serverConfig.credentialsRequirement$.map(r => r?.password)
serverService.server.credentialsRequirement$.map(r => r?.password)
);
const { authType } = useParams();
@@ -103,8 +101,7 @@ export const Component = () => {
}, [jumpToIndex]);
if (!passwordLimits) {
// TODO(@eyhn): loading UI
return null;
return <AppContainer fallback />;
}
switch (authType) {
@@ -171,36 +168,5 @@ export const loader: LoaderFunction = async args => {
return redirect('/404');
}
if (args.params.authType === 'confirm-change-email') {
const url = new URL(args.request.url);
const searchParams = url.searchParams;
const token = searchParams.get('token') ?? '';
const email = decodeURIComponent(searchParams.get('email') ?? '');
const res = await fetcher({
query: changeEmailMutation,
variables: {
token: token,
email: email,
},
}).catch(console.error);
// TODO(@eyhn): Add error handling
if (!res?.changeEmail) {
return redirect('/expired');
}
} else if (args.params.authType === 'verify-email') {
const url = new URL(args.request.url);
const searchParams = url.searchParams;
const token = searchParams.get('token') ?? '';
const res = await fetcher({
query: verifyEmailMutation,
variables: {
token: token,
},
}).catch(console.error);
if (!res?.verifyEmail) {
return redirect('/expired');
}
}
return null;
};
@@ -0,0 +1,64 @@
import { Button } from '@affine/component';
import { AuthPageContainer } from '@affine/component/auth-components';
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import { BackendError, GraphQLService } from '@affine/core/modules/cloud';
import { changeEmailMutation, ErrorNames } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useService } from '@toeverything/infra';
import { type FC, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { AppContainer } from '../../components/app-container';
export const ConfirmChangeEmail: FC<{
onOpenAffine: () => void;
}> = ({ onOpenAffine }) => {
const t = useI18n();
const [searchParams] = useSearchParams();
const navigateHelper = useNavigateHelper();
const graphqlService = useService(GraphQLService);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
(async () => {
const token = searchParams.get('token') ?? '';
const email = decodeURIComponent(searchParams.get('email') ?? '');
setIsLoading(true);
await graphqlService
.gql({
query: changeEmailMutation,
variables: {
token: token,
email: email,
},
})
.catch(err => {
if (err instanceof BackendError) {
const userFriendlyError = err.originError;
if (userFriendlyError.name === ErrorNames.INVALID_EMAIL_TOKEN) {
return navigateHelper.jumpToExpired();
}
}
throw err;
});
})().catch(err => {
// TODO(@eyhn): Add error handling
console.error(err);
});
}, [graphqlService, navigateHelper, searchParams]);
if (isLoading) {
return <AppContainer fallback />;
}
return (
<AuthPageContainer
title={t['com.affine.auth.change.email.page.success.title']()}
subtitle={t['com.affine.auth.change.email.page.success.subtitle']()}
>
<Button variant="primary" size="large" onClick={onOpenAffine}>
{t['com.affine.auth.open.affine']()}
</Button>
</AuthPageContainer>
);
};
@@ -0,0 +1,64 @@
import { Button } from '@affine/component';
import { AuthPageContainer } from '@affine/component/auth-components';
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import { GraphQLService } from '@affine/core/modules/cloud';
import {
ErrorNames,
UserFriendlyError,
verifyEmailMutation,
} from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useService } from '@toeverything/infra';
import { type FC, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { AppContainer } from '../../components/app-container';
export const ConfirmVerifiedEmail: FC<{
onOpenAffine: () => void;
}> = ({ onOpenAffine }) => {
const t = useI18n();
const graphqlService = useService(GraphQLService);
const [isLoading, setIsLoading] = useState(false);
const [searchParams] = useSearchParams();
const navigateHelper = useNavigateHelper();
useEffect(() => {
(async () => {
const token = searchParams.get('token') ?? '';
setIsLoading(true);
await graphqlService
.gql({
query: verifyEmailMutation,
variables: {
token: token,
},
})
.catch(error => {
const userFriendlyError = UserFriendlyError.fromAnyError(error);
if (userFriendlyError.name === ErrorNames.INVALID_EMAIL_TOKEN) {
return navigateHelper.jumpToExpired();
}
throw error;
});
})().catch(err => {
// TODO(@eyhn): Add error handling
console.error(err);
});
}, [graphqlService, navigateHelper, searchParams]);
if (isLoading) {
return <AppContainer fallback />;
}
return (
<AuthPageContainer
title={t['com.affine.auth.change.email.page.success.title']()}
subtitle={t['com.affine.auth.change.email.page.success.subtitle']()}
>
<Button variant="primary" size="large" onClick={onOpenAffine}>
{t['com.affine.auth.open.affine']()}
</Button>
</AuthPageContainer>
);
};
@@ -2,56 +2,29 @@ import { AcceptInvitePage } from '@affine/component/member-components';
import type { GetInviteInfoQuery } from '@affine/graphql';
import {
acceptInviteByInviteIdMutation,
fetcher,
getInviteInfoQuery,
} from '@affine/graphql';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect } from 'react';
import type { LoaderFunction } from 'react-router-dom';
import { redirect, useLoaderData } from 'react-router-dom';
import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
RouteLogic,
useNavigateHelper,
} from '../../../components/hooks/use-navigate-helper';
import { AuthService } from '../../../modules/cloud';
import { AuthService, GraphQLService } from '../../../modules/cloud';
import { AppContainer } from '../../components/app-container';
/**
* /invite/:inviteId page
*
* only for web
*/
export const loader: LoaderFunction = async args => {
const inviteId = args.params.inviteId || '';
const res = await fetcher({
query: getInviteInfoQuery,
variables: {
inviteId,
},
}).catch(console.error);
// If the inviteId is invalid, redirect to 404 page
if (!res || !res?.getInviteInfo) {
return redirect('/404');
}
// No mater sign in or not, we need to accept the invite
await fetcher({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: res.getInviteInfo.workspace.id,
inviteId,
sendAcceptMail: true,
},
}).catch(console.error);
return {
inviteId,
inviteInfo: res.getInviteInfo,
};
};
export const Component = () => {
const AcceptInvite = ({
inviteInfo,
}: {
inviteInfo: GetInviteInfoQuery['getInviteInfo'];
}) => {
const authService = useService(AuthService);
const isRevalidating = useLiveData(authService.session.isRevalidating$);
const loginStatus = useLiveData(authService.session.status$);
@@ -63,11 +36,6 @@ export const Component = () => {
const { jumpToSignIn } = useNavigateHelper();
const { jumpToPage } = useNavigateHelper();
const { inviteInfo } = useLoaderData() as {
inviteId: string;
inviteInfo: GetInviteInfoQuery['getInviteInfo'];
};
const openWorkspace = useCallback(() => {
jumpToPage(inviteInfo.workspace.id, 'all', RouteLogic.REPLACE);
}, [inviteInfo.workspace.id, jumpToPage]);
@@ -99,3 +67,57 @@ export const Component = () => {
return null;
};
export const Component = () => {
const graphqlService = useService(GraphQLService);
const params = useParams<{ inviteId: string }>();
const navigateHelper = useNavigateHelper();
const [data, setData] = useState<{
inviteId: string;
inviteInfo: GetInviteInfoQuery['getInviteInfo'];
} | null>(null);
useEffect(() => {
(async () => {
setData(null);
const inviteId = params.inviteId || '';
const res = await graphqlService.gql({
query: getInviteInfoQuery,
variables: {
inviteId,
},
});
// If the inviteId is invalid, redirect to 404 page
if (!res || !res?.getInviteInfo) {
return navigateHelper.jumpTo404();
}
// No mater sign in or not, we need to accept the invite
await graphqlService.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: res.getInviteInfo.workspace.id,
inviteId,
sendAcceptMail: true,
},
});
setData({
inviteId,
inviteInfo: res.getInviteInfo,
});
return;
})().catch(err => {
// TODO: handle error
console.error(err);
});
}, [graphqlService, navigateHelper, params.inviteId]);
if (!data) {
return <AppContainer fallback />;
}
return <AcceptInvite inviteInfo={data.inviteInfo} />;
};
@@ -1,14 +1,13 @@
import { GraphQLService } from '@affine/core/modules/cloud';
import { OpenInAppPage } from '@affine/core/modules/open-in-app/views/open-in-app-page';
import { appSchemes, channelToScheme } from '@affine/core/utils/channel';
import type { GetCurrentUserQuery } from '@affine/graphql';
import { fetcher, getCurrentUserQuery } from '@affine/graphql';
import type { LoaderFunction } from 'react-router-dom';
import { useLoaderData, useSearchParams } from 'react-router-dom';
import { getCurrentUserQuery } from '@affine/graphql';
import { useService } from '@toeverything/infra';
import { useEffect, useState } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
interface LoaderData {
action: 'url' | 'signin-redirect';
currentUser?: GetCurrentUserQuery['currentUser'];
}
import { AppContainer } from '../../components/app-container';
const OpenUrl = () => {
const [params] = useSearchParams();
@@ -33,8 +32,11 @@ const OpenUrl = () => {
* @deprecated
*/
const OpenOAuthJwt = () => {
const { currentUser } = useLoaderData() as LoaderData;
const [currentUser, setCurrentUser] = useState<
GetCurrentUserQuery['currentUser'] | null
>(null);
const [params] = useSearchParams();
const graphqlService = useService(GraphQLService);
const maybeScheme = appSchemes.safeParse(params.get('scheme'));
const scheme = maybeScheme.success
@@ -42,8 +44,19 @@ const OpenOAuthJwt = () => {
: channelToScheme[BUILD_CONFIG.appBuildType];
const next = params.get('next');
useEffect(() => {
graphqlService
.gql({
query: getCurrentUserQuery,
})
.then(res => {
setCurrentUser(res?.currentUser || null);
})
.catch(console.error);
}, [graphqlService]);
if (!currentUser || !currentUser?.token?.sessionToken) {
return null;
return <AppContainer fallback />;
}
const urlToOpen = `${scheme}://signin-redirect?token=${
@@ -54,7 +67,8 @@ const OpenOAuthJwt = () => {
};
export const Component = () => {
const { action } = useLoaderData() as LoaderData;
const params = useParams<{ action: string }>();
const action = params.action || '';
if (action === 'url') {
return <OpenUrl />;
@@ -63,22 +77,3 @@ export const Component = () => {
}
return null;
};
export const loader: LoaderFunction = async args => {
const action = args.params.action || '';
if (action === 'signin-redirect') {
const res = await fetcher({
query: getCurrentUserQuery,
}).catch(console.error);
return {
action,
currentUser: res?.currentUser || null,
};
} else {
return {
action,
};
}
};
@@ -1,4 +1,7 @@
import { NotificationCenter } from '@affine/component';
import { DefaultServerService } from '@affine/core/modules/cloud';
import { FrameworkScope, useService } from '@toeverything/infra';
import { useEffect, useState } from 'react';
import { Outlet } from 'react-router-dom';
import { GlobalDialogs } from '../../dialogs';
@@ -6,13 +9,34 @@ import { CustomThemeModifier } from './custom-theme';
import { FindInPageModal } from './find-in-page/find-in-page-modal';
export const RootWrapper = () => {
const defaultServerService = useService(DefaultServerService);
const [isServerReady, setIsServerReady] = useState(false);
useEffect(() => {
if (isServerReady) {
return;
}
const abortController = new AbortController();
defaultServerService.server
.waitForConfigRevalidation(abortController.signal)
.then(() => {
setIsServerReady(true);
})
.catch(error => {
console.error(error);
});
return () => {
abortController.abort();
};
}, [defaultServerService, isServerReady]);
return (
<>
<FrameworkScope scope={defaultServerService.server.scope}>
<GlobalDialogs />
<NotificationCenter />
<Outlet />
<CustomThemeModifier />
{BUILD_CONFIG.isElectron && <FindInPageModal />}
</>
</FrameworkScope>
);
};