From bf0a5e6f572c6cf28d925e7a2302f036dcad6b0b Mon Sep 17 00:00:00 2001 From: EYHN Date: Thu, 7 Nov 2024 13:31:33 +0800 Subject: [PATCH] refactor(core): support multiple servers --- packages/frontend/apps/ios/src/fetch.ts | 4 +- .../auth-components/confirm-change-email.tsx | 22 --- .../auth-components/email-verified-email.tsx | 22 --- .../src/components/auth-components/index.tsx | 2 - .../core/src/components/affine/auth/oauth.tsx | 14 +- .../src/components/affine/auth/send-email.tsx | 6 +- .../affine/auth/user-plan-button.tsx | 15 +- .../share-menu/share-page.tsx | 6 +- .../block-suite-editor/ai/copilot-client.ts | 23 ++- .../block-suite-editor/ai/setup-provider.tsx | 2 +- .../specs/custom/database-block.ts | 11 +- .../specs/custom/widgets/toolbar.ts | 13 +- .../components/hooks/affine/use-share-url.ts | 14 +- .../core/src/components/hooks/use-mutation.ts | 10 +- .../core/src/components/hooks/use-query.ts | 17 +- .../components/root-app-sidebar/user-info.tsx | 8 +- .../account-setting/ai-usage-panel.tsx | 6 +- .../dialogs/setting/account-setting/index.tsx | 10 +- .../account-setting/storage-progress.tsx | 6 +- .../general-setting/editor/general.tsx | 10 +- .../dialogs/setting/general-setting/index.tsx | 22 ++- .../new-workspace-setting-detail/members.tsx | 6 +- .../core/src/desktop/pages/auth/auth.tsx | 48 +----- .../pages/auth/confirm-change-email.tsx | 68 ++++++++ .../pages/auth/email-verified-email.tsx | 64 ++++++++ .../core/src/desktop/pages/invite/index.tsx | 104 +++++++----- .../core/src/desktop/pages/open-app/index.tsx | 53 +++--- .../core/src/desktop/pages/root/index.tsx | 28 +++- .../mobile/components/user-plan-tag/index.tsx | 11 +- .../dialogs/setting/user-usage/index.tsx | 8 +- .../core/src/mobile/pages/root/index.tsx | 40 +++++ packages/frontend/core/src/mobile/router.tsx | 10 +- .../core/src/modules/cloud/constant.ts | 152 ++++++++++++++++++ .../modules/cloud/entities/server-config.ts | 70 -------- .../core/src/modules/cloud/entities/server.ts | 110 +++++++++++++ .../cloud/entities/subscription-prices.ts | 12 +- .../modules/cloud/entities/subscription.ts | 8 +- .../cloud/entities/user-copilot-quota.ts | 8 +- .../frontend/core/src/modules/cloud/index.ts | 48 ++++-- .../core/src/modules/cloud/provider/fetch.ts | 7 +- .../core/src/modules/cloud/scopes/server.ts | 7 + .../src/modules/cloud/services/captcha.ts | 6 +- .../core/src/modules/cloud/services/fetch.ts | 18 +-- .../modules/cloud/services/global-server.ts | 26 +++ .../modules/cloud/services/server-config.ts | 12 -- .../core/src/modules/cloud/services/server.ts | 10 ++ .../src/modules/cloud/services/servers.ts | 55 +++++++ .../src/modules/cloud/services/websocket.ts | 7 +- .../cloud/services/workspace-server.ts | 11 ++ .../src/modules/cloud/stores/server-config.ts | 6 +- .../src/modules/cloud/stores/server-list.ts | 85 ++++++++++ .../src/modules/cloud/stores/subscription.ts | 9 +- .../frontend/core/src/modules/cloud/types.ts | 22 +++ .../modules/editor-setting/impls/user-db.ts | 14 +- .../core/src/modules/editor-setting/index.ts | 4 +- .../core/src/modules/favorite/index.ts | 10 +- .../src/modules/favorite/stores/favorite.ts | 31 ++-- .../core/src/modules/permissions/index.ts | 6 +- .../src/modules/permissions/stores/members.ts | 9 +- .../modules/permissions/stores/permission.ts | 14 +- .../frontend/core/src/modules/quota/index.ts | 4 +- .../core/src/modules/quota/stores/quota.ts | 9 +- .../core/src/modules/share-doc/index.ts | 8 +- .../modules/share-doc/stores/share-docs.ts | 9 +- .../modules/share-doc/stores/share-reader.ts | 12 +- .../src/modules/share-doc/stores/share.ts | 19 ++- .../core/src/modules/share-setting/index.ts | 4 +- .../share-setting/stores/share-setting.ts | 14 +- .../core/src/modules/telemetry/index.ts | 4 +- .../modules/telemetry/services/telemetry.ts | 19 ++- .../core/src/modules/userspace/index.ts | 2 + .../modules/workspace-engine/impls/cloud.ts | 26 ++- .../src/modules/workspace-engine/index.ts | 12 +- .../frontend/graphql/src/graphql/index.ts | 1 + .../graphql/src/graphql/server-config.gql | 1 + packages/frontend/graphql/src/index.ts | 20 --- packages/frontend/graphql/src/schema.ts | 1 + 77 files changed, 1113 insertions(+), 512 deletions(-) delete mode 100644 packages/frontend/component/src/components/auth-components/confirm-change-email.tsx delete mode 100644 packages/frontend/component/src/components/auth-components/email-verified-email.tsx create mode 100644 packages/frontend/core/src/desktop/pages/auth/confirm-change-email.tsx create mode 100644 packages/frontend/core/src/desktop/pages/auth/email-verified-email.tsx create mode 100644 packages/frontend/core/src/mobile/pages/root/index.tsx create mode 100644 packages/frontend/core/src/modules/cloud/constant.ts delete mode 100644 packages/frontend/core/src/modules/cloud/entities/server-config.ts create mode 100644 packages/frontend/core/src/modules/cloud/entities/server.ts create mode 100644 packages/frontend/core/src/modules/cloud/scopes/server.ts create mode 100644 packages/frontend/core/src/modules/cloud/services/global-server.ts delete mode 100644 packages/frontend/core/src/modules/cloud/services/server-config.ts create mode 100644 packages/frontend/core/src/modules/cloud/services/server.ts create mode 100644 packages/frontend/core/src/modules/cloud/services/servers.ts create mode 100644 packages/frontend/core/src/modules/cloud/services/workspace-server.ts create mode 100644 packages/frontend/core/src/modules/cloud/stores/server-list.ts create mode 100644 packages/frontend/core/src/modules/cloud/types.ts diff --git a/packages/frontend/apps/ios/src/fetch.ts b/packages/frontend/apps/ios/src/fetch.ts index bd4dc6e4fa63f..cb05ee7000c4f 100644 --- a/packages/frontend/apps/ios/src/fetch.ts +++ b/packages/frontend/apps/ios/src/fetch.ts @@ -3,7 +3,7 @@ * * for support arraybuffer response type */ -import { FetchProvider } from '@affine/core/modules/cloud/provider/fetch'; +import { RawFetchProvider } from '@affine/core/modules/cloud/provider/fetch'; import { CapacitorHttp } from '@capacitor/core'; import type { Framework } from '@toeverything/infra'; @@ -121,7 +121,7 @@ function base64ToUint8Array(base64: string) { return new Uint8Array(binaryArray); } export function configureFetchProvider(framework: Framework) { - framework.override(FetchProvider, { + framework.override(RawFetchProvider, { fetch: async (input, init) => { const request = new Request(input, init); const { method } = request; diff --git a/packages/frontend/component/src/components/auth-components/confirm-change-email.tsx b/packages/frontend/component/src/components/auth-components/confirm-change-email.tsx deleted file mode 100644 index 332a77ba48279..0000000000000 --- a/packages/frontend/component/src/components/auth-components/confirm-change-email.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useI18n } from '@affine/i18n'; -import type { FC } from 'react'; - -import { Button } from '../../ui/button'; -import { AuthPageContainer } from './auth-page-container'; - -export const ConfirmChangeEmail: FC<{ - onOpenAffine: () => void; -}> = ({ onOpenAffine }) => { - const t = useI18n(); - - return ( - - - - ); -}; diff --git a/packages/frontend/component/src/components/auth-components/email-verified-email.tsx b/packages/frontend/component/src/components/auth-components/email-verified-email.tsx deleted file mode 100644 index b0adc8384441d..0000000000000 --- a/packages/frontend/component/src/components/auth-components/email-verified-email.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useI18n } from '@affine/i18n'; -import type { FC } from 'react'; - -import { Button } from '../../ui/button'; -import { AuthPageContainer } from './auth-page-container'; - -export const ConfirmVerifiedEmail: FC<{ - onOpenAffine: () => void; -}> = ({ onOpenAffine }) => { - const t = useI18n(); - - return ( - - - - ); -}; diff --git a/packages/frontend/component/src/components/auth-components/index.tsx b/packages/frontend/component/src/components/auth-components/index.tsx index a179b3ca07ddb..a23d238dd7bc9 100644 --- a/packages/frontend/component/src/components/auth-components/index.tsx +++ b/packages/frontend/component/src/components/auth-components/index.tsx @@ -4,9 +4,7 @@ export * from './auth-page-container'; export * from './back-button'; export * from './change-email-page'; export * from './change-password-page'; -export * from './confirm-change-email'; export * from './count-down-render'; -export * from './email-verified-email'; export * from './modal'; export * from './modal-header'; export * from './onboarding-page'; diff --git a/packages/frontend/core/src/components/affine/auth/oauth.tsx b/packages/frontend/core/src/components/affine/auth/oauth.tsx index 3f8b65cdbb984..f6d4ddfb4b47a 100644 --- a/packages/frontend/core/src/components/affine/auth/oauth.tsx +++ b/packages/frontend/core/src/components/affine/auth/oauth.tsx @@ -1,13 +1,12 @@ import { Skeleton } from '@affine/component'; import { Button } from '@affine/component/ui/button'; +import { ServerService } from '@affine/core/modules/cloud'; import { UrlService } from '@affine/core/modules/url'; import { OAuthProviderType } from '@affine/graphql'; import { GithubIcon, GoogleDuotoneIcon } from '@blocksuite/icons/rc'; import { useLiveData, useService } from '@toeverything/infra'; import { type ReactElement, useCallback } from 'react'; -import { ServerConfigService } from '../../../modules/cloud'; - const OAuthProviderMap: Record< OAuthProviderType, { @@ -29,11 +28,11 @@ const OAuthProviderMap: Record< }; export function OAuth({ redirectUrl }: { redirectUrl?: string }) { - const serverConfig = useService(ServerConfigService).serverConfig; + const serverService = useService(ServerService); const urlService = useService(UrlService); - const oauth = useLiveData(serverConfig.features$.map(r => r?.oauth)); + const oauth = useLiveData(serverService.server.features$.map(r => r?.oauth)); const oauthProviders = useLiveData( - serverConfig.config$.map(r => r?.oauthProviders) + serverService.server.config$.map(r => r?.oauthProviders) ); const scheme = urlService.getClientScheme(); @@ -65,6 +64,7 @@ function OAuthProvider({ scheme?: string; popupWindow: (url: string) => void; }) { + const serverService = useService(ServerService); const { icon } = OAuthProviderMap[provider]; const onClick = useCallback(() => { @@ -84,10 +84,10 @@ function OAuthProvider({ // if (BUILD_CONFIG.isAndroid) {} const oauthUrl = - BUILD_CONFIG.serverUrlPrefix + `/oauth/login?${params.toString()}`; + serverService.server.baseUrl + `/oauth/login?${params.toString()}`; popupWindow(oauthUrl); - }, [popupWindow, provider, redirectUrl, scheme]); + }, [popupWindow, provider, redirectUrl, scheme, serverService]); return ( + + ); +}; diff --git a/packages/frontend/core/src/desktop/pages/auth/email-verified-email.tsx b/packages/frontend/core/src/desktop/pages/auth/email-verified-email.tsx new file mode 100644 index 0000000000000..5663d12979f3d --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/auth/email-verified-email.tsx @@ -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') ?? ''; + 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; + }); + setIsLoading(true); + })().catch(err => { + // TODO(@eyhn): Add error handling + console.error(err); + }); + }, [graphqlService, navigateHelper, searchParams]); + + if (isLoading) { + return ; + } + + return ( + + + + ); +}; diff --git a/packages/frontend/core/src/desktop/pages/invite/index.tsx b/packages/frontend/core/src/desktop/pages/invite/index.tsx index 1866c13960a19..e8f4ad98e8b70 100644 --- a/packages/frontend/core/src/desktop/pages/invite/index.tsx +++ b/packages/frontend/core/src/desktop/pages/invite/index.tsx @@ -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 ; + } + + return ; +}; diff --git a/packages/frontend/core/src/desktop/pages/open-app/index.tsx b/packages/frontend/core/src/desktop/pages/open-app/index.tsx index b0b2e82e1a919..773c72f1cc47d 100644 --- a/packages/frontend/core/src/desktop/pages/open-app/index.tsx +++ b/packages/frontend/core/src/desktop/pages/open-app/index.tsx @@ -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 } 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,15 +32,29 @@ 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 ? maybeScheme.data : 'affine'; 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 ; } const urlToOpen = `${scheme}://signin-redirect?token=${ @@ -52,7 +65,8 @@ const OpenOAuthJwt = () => { }; export const Component = () => { - const { action } = useLoaderData() as LoaderData; + const params = useParams<{ action: string }>(); + const action = params.action || ''; if (action === 'url') { return ; @@ -61,22 +75,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, - }; - } -}; diff --git a/packages/frontend/core/src/desktop/pages/root/index.tsx b/packages/frontend/core/src/desktop/pages/root/index.tsx index c7e61249e512d..b8b2327e82231 100644 --- a/packages/frontend/core/src/desktop/pages/root/index.tsx +++ b/packages/frontend/core/src/desktop/pages/root/index.tsx @@ -1,4 +1,7 @@ import { NotificationCenter } from '@affine/component'; +import { GlobalServerService } 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 globalServerService = useService(GlobalServerService); + const [isServerReady, setIsServerReady] = useState(false); + + useEffect(() => { + if (isServerReady) { + return; + } + const abortController = new AbortController(); + globalServerService.server + .waitForConfigRevalidation(abortController.signal) + .then(() => { + setIsServerReady(true); + }) + .catch(error => { + console.error(error); + }); + return () => { + abortController.abort(); + }; + }, [globalServerService, isServerReady]); + return ( - <> + {BUILD_CONFIG.isElectron && } - + ); }; diff --git a/packages/frontend/core/src/mobile/components/user-plan-tag/index.tsx b/packages/frontend/core/src/mobile/components/user-plan-tag/index.tsx index 91a7f48090e72..8f1745dbf3826 100644 --- a/packages/frontend/core/src/mobile/components/user-plan-tag/index.tsx +++ b/packages/frontend/core/src/mobile/components/user-plan-tag/index.tsx @@ -1,7 +1,4 @@ -import { - ServerConfigService, - SubscriptionService, -} from '@affine/core/modules/cloud'; +import { ServerService, SubscriptionService } from '@affine/core/modules/cloud'; import { SubscriptionPlan } from '@affine/graphql'; import { useLiveData, useServices } from '@toeverything/infra'; import clsx from 'clsx'; @@ -13,12 +10,12 @@ export const UserPlanTag = forwardRef< HTMLDivElement, HTMLProps >(function UserPlanTag({ className, ...attrs }, ref) { - const { serverConfigService, subscriptionService } = useServices({ - ServerConfigService, + const { serverService, subscriptionService } = useServices({ + ServerService, SubscriptionService, }); const hasPayment = useLiveData( - serverConfigService.serverConfig.features$.map(r => r?.payment) + serverService.server.features$.map(r => r?.payment) ); const plan = useLiveData( subscriptionService.subscription.pro$.map(subscription => diff --git a/packages/frontend/core/src/mobile/dialogs/setting/user-usage/index.tsx b/packages/frontend/core/src/mobile/dialogs/setting/user-usage/index.tsx index 19a660a026754..d930f9a1590fc 100644 --- a/packages/frontend/core/src/mobile/dialogs/setting/user-usage/index.tsx +++ b/packages/frontend/core/src/mobile/dialogs/setting/user-usage/index.tsx @@ -1,7 +1,7 @@ import { Skeleton } from '@affine/component'; import { AuthService, - ServerConfigService, + ServerService, UserCopilotQuotaService, UserQuotaService, } from '@affine/core/modules/cloud'; @@ -71,10 +71,8 @@ const Loading = () => { }; const UsagePanel = () => { - const serverConfigService = useService(ServerConfigService); - const serverFeatures = useLiveData( - serverConfigService.serverConfig.features$ - ); + const serverService = useService(ServerService); + const serverFeatures = useLiveData(serverService.server.features$); return ( diff --git a/packages/frontend/core/src/mobile/pages/root/index.tsx b/packages/frontend/core/src/mobile/pages/root/index.tsx new file mode 100644 index 0000000000000..5ca4106499fa4 --- /dev/null +++ b/packages/frontend/core/src/mobile/pages/root/index.tsx @@ -0,0 +1,40 @@ +import { NotificationCenter } from '@affine/component'; +import { GlobalServerService } 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'; +import { MobileSignInModal } from '../../views/sign-in/modal'; + +export const RootWrapper = () => { + const globalServerService = useService(GlobalServerService); + const [isServerReady, setIsServerReady] = useState(false); + + useEffect(() => { + if (isServerReady) { + return; + } + const abortController = new AbortController(); + globalServerService.server + .waitForConfigRevalidation(abortController.signal) + .then(() => { + setIsServerReady(true); + }) + .catch(error => { + console.error(error); + }); + return () => { + abortController.abort(); + }; + }, [globalServerService, isServerReady]); + + return ( + + + + + + + ); +}; diff --git a/packages/frontend/core/src/mobile/router.tsx b/packages/frontend/core/src/mobile/router.tsx index 78d2c4882af53..83842e6ad7af3 100644 --- a/packages/frontend/core/src/mobile/router.tsx +++ b/packages/frontend/core/src/mobile/router.tsx @@ -1,18 +1,15 @@ -import { NotificationCenter } from '@affine/component'; import { NavigateContext } from '@affine/core/components/hooks/use-navigate-helper'; import { wrapCreateBrowserRouter } from '@sentry/react'; import { useEffect, useState } from 'react'; import type { RouteObject } from 'react-router-dom'; import { createBrowserRouter as reactRouterCreateBrowserRouter, - Outlet, redirect, // eslint-disable-next-line @typescript-eslint/no-restricted-imports useNavigate, } from 'react-router-dom'; -import { GlobalDialogs } from './dialogs'; -import { MobileSignInModal } from './views/sign-in/modal'; +import { RootWrapper } from './pages/root'; function RootRouter() { const navigate = useNavigate(); @@ -25,10 +22,7 @@ function RootRouter() { return ( ready && ( - - - - + ) ); diff --git a/packages/frontend/core/src/modules/cloud/constant.ts b/packages/frontend/core/src/modules/cloud/constant.ts new file mode 100644 index 0000000000000..fa05e088f0587 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/constant.ts @@ -0,0 +1,152 @@ +import { + OAuthProviderType, + ServerDeploymentType, + ServerFeature, +} from '@affine/graphql'; + +import type { ServerConfig, ServerMetadata } from './types'; + +export const BUILD_IN_SERVERS: (ServerMetadata & { config: ServerConfig })[] = + environment.isSelfHosted + ? [ + { + id: 'affine-cloud', + baseUrl: location.origin, + // selfhosted baseUrl is `location.origin` + // this is ok for web app, but not for desktop app + // since we never build desktop app in selfhosted mode, so it's fine + config: { + serverName: 'Affine Selfhost', + features: [], + oauthProviders: [], + type: ServerDeploymentType.Selfhosted, + credentialsRequirement: { + password: { + minLength: 8, + maxLength: 32, + }, + }, + }, + }, + ] + : BUILD_CONFIG.debug + ? [ + { + id: 'affine-cloud', + baseUrl: 'http://localhost:8080', + config: { + serverName: 'Affine Cloud', + features: [ + ServerFeature.Captcha, + ServerFeature.Copilot, + ServerFeature.OAuth, + ServerFeature.Payment, + ], + oauthProviders: [OAuthProviderType.Google], + type: ServerDeploymentType.Affine, + credentialsRequirement: { + password: { + minLength: 8, + maxLength: 32, + }, + }, + }, + }, + ] + : BUILD_CONFIG.appBuildType === 'stable' + ? [ + { + id: 'affine-cloud', + baseUrl: 'https://app.affine.pro', + config: { + serverName: 'Affine Cloud', + features: [ + ServerFeature.Captcha, + ServerFeature.Copilot, + ServerFeature.OAuth, + ServerFeature.Payment, + ], + oauthProviders: [OAuthProviderType.Google], + type: ServerDeploymentType.Affine, + credentialsRequirement: { + password: { + minLength: 8, + maxLength: 32, + }, + }, + }, + }, + ] + : BUILD_CONFIG.appBuildType === 'beta' + ? [ + { + id: 'affine-cloud', + baseUrl: 'https://insider.affine.pro', + config: { + serverName: 'Affine Cloud', + features: [ + ServerFeature.Captcha, + ServerFeature.Copilot, + ServerFeature.OAuth, + ServerFeature.Payment, + ], + oauthProviders: [OAuthProviderType.Google], + type: ServerDeploymentType.Affine, + credentialsRequirement: { + password: { + minLength: 8, + maxLength: 32, + }, + }, + }, + }, + ] + : BUILD_CONFIG.appBuildType === 'internal' + ? [ + { + id: 'affine-cloud', + baseUrl: 'https://insider.affine.pro', + config: { + serverName: 'Affine Cloud', + features: [ + ServerFeature.Captcha, + ServerFeature.Copilot, + ServerFeature.OAuth, + ServerFeature.Payment, + ], + oauthProviders: [OAuthProviderType.Google], + type: ServerDeploymentType.Affine, + credentialsRequirement: { + password: { + minLength: 8, + maxLength: 32, + }, + }, + }, + }, + ] + : BUILD_CONFIG.appBuildType === 'canary' + ? [ + { + id: 'affine-cloud', + baseUrl: 'https://affine.fail', + config: { + serverName: 'Affine Cloud', + features: [ + ServerFeature.Captcha, + ServerFeature.Copilot, + ServerFeature.OAuth, + ServerFeature.Payment, + ], + oauthProviders: [OAuthProviderType.Google], + type: ServerDeploymentType.Affine, + credentialsRequirement: { + password: { + minLength: 8, + maxLength: 32, + }, + }, + }, + }, + ] + : []; diff --git a/packages/frontend/core/src/modules/cloud/entities/server-config.ts b/packages/frontend/core/src/modules/cloud/entities/server-config.ts deleted file mode 100644 index eb6f6f419f69b..0000000000000 --- a/packages/frontend/core/src/modules/cloud/entities/server-config.ts +++ /dev/null @@ -1,70 +0,0 @@ -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; -type ServerFeatureRecord = { - [key in LowercaseServerFeature]: boolean; -}; - -export type ServerConfigType = ServerConfigQuery['serverConfig'] & - OauthProvidersQuery['serverConfig']; - -export class ServerConfig extends Entity { - readonly config$ = new LiveData(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(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(); - } -} diff --git a/packages/frontend/core/src/modules/cloud/entities/server.ts b/packages/frontend/core/src/modules/cloud/entities/server.ts new file mode 100644 index 0000000000000..8960745ec99e5 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/entities/server.ts @@ -0,0 +1,110 @@ +import type { ServerFeature } from '@affine/graphql'; +import { + backoffRetry, + effect, + Entity, + fromPromise, + LiveData, + onComplete, + onStart, +} from '@toeverything/infra'; +import { EMPTY, exhaustMap, map, mergeMap } from 'rxjs'; + +import { ServerScope } from '../scopes/server'; +import { FetchService } from '../services/fetch'; +import { GraphQLService } from '../services/graphql'; +import { ServerConfigStore } from '../stores/server-config'; +import type { ServerListStore } from '../stores/server-list'; +import type { ServerConfig, ServerMetadata } from '../types'; + +type LowercaseServerFeature = Lowercase; +type ServerFeatureRecord = { + [key in LowercaseServerFeature]: boolean; +}; + +export class Server extends Entity<{ + serverMetadata: ServerMetadata; +}> { + readonly id = this.props.serverMetadata.id; + readonly baseUrl = this.props.serverMetadata.baseUrl; + readonly scope = this.framework.createScope(ServerScope, { + server: this as Server, + }); + + readonly serverConfigStore = this.scope.framework.get(ServerConfigStore); + readonly fetch = this.scope.framework.get(FetchService).fetch; + readonly gql = this.scope.framework.get(GraphQLService).gql; + readonly serverMetadata = this.props.serverMetadata; + + constructor(private readonly serverListStore: ServerListStore) { + super(); + } + + readonly config$ = LiveData.from( + this.serverListStore.watchServerConfig(this.serverMetadata.id).pipe( + map(config => { + if (!config) { + throw new Error('Failed to load server config'); + } + return config; + }) + ), + null as any + ); + + readonly isConfigRevalidating$ = new LiveData(false); + + readonly features$ = this.config$.map(config => { + return Array.from(new Set(config.features)).reduce((acc, cur) => { + acc[cur.toLowerCase() as LowercaseServerFeature] = true; + return acc; + }, {} as ServerFeatureRecord); + }); + + readonly credentialsRequirement$ = this.config$.map(config => { + return config ? config.credentialsRequirement : null; + }); + + readonly revalidateConfig = effect( + exhaustMap(() => { + return fromPromise(signal => + this.serverConfigStore.fetchServerConfig(signal) + ).pipe( + backoffRetry({ + count: Infinity, + }), + mergeMap(config => { + this.serverListStore.updateServerConfig(this.serverMetadata.id, { + credentialsRequirement: config.credentialsRequirement, + features: config.features, + oauthProviders: config.oauthProviders, + serverName: config.name, + type: config.type, + version: config.version, + initialized: config.initialized, + }); + return EMPTY; + }), + onStart(() => { + this.isConfigRevalidating$.next(true); + }), + onComplete(() => { + this.isConfigRevalidating$.next(false); + }) + ); + }) + ); + + async waitForConfigRevalidation(signal?: AbortSignal) { + this.revalidateConfig(); + await this.isConfigRevalidating$.waitFor( + isRevalidating => !isRevalidating, + signal + ); + } + + override dispose(): void { + this.scope.dispose(); + this.revalidateConfig.unsubscribe(); + } +} diff --git a/packages/frontend/core/src/modules/cloud/entities/subscription-prices.ts b/packages/frontend/core/src/modules/cloud/entities/subscription-prices.ts index 64e370a25998d..73b94e0064d42 100644 --- a/packages/frontend/core/src/modules/cloud/entities/subscription-prices.ts +++ b/packages/frontend/core/src/modules/cloud/entities/subscription-prices.ts @@ -13,7 +13,7 @@ import { import { exhaustMap } from 'rxjs'; import { isBackendError, isNetworkError } from '../error'; -import type { ServerConfigService } from '../services/server-config'; +import type { ServerService } from '../services/server'; import type { SubscriptionStore } from '../stores/subscription'; export class SubscriptionPrices extends Entity { @@ -35,7 +35,7 @@ export class SubscriptionPrices extends Entity { ); constructor( - private readonly serverConfigService: ServerConfigService, + private readonly serverService: ServerService, private readonly store: SubscriptionStore ) { super(); @@ -44,13 +44,7 @@ export class SubscriptionPrices extends Entity { 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 - ); + const serverConfig = this.serverService.server.features$.value; if (!serverConfig.payment) { // No payment feature, no subscription diff --git a/packages/frontend/core/src/modules/cloud/entities/subscription.ts b/packages/frontend/core/src/modules/cloud/entities/subscription.ts index 3f5f68df3e874..b70ef49b26282 100644 --- a/packages/frontend/core/src/modules/cloud/entities/subscription.ts +++ b/packages/frontend/core/src/modules/cloud/entities/subscription.ts @@ -19,7 +19,7 @@ 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 { ServerService } from '../services/server'; import type { SubscriptionStore } from '../stores/subscription'; export type SubscriptionType = NonNullable< @@ -54,7 +54,7 @@ export class Subscription extends Entity { constructor( private readonly authService: AuthService, - private readonly serverConfigService: ServerConfigService, + private readonly serverService: ServerService, private readonly store: SubscriptionStore ) { super(); @@ -100,9 +100,7 @@ export class Subscription extends Entity { } const serverConfig = - await this.serverConfigService.serverConfig.features$.waitForNonNull( - signal - ); + await this.serverService.server.features$.waitForNonNull(signal); if (!serverConfig.payment) { // No payment feature, no subscription diff --git a/packages/frontend/core/src/modules/cloud/entities/user-copilot-quota.ts b/packages/frontend/core/src/modules/cloud/entities/user-copilot-quota.ts index ed6decd7993fc..6f3509dca2020 100644 --- a/packages/frontend/core/src/modules/cloud/entities/user-copilot-quota.ts +++ b/packages/frontend/core/src/modules/cloud/entities/user-copilot-quota.ts @@ -13,7 +13,7 @@ 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 { ServerService } from '../services/server'; import type { UserCopilotQuotaStore } from '../stores/user-copilot-quota'; export class UserCopilotQuota extends Entity { @@ -26,7 +26,7 @@ export class UserCopilotQuota extends Entity { constructor( private readonly authService: AuthService, private readonly store: UserCopilotQuotaStore, - private readonly serverConfigService: ServerConfigService + private readonly serverService: ServerService ) { super(); } @@ -44,9 +44,7 @@ export class UserCopilotQuota extends Entity { } const serverConfig = - await this.serverConfigService.serverConfig.features$.waitForNonNull( - signal - ); + await this.serverService.server.features$.waitForNonNull(signal); let aiQuota = null; diff --git a/packages/frontend/core/src/modules/cloud/index.ts b/packages/frontend/core/src/modules/cloud/index.ts index e989138a2b176..394bdb12116c1 100644 --- a/packages/frontend/core/src/modules/cloud/index.ts +++ b/packages/frontend/core/src/modules/cloud/index.ts @@ -6,19 +6,23 @@ export { isNetworkError, NetworkError, } from './error'; +export { RawFetchProvider } from './provider/fetch'; export { ValidatorProvider } from './provider/validator'; export { WebSocketAuthProvider } from './provider/websocket-auth'; export { AccountChanged, AuthService } from './services/auth'; export { CaptchaService } from './services/captcha'; export { FetchService } from './services/fetch'; +export { GlobalServerService } from './services/global-server'; export { GraphQLService } from './services/graphql'; export { InvoicesService } from './services/invoices'; -export { ServerConfigService } from './services/server-config'; +export { ServerService } from './services/server'; +export { ServersService } from './services/servers'; export { SubscriptionService } from './services/subscription'; export { UserCopilotQuotaService } from './services/user-copilot-quota'; export { UserFeatureService } from './services/user-feature'; export { UserQuotaService } from './services/user-quota'; export { WebSocketService } from './services/websocket'; +export { WorkspaceServerService } from './services/workspace-server'; import { DocScope, @@ -26,38 +30,44 @@ import { type Framework, GlobalCache, GlobalState, + GlobalStateService, WorkspaceScope, } from '@toeverything/infra'; import { UrlService } from '../url'; import { CloudDocMeta } from './entities/cloud-doc-meta'; import { Invoices } from './entities/invoices'; -import { ServerConfig } from './entities/server-config'; +import { Server } from './entities/server'; import { AuthSession } from './entities/session'; import { Subscription } from './entities/subscription'; import { SubscriptionPrices } from './entities/subscription-prices'; import { UserCopilotQuota } from './entities/user-copilot-quota'; import { UserFeature } from './entities/user-feature'; import { UserQuota } from './entities/user-quota'; -import { DefaultFetchProvider, FetchProvider } from './provider/fetch'; +import { DefaultRawFetchProvider, RawFetchProvider } from './provider/fetch'; import { ValidatorProvider } from './provider/validator'; import { WebSocketAuthProvider } from './provider/websocket-auth'; +import { ServerScope } from './scopes/server'; import { AuthService } from './services/auth'; import { CaptchaService } from './services/captcha'; import { CloudDocMetaService } from './services/cloud-doc-meta'; import { FetchService } from './services/fetch'; +import { GlobalServerService } from './services/global-server'; import { GraphQLService } from './services/graphql'; import { InvoicesService } from './services/invoices'; -import { ServerConfigService } from './services/server-config'; +import { ServerService } from './services/server'; +import { ServersService } from './services/servers'; import { SubscriptionService } from './services/subscription'; import { UserCopilotQuotaService } from './services/user-copilot-quota'; import { UserFeatureService } from './services/user-feature'; import { UserQuotaService } from './services/user-quota'; import { WebSocketService } from './services/websocket'; +import { WorkspaceServerService } from './services/workspace-server'; import { AuthStore } from './stores/auth'; import { CloudDocMetaStore } from './stores/cloud-doc-meta'; import { InvoicesStore } from './stores/invoices'; import { ServerConfigStore } from './stores/server-config'; +import { ServerListStore } from './stores/server-list'; import { SubscriptionStore } from './stores/subscription'; import { UserCopilotQuotaStore } from './stores/user-copilot-quota'; import { UserFeatureStore } from './stores/user-feature'; @@ -65,23 +75,28 @@ import { UserQuotaStore } from './stores/user-quota'; export function configureCloudModule(framework: Framework) { framework - .service(FetchService, [FetchProvider]) - .impl(FetchProvider, DefaultFetchProvider) + .service(ServersService, [ServerListStore]) + .service(GlobalServerService, [ServersService]) + .store(ServerListStore, [GlobalStateService]) + .entity(Server, [ServerListStore]) + .scope(ServerScope) + .service(ServerService, [ServerScope]) + .service(FetchService, [RawFetchProvider, ServerService]) + .impl(RawFetchProvider, DefaultRawFetchProvider) .service(GraphQLService, [FetchService]) .service( WebSocketService, f => new WebSocketService( + f.get(ServerService), f.get(AuthService), f.getOptional(WebSocketAuthProvider) ) ) - .service(ServerConfigService) - .entity(ServerConfig, [ServerConfigStore]) .store(ServerConfigStore, [GraphQLService]) .service(CaptchaService, f => { return new CaptchaService( - f.get(ServerConfigService), + f.get(ServerService), f.get(FetchService), f.getOptional(ValidatorProvider) ); @@ -90,9 +105,14 @@ export function configureCloudModule(framework: Framework) { .store(AuthStore, [FetchService, GraphQLService, GlobalState]) .entity(AuthSession, [AuthStore]) .service(SubscriptionService, [SubscriptionStore]) - .store(SubscriptionStore, [GraphQLService, GlobalCache, UrlService]) - .entity(Subscription, [AuthService, ServerConfigService, SubscriptionStore]) - .entity(SubscriptionPrices, [ServerConfigService, SubscriptionStore]) + .store(SubscriptionStore, [ + GraphQLService, + GlobalCache, + UrlService, + ServerService, + ]) + .entity(Subscription, [AuthService, ServerService, SubscriptionStore]) + .entity(SubscriptionPrices, [ServerService, SubscriptionStore]) .service(UserQuotaService) .store(UserQuotaStore, [GraphQLService]) .entity(UserQuota, [AuthService, UserQuotaStore]) @@ -101,7 +121,7 @@ export function configureCloudModule(framework: Framework) { .entity(UserCopilotQuota, [ AuthService, UserCopilotQuotaStore, - ServerConfigService, + ServerService, ]) .service(UserFeatureService) .entity(UserFeature, [AuthService, UserFeatureStore]) @@ -114,4 +134,6 @@ export function configureCloudModule(framework: Framework) { .service(CloudDocMetaService) .entity(CloudDocMeta, [CloudDocMetaStore, DocService, GlobalCache]) .store(CloudDocMetaStore, [GraphQLService]); + + framework.scope(WorkspaceScope).service(WorkspaceServerService); } diff --git a/packages/frontend/core/src/modules/cloud/provider/fetch.ts b/packages/frontend/core/src/modules/cloud/provider/fetch.ts index 71991a76e2d4e..6fc49ea752eb0 100644 --- a/packages/frontend/core/src/modules/cloud/provider/fetch.ts +++ b/packages/frontend/core/src/modules/cloud/provider/fetch.ts @@ -2,15 +2,16 @@ import { createIdentifier } from '@toeverything/infra'; import type { FetchInit } from '../services/fetch'; -export interface FetchProvider { +export interface RawFetchProvider { /** * standard fetch, in ios&android, we can use native fetch to implement this */ fetch: (input: string | URL, init?: FetchInit) => Promise; } -export const FetchProvider = createIdentifier('FetchProvider'); +export const RawFetchProvider = + createIdentifier('FetchProvider'); -export const DefaultFetchProvider = { +export const DefaultRawFetchProvider = { fetch: globalThis.fetch.bind(globalThis), }; diff --git a/packages/frontend/core/src/modules/cloud/scopes/server.ts b/packages/frontend/core/src/modules/cloud/scopes/server.ts new file mode 100644 index 0000000000000..dd2e5e2adf532 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/scopes/server.ts @@ -0,0 +1,7 @@ +import { Scope } from '@toeverything/infra'; + +import type { Server } from '../entities/server'; + +export class ServerScope extends Scope<{ server: Server }> { + readonly server = this.props.server; +} diff --git a/packages/frontend/core/src/modules/cloud/services/captcha.ts b/packages/frontend/core/src/modules/cloud/services/captcha.ts index 8f9e92aeed794..5c41509cbbdce 100644 --- a/packages/frontend/core/src/modules/cloud/services/captcha.ts +++ b/packages/frontend/core/src/modules/cloud/services/captcha.ts @@ -11,10 +11,10 @@ import { EMPTY, exhaustMap, mergeMap } from 'rxjs'; import type { ValidatorProvider } from '../provider/validator'; import type { FetchService } from './fetch'; -import type { ServerConfigService } from './server-config'; +import type { ServerService } from './server'; export class CaptchaService extends Service { - needCaptcha$ = this.serverConfigService.serverConfig.features$.map( + needCaptcha$ = this.serverService.server.features$.map( r => r?.captcha || false ); challenge$ = new LiveData(undefined); @@ -23,7 +23,7 @@ export class CaptchaService extends Service { error$ = new LiveData(undefined); constructor( - private readonly serverConfigService: ServerConfigService, + private readonly serverService: ServerService, private readonly fetchService: FetchService, public readonly validatorProvider?: ValidatorProvider ) { diff --git a/packages/frontend/core/src/modules/cloud/services/fetch.ts b/packages/frontend/core/src/modules/cloud/services/fetch.ts index a03977e15583b..b989e629adc2c 100644 --- a/packages/frontend/core/src/modules/cloud/services/fetch.ts +++ b/packages/frontend/core/src/modules/cloud/services/fetch.ts @@ -3,22 +3,18 @@ import { UserFriendlyError } from '@affine/graphql'; import { fromPromise, Service } from '@toeverything/infra'; import { BackendError, NetworkError } from '../error'; -import type { FetchProvider } from '../provider/fetch'; - -export function getAffineCloudBaseUrl(): string { - if (BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS || BUILD_CONFIG.isAndroid) { - return BUILD_CONFIG.serverUrlPrefix; - } - const { protocol, hostname, port } = window.location; - return `${protocol}//${hostname}${port ? `:${port}` : ''}`; -} +import type { RawFetchProvider } from '../provider/fetch'; +import type { ServerService } from './server'; const logger = new DebugLogger('affine:fetch'); export type FetchInit = RequestInit & { timeout?: number }; export class FetchService extends Service { - constructor(private readonly fetchProvider: FetchProvider) { + constructor( + private readonly fetchProvider: RawFetchProvider, + private readonly serverService: ServerService + ) { super(); } rxFetch = ( @@ -55,7 +51,7 @@ export class FetchService extends Service { }, timeout); const res = await this.fetchProvider - .fetch(new URL(input, getAffineCloudBaseUrl()), { + .fetch(new URL(input, this.serverService.server.serverMetadata.baseUrl), { ...init, signal: abortController.signal, }) diff --git a/packages/frontend/core/src/modules/cloud/services/global-server.ts b/packages/frontend/core/src/modules/cloud/services/global-server.ts new file mode 100644 index 0000000000000..d48b1352b71ae --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/services/global-server.ts @@ -0,0 +1,26 @@ +import { ServerDeploymentType } from '@affine/graphql'; +import { Service } from '@toeverything/infra'; + +import type { Server } from '../entities/server'; +import type { ServersService } from './servers'; + +export class GlobalServerService extends Service { + readonly server: Server; + + constructor(private readonly serversService: ServersService) { + super(); + + // global server is always affine-cloud + const server = this.serversService.server$('affine-cloud').value; + if (!server) { + throw new Error('No server found'); + } + this.server = server; + } + + async waitForSelfhostedServerConfig() { + if (this.server.config$.value.type === ServerDeploymentType.Selfhosted) { + await this.server.waitForConfigRevalidation(); + } + } +} diff --git a/packages/frontend/core/src/modules/cloud/services/server-config.ts b/packages/frontend/core/src/modules/cloud/services/server-config.ts deleted file mode 100644 index 5555fdbc26409..0000000000000 --- a/packages/frontend/core/src/modules/cloud/services/server-config.ts +++ /dev/null @@ -1,12 +0,0 @@ -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(); - } -} diff --git a/packages/frontend/core/src/modules/cloud/services/server.ts b/packages/frontend/core/src/modules/cloud/services/server.ts new file mode 100644 index 0000000000000..cc10ce0d85483 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/services/server.ts @@ -0,0 +1,10 @@ +import { Service } from '@toeverything/infra'; + +import type { ServerScope } from '../scopes/server'; + +export class ServerService extends Service { + readonly server = this.serverScope.server; + constructor(private readonly serverScope: ServerScope) { + super(); + } +} diff --git a/packages/frontend/core/src/modules/cloud/services/servers.ts b/packages/frontend/core/src/modules/cloud/services/servers.ts new file mode 100644 index 0000000000000..4c4cb322238cd --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/services/servers.ts @@ -0,0 +1,55 @@ +import { LiveData, ObjectPool, Service } from '@toeverything/infra'; +import { finalize, of, switchMap } from 'rxjs'; + +import { Server } from '../entities/server'; +import type { ServerListStore } from '../stores/server-list'; +import type { ServerConfig, ServerMetadata } from '../types'; + +export class ServersService extends Service { + constructor(private readonly serverListStore: ServerListStore) { + super(); + } + + servers$ = LiveData.from( + this.serverListStore.watchServerList().pipe( + switchMap(metadatas => { + const refs = metadatas.map(metadata => { + const exists = this.serverPool.get(metadata.id); + if (exists) { + return exists; + } + const server = this.framework.createEntity(Server, { + serverMetadata: metadata, + }); + const ref = this.serverPool.put(metadata.id, server); + return ref; + }); + + return of(refs.map(ref => ref.obj)).pipe( + finalize(() => { + refs.forEach(ref => { + ref.release(); + }); + }) + ); + }) + ), + [] as any + ); + + server$(id: string) { + return this.servers$.map(servers => + servers.find(server => server.id === id) + ); + } + + private readonly serverPool = new ObjectPool({ + onDelete(obj) { + obj.dispose(); + }, + }); + + addServer(metadata: ServerMetadata, config: ServerConfig) { + this.serverListStore.addServer(metadata, config); + } +} diff --git a/packages/frontend/core/src/modules/cloud/services/websocket.ts b/packages/frontend/core/src/modules/cloud/services/websocket.ts index 2f5b49b76605e..29def8313022a 100644 --- a/packages/frontend/core/src/modules/cloud/services/websocket.ts +++ b/packages/frontend/core/src/modules/cloud/services/websocket.ts @@ -2,14 +2,14 @@ import { ApplicationStarted, OnEvent, Service } from '@toeverything/infra'; import { Manager } from 'socket.io-client'; import type { WebSocketAuthProvider } from '../provider/websocket-auth'; -import { getAffineCloudBaseUrl } from '../services/fetch'; import type { AuthService } from './auth'; import { AccountChanged } from './auth'; +import type { ServerService } from './server'; @OnEvent(AccountChanged, e => e.update) @OnEvent(ApplicationStarted, e => e.update) export class WebSocketService extends Service { - ioManager: Manager = new Manager(`${getAffineCloudBaseUrl()}/`, { + ioManager: Manager = new Manager(`${this.serverService.server.baseUrl}/`, { autoConnect: false, transports: ['websocket'], secure: location.protocol === 'https:', @@ -18,7 +18,7 @@ export class WebSocketService extends Service { auth: this.webSocketAuthProvider ? cb => { this.webSocketAuthProvider - ?.getAuthToken(`${getAffineCloudBaseUrl()}/`) + ?.getAuthToken(`${this.serverService.server.baseUrl}/`) .then(v => { cb(v ?? {}); }) @@ -31,6 +31,7 @@ export class WebSocketService extends Service { refCount = 0; constructor( + private readonly serverService: ServerService, private readonly authService: AuthService, private readonly webSocketAuthProvider?: WebSocketAuthProvider ) { diff --git a/packages/frontend/core/src/modules/cloud/services/workspace-server.ts b/packages/frontend/core/src/modules/cloud/services/workspace-server.ts new file mode 100644 index 0000000000000..e5cfdd27cd131 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/services/workspace-server.ts @@ -0,0 +1,11 @@ +import { Service } from '@toeverything/infra'; + +import type { Server } from '../entities/server'; + +export class WorkspaceServerService extends Service { + server: Server | null = null; + + bindServer(server: Server) { + this.server = server; + } +} diff --git a/packages/frontend/core/src/modules/cloud/stores/server-config.ts b/packages/frontend/core/src/modules/cloud/stores/server-config.ts index 508b4ca397e2a..a8a34ffddeaca 100644 --- a/packages/frontend/core/src/modules/cloud/stores/server-config.ts +++ b/packages/frontend/core/src/modules/cloud/stores/server-config.ts @@ -1,13 +1,17 @@ import { + type OauthProvidersQuery, oauthProvidersQuery, + type ServerConfigQuery, serverConfigQuery, ServerFeature, } from '@affine/graphql'; import { Store } from '@toeverything/infra'; -import type { ServerConfigType } from '../entities/server-config'; import type { GraphQLService } from '../services/graphql'; +export type ServerConfigType = ServerConfigQuery['serverConfig'] & + OauthProvidersQuery['serverConfig']; + export class ServerConfigStore extends Store { constructor(private readonly gqlService: GraphQLService) { super(); diff --git a/packages/frontend/core/src/modules/cloud/stores/server-list.ts b/packages/frontend/core/src/modules/cloud/stores/server-list.ts new file mode 100644 index 0000000000000..c8cedfe3bf2bc --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/stores/server-list.ts @@ -0,0 +1,85 @@ +import type { GlobalStateService } from '@toeverything/infra'; +import { Store } from '@toeverything/infra'; +import { map } from 'rxjs'; + +import { BUILD_IN_SERVERS } from '../constant'; +import type { ServerConfig, ServerMetadata } from '../types'; + +export class ServerListStore extends Store { + constructor(private readonly globalStateService: GlobalStateService) { + super(); + } + + watchServerList() { + return this.globalStateService.globalState + .watch('serverList') + .pipe( + map(servers => { + const serverList = [...BUILD_IN_SERVERS, ...(servers ?? [])]; + return serverList; + }) + ); + } + + getServerList() { + return [ + ...BUILD_IN_SERVERS, + ...(this.globalStateService.globalState.get( + 'serverList' + ) ?? []), + ]; + } + + addServer(server: ServerMetadata, serverConfig: ServerConfig) { + this.updateServerConfig(server.id, serverConfig); + const oldServers = + this.globalStateService.globalState.get('serverList') ?? + []; + + this.globalStateService.globalState.set('serverList', [ + ...oldServers, + server, + ]); + } + + removeServer(serverId: string) { + const oldServers = + this.globalStateService.globalState.get('serverList') ?? + []; + + this.globalStateService.globalState.set( + 'serverList', + oldServers.filter(server => server.id !== serverId) + ); + } + + watchServerConfig(serverId: string) { + return this.globalStateService.globalState + .watch(`serverConfig:${serverId}`) + .pipe( + map(config => { + if (!config) { + return BUILD_IN_SERVERS.find(server => server.id === serverId) + ?.config; + } else { + return config; + } + }) + ); + } + + getServerConfig(serverId: string) { + return ( + this.globalStateService.globalState.get( + `serverConfig:${serverId}` + ) ?? BUILD_IN_SERVERS.find(server => server.id === serverId)?.config + ); + } + + updateServerConfig(serverId: string, config: ServerConfig) { + this.globalStateService.globalState.set( + `serverConfig:${serverId}`, + config + ); + } +} diff --git a/packages/frontend/core/src/modules/cloud/stores/subscription.ts b/packages/frontend/core/src/modules/cloud/stores/subscription.ts index c4a478746d054..a1c44b1264fef 100644 --- a/packages/frontend/core/src/modules/cloud/stores/subscription.ts +++ b/packages/frontend/core/src/modules/cloud/stores/subscription.ts @@ -16,18 +16,19 @@ import { Store } from '@toeverything/infra'; import type { UrlService } from '../../url'; import type { SubscriptionType } from '../entities/subscription'; -import { getAffineCloudBaseUrl } from '../services/fetch'; import type { GraphQLService } from '../services/graphql'; +import type { ServerService } from '../services/server'; const SUBSCRIPTION_CACHE_KEY = 'subscription:'; const getDefaultSubscriptionSuccessCallbackLink = ( + baseUrl: string, plan: SubscriptionPlan | null, scheme?: string ) => { const path = plan === SubscriptionPlan.AI ? '/ai-upgrade-success' : '/upgrade-success'; - const urlString = getAffineCloudBaseUrl() + path; + const urlString = baseUrl + path; const url = new URL(urlString); if (scheme) { url.searchParams.set('scheme', scheme); @@ -39,7 +40,8 @@ export class SubscriptionStore extends Store { constructor( private readonly gqlService: GraphQLService, private readonly globalCache: GlobalCache, - private readonly urlService: UrlService + private readonly urlService: UrlService, + private readonly serverService: ServerService ) { super(); } @@ -132,6 +134,7 @@ export class SubscriptionStore extends Store { successCallbackLink: input.successCallbackLink || getDefaultSubscriptionSuccessCallbackLink( + this.serverService.server.baseUrl, input.plan, this.urlService.getClientScheme() ), diff --git a/packages/frontend/core/src/modules/cloud/types.ts b/packages/frontend/core/src/modules/cloud/types.ts new file mode 100644 index 0000000000000..4d86deab867c3 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/types.ts @@ -0,0 +1,22 @@ +import type { + CredentialsRequirementType, + OAuthProviderType, + ServerDeploymentType, + ServerFeature, +} from '@affine/graphql'; + +export interface ServerMetadata { + id: string; + + baseUrl: string; +} + +export interface ServerConfig { + serverName: string; + features: ServerFeature[]; + oauthProviders: OAuthProviderType[]; + type: ServerDeploymentType; + initialized?: boolean; + version?: string; + credentialsRequirement: CredentialsRequirementType; +} diff --git a/packages/frontend/core/src/modules/editor-setting/impls/user-db.ts b/packages/frontend/core/src/modules/editor-setting/impls/user-db.ts index 2c12704f989e8..5d132230d73f8 100644 --- a/packages/frontend/core/src/modules/editor-setting/impls/user-db.ts +++ b/packages/frontend/core/src/modules/editor-setting/impls/user-db.ts @@ -2,21 +2,29 @@ import type { GlobalState } from '@toeverything/infra'; import { Service } from '@toeverything/infra'; import { map, type Observable, switchMap } from 'rxjs'; -import type { UserDBService } from '../../userspace'; +import type { ServersService } from '../../cloud'; +import { UserDBService } from '../../userspace'; import type { EditorSettingProvider } from '../provider/editor-setting-provider'; export class CurrentUserDBEditorSettingProvider extends Service implements EditorSettingProvider { - currentUserDB$ = this.userDBService.currentUserDB.db$; + private readonly currentUserDB$; fallback = new GlobalStateEditorSettingProvider(this.globalState); constructor( - public readonly userDBService: UserDBService, + public readonly serversService: ServersService, public readonly globalState: GlobalState ) { super(); + + const affineCloudServer = this.serversService.server$('affine-cloud').value; // TODO: support multiple servers + if (!affineCloudServer) { + throw new Error('affine-cloud server not found'); + } + const userDBService = affineCloudServer.scope.get(UserDBService); + this.currentUserDB$ = userDBService.currentUserDB.db$; } set(key: string, value: string): void { diff --git a/packages/frontend/core/src/modules/editor-setting/index.ts b/packages/frontend/core/src/modules/editor-setting/index.ts index 68a36e55bbf30..1d08a2e06d204 100644 --- a/packages/frontend/core/src/modules/editor-setting/index.ts +++ b/packages/frontend/core/src/modules/editor-setting/index.ts @@ -1,6 +1,6 @@ import { type Framework, GlobalState } from '@toeverything/infra'; -import { UserDBService } from '../userspace'; +import { ServersService } from '../cloud'; import { EditorSetting } from './entities/editor-setting'; import { CurrentUserDBEditorSettingProvider } from './impls/user-db'; import { EditorSettingProvider } from './provider/editor-setting-provider'; @@ -14,7 +14,7 @@ export function configureEditorSettingModule(framework: Framework) { .service(EditorSettingService) .entity(EditorSetting, [EditorSettingProvider]) .impl(EditorSettingProvider, CurrentUserDBEditorSettingProvider, [ - UserDBService, + ServersService, GlobalState, ]); } diff --git a/packages/frontend/core/src/modules/favorite/index.ts b/packages/frontend/core/src/modules/favorite/index.ts index 441fc13724174..6c8d42e14318c 100644 --- a/packages/frontend/core/src/modules/favorite/index.ts +++ b/packages/frontend/core/src/modules/favorite/index.ts @@ -27,7 +27,15 @@ export function configureFavoriteModule(framework: Framework) { .scope(WorkspaceScope) .service(FavoriteService) .entity(FavoriteList, [FavoriteStore]) - .store(FavoriteStore, [AuthService, WorkspaceDBService, WorkspaceService]) + .store( + FavoriteStore, + framework => + new FavoriteStore( + framework.get(WorkspaceDBService), + framework.get(WorkspaceService), + framework.getOptional(AuthService) + ) + ) .service(MigrationFavoriteItemsAdapter, [WorkspaceService]) .service(CompatibleFavoriteItemsAdapter, [FavoriteService]); } diff --git a/packages/frontend/core/src/modules/favorite/stores/favorite.ts b/packages/frontend/core/src/modules/favorite/stores/favorite.ts index 71ca84ad4302f..ee59e31635bdf 100644 --- a/packages/frontend/core/src/modules/favorite/stores/favorite.ts +++ b/packages/frontend/core/src/modules/favorite/stores/favorite.ts @@ -15,26 +15,29 @@ export interface FavoriteRecord { export class FavoriteStore extends Store { constructor( - private readonly authService: AuthService, private readonly workspaceDBService: WorkspaceDBService, - private readonly workspaceService: WorkspaceService + private readonly workspaceService: WorkspaceService, + private readonly authService?: AuthService ) { super(); } private get userdataDB$() { - return this.authService.session.account$.map(account => { - // if is local workspace or no account, use __local__ userdata - // sometimes we may have cloud workspace but no account for a short time, we also use __local__ userdata - if ( - this.workspaceService.workspace.meta.flavour === - WorkspaceFlavour.LOCAL || - !account - ) { - return this.workspaceDBService.userdataDB('__local__'); - } - return this.workspaceDBService.userdataDB(account.id); - }); + // if is local workspace or no account, use __local__ userdata + // sometimes we may have cloud workspace but no account for a short time, we also use __local__ userdata + if ( + this.workspaceService.workspace.meta.flavour === WorkspaceFlavour.LOCAL || + !this.authService + ) { + return new LiveData(this.workspaceDBService.userdataDB('__local__')); + } else { + return this.authService.session.account$.map(account => { + if (!account) { + return this.workspaceDBService.userdataDB('__local__'); + } + return this.workspaceDBService.userdataDB(account.id); + }); + } } watchIsLoading() { diff --git a/packages/frontend/core/src/modules/permissions/index.ts b/packages/frontend/core/src/modules/permissions/index.ts index 17b5b949d31b4..ca67a5aab8f1a 100644 --- a/packages/frontend/core/src/modules/permissions/index.ts +++ b/packages/frontend/core/src/modules/permissions/index.ts @@ -2,7 +2,6 @@ export type { Member } from './entities/members'; export { WorkspaceMembersService } from './services/members'; export { WorkspacePermissionService } from './services/permission'; -import { GraphQLService } from '@affine/core/modules/cloud'; import { type Framework, WorkspaceScope, @@ -10,6 +9,7 @@ import { WorkspacesService, } from '@toeverything/infra'; +import { WorkspaceServerService } from '../cloud'; import { WorkspaceMembers } from './entities/members'; import { WorkspacePermission } from './entities/permission'; import { WorkspaceMembersService } from './services/members'; @@ -25,9 +25,9 @@ export function configurePermissionsModule(framework: Framework) { WorkspacesService, WorkspacePermissionStore, ]) - .store(WorkspacePermissionStore, [GraphQLService]) + .store(WorkspacePermissionStore, [WorkspaceServerService]) .entity(WorkspacePermission, [WorkspaceService, WorkspacePermissionStore]) .service(WorkspaceMembersService) - .store(WorkspaceMembersStore, [GraphQLService]) + .store(WorkspaceMembersStore, [WorkspaceServerService]) .entity(WorkspaceMembers, [WorkspaceMembersStore, WorkspaceService]); } diff --git a/packages/frontend/core/src/modules/permissions/stores/members.ts b/packages/frontend/core/src/modules/permissions/stores/members.ts index 37bf2b81444aa..25b14635496e3 100644 --- a/packages/frontend/core/src/modules/permissions/stores/members.ts +++ b/packages/frontend/core/src/modules/permissions/stores/members.ts @@ -1,10 +1,10 @@ import { getMembersByWorkspaceIdQuery } from '@affine/graphql'; import { Store } from '@toeverything/infra'; -import type { GraphQLService } from '../../cloud'; +import type { WorkspaceServerService } from '../../cloud'; export class WorkspaceMembersStore extends Store { - constructor(private readonly graphqlService: GraphQLService) { + constructor(private readonly workspaceServerService: WorkspaceServerService) { super(); } @@ -14,7 +14,10 @@ export class WorkspaceMembersStore extends Store { take: number, signal?: AbortSignal ) { - const data = await this.graphqlService.gql({ + if (!this.workspaceServerService.server) { + throw new Error('No Server'); + } + const data = await this.workspaceServerService.server.gql({ query: getMembersByWorkspaceIdQuery, variables: { workspaceId, diff --git a/packages/frontend/core/src/modules/permissions/stores/permission.ts b/packages/frontend/core/src/modules/permissions/stores/permission.ts index 02f756f729594..6a745f8f2e0a6 100644 --- a/packages/frontend/core/src/modules/permissions/stores/permission.ts +++ b/packages/frontend/core/src/modules/permissions/stores/permission.ts @@ -1,14 +1,17 @@ -import type { GraphQLService } from '@affine/core/modules/cloud'; +import type { WorkspaceServerService } from '@affine/core/modules/cloud'; import { getIsOwnerQuery, leaveWorkspaceMutation } from '@affine/graphql'; import { Store } from '@toeverything/infra'; export class WorkspacePermissionStore extends Store { - constructor(private readonly graphqlService: GraphQLService) { + constructor(private readonly workspaceServerService: WorkspaceServerService) { super(); } async fetchIsOwner(workspaceId: string, signal?: AbortSignal) { - const isOwner = await this.graphqlService.gql({ + if (!this.workspaceServerService.server) { + throw new Error('No Server'); + } + const isOwner = await this.workspaceServerService.server.gql({ query: getIsOwnerQuery, variables: { workspaceId, @@ -23,7 +26,10 @@ export class WorkspacePermissionStore extends Store { * @param workspaceName for send email */ async leaveWorkspace(workspaceId: string, workspaceName: string) { - await this.graphqlService.gql({ + if (!this.workspaceServerService.server) { + throw new Error('No Server'); + } + await this.workspaceServerService.server.gql({ query: leaveWorkspaceMutation, variables: { workspaceId, diff --git a/packages/frontend/core/src/modules/quota/index.ts b/packages/frontend/core/src/modules/quota/index.ts index b0891bed569ce..0d75a8e4aed9e 100644 --- a/packages/frontend/core/src/modules/quota/index.ts +++ b/packages/frontend/core/src/modules/quota/index.ts @@ -1,12 +1,12 @@ export { WorkspaceQuotaService } from './services/quota'; -import { GraphQLService } from '@affine/core/modules/cloud'; import { type Framework, WorkspaceScope, WorkspaceService, } from '@toeverything/infra'; +import { WorkspaceServerService } from '../cloud'; import { WorkspaceQuota } from './entities/quota'; import { WorkspaceQuotaService } from './services/quota'; import { WorkspaceQuotaStore } from './stores/quota'; @@ -15,6 +15,6 @@ export function configureQuotaModule(framework: Framework) { framework .scope(WorkspaceScope) .service(WorkspaceQuotaService) - .store(WorkspaceQuotaStore, [GraphQLService]) + .store(WorkspaceQuotaStore, [WorkspaceServerService]) .entity(WorkspaceQuota, [WorkspaceService, WorkspaceQuotaStore]); } diff --git a/packages/frontend/core/src/modules/quota/stores/quota.ts b/packages/frontend/core/src/modules/quota/stores/quota.ts index 1db66f5bc2475..3de8016fdaf3a 100644 --- a/packages/frontend/core/src/modules/quota/stores/quota.ts +++ b/packages/frontend/core/src/modules/quota/stores/quota.ts @@ -1,14 +1,17 @@ -import type { GraphQLService } from '@affine/core/modules/cloud'; +import type { WorkspaceServerService } from '@affine/core/modules/cloud'; import { workspaceQuotaQuery } from '@affine/graphql'; import { Store } from '@toeverything/infra'; export class WorkspaceQuotaStore extends Store { - constructor(private readonly graphqlService: GraphQLService) { + constructor(private readonly workspaceServerService: WorkspaceServerService) { super(); } async fetchWorkspaceQuota(workspaceId: string, signal?: AbortSignal) { - const data = await this.graphqlService.gql({ + if (!this.workspaceServerService.server) { + throw new Error('No Server'); + } + const data = await this.workspaceServerService.server.gql({ query: workspaceQuotaQuery, variables: { id: workspaceId, diff --git a/packages/frontend/core/src/modules/share-doc/index.ts b/packages/frontend/core/src/modules/share-doc/index.ts index 2da715595deea..3feaa92939d34 100644 --- a/packages/frontend/core/src/modules/share-doc/index.ts +++ b/packages/frontend/core/src/modules/share-doc/index.ts @@ -12,7 +12,7 @@ import { WorkspaceService, } from '@toeverything/infra'; -import { FetchService, GraphQLService } from '../cloud'; +import { RawFetchProvider, WorkspaceServerService } from '../cloud'; import { ShareDocsList } from './entities/share-docs-list'; import { ShareInfo } from './entities/share-info'; import { ShareReader } from './entities/share-reader'; @@ -27,10 +27,10 @@ export function configureShareDocsModule(framework: Framework) { framework .service(ShareReaderService) .entity(ShareReader, [ShareReaderStore]) - .store(ShareReaderStore, [FetchService]) + .store(ShareReaderStore, [RawFetchProvider]) .scope(WorkspaceScope) .service(ShareDocsListService, [WorkspaceService]) - .store(ShareDocsStore, [GraphQLService]) + .store(ShareDocsStore, [WorkspaceServerService]) .entity(ShareDocsList, [ WorkspaceService, ShareDocsStore, @@ -39,5 +39,5 @@ export function configureShareDocsModule(framework: Framework) { .scope(DocScope) .service(ShareInfoService) .entity(ShareInfo, [WorkspaceService, DocService, ShareStore]) - .store(ShareStore, [GraphQLService]); + .store(ShareStore, [WorkspaceServerService]); } diff --git a/packages/frontend/core/src/modules/share-doc/stores/share-docs.ts b/packages/frontend/core/src/modules/share-doc/stores/share-docs.ts index 2f67867ec56f4..79f23cedaea08 100644 --- a/packages/frontend/core/src/modules/share-doc/stores/share-docs.ts +++ b/packages/frontend/core/src/modules/share-doc/stores/share-docs.ts @@ -1,14 +1,17 @@ -import type { GraphQLService } from '@affine/core/modules/cloud'; +import type { WorkspaceServerService } from '@affine/core/modules/cloud'; import { getWorkspacePublicPagesQuery } from '@affine/graphql'; import { Store } from '@toeverything/infra'; export class ShareDocsStore extends Store { - constructor(private readonly graphqlService: GraphQLService) { + constructor(private readonly workspaceServerService: WorkspaceServerService) { super(); } async getWorkspacesShareDocs(workspaceId: string, signal?: AbortSignal) { - const data = await this.graphqlService.gql({ + if (!this.workspaceServerService.server) { + throw new Error('No Server'); + } + const data = await this.workspaceServerService.server.gql({ query: getWorkspacePublicPagesQuery, variables: { workspaceId: workspaceId, diff --git a/packages/frontend/core/src/modules/share-doc/stores/share-reader.ts b/packages/frontend/core/src/modules/share-doc/stores/share-reader.ts index fd9e66bea65ce..bf41d02ffdd41 100644 --- a/packages/frontend/core/src/modules/share-doc/stores/share-reader.ts +++ b/packages/frontend/core/src/modules/share-doc/stores/share-reader.ts @@ -2,16 +2,20 @@ import { ErrorNames, UserFriendlyError } from '@affine/graphql'; import type { DocMode } from '@blocksuite/affine/blocks'; import { Store } from '@toeverything/infra'; -import { type FetchService, isBackendError } from '../../cloud'; +import type { RawFetchProvider } from '../../cloud'; +import { isBackendError } from '../../cloud'; export class ShareReaderStore extends Store { - constructor(private readonly fetchService: FetchService) { + constructor(private readonly rawFetch?: RawFetchProvider) { super(); } async loadShare(workspaceId: string, docId: string) { + if (!this.rawFetch) { + throw new Error('No Fetch Service'); + } try { - const docResponse = await this.fetchService.fetch( + const docResponse = await this.rawFetch.fetch( `/api/workspaces/${workspaceId}/docs/${docId}` ); const publishMode = docResponse.headers.get( @@ -19,7 +23,7 @@ export class ShareReaderStore extends Store { ) as DocMode | null; const docBinary = await docResponse.arrayBuffer(); - const workspaceResponse = await this.fetchService.fetch( + const workspaceResponse = await this.rawFetch.fetch( `/api/workspaces/${workspaceId}/docs/${workspaceId}` ); const workspaceBinary = await workspaceResponse.arrayBuffer(); diff --git a/packages/frontend/core/src/modules/share-doc/stores/share.ts b/packages/frontend/core/src/modules/share-doc/stores/share.ts index f6abe0bf86667..2555102ff2be0 100644 --- a/packages/frontend/core/src/modules/share-doc/stores/share.ts +++ b/packages/frontend/core/src/modules/share-doc/stores/share.ts @@ -6,10 +6,10 @@ import { } from '@affine/graphql'; import { Store } from '@toeverything/infra'; -import type { GraphQLService } from '../../cloud'; +import type { WorkspaceServerService } from '../../cloud'; export class ShareStore extends Store { - constructor(private readonly gqlService: GraphQLService) { + constructor(private readonly workspaceServerService: WorkspaceServerService) { super(); } @@ -18,7 +18,10 @@ export class ShareStore extends Store { docId: string, signal?: AbortSignal ) { - const data = await this.gqlService.gql({ + if (!this.workspaceServerService.server) { + throw new Error('No Server'); + } + const data = await this.workspaceServerService.server.gql({ query: getWorkspacePublicPageByIdQuery, variables: { pageId: docId, @@ -37,7 +40,10 @@ export class ShareStore extends Store { docMode?: PublicPageMode, signal?: AbortSignal ) { - await this.gqlService.gql({ + if (!this.workspaceServerService.server) { + throw new Error('No Server'); + } + await this.workspaceServerService.server.gql({ query: publishPageMutation, variables: { pageId, @@ -55,7 +61,10 @@ export class ShareStore extends Store { pageId: string, signal?: AbortSignal ) { - await this.gqlService.gql({ + if (!this.workspaceServerService.server) { + throw new Error('No Server'); + } + await this.workspaceServerService.server.gql({ query: revokePublicPageMutation, variables: { pageId, diff --git a/packages/frontend/core/src/modules/share-setting/index.ts b/packages/frontend/core/src/modules/share-setting/index.ts index d751ba7cd8e71..3e24a82c49e57 100644 --- a/packages/frontend/core/src/modules/share-setting/index.ts +++ b/packages/frontend/core/src/modules/share-setting/index.ts @@ -1,12 +1,12 @@ export { WorkspaceShareSettingService } from './services/share-setting'; -import { GraphQLService } from '@affine/core/modules/cloud'; import { type Framework, WorkspaceScope, WorkspaceService, } from '@toeverything/infra'; +import { WorkspaceServerService } from '../cloud'; import { WorkspaceShareSetting } from './entities/share-setting'; import { WorkspaceShareSettingService } from './services/share-setting'; import { WorkspaceShareSettingStore } from './stores/share-setting'; @@ -15,7 +15,7 @@ export function configureShareSettingModule(framework: Framework) { framework .scope(WorkspaceScope) .service(WorkspaceShareSettingService) - .store(WorkspaceShareSettingStore, [GraphQLService]) + .store(WorkspaceShareSettingStore, [WorkspaceServerService]) .entity(WorkspaceShareSetting, [ WorkspaceService, WorkspaceShareSettingStore, diff --git a/packages/frontend/core/src/modules/share-setting/stores/share-setting.ts b/packages/frontend/core/src/modules/share-setting/stores/share-setting.ts index 7521303b42a88..5a3006c4ebb5b 100644 --- a/packages/frontend/core/src/modules/share-setting/stores/share-setting.ts +++ b/packages/frontend/core/src/modules/share-setting/stores/share-setting.ts @@ -1,4 +1,4 @@ -import type { GraphQLService } from '@affine/core/modules/cloud'; +import type { WorkspaceServerService } from '@affine/core/modules/cloud'; import { getEnableUrlPreviewQuery, setEnableUrlPreviewMutation, @@ -6,7 +6,7 @@ import { import { Store } from '@toeverything/infra'; export class WorkspaceShareSettingStore extends Store { - constructor(private readonly graphqlService: GraphQLService) { + constructor(private readonly workspaceServerService: WorkspaceServerService) { super(); } @@ -14,7 +14,10 @@ export class WorkspaceShareSettingStore extends Store { workspaceId: string, signal?: AbortSignal ) { - const data = await this.graphqlService.gql({ + if (!this.workspaceServerService.server) { + throw new Error('No Server'); + } + const data = await this.workspaceServerService.server.gql({ query: getEnableUrlPreviewQuery, variables: { id: workspaceId, @@ -31,7 +34,10 @@ export class WorkspaceShareSettingStore extends Store { enableUrlPreview: boolean, signal?: AbortSignal ) { - await this.graphqlService.gql({ + if (!this.workspaceServerService.server) { + throw new Error('No Server'); + } + await this.workspaceServerService.server.gql({ query: setEnableUrlPreviewMutation, variables: { id: workspaceId, diff --git a/packages/frontend/core/src/modules/telemetry/index.ts b/packages/frontend/core/src/modules/telemetry/index.ts index 547b865f5e791..5a7d827ca0067 100644 --- a/packages/frontend/core/src/modules/telemetry/index.ts +++ b/packages/frontend/core/src/modules/telemetry/index.ts @@ -1,8 +1,8 @@ import { type Framework, GlobalContextService } from '@toeverything/infra'; -import { AuthService } from '../cloud'; +import { ServersService } from '../cloud/services/servers'; import { TelemetryService } from './services/telemetry'; export function configureTelemetryModule(framework: Framework) { - framework.service(TelemetryService, [AuthService, GlobalContextService]); + framework.service(TelemetryService, [ServersService, GlobalContextService]); } diff --git a/packages/frontend/core/src/modules/telemetry/services/telemetry.ts b/packages/frontend/core/src/modules/telemetry/services/telemetry.ts index b13325a24aefc..2646a8292e291 100644 --- a/packages/frontend/core/src/modules/telemetry/services/telemetry.ts +++ b/packages/frontend/core/src/modules/telemetry/services/telemetry.ts @@ -2,26 +2,31 @@ import { mixpanel } from '@affine/track'; import type { GlobalContextService } from '@toeverything/infra'; import { ApplicationStarted, OnEvent, Service } from '@toeverything/infra'; -import { - AccountChanged, - type AuthAccountInfo, - type AuthService, -} from '../../cloud'; +import { AccountChanged, type AuthAccountInfo, AuthService } from '../../cloud'; import { AccountLoggedOut } from '../../cloud/services/auth'; +import type { ServersService } from '../../cloud/services/servers'; @OnEvent(ApplicationStarted, e => e.onApplicationStart) @OnEvent(AccountChanged, e => e.updateIdentity) @OnEvent(AccountLoggedOut, e => e.onAccountLoggedOut) export class TelemetryService extends Service { + private readonly authService; constructor( - private readonly auth: AuthService, + serversService: ServersService, private readonly globalContextService: GlobalContextService ) { super(); + + // TODO: support multiple servers + const affineCloudServer = serversService.server$('affine-cloud').value; + if (!affineCloudServer) { + throw new Error('affine-cloud server not found'); + } + this.authService = affineCloudServer.scope.get(AuthService); } onApplicationStart() { - const account = this.auth.session.account$.value; + const account = this.authService.session.account$.value; this.updateIdentity(account); this.registerMiddlewares(); } diff --git a/packages/frontend/core/src/modules/userspace/index.ts b/packages/frontend/core/src/modules/userspace/index.ts index e7135007fa6b3..fddc8f7fd22a6 100644 --- a/packages/frontend/core/src/modules/userspace/index.ts +++ b/packages/frontend/core/src/modules/userspace/index.ts @@ -3,6 +3,7 @@ export { UserspaceService as UserDBService } from './services/userspace'; import type { Framework } from '@toeverything/infra'; import { AuthService, WebSocketService } from '../cloud'; +import { ServerScope } from '../cloud/scopes/server'; import { DesktopApiService } from '../desktop-api/service/desktop-api'; import { CurrentUserDB } from './entities/current-user-db'; import { UserDB } from './entities/user-db'; @@ -15,6 +16,7 @@ import { UserspaceService } from './services/userspace'; export function configureUserspaceModule(framework: Framework) { framework + .scope(ServerScope) .service(UserspaceService) .entity(CurrentUserDB, [UserspaceService, AuthService]) .entity(UserDB) diff --git a/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts b/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts index dab92dc960839..214f0f0a3c26b 100644 --- a/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts +++ b/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts @@ -30,13 +30,14 @@ import { nanoid } from 'nanoid'; import { EMPTY, map, mergeMap } from 'rxjs'; import { applyUpdate, encodeStateAsUpdate } from 'yjs'; -import type { +import { + AccountChanged, AuthService, FetchService, GraphQLService, WebSocketService, } from '../../cloud'; -import { AccountChanged } from '../../cloud'; +import type { ServersService } from '../../cloud/services/servers'; import type { WorkspaceEngineStorageProvider } from '../providers/engine'; import { BroadcastChannelAwarenessConnection } from './engine/awareness-broadcast-channel'; import { CloudAwarenessConnection } from './engine/awareness-cloud'; @@ -55,16 +56,28 @@ export class CloudWorkspaceFlavourProviderService extends Service implements WorkspaceFlavourProvider { + private readonly authService: AuthService; + private readonly webSocketService: WebSocketService; + private readonly fetchService: FetchService; + private readonly graphqlService: GraphQLService; + constructor( private readonly globalState: GlobalState, - private readonly authService: AuthService, private readonly storageProvider: WorkspaceEngineStorageProvider, - private readonly graphqlService: GraphQLService, - private readonly webSocketService: WebSocketService, - private readonly fetchService: FetchService + serversService: ServersService ) { super(); + // TODO: support multiple servers + const affineCloudServer = serversService.server$('affine-cloud').value; + if (!affineCloudServer) { + throw new Error('affine-cloud server not found'); + } + this.authService = affineCloudServer.scope.get(AuthService); + this.webSocketService = affineCloudServer.scope.get(WebSocketService); + this.fetchService = affineCloudServer.scope.get(FetchService); + this.graphqlService = affineCloudServer.scope.get(GraphQLService); } + flavour: WorkspaceFlavour = WorkspaceFlavour.AFFINE_CLOUD; async deleteWorkspace(id: string): Promise { @@ -244,6 +257,7 @@ export class CloudWorkspaceFlavourProviderService ); return await cloudBlob.get(blob); } + getEngineProvider(workspaceId: string): WorkspaceEngineProvider { return { getAwarenessConnections: () => { diff --git a/packages/frontend/core/src/modules/workspace-engine/index.ts b/packages/frontend/core/src/modules/workspace-engine/index.ts index 40d753d1945c9..65581eade662a 100644 --- a/packages/frontend/core/src/modules/workspace-engine/index.ts +++ b/packages/frontend/core/src/modules/workspace-engine/index.ts @@ -1,15 +1,10 @@ -import { - AuthService, - FetchService, - GraphQLService, - WebSocketService, -} from '@affine/core/modules/cloud'; import { type Framework, GlobalState, WorkspaceFlavourProvider, } from '@toeverything/infra'; +import { ServersService } from '../cloud/services/servers'; import { DesktopApiService } from '../desktop-api'; import { CloudWorkspaceFlavourProviderService } from './impls/cloud'; import { IndexedDBBlobStorage } from './impls/engine/blob-indexeddb'; @@ -31,11 +26,8 @@ export function configureBrowserWorkspaceFlavours(framework: Framework) { ]) .service(CloudWorkspaceFlavourProviderService, [ GlobalState, - AuthService, WorkspaceEngineStorageProvider, - GraphQLService, - WebSocketService, - FetchService, + ServersService, ]) .impl(WorkspaceFlavourProvider('CLOUD'), p => p.get(CloudWorkspaceFlavourProviderService) diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index 2bc168d6cf542..07288ef8fe6b6 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -998,6 +998,7 @@ query serverConfig { name features type + initialized credentialsRequirement { ...CredentialsRequirement } diff --git a/packages/frontend/graphql/src/graphql/server-config.gql b/packages/frontend/graphql/src/graphql/server-config.gql index ef17ef188a6c8..1987157dbc4c5 100644 --- a/packages/frontend/graphql/src/graphql/server-config.gql +++ b/packages/frontend/graphql/src/graphql/server-config.gql @@ -8,6 +8,7 @@ query serverConfig { name features type + initialized credentialsRequirement { ...CredentialsRequirement } diff --git a/packages/frontend/graphql/src/index.ts b/packages/frontend/graphql/src/index.ts index 7ad9c47580b6e..95546b71bc349 100644 --- a/packages/frontend/graphql/src/index.ts +++ b/packages/frontend/graphql/src/index.ts @@ -2,23 +2,3 @@ export * from './error'; export * from './fetcher'; export * from './graphql'; export * from './schema'; - -import { setupGlobal } from '@affine/env/global'; - -import { gqlFetcherFactory } from './fetcher'; - -setupGlobal(); - -export function getBaseUrl(): string { - if (BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS || BUILD_CONFIG.isAndroid) { - return BUILD_CONFIG.serverUrlPrefix; - } - if (typeof window === 'undefined') { - // is nodejs - return ''; - } - const { protocol, hostname, port } = window.location; - return `${protocol}//${hostname}${port ? `:${port}` : ''}`; -} - -export const fetcher = gqlFetcherFactory(getBaseUrl() + '/graphql'); diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index 039a08c63f1b8..2ff409632e802 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -2175,6 +2175,7 @@ export type ServerConfigQuery = { name: string; features: Array; type: ServerDeploymentType; + initialized: boolean; credentialsRequirement: { __typename?: 'CredentialsRequirementType'; password: {