refactor: remove package @affine/datacenter (#1705)

This commit is contained in:
Himself65 2023-03-27 17:48:22 -05:00 committed by GitHub
parent 021bf6534b
commit ed29c5fbd9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
87 changed files with 187 additions and 5231 deletions

View File

@ -5,7 +5,6 @@
"editor.formatOnSaveMode": "file",
"cSpell.words": [
"blocksuite",
"datacenter",
"livedemo",
"yarn",
"jwst",

View File

@ -68,7 +68,6 @@ const nextConfig = {
reactStrictMode: true,
transpilePackages: [
'@affine/component',
'@affine/datacenter',
'@affine/i18n',
'@affine/debug',
'@affine/env',

View File

@ -10,7 +10,6 @@
},
"dependencies": {
"@affine/component": "workspace:*",
"@affine/datacenter": "workspace:*",
"@affine/debug": "workspace:*",
"@affine/env": "workspace:*",
"@affine/i18n": "workspace:*",

View File

@ -1,8 +1,9 @@
import { getLoginStorage } from '@affine/workspace/affine/login';
import { createEmptyBlockSuiteWorkspace } from '@affine/workspace/utils';
import { atom } from 'jotai/index';
import { BlockSuiteWorkspace } from '../../shared';
import { apis } from '../../shared/apis';
import { affineApis } from '../../shared/apis';
export const publicWorkspaceIdAtom = atom<string | null>(null);
export const publicBlockSuiteAtom = atom<Promise<BlockSuiteWorkspace>>(
@ -11,7 +12,7 @@ export const publicBlockSuiteAtom = atom<Promise<BlockSuiteWorkspace>>(
if (!workspaceId) {
throw new Error('No workspace id');
}
const binary = await apis.downloadWorkspace(workspaceId, true);
const binary = await affineApis.downloadWorkspace(workspaceId, true);
// fixme: this is a hack
const params = new URLSearchParams(window.location.search);
const prefixUrl = params.get('prefixUrl')
@ -21,7 +22,9 @@ export const publicBlockSuiteAtom = atom<Promise<BlockSuiteWorkspace>>(
workspaceId,
(k: string) =>
// fixme: token could be expired
({ api: `${prefixUrl}api/workspace`, token: apis.auth.token }[k])
({ api: `${prefixUrl}api/workspace`, token: getLoginStorage()?.token }[
k
])
);
BlockSuiteWorkspace.Y.applyUpdate(
blockSuiteWorkspace.doc,

View File

@ -2,7 +2,7 @@ import { assertExists } from '@blocksuite/store';
import type { AffineDownloadProvider } from '../../../shared';
import { BlockSuiteWorkspace } from '../../../shared';
import { apis } from '../../../shared/apis';
import { affineApis } from '../../../shared/apis';
import { providerLogger } from '../../logger';
const hashMap = new Map<string, ArrayBuffer>();
@ -25,7 +25,7 @@ export const createAffineDownloadProvider = (
);
return;
}
apis.downloadWorkspace(id, false).then(binary => {
affineApis.downloadWorkspace(id, false).then(binary => {
hashMap.set(id, binary);
providerLogger.debug('applyUpdate');
BlockSuiteWorkspace.Y.applyUpdate(

View File

@ -1,4 +1,5 @@
import { WebsocketProvider } from '@affine/datacenter';
import { getLoginStorage } from '@affine/workspace/affine/login';
import { WebsocketProvider } from '@affine/workspace/affine/sync';
import { assertExists } from '@blocksuite/store';
import { IndexeddbPersistence } from 'y-indexeddb';
@ -7,7 +8,6 @@ import type {
BlockSuiteWorkspace,
LocalIndexedDBProvider,
} from '../../shared';
import { apis } from '../../shared/apis';
import { providerLogger } from '../logger';
import { createBroadCastChannelProvider } from './broad-cast-channel';
@ -32,7 +32,7 @@ const createAffineWebSocketProvider = (
blockSuiteWorkspace.id,
blockSuiteWorkspace.doc,
{
params: { token: apis.auth.refresh },
params: { token: getLoginStorage()?.token ?? '' },
// @ts-expect-error ignore the type
awareness: blockSuiteWorkspace.awarenessStore.awareness,
// we maintain broadcast channel by ourselves

View File

@ -1,4 +1,4 @@
import { RequestError } from '@affine/datacenter';
import { RequestError } from '@affine/workspace/affine/api';
import type { NextRouter } from 'next/router';
import type { ErrorInfo } from 'react';
import type React from 'react';

View File

@ -1,6 +1,6 @@
import { Button, IconButton, Menu, MenuItem, Wrapper } from '@affine/component';
import { PermissionType } from '@affine/datacenter';
import { useTranslation } from '@affine/i18n';
import { PermissionType } from '@affine/workspace/affine/api';
import { WorkspaceFlavour } from '@affine/workspace/type';
import {
DeleteTemporarilyIcon,

View File

@ -1,5 +1,10 @@
import { displayFlex, IconButton, styled, Tooltip } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import {
getLoginStorage,
setLoginStorage,
SignMethod,
} from '@affine/workspace/affine/login';
import { WorkspaceFlavour } from '@affine/workspace/type';
import {
CloudWorkspaceIcon,
@ -10,13 +15,13 @@ import { assertEquals, assertExists } from '@blocksuite/store';
import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react';
import { affineAuth } from '../../../../hooks/affine/use-affine-log-in';
import { useCurrentWorkspace } from '../../../../hooks/current/use-current-workspace';
import { useTransformWorkspace } from '../../../../hooks/use-transform-workspace';
import type {
AffineOfficialWorkspace,
LocalWorkspace,
} from '../../../../shared';
import { apis } from '../../../../shared/apis';
import { TransformWorkspaceToAffineModal } from '../../../affine/transform-workspace-to-affine-modal';
const IconWrapper = styled('div')(({ theme }) => {
@ -112,8 +117,13 @@ export const SyncUser = () => {
setOpen(false);
}}
onConform={async () => {
if (!apis.auth.isLogin) {
await apis.signInWithGoogle();
if (!getLoginStorage()) {
const response = await affineAuth.generateToken(
SignMethod.Google
);
if (response) {
setLoginStorage(response);
}
router.reload();
return;
}

View File

@ -1,8 +1,8 @@
import { FlexWrapper } from '@affine/component';
import { IconButton } from '@affine/component';
import { Tooltip } from '@affine/component';
import type { AccessTokenMessage } from '@affine/datacenter';
import { useTranslation } from '@affine/i18n';
import type { AccessTokenMessage } from '@affine/workspace/affine/login';
import { CloudWorkspaceIcon, SignOutIcon } from '@blocksuite/icons';
import type { CSSProperties } from 'react';
import type React from 'react';

View File

@ -1,10 +1,10 @@
import { MessageCode } from '@affine/datacenter';
import { messages } from '@affine/datacenter';
import { MessageCode, Messages } from '@affine/env/constant';
import { setLoginStorage, SignMethod } from '@affine/workspace/affine/login';
import type React from 'react';
import { memo, useEffect, useState } from 'react';
import { affineAuth } from '../../../hooks/affine/use-affine-log-in';
import { useAffineLogOut } from '../../../hooks/affine/use-affine-log-out';
import { apis } from '../../../shared/apis';
import { toast } from '../../../utils';
declare global {
@ -33,9 +33,12 @@ export const MessageCenter: React.FC = memo(function MessageCenter() {
event.detail.code === MessageCode.loginError)
) {
setPopup(true);
apis
.signInWithGoogle()
.then(() => {
affineAuth
.generateToken(SignMethod.Google)
.then(response => {
if (response) {
setLoginStorage(response);
}
setPopup(false);
})
.catch(() => {
@ -43,7 +46,7 @@ export const MessageCenter: React.FC = memo(function MessageCenter() {
onLogout();
});
} else {
toast(messages[event.detail.code].message);
toast(Messages[event.detail.code].message);
}
};

View File

@ -1,5 +1,5 @@
import { PermissionType } from '@affine/datacenter';
import { useTranslation } from '@affine/i18n';
import { PermissionType } from '@affine/workspace/affine/api';
import { WorkspaceFlavour } from '@affine/workspace/type';
import { SettingsIcon } from '@blocksuite/icons';
import type React from 'react';

View File

@ -4,8 +4,8 @@ import {
ModalWrapper,
Tooltip,
} from '@affine/component';
import type { AccessTokenMessage } from '@affine/datacenter';
import { useTranslation } from '@affine/i18n';
import type { AccessTokenMessage } from '@affine/workspace/affine/login';
import { HelpIcon, PlusIcon } from '@blocksuite/icons';
import type { RemWorkspace } from '../../../shared';

View File

@ -1,26 +1,30 @@
import { currentAffineUserAtom } from '@affine/workspace/affine/atom';
import {
createAffineAuth,
parseIdToken,
setLoginStorage,
SignMethod,
} from '@affine/workspace/affine/login';
import { useSetAtom } from 'jotai';
import { useRouter } from 'next/router';
import { useCallback } from 'react';
import { apis } from '../../shared/apis';
import { toast } from '../../utils';
export const affineAuth = createAffineAuth();
export function useAffineLogIn() {
const router = useRouter();
const setUser = useSetAtom(currentAffineUserAtom);
return useCallback(async () => {
const response = await affineAuth.generateToken(SignMethod.Google);
if (response) {
setLoginStorage(response);
apis.auth.setLogin(response);
const user = parseIdToken(response.token);
setUser(user);
router.reload();
} else {
toast('Login failed');
}
}, [router]);
}, [router, setUser]);
}

View File

@ -1,3 +1,4 @@
import { currentAffineUserAtom } from '@affine/workspace/affine/atom';
import { clearLoginStorage } from '@affine/workspace/affine/login';
import { WorkspaceFlavour } from '@affine/workspace/type';
import { useSetAtom } from 'jotai';
@ -6,13 +7,12 @@ import { useCallback } from 'react';
import { jotaiWorkspacesAtom } from '../../atoms';
import { WorkspacePlugins } from '../../plugins';
import { apis } from '../../shared/apis';
export function useAffineLogOut() {
const set = useSetAtom(jotaiWorkspacesAtom);
const router = useRouter();
const setCurrentUser = useSetAtom(currentAffineUserAtom);
return useCallback(() => {
apis.auth.clear();
set(workspaces =>
workspaces.filter(
workspace => workspace.flavour !== WorkspaceFlavour.AFFINE
@ -20,6 +20,7 @@ export function useAffineLogOut() {
);
WorkspacePlugins[WorkspaceFlavour.AFFINE].cleanup?.();
clearLoginStorage();
setCurrentUser(null);
router.reload();
}, [router, set]);
}, [router, set, setCurrentUser]);
}

View File

@ -7,7 +7,6 @@ import {
} from '@affine/workspace/affine/login';
import useSWR from 'swr';
import { apis } from '../../shared/apis';
import { affineAuth } from './use-affine-log-in';
const logger = new DebugLogger('auth-token');
@ -22,7 +21,6 @@ const revalidate = async () => {
const response = await affineAuth.refreshToken(storage);
if (response) {
setLoginStorage(response);
apis.auth.setLogin(response);
}
}
}

View File

@ -1,4 +1,4 @@
import { PermissionType } from '@affine/datacenter';
import { PermissionType } from '@affine/workspace/affine/api';
import type { AffineOfficialWorkspace } from '../../shared';

View File

@ -1,9 +1,9 @@
import type { Member } from '@affine/datacenter';
import type { Member } from '@affine/workspace/affine/api';
import { useCallback } from 'react';
import useSWR from 'swr';
import { QueryKey } from '../../plugins/affine/fetcher';
import { apis } from '../../shared/apis';
import { affineApis } from '../../shared/apis';
export function useMembers(workspaceId: string) {
const { data, mutate } = useSWR<Member[]>(
@ -15,7 +15,7 @@ export function useMembers(workspaceId: string) {
const inviteMember = useCallback(
async (email: string) => {
await apis.inviteMember({
await affineApis.inviteMember({
id: workspaceId,
email,
});
@ -26,8 +26,7 @@ export function useMembers(workspaceId: string) {
const removeMember = useCallback(
async (permissionId: number) => {
// fixme: what about the workspaceId?
await apis.removeMember({
await affineApis.removeMember({
permissionId,
});
return mutate();

View File

@ -4,13 +4,13 @@ import useSWR from 'swr';
import { jotaiStore, jotaiWorkspacesAtom } from '../../atoms';
import { QueryKey } from '../../plugins/affine/fetcher';
import type { AffineWorkspace } from '../../shared';
import { apis } from '../../shared/apis';
import { affineApis } from '../../shared/apis';
export function useToggleWorkspacePublish(workspace: AffineWorkspace) {
const { mutate } = useSWR(QueryKey.getWorkspaces);
return useCallback(
async (isPublish: boolean) => {
await apis.updateWorkspace({
await affineApis.updateWorkspace({
id: workspace.id,
public: isPublish,
});

View File

@ -1,11 +1,7 @@
import type { AccessTokenMessage } from '@affine/datacenter';
import useSWR from 'swr';
import { QueryKey } from '../../plugins/affine/fetcher';
import { currentAffineUserAtom } from '@affine/workspace/affine/atom';
import type { AccessTokenMessage } from '@affine/workspace/affine/login';
import { useAtomValue } from 'jotai';
export function useCurrentUser(): AccessTokenMessage | null {
const { data } = useSWR<AccessTokenMessage | null>(QueryKey.getUser, {
fallbackData: null,
});
return data ?? null;
return useAtomValue(currentAffineUserAtom);
}

View File

@ -1,6 +1,6 @@
import { displayFlex, styled } from '@affine/component';
import { Button } from '@affine/component';
import type { Permission } from '@affine/datacenter';
import type { Permission } from '@affine/workspace/affine/api';
import {
SucessfulDuotoneIcon,
UnsucessfulDuotoneIcon,

View File

@ -1,4 +1,11 @@
import { useTranslation } from '@affine/i18n';
import { currentAffineUserAtom } from '@affine/workspace/affine/atom';
import {
getLoginStorage,
parseIdToken,
setLoginStorage,
SignMethod,
} from '@affine/workspace/affine/login';
import type { SettingPanel, WorkspaceRegistry } from '@affine/workspace/type';
import {
settingPanel,
@ -7,7 +14,7 @@ import {
} from '@affine/workspace/type';
import { SettingsIcon } from '@blocksuite/icons';
import { assertExists } from '@blocksuite/store';
import { useAtom } from 'jotai';
import { useAtom, useSetAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import Head from 'next/head';
import { useRouter } from 'next/router';
@ -16,6 +23,7 @@ import React, { useCallback, useEffect } from 'react';
import { Unreachable } from '../../../components/affine/affine-error-eoundary';
import { PageLoading } from '../../../components/pure/loading';
import { WorkspaceTitle } from '../../../components/pure/workspace-title';
import { affineAuth } from '../../../hooks/affine/use-affine-log-in';
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
import { useSyncRouterWithCurrentWorkspace } from '../../../hooks/use-sync-router-with-current-workspace';
import { useTransformWorkspace } from '../../../hooks/use-transform-workspace';
@ -23,7 +31,6 @@ import { useWorkspacesHelper } from '../../../hooks/use-workspaces';
import { WorkspaceLayout } from '../../../layouts';
import { WorkspacePlugins } from '../../../plugins';
import type { NextPageWithLayout } from '../../../shared';
import { apis } from '../../../shared/apis';
const settingPanelAtom = atomWithStorage<SettingPanel>(
'workspaceId',
@ -101,15 +108,20 @@ const SettingPage: NextPageWithLayout = () => {
return helper.deleteWorkspace(workspaceId);
}, [currentWorkspace, helper]);
const transformWorkspace = useTransformWorkspace();
const setUser = useSetAtom(currentAffineUserAtom);
const onTransformWorkspace = useCallback(
async <From extends WorkspaceFlavour, To extends WorkspaceFlavour>(
from: From,
to: To,
workspace: WorkspaceRegistry[From]
): Promise<void> => {
const needRefresh = to === WorkspaceFlavour.AFFINE && !apis.auth.isLogin;
const needRefresh = to === WorkspaceFlavour.AFFINE && !getLoginStorage();
if (needRefresh) {
await apis.signInWithGoogle();
const response = await affineAuth.generateToken(SignMethod.Google);
if (response) {
setLoginStorage(response);
setUser(parseIdToken(response.token));
}
}
const workspaceId = await transformWorkspace(from, to, workspace);
await router.replace({
@ -119,11 +131,8 @@ const SettingPage: NextPageWithLayout = () => {
workspaceId,
},
});
if (needRefresh) {
router.reload();
}
},
[router, transformWorkspace]
[router, setUser, transformWorkspace]
);
if (!router.isReady) {
return <PageLoading />;

View File

@ -1,3 +1,4 @@
import { getLoginStorage } from '@affine/workspace/affine/login';
import { WorkspaceFlavour } from '@affine/workspace/type';
import { createEmptyBlockSuiteWorkspace } from '@affine/workspace/utils';
import { assertExists } from '@blocksuite/store';
@ -6,7 +7,7 @@ import { jotaiStore, workspacesAtom } from '../../atoms';
import { createAffineProviders } from '../../blocksuite';
import { Unreachable } from '../../components/affine/affine-error-eoundary';
import type { AffineWorkspace } from '../../shared';
import { apis } from '../../shared/apis';
import { affineApis } from '../../shared/apis';
type Query = (typeof QueryKey)[keyof typeof QueryKey];
@ -17,24 +18,21 @@ export const fetcher = async (
| [Query, string]
| [Query, string, string]
) => {
if (query === QueryKey.getUser) {
return apis.auth.user ?? null;
}
if (Array.isArray(query)) {
if (query[0] === QueryKey.downloadWorkspace) {
if (typeof query[2] !== 'boolean') {
throw new Unreachable();
}
return apis.downloadWorkspace(query[1], query[2]);
return affineApis.downloadWorkspace(query[1], query[2]);
} else if (query[0] === QueryKey.getMembers) {
return apis.getWorkspaceMembers({
return affineApis.getWorkspaceMembers({
id: query[1],
});
} else if (query[0] === QueryKey.getUserByEmail) {
if (typeof query[2] !== 'string') {
throw new Unreachable();
}
return apis.getUserByEmail({
return affineApis.getUserByEmail({
workspace_id: query[1],
email: query[2],
});
@ -57,19 +55,19 @@ export const fetcher = async (
if (typeof invitingCode !== 'string') {
throw new TypeError('invitingCode must be a string');
}
return apis.acceptInviting({
return affineApis.acceptInviting({
invitingCode,
});
}
} else {
if (query === QueryKey.getWorkspaces) {
return apis.getWorkspaces().then(workspaces => {
return affineApis.getWorkspaces().then(workspaces => {
return workspaces.map(workspace => {
const blockSuiteWorkspace = createEmptyBlockSuiteWorkspace(
workspace.id,
(k: string) =>
// fixme: token could be expired
({ api: '/api/workspace', token: apis.auth.token }[k])
({ api: '/api/workspace', token: getLoginStorage()?.token }[k])
);
const remWorkspace: AffineWorkspace = {
...workspace,
@ -81,14 +79,13 @@ export const fetcher = async (
});
});
}
return (apis as any)[query]();
return (affineApis as any)[query]();
}
};
export const QueryKey = {
acceptInvite: 'acceptInvite',
getImage: 'getImage',
getUser: 'getUser',
getWorkspaces: 'getWorkspaces',
downloadWorkspace: 'downloadWorkspace',
getMembers: 'getMembers',

View File

@ -1,3 +1,4 @@
import { getLoginStorage } from '@affine/workspace/affine/login';
import { LoadPriority, WorkspaceFlavour } from '@affine/workspace/type';
import { createEmptyBlockSuiteWorkspace } from '@affine/workspace/utils';
import { createJSONStorage } from 'jotai/utils';
@ -12,8 +13,8 @@ import { BlockSuitePageList } from '../../components/blocksuite/block-suite-page
import { PageDetailEditor } from '../../components/page-detail-editor';
import type { AffineWorkspace } from '../../shared';
import { BlockSuiteWorkspace } from '../../shared';
import { apis, clientAuth } from '../../shared/apis';
import { initPage } from '../../utils/blocksuite';
import { affineApis } from '../../shared/apis';
import { initPage } from '../../utils';
import type { WorkspacePlugin } from '..';
import { QueryKey } from './fetcher';
@ -39,7 +40,7 @@ const getPersistenceAllWorkspace = () => {
item.id,
(k: string) =>
// fixme: token could be expired
({ api: '/api/workspace', token: apis.auth.token }[k])
({ api: '/api/workspace', token: getLoginStorage()?.token }[k])
);
const affineWorkspace: AffineWorkspace = {
...item,
@ -65,7 +66,9 @@ export const AffinePlugin: WorkspacePlugin<WorkspaceFlavour.AFFINE> = {
const binary = BlockSuiteWorkspace.Y.encodeStateAsUpdate(
blockSuiteWorkspace.doc
);
const { id } = await apis.createWorkspace(new Blob([binary.buffer]));
const { id } = await affineApis.createWorkspace(
new Blob([binary.buffer])
);
// fixme: syncing images
const newWorkspaceId = id;
@ -77,12 +80,7 @@ export const AffinePlugin: WorkspacePlugin<WorkspaceFlavour.AFFINE> = {
const url = await blobs.get(id);
if (url) {
const blob = await fetch(url).then(res => res.blob());
await clientAuth.put(`api/workspace/${newWorkspaceId}/blob`, {
body: blob,
headers: {
'Content-Type': blob.type,
},
});
await affineApis.uploadBlob(newWorkspaceId, blob);
}
}
}
@ -103,14 +101,14 @@ export const AffinePlugin: WorkspacePlugin<WorkspaceFlavour.AFFINE> = {
items.filter(item => item.id !== workspace.id)
);
}
await apis.deleteWorkspace({
await affineApis.deleteWorkspace({
id: workspace.id,
});
await mutate(matcher => matcher === QueryKey.getWorkspaces);
},
get: async workspaceId => {
try {
if (!apis.auth.isLogin) {
if (!getLoginStorage()) {
const workspaces = getPersistenceAllWorkspace();
return (
workspaces.find(workspace => workspace.id === workspaceId) ?? null
@ -130,69 +128,65 @@ export const AffinePlugin: WorkspacePlugin<WorkspaceFlavour.AFFINE> = {
list: async () => {
const allWorkspaces = getPersistenceAllWorkspace();
try {
if (apis.auth.isLogin) {
const workspaces = await apis.getWorkspaces().then(workspaces => {
return workspaces.map(workspace => {
const blockSuiteWorkspace = createEmptyBlockSuiteWorkspace(
workspace.id,
(k: string) =>
// fixme: token could be expired
({ api: '/api/workspace', token: apis.auth.token }[k])
);
const dump = workspaces.map(workspace => {
return {
id: workspace.id,
type: workspace.type,
public: workspace.public,
permission: workspace.permission,
} satisfies z.infer<typeof schema>;
});
const old = storage.getItem(kAffineLocal);
if (
Array.isArray(old) &&
old.every(item => schema.safeParse(item).success)
) {
const data = [...dump];
old.forEach((item: z.infer<typeof schema>) => {
const has = dump.find(dump => dump.id === item.id);
if (!has) {
data.push(item);
}
});
storage.setItem(kAffineLocal, [...data]);
}
const affineWorkspace: AffineWorkspace = {
...workspace,
flavour: WorkspaceFlavour.AFFINE,
blockSuiteWorkspace,
providers: [...createAffineProviders(blockSuiteWorkspace)],
};
return affineWorkspace;
});
});
workspaces.forEach(workspace => {
const idx = allWorkspaces.findIndex(
({ id }) => id === workspace.id
const workspaces = await affineApis.getWorkspaces().then(workspaces => {
return workspaces.map(workspace => {
const blockSuiteWorkspace = createEmptyBlockSuiteWorkspace(
workspace.id,
(k: string) =>
// fixme: token could be expired
({ api: '/api/workspace', token: getLoginStorage()?.token }[k])
);
if (idx !== -1) {
allWorkspaces.splice(idx, 1, workspace);
} else {
allWorkspaces.push(workspace);
const dump = workspaces.map(workspace => {
return {
id: workspace.id,
type: workspace.type,
public: workspace.public,
permission: workspace.permission,
} satisfies z.infer<typeof schema>;
});
const old = storage.getItem(kAffineLocal);
if (
Array.isArray(old) &&
old.every(item => schema.safeParse(item).success)
) {
const data = [...dump];
old.forEach((item: z.infer<typeof schema>) => {
const has = dump.find(dump => dump.id === item.id);
if (!has) {
data.push(item);
}
});
storage.setItem(kAffineLocal, [...data]);
}
});
// only save data when login in
const dump = allWorkspaces.map(workspace => {
return {
id: workspace.id,
type: workspace.type,
public: workspace.public,
permission: workspace.permission,
} satisfies z.infer<typeof schema>;
const affineWorkspace: AffineWorkspace = {
...workspace,
flavour: WorkspaceFlavour.AFFINE,
blockSuiteWorkspace,
providers: [...createAffineProviders(blockSuiteWorkspace)],
};
return affineWorkspace;
});
storage.setItem(kAffineLocal, [...dump]);
}
});
workspaces.forEach(workspace => {
const idx = allWorkspaces.findIndex(({ id }) => id === workspace.id);
if (idx !== -1) {
allWorkspaces.splice(idx, 1, workspace);
} else {
allWorkspaces.push(workspace);
}
});
// only save data when login in
const dump = allWorkspaces.map(workspace => {
return {
id: workspace.id,
type: workspace.type,
public: workspace.public,
permission: workspace.permission,
} satisfies z.infer<typeof schema>;
});
storage.setItem(kAffineLocal, [...dump]);
} catch (e) {
console.error('fetch affine workspaces failed', e);
}

View File

@ -1,16 +1,14 @@
import {
createAuthClient,
createBareClient,
getApis,
GoogleAuth,
} from '@affine/datacenter';
import { config } from '@affine/env';
import {
createUserApis,
createWorkspaceApis,
} from '@affine/workspace/affine/api';
import { currentAffineUserAtom } from '@affine/workspace/affine/atom';
import type { LoginResponse } from '@affine/workspace/affine/login';
import { parseIdToken, setLoginStorage } from '@affine/workspace/affine/login';
import { isValidIPAddress } from '../utils/is-valid-ip-address';
import { jotaiStore } from '../atoms';
import { isValidIPAddress } from '../utils';
let prefixUrl = '/';
if (typeof window === 'undefined') {
@ -31,32 +29,27 @@ if (typeof window === 'undefined') {
params.get('prefixUrl') && (prefixUrl = params.get('prefixUrl') as string);
}
const affineApis = {} as ReturnType<typeof createUserApis> &
ReturnType<typeof createWorkspaceApis>;
Object.assign(affineApis, createUserApis(prefixUrl));
Object.assign(affineApis, createWorkspaceApis(prefixUrl));
if (!globalThis.AFFINE_APIS) {
globalThis.AFFINE_APIS = affineApis;
globalThis.setLogin = (response: LoginResponse) => {
jotaiStore.set(currentAffineUserAtom, parseIdToken(response.token));
setLoginStorage(response);
};
}
declare global {
// eslint-disable-next-line no-var
var affineApis:
var setLogin: typeof setLoginStorage;
// eslint-disable-next-line no-var
var AFFINE_APIS:
| undefined
| (ReturnType<typeof createUserApis> &
ReturnType<typeof createWorkspaceApis>);
}
const affineApis = {} as ReturnType<typeof createUserApis> &
ReturnType<typeof createWorkspaceApis>;
Object.assign(affineApis, createUserApis(prefixUrl));
Object.assign(affineApis, createWorkspaceApis(prefixUrl));
if (!globalThis.affineApis) {
globalThis.affineApis = affineApis;
}
const bareAuth = createBareClient(prefixUrl);
const googleAuth = new GoogleAuth(bareAuth);
export const clientAuth = createAuthClient(bareAuth, googleAuth);
export const apis = getApis(bareAuth, clientAuth, googleAuth);
if (!globalThis.AFFINE_APIS) {
globalThis.AFFINE_APIS = apis;
}
declare global {
// eslint-disable-next-line no-var
var AFFINE_APIS: ReturnType<typeof getApis>;
}
export { affineApis };

View File

@ -1,4 +1,4 @@
import type { Workspace as RemoteWorkspace } from '@affine/datacenter';
import type { Workspace as RemoteWorkspace } from '@affine/workspace/affine/api';
import type { WorkspaceFlavour } from '@affine/workspace/type';
import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store';
import type { NextPage } from 'next';

View File

@ -4,9 +4,5 @@
const defaultExclude = require('@istanbuljs/schema/default-exclude');
module.exports = {
exclude: [
...defaultExclude,
// data-center will be removed in the future, we don't need to coverage it
'packages/data-center/**',
],
exclude: [...defaultExclude],
};

View File

@ -1,4 +0,0 @@
node_modules/
/test-results/
/playwright-report/
/playwright/.cache/

View File

@ -1,32 +0,0 @@
{
"name": "@affine/datacenter",
"version": "0.3.0",
"private": true,
"description": "",
"main": "./src/index.ts",
"repository": {
"type": "git",
"url": "git+https://github.com/toeverything/AFFiNE.git"
},
"devDependencies": {
"fake-indexeddb": "4.0.1",
"typescript": "^5.0.2"
},
"dependencies": {
"@affine/debug": "workspace:*",
"@blocksuite/blocks": "0.5.0-20230324040005-14417c2",
"@blocksuite/global": "0.5.0-20230324040005-14417c2",
"@blocksuite/store": "0.5.0-20230324040005-14417c2",
"@tauri-apps/api": "^1.2.0",
"encoding": "^0.1.13",
"firebase": "^9.18.0",
"idb-keyval": "^6.2.0",
"js-base64": "^3.7.5",
"ky": "^0.33.3",
"ky-universal": "^0.11.0",
"lib0": "^0.2.69",
"lit": "^2.6.1",
"y-protocols": "^1.0.5",
"yjs": "^13.5.50"
}
}

View File

@ -1,454 +0,0 @@
import { DebugLogger } from '@affine/debug';
import type { Workspace as BlocksuiteWorkspace } from '@blocksuite/store';
import assert from 'assert';
import { MessageCenter } from './message';
import { AffineProvider } from './provider';
import type {
BaseProvider,
CreateWorkspaceInfoParams,
UpdateWorkspaceMetaParams,
} from './provider/base';
import { LocalProvider } from './provider/local';
import type { Message } from './types';
import { createBlocksuiteWorkspace } from './utils';
import { WorkspaceUnit } from './workspace-unit';
import type { WorkspaceUnitCollectionChangeEvent } from './workspace-unit-collection';
import { WorkspaceUnitCollection } from './workspace-unit-collection';
/**
* @class DataCenter
* @classdesc Data center is made for managing different providers for business
*/
export class DataCenter {
private readonly _workspaceUnitCollection = new WorkspaceUnitCollection();
private readonly _logger = new DebugLogger('datacenter');
private _workspaceInstances: Map<string, BlocksuiteWorkspace> = new Map();
private _messageCenter = MessageCenter.getInstance();
/**
* A mainProvider must exist as the only data trustworthy source.
*/
private _mainProvider?: BaseProvider;
providerMap: Map<string, BaseProvider> = new Map();
static initEmpty() {
return new DataCenter();
}
static async init(exclude: 'affine'[] = []): Promise<DataCenter> {
const dc = new DataCenter();
const getInitParams = () => {
return {
logger: dc._logger,
workspaces: dc._workspaceUnitCollection.createScope(),
messageCenter: dc._messageCenter,
};
};
// TODO: switch different provider
if (
typeof window !== 'undefined' &&
window.CLIENT_APP &&
typeof window.__TAURI_IPC__ === 'function'
) {
const { TauriIPCProvider } = await import('./provider/tauri-ipc');
await dc.registerProvider(new TauriIPCProvider(getInitParams()));
} else {
await dc.registerProvider(new LocalProvider(getInitParams()));
}
if (!exclude.includes('affine')) {
await dc.registerProvider(new AffineProvider(getInitParams()));
}
for (const provider of dc.providerMap.values()) {
await provider.loadWorkspaces();
}
return dc;
}
/**
* Register provider.
* We will automatically set the first provider to default provider.
*/
async registerProvider(provider: BaseProvider) {
if (!this._mainProvider) {
this._mainProvider = provider;
}
await provider.init();
this.providerMap.set(provider.id, provider);
}
setMainProvider(providerId: string) {
this._mainProvider = this.providerMap.get(providerId);
}
get providers() {
return Array.from(this.providerMap.values());
}
public get workspaces() {
return this._workspaceUnitCollection.workspaces;
}
public async refreshWorkspaces() {
return Promise.allSettled(
Object.values(this.providerMap).map(provider => provider.loadWorkspaces())
);
}
/**
* create new workspace , new workspace is a local workspace
* @param {string} name workspace name
* @returns {Promise<Workspace>}
*/
public async createWorkspace(params: CreateWorkspaceInfoParams) {
assert(
this._mainProvider,
'There is no provider. You should add provider first.'
);
const workspaceUnit = await this._mainProvider.createWorkspace(params);
return workspaceUnit;
}
/**
* delete workspace by id
* @param {string} workspaceId workspace id
*/
public async deleteWorkspace(workspaceId: string) {
const workspaceInfo = this._workspaceUnitCollection.find(workspaceId);
assert(workspaceInfo, 'Workspace not found');
const provider = this.providerMap.get(workspaceInfo.provider);
assert(provider, `Workspace exists, but we couldn't find its provider.`);
await provider.deleteWorkspace(workspaceId);
}
/**
* get a new workspace only has room id
* @param {string} workspaceId workspace id
*/
private _getBlocksuiteWorkspace(workspaceId: string) {
// const workspaceInfo = this._workspaceUnitCollection.find(workspaceId);
// assert(workspaceInfo, 'Workspace not found');
return (
// this._workspaceInstances.get(workspaceId) ||
createBlocksuiteWorkspace({ id: workspaceId })
);
}
/**
* login to all providers, it will default run all auth ,
* maybe need a params to control which provider to auth
*/
public async login(providerId = 'affine') {
const provider = this.providerMap.get(providerId);
assert(provider, `provide '${providerId}' is not registered`);
await provider.auth();
provider.loadWorkspaces();
}
/**
* logout from all providers
*/
public async logout(providerId = 'affine') {
const provider = this.providerMap.get(providerId);
assert(provider, `provide '${providerId}' is not registered`);
await provider.logout();
}
/**
* load workspace instance by id
* @param {string} workspaceId workspace id
* @returns {Promise<WorkspaceUnit>}
*/
public async loadWorkspace(
workspaceId: string
): Promise<WorkspaceUnit | null> {
const workspaceUnit = this._workspaceUnitCollection.find(workspaceId);
assert(workspaceUnit, 'Workspace not found');
const currentProvider = this.providerMap.get(workspaceUnit.provider);
if (currentProvider) {
currentProvider.closeWorkspace(workspaceId);
}
const provider = this.providerMap.get(workspaceUnit.provider);
assert(provider, `provide '${workspaceUnit.provider}' is not registered`);
this._logger.debug(
`Loading ${workspaceUnit.provider} workspace: `,
workspaceId
);
const workspace = this._getBlocksuiteWorkspace(workspaceId);
this._workspaceInstances.set(workspaceId, workspace);
await provider.warpWorkspace(workspace);
this._workspaceUnitCollection.workspaces.forEach(workspaceUnit => {
const provider = this.providerMap.get(workspaceUnit.provider);
assert(provider);
provider.closeWorkspace(workspaceUnit.id);
});
workspaceUnit.setBlocksuiteWorkspace(workspace);
return workspaceUnit;
}
public async loadPublicWorkspace(workspaceId: string) {
// FIXME: hard code for public workspace
const provider = this.providerMap.get('affine');
assert(provider);
const blocksuiteWorkspace = this._getBlocksuiteWorkspace(workspaceId);
await provider.loadPublicWorkspace(blocksuiteWorkspace);
const workspaceUnitForPublic = new WorkspaceUnit({
id: workspaceId,
name: blocksuiteWorkspace.meta.name ?? '',
avatar: blocksuiteWorkspace.meta.avatar ?? '',
owner: undefined,
published: true,
provider: 'affine',
memberCount: 1,
syncMode: 'core',
});
workspaceUnitForPublic.setBlocksuiteWorkspace(blocksuiteWorkspace);
return workspaceUnitForPublic;
}
/**
* get user info by provider id
* @param {string} providerId the provider name of workspace
* @returns {Promise<User>}
*/
public async getUserInfo(providerId = 'affine') {
// XXX: maybe return all user info
const provider = this.providerMap.get(providerId);
assert(provider, `provide '${providerId}' is not registered`);
return provider.getUserInfo();
}
/**
* listen workspaces list change
* @param {Function} callback callback function
*/
public onWorkspacesChange(
callback: (workspaces: WorkspaceUnitCollectionChangeEvent) => void,
{ immediate = true }: { immediate?: boolean } = {}
) {
if (immediate) {
callback({
added: this._workspaceUnitCollection.workspaces,
});
}
this._workspaceUnitCollection.on('change', callback);
return () => {
this._workspaceUnitCollection.off('change', callback);
};
}
/**
* change workspaces meta
* @param {WorkspaceMeta} workspaceMeta workspace meta
* @param {WorkspaceUnit} workspace workspace instance
*/
public async updateWorkspaceMeta(
{ name, avatar }: UpdateWorkspaceMetaParams,
workspaceUnit: WorkspaceUnit
) {
assert(workspaceUnit?.id, 'No workspace to set meta');
const workspace = workspaceUnit.blocksuiteWorkspace;
assert(workspace);
const update: Partial<UpdateWorkspaceMetaParams> = {};
if (name) {
workspace.meta.setName(name);
update.name = name;
}
if (avatar) {
workspace.meta.setAvatar(avatar);
update.avatar = avatar;
}
// may run for change workspace meta
const workspaceInfo = this._workspaceUnitCollection.find(workspaceUnit.id);
assert(workspaceInfo, 'Workspace not found');
const provider = this.providerMap.get(workspaceInfo.provider);
provider?.updateWorkspaceMeta(workspaceUnit.id, update);
}
/**
*
* leave workspace by id
* @param id workspace id
*/
public async leaveWorkspace(workspaceId: string) {
const workspaceInfo = this._workspaceUnitCollection.find(workspaceId);
assert(workspaceInfo, 'Workspace not found');
const provider = this.providerMap.get(workspaceInfo.provider);
if (provider) {
await provider.closeWorkspace(workspaceId);
await provider.leaveWorkspace(workspaceId);
}
}
public async setWorkspacePublish(workspaceId: string, isPublish: boolean) {
const workspaceInfo = this._workspaceUnitCollection.find(workspaceId);
assert(workspaceInfo, 'Workspace not found');
const provider = this.providerMap.get(workspaceInfo.provider);
if (provider) {
await provider.publish(workspaceId, isPublish);
}
}
/**
* invite the new member to the workspace
* @param {string} workspaceId workspace id
* @param {string} email
*/
public async inviteMember(workspaceId: string, email: string) {
const workspaceInfo = this._workspaceUnitCollection.find(workspaceId);
assert(workspaceInfo, 'Workspace not found');
const provider = this.providerMap.get(workspaceInfo.provider);
if (provider) {
await provider.invite(workspaceId, email);
}
}
/**
* remove the new member to the workspace
*/
public async removeMember(workspaceId: string, permissionId: number) {
const workspaceInfo = this._workspaceUnitCollection.find(workspaceId);
assert(workspaceInfo, 'Workspace not found');
const provider = this.providerMap.get(workspaceInfo.provider);
if (provider) {
await provider.removeMember(permissionId);
}
}
/**
* get user info by email
* @param workspaceId
* @param email
* @param provider
* @returns {Promise<User>} User info
*/
public async getUserByEmail(
workspaceId: string,
email: string,
provider = 'affine'
) {
const providerInstance = this.providerMap.get(provider);
if (providerInstance) {
return await providerInstance.getUserByEmail(workspaceId, email);
}
}
public async enableProvider(
workspaceUnit: WorkspaceUnit,
providerId = 'affine'
) {
if (workspaceUnit.provider === providerId) {
this._logger.error('Workspace provider is same');
return;
}
const provider = this.providerMap.get(providerId);
assert(provider);
const newWorkspaceUnit = await provider.extendWorkspace(workspaceUnit);
// Currently we only allow enable one provider, so after enable new provider,
// delete the old workspace from its provider.
const oldProvider = this.providerMap.get(workspaceUnit.provider);
assert(oldProvider);
await oldProvider.deleteWorkspace(workspaceUnit.id);
return newWorkspaceUnit;
}
/**
* Enable workspace cloud
* @param {string} id ID of workspace.
*/
public async enableWorkspaceCloud(workspaceUnit: WorkspaceUnit) {
return this.enableProvider(workspaceUnit);
}
/**
* @deprecated
* clear all workspaces and data
*/
public async clear() {
for (const provider of this.providerMap.values()) {
await provider.clear();
}
}
/**
* Select a file to import the workspace
* @param {File} file file of workspace.
*/
public async importWorkspace(file: File) {
file;
return;
}
/**
* Generate a file ,and export it to local file system
* @param {string} id ID of workspace.
*/
public async exportWorkspace(id: string) {
id;
return;
}
/**
* get blob url by workspaces id
* @param id
* @returns {Promise<string | null>} blob url
*/
async getBlob(
workspaceUnit: WorkspaceUnit,
id: string
): Promise<string | null> {
const blob = await workspaceUnit.blocksuiteWorkspace?.blobs;
return (await blob?.get(id)) || '';
}
/**
* up load blob and get a blob url
* @param id
* @returns {Promise<string | null>} blob url
*/
async setBlob(workspace: WorkspaceUnit, blob: Blob): Promise<string> {
const blobStorage = await workspace.blocksuiteWorkspace?.blobs;
return (await blobStorage?.set(blob)) || '';
}
/**
* get members of a workspace
* @param workspaceId
*/
async getMembers(workspaceId: string) {
const workspaceInfo = this._workspaceUnitCollection.find(workspaceId);
assert(workspaceInfo, 'Workspace not found');
const provider = this.providerMap.get(workspaceInfo.provider);
if (provider) {
return await provider.getWorkspaceMembers(workspaceId);
}
return [];
}
/**
* accept invitation
* @param {string} inviteCode
* @returns {Promise<Permission | null>} permission
*/
async acceptInvitation(inviteCode: string, providerStr = 'affine') {
const provider = this.providerMap.get(providerStr);
if (provider) {
return await provider.acceptInvitation(inviteCode);
}
return null;
}
onMessage(cb: (message: Message) => void) {
return this._messageCenter.onMessage(cb);
}
}

View File

@ -1,8 +0,0 @@
declare global {
interface Window {
CLIENT_APP?: boolean;
__editoVersion?: string;
}
}
export {};

View File

@ -1,43 +0,0 @@
import { DataCenter } from './datacenter';
const _initializeDataCenter = () => {
let _dataCenterInstance: Promise<DataCenter>;
return () => {
if (!_dataCenterInstance) {
_dataCenterInstance = DataCenter.init();
_dataCenterInstance.then(dc => {
try {
if (window) {
(window as any).dc = dc;
}
} catch (_) {
// ignore
}
return dc;
});
}
return _dataCenterInstance;
};
};
export const getDataCenter = _initializeDataCenter();
export type { DataCenter };
export * from './message';
export { messages } from './message/code';
export { AffineProvider } from './provider/affine';
export * from './provider/affine/apis';
export { getAuthorizer, GoogleAuth } from './provider/affine/apis/google';
export {
createAuthClient,
createBareClient,
} from './provider/affine/apis/request';
export { RequestError } from './provider/affine/apis/request-error';
export * from './provider/affine/apis/workspace';
export { WebsocketProvider } from './provider/affine/sync';
export { IndexedDBProvider } from './provider/local/indexeddb/indexeddb';
export * from './types';
export { WorkspaceUnit } from './workspace-unit';

View File

@ -1,65 +0,0 @@
export enum MessageCode {
loginError,
noPermission,
loadListFailed,
getDetailFailed,
createWorkspaceFailed,
getMembersFailed,
updateWorkspaceFailed,
deleteWorkspaceFailed,
inviteMemberFailed,
removeMemberFailed,
acceptInvitingFailed,
getBlobFailed,
leaveWorkspaceFailed,
downloadWorkspaceFailed,
refreshTokenError,
}
export const messages = {
[MessageCode.loginError]: {
message: 'Login failed',
},
[MessageCode.noPermission]: {
message: 'No permission',
},
[MessageCode.loadListFailed]: {
message: 'Load list failed',
},
[MessageCode.getDetailFailed]: {
message: 'Get detail failed',
},
[MessageCode.createWorkspaceFailed]: {
message: 'Create workspace failed',
},
[MessageCode.getMembersFailed]: {
message: 'Get members failed',
},
[MessageCode.updateWorkspaceFailed]: {
message: 'Update workspace failed',
},
[MessageCode.deleteWorkspaceFailed]: {
message: 'Delete workspace failed',
},
[MessageCode.inviteMemberFailed]: {
message: 'Invite member failed',
},
[MessageCode.removeMemberFailed]: {
message: 'Remove member failed',
},
[MessageCode.acceptInvitingFailed]: {
message: 'Accept inviting failed',
},
[MessageCode.getBlobFailed]: {
message: 'Get blob failed',
},
[MessageCode.leaveWorkspaceFailed]: {
message: 'Leave workspace failed',
},
[MessageCode.downloadWorkspaceFailed]: {
message: 'Download workspace failed',
},
[MessageCode.refreshTokenError]: {
message: 'Refresh token failed',
},
} as const;

View File

@ -1,2 +0,0 @@
export { MessageCode } from './code';
export { MessageCenter } from './message';

View File

@ -1,47 +0,0 @@
import { Observable } from 'lib0/observable';
import type { Message } from '../types';
import { MessageCode, messages } from './code';
export class MessageCenter extends Observable<string> {
private _messages: Record<number, Omit<Message, 'provider' | 'code'>> =
messages;
constructor() {
super();
}
static instance: MessageCenter;
static getInstance() {
if (!MessageCenter.instance) {
MessageCenter.instance = new MessageCenter();
}
return MessageCenter.instance;
}
static messageCode = MessageCode;
public getMessageSender(provider: string) {
return this._send.bind(this, provider);
}
private _send(provider: string, messageCode: MessageCode) {
document.dispatchEvent(
new CustomEvent('affine-error', {
detail: {
code: messageCode,
},
})
);
this.emit('message', [
{ ...this._messages[messageCode], provider, code: messageCode },
]);
}
public onMessage(callback: (message: Message) => void) {
this.on('message', callback);
return () => {
this.off('message', callback);
};
}
}

View File

@ -1,19 +0,0 @@
import type { AccessTokenMessage, Apis } from '../apis';
const user: AccessTokenMessage = {
created_at: Date.now(),
exp: 100000000,
email: 'demo@demo.demo',
id: '123',
name: 'demo',
avatar_url: 'demo-avatar-url',
};
export const apis = {
signInWithGoogle: () => {
return Promise.resolve(user);
},
createWorkspace: mate => {
return Promise.resolve({ id: 'test' });
},
} as Apis;

View File

@ -1,491 +0,0 @@
import { Workspace as BlocksuiteWorkspace } from '@blocksuite/store';
import assert from 'assert';
import type { KyInstance } from 'ky/distribution/types/ky';
import { MessageCenter } from '../../message';
import type { User } from '../../types';
import { applyUpdate } from '../../utils';
import type { SyncMode } from '../../workspace-unit';
import { WorkspaceUnit } from '../../workspace-unit';
import type {
CreateWorkspaceInfoParams,
ProviderConstructorParams,
} from '../base';
import { BaseProvider } from '../base';
import type { Apis, Workspace, WorkspaceDetail } from './apis';
import { getApis } from './apis';
import { createGoogleAuth } from './apis/google';
import { createAuthClient, createBareClient } from './apis/request';
import { WebsocketClient } from './channel';
import { WebsocketProvider } from './sync';
import { createWorkspaceUnit, loadWorkspaceUnit, migrateBlobDB } from './utils';
type ChannelMessage = {
ws_list: Workspace[];
ws_details: Record<string, WorkspaceDetail>;
metadata: Record<string, { avatar: string; name: string }>;
};
export interface AffineProviderConstructorParams
extends ProviderConstructorParams {
apis?: Apis;
}
const {
Y: { encodeStateAsUpdate },
} = BlocksuiteWorkspace;
export class AffineProvider extends BaseProvider {
private readonly bareClient: KyInstance;
private readonly authClient: KyInstance;
public id = 'affine';
private _wsMap: Map<BlocksuiteWorkspace, WebsocketProvider> = new Map();
private _apis: Apis;
private _channel?: WebsocketClient;
private _refreshToken?: string;
// private _idbMap: Map<string, IndexedDBProvider> = new Map();
private _workspaceLoadingQueue: Map<string, Promise<WorkspaceUnit>> =
new Map();
private _workspaces$: Promise<Workspace[]> | undefined;
constructor({ apis, ...params }: AffineProviderConstructorParams) {
super(params);
this.bareClient = createBareClient('/');
const googleAuth = createGoogleAuth(this.bareClient);
this.authClient = createAuthClient(this.bareClient, googleAuth);
this._apis = apis || getApis(this.bareClient, this.authClient, googleAuth);
}
public get apis() {
return Object.freeze(this._apis);
}
override async init() {
this._apis.auth.onChange(() => {
if (this._apis.auth.isLogin) {
this._reconnectChannel();
} else {
this._destroyChannel();
}
});
if (this._apis.auth.isExpired && this._apis.auth.refresh) {
// do we need to await the following?
this._apis.auth.refreshToken();
}
}
private _reconnectChannel() {
if (this._refreshToken !== this._apis.auth.refresh) {
// need to reconnect
this._destroyChannel();
this._channel = new WebsocketClient(
`${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${
window.location.host
}/api/global/sync/`,
this._apis.auth,
{
params: {
token: this._apis.auth.refresh,
},
}
);
this._channel.on('message', (msg: ChannelMessage) => {
this._handlerAffineListMessage(msg);
});
this._refreshToken = this._apis.auth.refresh;
}
}
private _destroyChannel() {
if (this._channel) {
this._channel.disconnect();
this._channel.destroy();
this._channel = undefined;
}
}
private async _handlerAffineListMessage({
ws_details,
metadata,
}: ChannelMessage) {
this._logger.debug('receive server message');
const newlyCreatedWorkspaces: WorkspaceUnit[] = [];
const currentWorkspaceIds = this._workspaces.list().map(w => w.id);
const newlyRemovedWorkspaceIds = currentWorkspaceIds;
for (const [id, detail] of Object.entries(ws_details)) {
const { name, avatar } = metadata[id];
/**
* collect the workspaces that need to be removed in the context
*/
const workspaceIndex = currentWorkspaceIds.indexOf(id);
const ifWorkspaceExist = workspaceIndex !== -1;
if (ifWorkspaceExist) {
newlyRemovedWorkspaceIds.splice(workspaceIndex, 1);
}
/**
* if workspace name is not empty, it is a valid workspace, so sync its state
*/
if (name) {
const workspace = {
name: name,
avatar,
owner: {
name: detail.owner.name,
id: detail.owner.id,
email: detail.owner.email,
avatar: detail.owner.avatar_url,
},
published: detail.public,
memberCount: detail.member_count,
provider: this.id,
syncMode: 'core' as SyncMode,
};
if (this._workspaces.get(id)) {
// update workspaces
this._workspaces.update(id, workspace);
} else {
if (!this._workspaceLoadingQueue.has(id)) {
const p = loadWorkspaceUnit({ id, ...workspace }, this._apis);
this._workspaceLoadingQueue.set(id, p);
newlyCreatedWorkspaces.push(await p);
this._workspaceLoadingQueue.delete(id);
}
}
} else {
console.log(`[log warn] ${id} name is empty`);
}
}
// sync newlyCreatedWorkspaces to context
this._workspaces.add(newlyCreatedWorkspaces);
// sync newlyRemoveWorkspaces to context
this._workspaces.remove(newlyRemovedWorkspaceIds);
}
private _getWebsocketProvider(workspace: BlocksuiteWorkspace) {
const { doc, id } = workspace;
assert(id);
assert(doc);
let ws = this._wsMap.get(workspace);
if (!ws) {
const wsUrl = `${
window.location.protocol === 'https:' ? 'wss' : 'ws'
}://${window.location.host}/api/sync/`;
ws = new WebsocketProvider(wsUrl, id, doc, {
params: { token: this._apis.auth.refresh },
// @ts-expect-error ignore the type
awareness: workspace.awarenessStore.awareness,
});
workspace.awarenessStore.awareness.setLocalStateField('user', {
name: this._apis.auth.user?.name ?? 'other',
id: Number(this._apis.auth.user?.id ?? -1),
color: '#ffa500',
});
this._wsMap.set(workspace, ws);
}
return ws;
}
private async _applyCloudUpdates(
blocksuiteWorkspace: BlocksuiteWorkspace,
published = false
) {
const { id: workspaceId } = blocksuiteWorkspace;
assert(workspaceId, 'Blocksuite Workspace without room(workspaceId).');
const updates = await this._apis.downloadWorkspace(workspaceId, published);
await applyUpdate(blocksuiteWorkspace, new Uint8Array(updates));
}
override async loadPublicWorkspace(blocksuiteWorkspace: BlocksuiteWorkspace) {
await this._applyCloudUpdates(blocksuiteWorkspace, true);
return blocksuiteWorkspace;
}
override async warpWorkspace(workspace: BlocksuiteWorkspace) {
workspace.setGettingBlobOptions(
(k: string) => ({ api: '/api/workspace', token: this.getToken() }[k])
);
// FIXME: if add indexedDB cache in the future, can remove following line.
await this._applyCloudUpdates(workspace);
const { id } = workspace;
assert(id);
this.linkLocal(workspace);
const ws = this._getWebsocketProvider(workspace);
// close all websocket links
Array.from(this._wsMap.entries()).forEach(([blocksuiteWorkspace, ws]) => {
if (blocksuiteWorkspace !== workspace) {
ws.disconnect();
}
});
ws.connect();
await new Promise<void>((resolve, reject) => {
// TODO: synced will also be triggered on reconnection after losing sync
// There needs to be an event mechanism to emit the synchronization state to the upper layer
assert(ws);
ws.once('synced', () => resolve());
ws.once('lost-connection', () => resolve());
ws.once('connection-error', () => reject());
});
return workspace;
}
override async loadWorkspaces() {
if (!this._apis.auth.isLogin) {
return [];
}
// cache workspaces and workspaceUnits results so that simultaneous calls
// to loadWorkspaces will not cause multiple requests
if (!this._workspaces$) {
this._workspaces$ = this._apis.getWorkspaces();
}
const workspacesList = await this._workspaces$;
const workspaceUnits = await Promise.all(
workspacesList.map(async w => {
let p = this._workspaceLoadingQueue.get(w.id);
if (!p) {
// may only need to load the primary one instead of all of them?
// it will take a long time to load all of the workspaces
// at least we shall use p-map to load them in chunks
p = loadWorkspaceUnit(
{
id: w.id,
name: '',
avatar: undefined,
owner: undefined,
published: w.public,
memberCount: 1,
provider: this.id,
syncMode: 'core',
},
this._apis
);
this._workspaceLoadingQueue.set(w.id, p);
}
const workspaceUnit = await p;
this._workspaceLoadingQueue.delete(w.id);
return workspaceUnit;
})
);
// release cache
this._workspaces$ = undefined;
this._workspaces.add(workspaceUnits);
return workspaceUnits;
}
override async auth() {
if (this._apis.auth.isLogin) {
await this._apis.auth.refreshToken();
if (this._apis.auth.isLogin && !this._apis.auth.isExpired) {
// login success
return;
}
}
const user = await this._apis.signInWithGoogle?.();
if (!user) {
this._sendMessage(MessageCenter.messageCode.loginError);
}
}
// TODO: may need to update related workspace attributes on user info change?
public override async getUserInfo(): Promise<User | undefined> {
const user = this._apis.auth.user;
return user
? {
id: user.id,
name: user.name,
avatar: user.avatar_url,
email: user.email,
}
: undefined;
}
public override async deleteWorkspace(id: string): Promise<void> {
await this.closeWorkspace(id);
// IndexedDBProvider.delete(id);
await this._apis.deleteWorkspace({ id });
this._workspaces.remove(id);
}
public override async clear(): Promise<void> {
for (const w of this._workspaces.list()) {
if (w.id) {
try {
await this.deleteWorkspace(w.id);
this._workspaces.remove(w.id);
} catch (e) {
this._logger.error('has a problem of delete workspace ', e);
}
}
}
this._workspaces.clear();
}
public override async closeWorkspace(id: string) {
// const idb = this._idbMap.get(id);
// idb?.destroy();
const workspaceUnit = this._workspaces.get(id);
const ws = workspaceUnit?.blocksuiteWorkspace
? this._wsMap.get(workspaceUnit?.blocksuiteWorkspace)
: null;
if (!ws) {
console.error('close workspace websocket which not exist.');
}
ws?.disconnect();
}
public override async leaveWorkspace(id: string): Promise<void> {
await this._apis.leaveWorkspace({ id });
}
public override async invite(id: string, email: string): Promise<void> {
return await this._apis.inviteMember({ id, email });
}
public override async removeMember(permissionId: number): Promise<void> {
return await this._apis.removeMember({ permissionId });
}
public override async linkLocal(workspace: BlocksuiteWorkspace) {
return workspace;
// assert(workspace.id);
// let idb = this._idbMap.get(workspace.id);
// idb?.destroy();
// idb = new IndexedDBProvider(workspace.id, workspace.doc);
// this._idbMap.set(workspace.id, idb);
// await idb.whenSynced;
// this._logger('Local data loaded');
// return workspace;
}
public override async createWorkspace(
meta: CreateWorkspaceInfoParams
): Promise<WorkspaceUnit | undefined> {
const workspaceUnitForUpload = await createWorkspaceUnit(
{
id: '',
name: meta.name,
avatar: undefined,
owner: await this.getUserInfo(),
published: false,
memberCount: 1,
provider: this.id,
syncMode: 'core',
},
this._apis
);
const { id } = await this._apis.createWorkspace(
new Blob([
encodeStateAsUpdate(workspaceUnitForUpload.blocksuiteWorkspace!.doc)
.buffer,
])
);
const workspaceUnit = await createWorkspaceUnit(
{
id,
name: meta.name,
avatar: undefined,
owner: await this.getUserInfo(),
published: false,
memberCount: 1,
provider: this.id,
syncMode: 'core',
},
this._apis
);
this._workspaces.add(workspaceUnit);
return workspaceUnit;
}
public override async publish(id: string, isPublish: boolean): Promise<void> {
await this._apis.updateWorkspace({ id, public: isPublish });
}
public override getToken(): string {
return this._apis.auth.token;
}
public override async getUserByEmail(
workspace_id: string,
email: string
): Promise<User | null> {
const users = await this._apis.getUserByEmail({ workspace_id, email });
return users?.length
? {
id: users[0].id,
name: users[0].name,
avatar: users[0].avatar_url,
email: users[0].email,
}
: null;
}
public override async extendWorkspace(
workspaceUnit: WorkspaceUnit
): Promise<WorkspaceUnit> {
const { id } = await this._apis.createWorkspace(
new Blob([
encodeStateAsUpdate(workspaceUnit.blocksuiteWorkspace!.doc).buffer,
])
);
const newWorkspaceUnit = new WorkspaceUnit({
id,
name: workspaceUnit.name,
avatar: undefined,
owner: await this.getUserInfo(),
published: false,
memberCount: 1,
provider: this.id,
syncMode: 'core',
});
await migrateBlobDB(workspaceUnit.id, id);
const blocksuiteWorkspace =
await this._apis.createBlockSuiteWorkspaceWithAuth(id);
assert(workspaceUnit.blocksuiteWorkspace);
await applyUpdate(
blocksuiteWorkspace,
encodeStateAsUpdate(workspaceUnit.blocksuiteWorkspace.doc)
);
newWorkspaceUnit.setBlocksuiteWorkspace(blocksuiteWorkspace);
this._workspaces.add(newWorkspaceUnit);
return newWorkspaceUnit;
}
public override async logout(): Promise<void> {
this._apis.auth.clear();
this._destroyChannel();
this._wsMap.forEach(ws => ws.disconnect());
this._workspaces.clear(false);
await this._apis.signOutFirebase();
}
public override async getWorkspaceMembers(id: string) {
return this._apis.getWorkspaceMembers({ id });
}
public override async acceptInvitation(invitingCode: string) {
return await this._apis.acceptInviting({ invitingCode });
}
}

View File

@ -1,22 +0,0 @@
import { describe, expect, test } from 'vitest';
import { GoogleAuth } from '../google';
describe('class Auth', () => {
test('parse tokens', () => {
const tokenString = `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2NzU2Nzk1MjAsImlkIjo2LCJuYW1lIjoidGVzdCIsImVtYWlsIjoidGVzdEBnbWFpbC5jb20iLCJhdmF0YXJfdXJsIjoiaHR0cHM6Ly90ZXN0LmNvbS9hdmF0YXIiLCJjcmVhdGVkX2F0IjoxNjc1Njc4OTIwMzU4fQ.R8GxrNhn3gNumtapthrP6_J5eQjXLV7i-LanSPqe7hw`;
expect(GoogleAuth.parseIdToken(tokenString)).toEqual({
avatar_url: 'https://test.com/avatar',
created_at: 1675678920358,
email: 'test@gmail.com',
exp: 1675679520,
id: 6,
name: 'test',
});
});
test('parse invalid tokens', () => {
const tokenString = `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.aaa.R8GxrNhn3gNumtapthrP6_J5eQjXLV7i-LanSPqe7hw`;
expect(GoogleAuth.parseIdToken(tokenString)).toEqual(null);
});
});

View File

@ -1,288 +0,0 @@
import { DebugLogger } from '@affine/debug';
import { initializeApp } from 'firebase/app';
import type { User } from 'firebase/auth';
import { connectAuthEmulator } from 'firebase/auth';
import {
type Auth as FirebaseAuth,
getAuth as getFirebaseAuth,
GoogleAuthProvider,
signInWithPopup,
signOut,
} from 'firebase/auth';
import { decode } from 'js-base64';
import type { KyInstance } from 'ky/distribution/types/ky';
import { MessageCenter } from '../../../message';
import { storage } from '../storage';
import { RequestError } from './request-error';
export interface AccessTokenMessage {
created_at: number;
exp: number;
email: string;
id: string;
name: string;
avatar_url: string;
}
export type Callback = (user: AccessTokenMessage | null) => void;
type LoginParams = {
type: 'Google' | 'Refresh';
token: string;
};
type LoginResponse = {
// access token, expires in a very short time
token: string;
// Refresh token
refresh: string;
};
// TODO: organize storage keys in a better way
const AFFINE_LOGIN_STORAGE_KEY = 'affine:login';
const messageCenter = MessageCenter.getInstance();
const sendMessage = messageCenter.getMessageSender('affine');
const { messageCode } = MessageCenter;
/**
* Use refresh token to get a new access token (JWT)
* The returned token also contains the user info payload.
*/
const createDoLogin =
(bareClient: KyInstance) =>
(params: LoginParams): Promise<LoginResponse> =>
bareClient.post('api/user/token', { json: params }).json();
export class GoogleAuth {
private readonly _logger;
private _accessToken = ''; // idtoken (JWT)
private _refreshToken = '';
private _user: AccessTokenMessage | null = null;
private _padding?: Promise<LoginResponse>;
private readonly _doLogin: ReturnType<typeof createDoLogin>;
constructor(bareClient: KyInstance) {
this._logger = new DebugLogger('token');
this._logger.enabled = true;
this._doLogin = createDoLogin(bareClient);
this.restoreLogin();
}
setLogin(login: LoginResponse) {
this._accessToken = login.token;
this._refreshToken = login.refresh;
this._user = GoogleAuth.parseIdToken(this._accessToken);
this.triggerChange(this._user);
this.storeLogin();
}
private storeLogin() {
if (this.refresh) {
const { token, refresh } = this;
storage.setItem(
AFFINE_LOGIN_STORAGE_KEY,
JSON.stringify({ token, refresh })
);
}
}
private restoreLogin() {
const loginStr = storage.getItem(AFFINE_LOGIN_STORAGE_KEY);
if (!loginStr) {
return;
}
try {
const login: LoginResponse = JSON.parse(loginStr);
this.setLogin(login);
} catch (err) {
this._logger.warn('Failed to parse login info', err);
}
}
async initToken(token: string) {
try {
const res = await this._doLogin({
token,
type: 'Google',
});
this.setLogin(res);
return this._user;
} catch (error) {
sendMessage(messageCode.loginError);
throw new RequestError('Login failed', error);
}
}
async refreshToken(refreshToken?: string) {
if (!this._padding) {
this._padding = this._doLogin({
type: 'Refresh',
token: refreshToken || this._refreshToken,
});
this._refreshToken = refreshToken || this._refreshToken;
}
try {
const res = await this._padding;
if (res && (!refreshToken || refreshToken !== this._refreshToken)) {
this.setLogin(res);
}
return true;
} catch (error) {
sendMessage(messageCode.refreshTokenError);
throw new RequestError('Refresh token failed', error);
} finally {
// clear on settled
this._padding = undefined;
}
return false;
}
get user() {
// computed through access token
return this._user;
}
get token() {
return this._accessToken;
}
get refresh() {
return this._refreshToken;
}
get isLogin() {
return !!this._refreshToken;
}
get isExpired() {
if (!this._user) return true;
// exp is in seconds
return Date.now() > this._user.exp * 1000;
}
static parseIdToken(token: string): AccessTokenMessage | null {
try {
return JSON.parse(decode(token.split('.')[1]));
} catch (error) {
// todo: log errors?
return null;
}
}
private callbacks: Callback[] = [];
private lastState: AccessTokenMessage | null = null;
triggerChange(user: AccessTokenMessage | null) {
this.lastState = user;
this.callbacks.forEach(callback => callback(user));
}
onChange(callback: Callback) {
this.callbacks.push(callback);
callback(this.lastState);
}
offChange(callback: Callback) {
const index = this.callbacks.indexOf(callback);
if (index > -1) {
this.callbacks.splice(index, 1);
}
}
clear() {
this._accessToken = '';
this._refreshToken = '';
storage.removeItem(AFFINE_LOGIN_STORAGE_KEY);
}
}
export function createGoogleAuth(bareAuth: KyInstance): GoogleAuth {
return new GoogleAuth(bareAuth);
}
// Connect emulators based on env vars
const envConnectEmulators = process.env.REACT_APP_FIREBASE_EMULATORS === 'true';
export const getAuthorizer = (googleAuth: GoogleAuth) => {
let _firebaseAuth: FirebaseAuth | null = null;
const logger = new DebugLogger('authorizer');
// getAuth will send requests on calling thus we can lazy init it
const getAuth = () => {
try {
if (!_firebaseAuth) {
const app = initializeApp({
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId:
process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
measurementId: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID,
});
_firebaseAuth = getFirebaseAuth(app);
}
if (envConnectEmulators && !(window as any).firebaseAuthEmulatorStarted) {
connectAuthEmulator(_firebaseAuth, 'http://localhost:9099', {
disableWarnings: true,
});
(window as any).firebaseAuthEmulatorStarted = true;
}
return _firebaseAuth;
} catch (error) {
logger.error('Failed to initialize firebase', error);
return null;
}
};
const getToken = async () => {
const currentUser = getAuth()?.currentUser;
if (currentUser) {
await currentUser.getIdTokenResult(true);
if (!currentUser.isAnonymous) {
return currentUser.getIdToken();
}
}
return;
};
const signInWithGoogle = async () => {
const idToken = await getToken();
let loginUser: AccessTokenMessage | null = null;
if (idToken) {
loginUser = await googleAuth.initToken(idToken);
} else {
const firebaseAuth = getAuth();
if (firebaseAuth) {
const googleAuthProvider = new GoogleAuthProvider();
// make sure the user has a chance to select an account
// https://developers.google.com/identity/openid-connect/openid-connect#prompt
googleAuthProvider.setCustomParameters({
prompt: 'select_account',
});
const user = await signInWithPopup(firebaseAuth, googleAuthProvider);
const idToken = await user.user.getIdToken();
loginUser = await googleAuth.initToken(idToken);
}
}
return loginUser;
};
const onAuthStateChanged = (callback: (user: User | null) => void) => {
getAuth()?.onAuthStateChanged(callback);
};
const signOutFirebase = async () => {
const firebaseAuth = getAuth();
if (firebaseAuth?.currentUser) {
await signOut(firebaseAuth);
}
};
return [signInWithGoogle, onAuthStateChanged, signOutFirebase] as const;
};

View File

@ -1,45 +0,0 @@
export type { Callback } from './google';
import type { KyInstance } from 'ky/distribution/types/ky';
import type { createGoogleAuth, GoogleAuth } from './google';
import { getAuthorizer } from './google';
import { createUserApis } from './user';
import { createWorkspaceApis } from './workspace';
// See https://twitter.com/mattpocockuk/status/1622730173446557697
// TODO: move to ts utils?
type Prettify<T> = {
[K in keyof T]: T[K];
// eslint-disable-next-line @typescript-eslint/ban-types
} & {};
export type Apis = Prettify<
ReturnType<typeof createUserApis> &
ReturnType<typeof createWorkspaceApis> & {
signInWithGoogle: ReturnType<typeof getAuthorizer>[0];
onAuthStateChanged: ReturnType<typeof getAuthorizer>[1];
signOutFirebase: ReturnType<typeof getAuthorizer>[2];
} & { auth: ReturnType<typeof createGoogleAuth> }
>;
export const getApis = (
bareClient: KyInstance,
authClient: KyInstance,
googleAuth: GoogleAuth
): Apis => {
const [signInWithGoogle, onAuthStateChanged, signOutFirebase] =
getAuthorizer(googleAuth);
return {
...createUserApis(bareClient, authClient),
...createWorkspaceApis(bareClient, authClient, googleAuth),
signInWithGoogle,
signOutFirebase,
onAuthStateChanged,
auth: googleAuth,
};
};
export type { AccessTokenMessage } from './google';
export type { Member, Workspace, WorkspaceDetail } from './workspace';
export * from './workspace';

View File

@ -1,9 +0,0 @@
export class RequestError extends Error {
constructor(message: string, cause: unknown | null = null) {
super(message);
this.name = 'RequestError';
this.cause = cause;
}
}
export default RequestError;

View File

@ -1,65 +0,0 @@
import ky from 'ky-universal';
import { MessageCenter } from '../../../message';
import type { GoogleAuth } from './google';
type KyInstance = typeof ky;
const messageCenter = MessageCenter.getInstance();
const _sendMessage = messageCenter.getMessageSender('affine');
export const createBareClient = (prefixUrl: string): KyInstance =>
ky.extend({
prefixUrl: prefixUrl,
retry: 1,
// todo: report timeout error
timeout: 60000,
hooks: {
beforeError: [
error => {
const { response } = error;
if (response.status === 401) {
_sendMessage(MessageCenter.messageCode.noPermission);
}
return error;
},
],
},
});
const refreshTokenIfExpired = async (googleAuth: GoogleAuth) => {
if (googleAuth.isLogin && googleAuth.isExpired) {
try {
await googleAuth.refreshToken();
} catch (err) {
return new Response('Unauthorized', { status: 401 });
}
}
};
export const createAuthClient = (
bareClient: KyInstance,
googleAuth: GoogleAuth
): KyInstance =>
bareClient.extend({
hooks: {
beforeRequest: [
async request => {
if (googleAuth.isLogin) {
await refreshTokenIfExpired(googleAuth);
request.headers.set('Authorization', googleAuth.token);
} else {
return new Response('Unauthorized', { status: 401 });
}
},
],
beforeRetry: [
async ({ request }) => {
await refreshTokenIfExpired(googleAuth);
request.headers.set('Authorization', googleAuth.token);
},
],
},
});

View File

@ -1,25 +0,0 @@
import type { KyInstance } from 'ky/distribution/types/ky';
export interface GetUserByEmailParams {
email: string;
workspace_id: string;
}
export interface User {
id: string;
name: string;
email: string;
avatar_url: string;
create_at: string;
}
export function createUserApis(bareClient: KyInstance, authClient: KyInstance) {
return {
getUserByEmail: async (
params: GetUserByEmailParams
): Promise<User[] | null> => {
const searchParams = new URLSearchParams({ ...params });
return authClient.get('api/user', { searchParams }).json<User[] | null>();
},
} as const;
}

View File

@ -1,260 +0,0 @@
import type { KyInstance } from 'ky/distribution/types/ky';
import { MessageCenter } from '../../../message';
import { createBlocksuiteWorkspace as _createBlocksuiteWorkspace } from '../../../utils';
import type { GoogleAuth } from './google';
import RequestError from './request-error';
import type { User } from './user';
const messageCenter = MessageCenter.getInstance();
const sendMessage = messageCenter.getMessageSender('affine');
const { messageCode } = MessageCenter;
export interface GetWorkspaceDetailParams {
id: string;
}
export enum WorkspaceType {
Private = 0,
Normal = 1,
}
export enum PermissionType {
Read = 0,
Write = 1,
Admin = 10,
Owner = 99,
}
export interface Workspace {
id: string;
type: WorkspaceType;
public: boolean;
permission: PermissionType;
}
export interface WorkspaceDetail extends Workspace {
owner: User;
member_count: number;
}
export interface Permission {
id: string;
type: PermissionType;
workspace_id: string;
user_id: string;
user_email: string;
accepted: boolean;
create_at: number;
}
export interface RegisteredUser extends User {
type: 'Registered';
}
export interface UnregisteredUser {
type: 'Unregistered';
email: string;
}
export interface Member extends Permission {
user: RegisteredUser | UnregisteredUser;
}
export interface GetWorkspaceMembersParams {
id: string;
}
export interface CreateWorkspaceParams {
name: string;
}
export interface UpdateWorkspaceParams {
id: string;
public: boolean;
}
export interface DeleteWorkspaceParams {
id: string;
}
export interface InviteMemberParams {
id: string;
email: string;
}
export interface RemoveMemberParams {
permissionId: number;
}
export interface AcceptInvitingParams {
invitingCode: string;
}
export interface LeaveWorkspaceParams {
id: number | string;
}
export function createWorkspaceApis(
bareClient: KyInstance,
authClient: KyInstance,
googleAuth: GoogleAuth
) {
return {
getWorkspaces: async (): Promise<Workspace[]> => {
try {
return await authClient
.get('api/workspace', {
headers: {
'Cache-Control': 'no-cache',
},
})
.json();
} catch (error) {
sendMessage(messageCode.loadListFailed);
throw new RequestError('load list failed', error);
}
},
getWorkspaceDetail: async (
params: GetWorkspaceDetailParams
): Promise<WorkspaceDetail | null> => {
try {
return await authClient.get(`api/workspace/${params.id}`).json();
} catch (error) {
sendMessage(messageCode.getDetailFailed);
throw new RequestError('get detail failed', error);
}
},
getWorkspaceMembers: async (
params: GetWorkspaceDetailParams
): Promise<Member[]> => {
try {
return await authClient
.get(`api/workspace/${params.id}/permission`)
.json();
} catch (error) {
sendMessage(messageCode.getMembersFailed);
throw new RequestError('get members failed', error);
}
},
createWorkspace: async (encodedYDoc: Blob): Promise<{ id: string }> => {
try {
return await authClient
.post('api/workspace', { body: encodedYDoc })
.json();
} catch (error) {
sendMessage(messageCode.createWorkspaceFailed);
throw new RequestError('create workspace failed', error);
}
},
updateWorkspace: async (
params: UpdateWorkspaceParams
): Promise<{ public: boolean | null }> => {
try {
return await authClient
.post(`api/workspace/${params.id}`, {
json: {
public: params.public,
},
})
.json();
} catch (error) {
sendMessage(messageCode.updateWorkspaceFailed);
throw new RequestError('update workspace failed', error);
}
},
deleteWorkspace: async (params: DeleteWorkspaceParams): Promise<void> => {
try {
await authClient.delete(`api/workspace/${params.id}`);
} catch (error) {
sendMessage(messageCode.deleteWorkspaceFailed);
throw new RequestError('delete workspace failed', error);
}
},
/**
* Notice: Only support normal(contrast to private) workspace.
*/
inviteMember: async (params: InviteMemberParams): Promise<void> => {
try {
await authClient.post(`api/workspace/${params.id}/permission`, {
json: {
email: params.email,
},
});
} catch (error) {
sendMessage(messageCode.inviteMemberFailed);
throw new RequestError('invite member failed', error);
}
},
removeMember: async (params: RemoveMemberParams): Promise<void> => {
try {
await authClient.delete(`api/permission/${params.permissionId}`);
} catch (error) {
sendMessage(messageCode.removeMemberFailed);
throw new RequestError('remove member failed', error);
}
},
acceptInviting: async (
params: AcceptInvitingParams
): Promise<Permission> => {
try {
return await bareClient
.post(`api/invitation/${params.invitingCode}`)
.json();
} catch (error) {
sendMessage(messageCode.acceptInvitingFailed);
throw new RequestError('accept inviting failed', error);
}
},
uploadBlob: async (params: { blob: Blob }): Promise<string> => {
return authClient.put('api/blob', { body: params.blob }).text();
},
getBlob: async (params: { blobId: string }): Promise<ArrayBuffer> => {
try {
return await authClient.get(`api/blob/${params.blobId}`).arrayBuffer();
} catch (error) {
sendMessage(messageCode.getBlobFailed);
throw new RequestError('get blob failed', error);
}
},
leaveWorkspace: async ({ id }: LeaveWorkspaceParams) => {
try {
await authClient.delete(`api/workspace/${id}/permission`);
} catch (error) {
sendMessage(messageCode.leaveWorkspaceFailed);
throw new RequestError('leave workspace failed', error);
}
},
downloadWorkspace: async (
workspaceId: string,
published = false
): Promise<ArrayBuffer> => {
try {
if (published) {
return await bareClient
.get(`api/public/doc/${workspaceId}`)
.arrayBuffer();
}
return await authClient
.get(`api/workspace/${workspaceId}/doc`)
.arrayBuffer();
} catch (error) {
sendMessage(messageCode.downloadWorkspaceFailed);
throw new RequestError('download workspace failed', error);
}
},
createBlockSuiteWorkspaceWithAuth: async (newWorkspaceId: string) => {
if (googleAuth.isExpired && googleAuth.isLogin) {
await googleAuth.refreshToken();
}
return _createBlocksuiteWorkspace({
id: newWorkspaceId,
blobOptionsGetter: (k: string) =>
// token could be expired
({ api: '/api/workspace', token: googleAuth.token }[k]),
});
},
} as const;
}

View File

@ -1,66 +0,0 @@
import { DebugLogger } from '@affine/debug';
import * as url from 'lib0/url';
import * as websocket from 'lib0/websocket';
import type { GoogleAuth } from './apis/google';
const RECONNECT_INTERVAL_TIME = 500;
const MAX_RECONNECT_TIMES = 50;
export class WebsocketClient extends websocket.WebsocketClient {
public shouldReconnect = false;
private _retryTimes = 0;
private _auth: GoogleAuth;
private _logger = new DebugLogger('affine:channel');
constructor(
serverUrl: string,
auth: GoogleAuth,
options?: ConstructorParameters<typeof websocket.WebsocketClient>[1] & {
params: Record<string, string>;
}
) {
const params = options?.params || {};
// ensure that url is always ends with /
while (serverUrl[serverUrl.length - 1] === '/') {
serverUrl = serverUrl.slice(0, serverUrl.length - 1);
}
const encodedParams = url.encodeQueryParams(params);
const newUrl =
serverUrl + '/' + (encodedParams.length === 0 ? '' : '?' + encodedParams);
super(newUrl, options);
this._auth = auth;
this._setupChannel();
}
private _setupChannel() {
this.on('connect', () => {
this._logger.debug('Affine channel connected');
this.shouldReconnect = true;
this._retryTimes = 0;
});
this.on('disconnect', ({ error }: { error: Error }) => {
if (error) {
// Try reconnect if connect error has occurred
if (this.shouldReconnect && this._auth.isLogin && !this.connected) {
try {
setTimeout(() => {
if (this._retryTimes <= MAX_RECONNECT_TIMES) {
this.connect();
this._logger.info(
`try reconnect channel ${++this._retryTimes} times`
);
} else {
this._logger.error(
'reconnect failed, max reconnect times reached'
);
}
}, RECONNECT_INTERVAL_TIME);
} catch (e) {
this._logger.error('reconnect failed', e);
}
}
}
});
}
}

View File

@ -1,25 +0,0 @@
import { clear, createStore, getMany, keys, setMany } from 'idb-keyval';
import * as idb from 'lib0/indexeddb';
type IDBInstance<T = ArrayBufferLike> = {
keys: () => Promise<string[]>;
clear: () => Promise<void>;
deleteDB: () => Promise<void>;
setMany: (entries: [string, T][]) => Promise<void>;
getMany: (keys: string[]) => Promise<T[]>;
};
export function getDatabase<T = ArrayBufferLike>(
type: string,
database: string
): IDBInstance<T> {
const name = `${database}_${type}`;
const db = createStore(name, type);
return {
keys: () => keys(db),
clear: () => clear(db),
deleteDB: () => idb.deleteDB(name),
setMany: entries => setMany(entries, db),
getMany: keys => getMany(keys, db),
};
}

View File

@ -1 +0,0 @@
export * from './affine';

View File

@ -1,3 +0,0 @@
import { varStorage } from 'lib0/storage';
export const storage = varStorage as Storage;

View File

@ -1,94 +0,0 @@
import { applyUpdate } from '../../utils';
import type { WorkspaceUnitCtorParams } from '../../workspace-unit';
import { WorkspaceUnit } from '../../workspace-unit';
import { setDefaultAvatar } from '../utils';
import type { Apis } from './apis';
import { getDatabase } from './idb-kv';
export const loadWorkspaceUnit = async (
params: WorkspaceUnitCtorParams,
apis: Apis
) => {
const workspaceUnit = new WorkspaceUnit(params);
const blocksuiteWorkspace = await apis.createBlockSuiteWorkspaceWithAuth(
workspaceUnit.id
);
const updates = await apis.downloadWorkspace(
workspaceUnit.id,
params.published
);
applyUpdate(blocksuiteWorkspace, new Uint8Array(updates));
const details = await apis.getWorkspaceDetail({ id: workspaceUnit.id });
const owner = details?.owner;
workspaceUnit.setBlocksuiteWorkspace(blocksuiteWorkspace);
workspaceUnit.update({
name: blocksuiteWorkspace.meta.name,
avatar: blocksuiteWorkspace.meta.avatar,
memberCount: details?.member_count || 1,
owner: owner
? {
id: owner.id,
name: owner.name,
avatar: owner.avatar_url,
email: owner.email,
}
: undefined,
});
return workspaceUnit;
};
export const createWorkspaceUnit = async (
params: WorkspaceUnitCtorParams,
apis: Apis
) => {
const workspaceUnit = new WorkspaceUnit(params);
const blocksuiteWorkspace = await apis.createBlockSuiteWorkspaceWithAuth(
workspaceUnit.id
);
blocksuiteWorkspace.meta.setName(workspaceUnit.name);
if (!workspaceUnit.avatar) {
await setDefaultAvatar(blocksuiteWorkspace);
workspaceUnit.update({ avatar: blocksuiteWorkspace.meta.avatar });
}
workspaceUnit.setBlocksuiteWorkspace(blocksuiteWorkspace);
return workspaceUnit;
};
interface PendingTask {
id: string;
blob: ArrayBufferLike;
}
export const migrateBlobDB = async (
oldWorkspaceId: string,
newWorkspaceId: string
) => {
const oldDB = getDatabase('blob', oldWorkspaceId);
const oldPendingDB = getDatabase<PendingTask>('pending', oldWorkspaceId);
const newDB = getDatabase('blob', newWorkspaceId);
const newPendingDB = getDatabase<PendingTask>('pending', newWorkspaceId);
const keys = await oldDB.keys();
const values = await oldDB.getMany(keys);
const entries = keys.map((key, index) => {
return [key, values[index]] as [string, ArrayBufferLike];
});
await newDB.setMany(entries);
const pendingEntries = entries.map(([id, blob]) => {
return [id, { id, blob }] as [string, PendingTask];
});
await newPendingDB.setMany(pendingEntries);
await oldDB.clear();
await oldPendingDB.clear();
};

View File

@ -1,240 +0,0 @@
import { DebugLogger } from '@affine/debug';
import type { Workspace as BlocksuiteWorkspace } from '@blocksuite/store';
import type { MessageCenter } from '../message';
import type { User } from '../types';
import type { WorkspaceUnit, WorkspaceUnitCtorParams } from '../workspace-unit';
import type { WorkspaceUnitCollectionScope } from '../workspace-unit-collection';
import type { Member } from './affine/apis';
import type { Permission } from './affine/apis/workspace';
export interface ProviderConstructorParams {
workspaces: WorkspaceUnitCollectionScope;
messageCenter: MessageCenter;
}
export type WorkspaceMeta0 = WorkspaceUnitCtorParams;
export type CreateWorkspaceInfoParams = Pick<WorkspaceUnitCtorParams, 'name'>;
export type UpdateWorkspaceMetaParams = Partial<
Pick<WorkspaceUnitCtorParams, 'name' | 'avatar'>
>;
export abstract class BaseProvider {
/** provider id */
public readonly id: string = 'base';
/** workspace unit collection */
protected _workspaces!: WorkspaceUnitCollectionScope;
protected _logger: DebugLogger;
/** send message with message center */
protected _sendMessage!: ReturnType<
InstanceType<typeof MessageCenter>['getMessageSender']
>;
public constructor({ workspaces, messageCenter }: ProviderConstructorParams) {
this._workspaces = workspaces;
this._sendMessage = messageCenter.getMessageSender(this.id);
this._logger = new DebugLogger(`provider:${this.id}`);
}
/**
* hook after provider registered
*/
public async init() {
return;
}
/**
* auth provider
*/
public async auth() {
return;
}
/**
* logout provider
*/
public async logout() {
return;
}
public getToken(): string {
return '';
}
/**
* warp workspace with provider functions
* @param workspace
* @returns
*/
public async warpWorkspace(
workspace: BlocksuiteWorkspace
): Promise<BlocksuiteWorkspace> {
return workspace;
}
/**
* @deprecated Temporary for public workspace
* @param blocksuiteWorkspace
* @returns
*/
public async loadPublicWorkspace(blocksuiteWorkspace: BlocksuiteWorkspace) {
return blocksuiteWorkspace;
}
/**
* load workspaces
**/
public async loadWorkspaces(): Promise<WorkspaceUnit[]> {
throw new Error(`provider: ${this.id} loadWorkSpace Not implemented`);
}
/**
* get auth user info
* @returns
*/
public async getUserInfo(): Promise<User | undefined> {
return;
}
// async getBlob(id: string): Promise<string | null> {
// return await this._blobs.get(id);
// }
// async setBlob(blob: Blob): Promise<string> {
// return await this._blobs.set(blob);
// }
/**
* clear all local data in provider
*/
async clear() {
// this._blobs.clear();
}
/**
* delete workspace include all data
* @param id workspace id
*/
public async deleteWorkspace(id: string): Promise<void> {
id;
return;
}
/**
* leave workspace by workspace id
* @param id workspace id
*/
public async leaveWorkspace(id: string): Promise<void> {
id;
return;
}
/**
* close db link and websocket connection and other resources
* @param id workspace id
*/
public async closeWorkspace(id: string) {
id;
return;
}
/**
* invite workspace member
* @param id workspace id
*/
public async invite(id: string, email: string): Promise<void> {
id;
email;
return;
}
/**
* remove workspace member by permission id
* @param permissionId
*/
public async removeMember(permissionId: number): Promise<void> {
permissionId;
return;
}
public async publish(id: string, isPublish: boolean): Promise<void> {
id;
isPublish;
return;
}
/**
* change workspace meta by workspace id , work for cached list in different provider
* @param id
* @param meta
* @returns
*/
public async updateWorkspaceMeta(
id: string,
params: UpdateWorkspaceMetaParams
): Promise<void> {
id;
params;
return;
}
/**
* create workspace by workspace meta
* @param {CreateWorkspaceInfoParams} meta
*/
public async createWorkspace(
meta: CreateWorkspaceInfoParams
): Promise<WorkspaceUnit | undefined> {
throw new Error(`provider: ${this.id} createWorkspace not implemented`);
}
public async extendWorkspace(
workspaceUnit: WorkspaceUnit
): Promise<WorkspaceUnit | undefined> {
throw new Error(`provider: ${this.id} extendWorkspace not implemented`);
}
/**
* get user by email
* @param {string} id
* @param {string} email
* @returns
*/
public async getUserByEmail(id: string, email: string): Promise<User | null> {
email;
return null;
}
/**
* link workspace to local caches
* @param workspace
* @returns
*/
public async linkLocal(
workspace: BlocksuiteWorkspace
): Promise<BlocksuiteWorkspace> {
return workspace;
}
/**
* get workspace members
* @param {string} workspaceId
* @returns
*/
public getWorkspaceMembers(workspaceId: string): Promise<Member[]> {
workspaceId;
return Promise.resolve([]);
}
/**
* accept invitation
* @param {string} inviteCode
* @returns
*/
public async acceptInvitation(
inviteCode: string
): Promise<Permission | null> {
inviteCode;
return null;
}
}

View File

@ -1 +0,0 @@
export * from './affine/affine';

View File

@ -1 +0,0 @@
export * from './local';

View File

@ -1,208 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Workspace as BlocksuiteWorkspace } from '@blocksuite/store';
import * as idb from 'lib0/indexeddb';
import { Observable } from 'lib0/observable';
const customStoreName = 'custom';
const updatesStoreName = 'updates';
const PREFERRED_TRIM_SIZE = 500;
const {
Y: { applyUpdate, transact, encodeStateAsUpdate },
} = BlocksuiteWorkspace;
type Doc = Parameters<typeof transact>[0];
const fetchUpdates = async (provider: IndexedDBProvider) => {
const [updatesStore] = idb.transact(provider.db as IDBDatabase, [
updatesStoreName,
]); // , 'readonly')
if (updatesStore) {
const updates = await idb.getAll(
updatesStore,
idb.createIDBKeyRangeLowerBound(provider._dbref, false)
);
transact(
provider.doc,
() => {
updates.forEach(val => applyUpdate(provider.doc, val));
},
provider,
false
);
const lastKey = await idb.getLastKey(updatesStore);
provider._dbref = lastKey + 1;
const cnt = await idb.count(updatesStore);
provider._dbsize = cnt;
}
return updatesStore;
};
const storeState = (provider: IndexedDBProvider, forceStore = true) =>
fetchUpdates(provider).then(updatesStore => {
if (
updatesStore &&
(forceStore || provider._dbsize >= PREFERRED_TRIM_SIZE)
) {
idb
.addAutoKey(updatesStore, encodeStateAsUpdate(provider.doc))
.then(() =>
idb.del(
updatesStore,
idb.createIDBKeyRangeUpperBound(provider._dbref, true)
)
)
.then(() =>
idb.count(updatesStore).then(cnt => {
provider._dbsize = cnt;
})
);
}
});
export class IndexedDBProvider extends Observable<string> {
doc: Doc;
name: string;
_dbref: number;
_dbsize: number;
private _destroyed: boolean;
whenSynced: Promise<IndexedDBProvider>;
db: IDBDatabase | null;
private _db: Promise<IDBDatabase>;
private _storeTimeout: number;
private _storeTimeoutId: NodeJS.Timeout | null;
private _storeUpdate: (update: Uint8Array, origin: any) => void;
constructor(name: string, doc: Doc) {
super();
this.doc = doc;
this.name = name;
this._dbref = 0;
this._dbsize = 0;
this._destroyed = false;
this.db = null;
this._db = idb.openDB(name, db =>
idb.createStores(db, [['updates', { autoIncrement: true }], ['custom']])
);
this.whenSynced = this._db.then(async db => {
this.db = db;
const currState = encodeStateAsUpdate(doc);
const updatesStore = await fetchUpdates(this);
if (updatesStore) {
await idb.addAutoKey(updatesStore, currState);
}
if (this._destroyed) {
return this;
}
this.emit('synced', [this]);
return this;
});
// Timeout in ms untill data is merged and persisted in idb.
this._storeTimeout = 1000;
this._storeTimeoutId = null;
this._storeUpdate = (update: Uint8Array, origin: any) => {
if (this.db && origin !== this) {
const [updatesStore] = idb.transact(
/** @type {IDBDatabase} */ this.db,
[updatesStoreName]
);
if (updatesStore) {
idb.addAutoKey(updatesStore, update);
}
if (++this._dbsize >= PREFERRED_TRIM_SIZE) {
// debounce store call
if (this._storeTimeoutId !== null) {
clearTimeout(this._storeTimeoutId);
}
this._storeTimeoutId = setTimeout(() => {
storeState(this, false);
this._storeTimeoutId = null;
}, this._storeTimeout);
}
}
};
doc.on('update', this._storeUpdate);
this.destroy = this.destroy.bind(this);
doc.on('destroy', this.destroy);
}
override destroy() {
if (this._storeTimeoutId) {
clearTimeout(this._storeTimeoutId);
}
this.doc.off('update', this._storeUpdate);
this.doc.off('destroy', this.destroy);
this._destroyed = true;
return this._db.then(db => {
db.close();
});
}
/**
* Destroys this instance and removes all data from indexeddb.
*
* @return {Promise<void>}
*/
async clearData(): Promise<void> {
return this.destroy().then(() => {
idb.deleteDB(this.name);
});
}
/**
* @param {String | number | ArrayBuffer | Date} key
* @return {Promise<String | number | ArrayBuffer | Date | any>}
*/
async get(
key: string | number | ArrayBuffer | Date
): Promise<string | number | ArrayBuffer | Date | any> {
return this._db.then(db => {
const [custom] = idb.transact(db, [customStoreName], 'readonly');
if (custom) {
return idb.get(custom, key);
}
return undefined;
});
}
/**
* @param {String | number | ArrayBuffer | Date} key
* @param {String | number | ArrayBuffer | Date} value
* @return {Promise<String | number | ArrayBuffer | Date>}
*/
async set(
key: string | number | ArrayBuffer | Date,
value: string | number | ArrayBuffer | Date
): Promise<string | number | ArrayBuffer | Date> {
return this._db.then(db => {
const [custom] = idb.transact(db, [customStoreName]);
if (custom) {
return idb.put(custom, value, key);
}
return undefined;
});
}
/**
* @param {String | number | ArrayBuffer | Date} key
* @return {Promise<undefined>}
*/
async del(key: string | number | ArrayBuffer | Date): Promise<undefined> {
return this._db.then(db => {
const [custom] = idb.transact(db, [customStoreName]);
if (custom) {
return idb.del(custom, key);
}
return undefined;
});
}
static delete(name: string): Promise<void> {
return idb.deleteDB(name);
}
}

View File

@ -1,43 +0,0 @@
import { Workspace as BlocksuiteWorkspace } from '@blocksuite/store';
import assert from 'assert';
import * as idb from 'lib0/indexeddb';
import { applyUpdate } from '../../../utils';
const { encodeStateAsUpdate, mergeUpdates } = BlocksuiteWorkspace.Y;
export const writeUpdatesToLocal = async (
blocksuiteWorkspace: BlocksuiteWorkspace
) => {
const workspaceId = blocksuiteWorkspace.id;
assert(workspaceId);
await idb.deleteDB(workspaceId);
const db = await idb.openDB(workspaceId, db =>
idb.createStores(db, [['updates', { autoIncrement: true }], ['custom']])
);
const currState = encodeStateAsUpdate(blocksuiteWorkspace.doc);
const [updatesStore] = idb.transact(db, ['updates']); // , 'readonly')
if (updatesStore) {
await idb.addAutoKey(updatesStore, currState);
}
db.close();
};
export const applyLocalUpdates = async (
blocksuiteWorkspace: BlocksuiteWorkspace
) => {
const workspaceId = blocksuiteWorkspace.id;
assert(workspaceId, 'Blocksuite workspace without room(workspaceId).');
const db = await idb.openDB(workspaceId, db =>
idb.createStores(db, [['updates', { autoIncrement: true }], ['custom']])
);
const [updatesStore] = idb.transact(db, ['updates']); // , 'readonly')
if (updatesStore) {
const updates = await idb.getAll(updatesStore);
const mergedUpdates = mergeUpdates(updates);
await applyUpdate(blocksuiteWorkspace, mergedUpdates);
}
return blocksuiteWorkspace;
};

View File

@ -1,61 +0,0 @@
import 'fake-indexeddb/auto';
import { describe, expect, test } from 'vitest';
import { MessageCenter } from '../../message';
import { WorkspaceUnitCollection } from '../../workspace-unit-collection';
import { LocalProvider } from './local';
describe('local provider', () => {
const workspaceMetaCollection = new WorkspaceUnitCollection();
const provider = new LocalProvider({
workspaces: workspaceMetaCollection.createScope(),
messageCenter: new MessageCenter(),
});
const workspaceName = 'workspace-test';
let workspaceId: string | undefined;
test('create workspace', async () => {
const workspaceUnit = await provider.createWorkspace({
name: workspaceName,
});
workspaceId = workspaceUnit?.id;
expect(workspaceMetaCollection.workspaces.length).toEqual(1);
expect(workspaceMetaCollection.workspaces[0].name).toEqual(workspaceName);
});
test('workspace list cache', async () => {
const workspacesMetaCollection1 = new WorkspaceUnitCollection();
const provider1 = new LocalProvider({
workspaces: workspacesMetaCollection1.createScope(),
messageCenter: new MessageCenter(),
});
await provider1.loadWorkspaces();
expect(workspacesMetaCollection1.workspaces.length).toEqual(1);
expect(workspacesMetaCollection1.workspaces[0].name).toEqual(workspaceName);
expect(workspacesMetaCollection1.workspaces[0].id).toEqual(workspaceId);
});
test('update workspace', async () => {
await provider.updateWorkspaceMeta(workspaceId!, {
name: '1111',
});
expect(workspaceMetaCollection.workspaces[0].name).toEqual('1111');
});
test('delete workspace', async () => {
expect(workspaceMetaCollection.workspaces.length).toEqual(1);
/**
* FIXME
* If we don't wrap setTimeout,
* Running deleteWorkspace will crash the worker, and get error like next line:
* InvalidStateError: An operation was called on an object on which it is not allowed or at a time when it is not allowed. Also occurs if a request is made on a source object that has been deleted or removed. Use TransactionInactiveError or ReadOnlyError when possible, as they are more specific variations of InvalidStateError.
* */
setTimeout(async () => {
await provider.deleteWorkspace(workspaceMetaCollection.workspaces[0].id);
expect(workspaceMetaCollection.workspaces.length).toEqual(0);
}, 10);
});
});

View File

@ -1,125 +0,0 @@
import type { Workspace as BlocksuiteWorkspace } from '@blocksuite/store';
import { uuidv4 } from '@blocksuite/store';
import assert from 'assert';
import { varStorage as storage } from 'lib0/storage';
import type { WorkspaceUnit } from '../../workspace-unit';
import type {
CreateWorkspaceInfoParams,
ProviderConstructorParams,
UpdateWorkspaceMetaParams,
WorkspaceMeta0,
} from '../base';
import { BaseProvider } from '../base';
import { IndexedDBProvider } from './indexeddb/indexeddb';
import { applyLocalUpdates } from './indexeddb/utils';
import { createWorkspaceUnit, loadWorkspaceUnit } from './utils';
const WORKSPACE_KEY = 'workspaces';
export class LocalProvider extends BaseProvider {
public id = 'local';
private _idbMap: Map<BlocksuiteWorkspace, IndexedDBProvider> = new Map();
constructor(params: ProviderConstructorParams) {
super(params);
}
private _storeWorkspaces(workspaceUnits: WorkspaceUnit[]) {
storage.setItem(
WORKSPACE_KEY,
JSON.stringify(
workspaceUnits.map(w => {
return w.toJSON();
})
)
);
}
public override async linkLocal(workspace: BlocksuiteWorkspace) {
assert(workspace.id);
let idb = this._idbMap.get(workspace);
if (!idb) {
idb = new IndexedDBProvider(workspace.id, workspace.doc);
}
this._idbMap.set(workspace, idb);
this._logger.debug('Local data loaded');
return workspace;
}
public override async warpWorkspace(
workspace: BlocksuiteWorkspace
): Promise<BlocksuiteWorkspace> {
assert(workspace.id);
await applyLocalUpdates(workspace);
await this.linkLocal(workspace);
return workspace;
}
override async loadWorkspaces(): Promise<WorkspaceUnit[]> {
const workspaceStr = storage.getItem(WORKSPACE_KEY);
if (workspaceStr) {
try {
const workspaceMetas = JSON.parse(workspaceStr) as WorkspaceMeta0[];
const workspaceUnits = await Promise.all(
workspaceMetas.map(meta => {
return loadWorkspaceUnit(meta);
})
);
this._workspaces.add(workspaceUnits);
return workspaceUnits;
} catch (error) {
this._logger.error(`Failed to parse workspaces from storage`);
}
}
return [];
}
public override async deleteWorkspace(id: string): Promise<void> {
const workspace = this._workspaces.get(id);
if (workspace) {
IndexedDBProvider.delete(id);
this._workspaces.remove(id);
this._storeWorkspaces(this._workspaces.list());
if (workspace.blocksuiteWorkspace) {
this._idbMap.delete(workspace.blocksuiteWorkspace);
}
} else {
this._logger.error(`Failed to delete workspace ${id}`);
}
}
public override async updateWorkspaceMeta(
id: string,
meta: UpdateWorkspaceMetaParams
) {
this._workspaces.update(id, meta);
this._storeWorkspaces(this._workspaces.list());
}
public override async createWorkspace(
meta: CreateWorkspaceInfoParams
): Promise<WorkspaceUnit | undefined> {
const workspaceUnit = await createWorkspaceUnit({
name: meta.name,
id: uuidv4(),
published: false,
avatar: '',
owner: undefined,
syncMode: 'core',
memberCount: 1,
provider: this.id,
});
this._workspaces.add(workspaceUnit);
this._storeWorkspaces(this._workspaces.list());
return workspaceUnit;
}
public override async clear(): Promise<void> {
const workspaces = await this.loadWorkspaces();
workspaces.forEach(ws => IndexedDBProvider.delete(ws.id));
this._storeWorkspaces([]);
this._workspaces.clear();
this._idbMap.clear();
}
}

View File

@ -1,41 +0,0 @@
import { createBlocksuiteWorkspace } from '../../utils';
import type { WorkspaceUnitCtorParams } from '../../workspace-unit';
import { WorkspaceUnit } from '../../workspace-unit';
import { setDefaultAvatar } from '../utils';
import { applyLocalUpdates, writeUpdatesToLocal } from './indexeddb/utils';
export const loadWorkspaceUnit = async (params: WorkspaceUnitCtorParams) => {
const workspaceUnit = new WorkspaceUnit(params);
const blocksuiteWorkspace = createBlocksuiteWorkspace({
id: workspaceUnit.id,
blobOptionsGetter: (k: string) => undefined,
});
await applyLocalUpdates(blocksuiteWorkspace);
workspaceUnit.setBlocksuiteWorkspace(blocksuiteWorkspace);
return workspaceUnit;
};
export const createWorkspaceUnit = async (params: WorkspaceUnitCtorParams) => {
const workspaceUnit = new WorkspaceUnit(params);
const blocksuiteWorkspace = createBlocksuiteWorkspace({
id: workspaceUnit.id,
blobOptionsGetter: (k: string) => undefined,
});
blocksuiteWorkspace.meta.setName(workspaceUnit.name);
if (!workspaceUnit.avatar) {
await setDefaultAvatar(blocksuiteWorkspace);
workspaceUnit.update({ avatar: blocksuiteWorkspace.meta.avatar });
}
if (typeof window !== 'undefined') {
await writeUpdatesToLocal(blocksuiteWorkspace);
}
workspaceUnit.setBlocksuiteWorkspace(blocksuiteWorkspace);
return workspaceUnit;
};

View File

@ -1,63 +0,0 @@
// import assert from 'assert';
// import { LocalProvider } from '../local/index.js';
// import { WebsocketProvider } from './sync.js';
// export class SelfHostedProvider extends LocalProvider {
// static id = 'selfhosted';
// private _ws?: WebsocketProvider;
// constructor() {
// super();
// }
// async destroy() {
// this._ws?.disconnect();
// }
// async initData() {
// const databases = await indexedDB.databases();
// await super.initData(
// // set locally to true if exists a same name db
// databases
// .map(db => db.name)
// .filter(v => v)
// .includes(this._workspace.id)
// );
// const workspace = this._workspace;
// const doc = workspace.doc;
// if (workspace.id) {
// try {
// // Wait for ws synchronization to complete, otherwise the data will be modified in reverse, which can be optimized later
// this._ws = new WebsocketProvider(this.host, workspace.id, doc);
// await new Promise<void>((resolve, reject) => {
// // TODO: synced will also be triggered on reconnection after losing sync
// // There needs to be an event mechanism to emit the synchronization state to the upper layer
// assert(this._ws);
// this._ws.once('synced', () => resolve());
// this._ws.once('lost-connection', () => resolve());
// this._ws.once('connection-error', () => reject());
// });
// this._slots.listAdd.emit({
// workspace: workspace.id,
// provider: this.id,
// locally: true,
// });
// } catch (e) {
// this._logger('Failed to init cloud workspace', e);
// }
// }
// // if after update, the space:meta is empty
// // then we need to get map with doc
// // just a workaround for yjs
// doc.getMap('space:meta');
// }
// private get host() {
// const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
// return `${protocol}//${location.host}/collaboration/`;
// }
// }

View File

@ -1,508 +0,0 @@
/* eslint-disable no-undef */
/**
* @module provider/websocket
*/
/* eslint-env browser */
// import * as Y from 'yjs'; // eslint-disable-line
import * as bc from 'lib0/broadcastchannel';
import * as decoding from 'lib0/decoding';
import * as encoding from 'lib0/encoding';
import * as math from 'lib0/math';
import { Observable } from 'lib0/observable';
import * as time from 'lib0/time';
import * as url from 'lib0/url';
import * as authProtocol from 'y-protocols/auth';
import * as awarenessProtocol from 'y-protocols/awareness';
import * as syncProtocol from 'y-protocols/sync';
export const messageSync = 0;
export const messageQueryAwareness = 3;
export const messageAwareness = 1;
export const messageAuth = 2;
/**
* encoder, decoder, provider, emitSynced, messageType
* @type {Array<function(encoding.Encoder, decoding.Decoder, WebsocketProvider, boolean, number):void>}
*/
const messageHandlers = [];
messageHandlers[messageSync] = (
encoder,
decoder,
provider,
emitSynced,
_messageType
) => {
encoding.writeVarUint(encoder, messageSync);
const syncMessageType = syncProtocol.readSyncMessage(
decoder,
encoder,
provider.doc,
provider
);
if (
emitSynced &&
syncMessageType === syncProtocol.messageYjsSyncStep2 &&
!provider.synced
) {
provider.synced = true;
}
};
messageHandlers[messageQueryAwareness] = (
encoder,
_decoder,
provider,
_emitSynced,
_messageType
) => {
encoding.writeVarUint(encoder, messageAwareness);
encoding.writeVarUint8Array(
encoder,
awarenessProtocol.encodeAwarenessUpdate(
provider.awareness,
Array.from(provider.awareness.getStates().keys())
)
);
};
messageHandlers[messageAwareness] = (
_encoder,
decoder,
provider,
_emitSynced,
_messageType
) => {
awarenessProtocol.applyAwarenessUpdate(
provider.awareness,
decoding.readVarUint8Array(decoder),
provider
);
};
messageHandlers[messageAuth] = (
_encoder,
decoder,
provider,
_emitSynced,
_messageType
) => {
authProtocol.readAuthMessage(decoder, provider.doc, (_ydoc, reason) =>
permissionDeniedHandler(provider, reason)
);
};
// @todo - this should depend on awareness.outdatedTime
const messageReconnectTimeout = 30000;
/**
* @param {WebsocketProvider} provider
* @param {string} reason
*/
const permissionDeniedHandler = (provider, reason) =>
console.warn(`Permission denied to access ${provider.url}.\n${reason}`);
/**
* @param {WebsocketProvider} provider
* @param {Uint8Array} buf
* @param {boolean} emitSynced
* @return {encoding.Encoder}
*/
const readMessage = (provider, buf, emitSynced) => {
const decoder = decoding.createDecoder(buf);
const encoder = encoding.createEncoder();
const messageType = decoding.readVarUint(decoder);
const messageHandler = provider.messageHandlers[messageType];
if (/** @type {any} */ (messageHandler)) {
messageHandler(encoder, decoder, provider, emitSynced, messageType);
} else {
console.error('Unable to compute message');
}
return encoder;
};
/**
* @param {WebsocketProvider} provider
*/
const setupWS = provider => {
if (provider.shouldConnect && provider.ws === null) {
const websocket = new provider._WS(provider.url, 'AFFiNE');
websocket.binaryType = 'arraybuffer';
provider.ws = websocket;
provider.wsconnecting = true;
provider.wsconnected = false;
provider.synced = false;
websocket.onmessage = event => {
provider.wsLastMessageReceived = time.getUnixTime();
const encoder = readMessage(provider, new Uint8Array(event.data), true);
if (encoding.length(encoder) > 1) {
websocket.send(encoding.toUint8Array(encoder));
}
};
websocket.onerror = event => {
provider.emit('connection-error', [event, provider]);
};
websocket.onclose = event => {
provider.emit('connection-close', [event, provider]);
provider.ws = null;
provider.wsconnecting = false;
if (provider.wsconnected) {
provider.wsconnected = false;
provider.synced = false;
// update awareness (all users except local left)
awarenessProtocol.removeAwarenessStates(
provider.awareness,
Array.from(provider.awareness.getStates().keys()).filter(
client => client !== provider.doc.clientID
),
provider
);
provider.emit('status', [
{
status: 'disconnected',
},
]);
} else {
provider.wsUnsuccessfulReconnects++;
}
// Start with no reconnect timeout and increase timeout by
// using exponential backoff starting with 100ms
setTimeout(
setupWS,
math.min(
math.pow(2, provider.wsUnsuccessfulReconnects) * 100,
provider.maxBackoffTime
),
provider
);
};
websocket.onopen = () => {
provider.wsLastMessageReceived = time.getUnixTime();
provider.wsconnecting = false;
provider.wsconnected = true;
provider.wsUnsuccessfulReconnects = 0;
provider.emit('status', [
{
status: 'connected',
},
]);
// always send sync step 1 when connected
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, messageSync);
syncProtocol.writeSyncStep1(encoder, provider.doc);
websocket.send(encoding.toUint8Array(encoder));
// broadcast local awareness state
if (provider.awareness.getLocalState() !== null) {
const encoderAwarenessState = encoding.createEncoder();
encoding.writeVarUint(encoderAwarenessState, messageAwareness);
encoding.writeVarUint8Array(
encoderAwarenessState,
awarenessProtocol.encodeAwarenessUpdate(provider.awareness, [
provider.doc.clientID,
])
);
websocket.send(encoding.toUint8Array(encoderAwarenessState));
}
};
provider.emit('status', [
{
status: 'connecting',
},
]);
}
};
/**
* @param {WebsocketProvider} provider
* @param {ArrayBuffer} buf
*/
const broadcastMessage = (provider, buf) => {
if (provider.wsconnected) {
/** @type {WebSocket} */ (provider.ws).send(buf);
}
if (provider.bcconnected) {
bc.publish(provider.bcChannel, buf, provider);
}
};
/**
* Websocket Provider for Yjs. Creates a websocket connection to sync the shared document.
* The document name is attached to the provided url. I.e. the following example
* creates a websocket connection to http://localhost:1234/my-document-name
*
* @example
* import * as Y from 'yjs'
* import { WebsocketProvider } from 'y-websocket'
* const doc = new Y.Doc()
* const provider = new WebsocketProvider('http://localhost:1234', 'my-document-name', doc)
*
* @extends {Observable<string>}
*/
export class WebsocketProvider extends Observable {
/**
* @param {string} serverUrl
* @param {string} roomname
* @param {Y.Doc} doc
* @param {object} [opts]
* @param {boolean} [opts.connect]
* @param {awarenessProtocol.Awareness} [opts.awareness]
* @param {Object<string,string>} [opts.params]
* @param {typeof WebSocket} [opts.WebSocketPolyfill] Optionall provide a WebSocket polyfill
* @param {number} [opts.resyncInterval] Request server state every `resyncInterval` milliseconds
* @param {number} [opts.maxBackoffTime] Maximum amount of time to wait before trying to reconnect (we try to reconnect using exponential backoff)
* @param {boolean} [opts.disableBc] Disable cross-tab BroadcastChannel communication
*/
constructor(
serverUrl,
roomname,
doc,
{
connect = true,
awareness = new awarenessProtocol.Awareness(doc),
params = {},
WebSocketPolyfill = WebSocket,
resyncInterval = -1,
maxBackoffTime = 2500,
disableBc = false,
} = {}
) {
super();
// ensure that url is always ends with /
while (serverUrl[serverUrl.length - 1] === '/') {
serverUrl = serverUrl.slice(0, serverUrl.length - 1);
}
const encodedParams = url.encodeQueryParams(params);
this.maxBackoffTime = maxBackoffTime;
this.bcChannel = serverUrl + '/' + roomname;
this.url =
serverUrl +
'/' +
roomname +
(encodedParams.length === 0 ? '' : '?' + encodedParams);
this.roomname = roomname;
this.doc = doc;
this._WS = WebSocketPolyfill;
this.awareness = awareness;
this.wsconnected = false;
this.wsconnecting = false;
this.bcconnected = false;
this.disableBc = disableBc;
this.wsUnsuccessfulReconnects = 0;
this.messageHandlers = messageHandlers.slice();
/**
* @type {boolean}
*/
this._synced = false;
/**
* @type {WebSocket?}
*/
this.ws = null;
this.wsLastMessageReceived = 0;
/**
* Whether to connect to other peers or not
* @type {boolean}
*/
this.shouldConnect = connect;
/**
* @type {number}
*/
this._resyncInterval = 0;
if (resyncInterval > 0) {
this._resyncInterval = /** @type {any} */ (
setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
// resend sync step 1
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, messageSync);
syncProtocol.writeSyncStep1(encoder, doc);
this.ws.send(encoding.toUint8Array(encoder));
}
}, resyncInterval)
);
}
/**
* @param {ArrayBuffer} data
* @param {any} origin
*/
this._bcSubscriber = (data, origin) => {
if (origin !== this) {
const encoder = readMessage(this, new Uint8Array(data), false);
if (encoding.length(encoder) > 1) {
bc.publish(this.bcChannel, encoding.toUint8Array(encoder), this);
}
}
};
/**
* Listens to Yjs updates and sends them to remote peers (ws and broadcastchannel)
* @param {Uint8Array} update
* @param {any} origin
*/
this._updateHandler = (update, origin) => {
if (origin !== this) {
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, messageSync);
syncProtocol.writeUpdate(encoder, update);
broadcastMessage(this, encoding.toUint8Array(encoder));
}
};
this.doc.on('update', this._updateHandler);
/**
* @param {any} changed
* @param {any} _origin
*/
this._awarenessUpdateHandler = ({ added, updated, removed }, _origin) => {
const changedClients = added.concat(updated).concat(removed);
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, messageAwareness);
encoding.writeVarUint8Array(
encoder,
awarenessProtocol.encodeAwarenessUpdate(awareness, changedClients)
);
broadcastMessage(this, encoding.toUint8Array(encoder));
};
this._unloadHandler = () => {
awarenessProtocol.removeAwarenessStates(
this.awareness,
[doc.clientID],
'window unload'
);
};
if (typeof window !== 'undefined') {
window.addEventListener('unload', this._unloadHandler);
} else if (typeof process !== 'undefined') {
process.on('exit', this._unloadHandler);
}
awareness.on('update', this._awarenessUpdateHandler);
this._checkInterval = /** @type {any} */ (
setInterval(() => {
if (
this.wsconnected &&
messageReconnectTimeout <
time.getUnixTime() - this.wsLastMessageReceived
) {
// no message received in a long time - not even your own awareness
// updates (which are updated every 15 seconds)
/** @type {WebSocket} */ (this.ws).close();
}
}, messageReconnectTimeout / 10)
);
if (connect) {
this.connect();
}
}
/**
* @type {boolean}
*/
get synced() {
return this._synced;
}
set synced(state) {
if (this._synced !== state) {
this._synced = state;
this.emit('synced', [state]);
this.emit('sync', [state]);
}
}
destroy() {
if (this._resyncInterval !== 0) {
clearInterval(this._resyncInterval);
}
clearInterval(this._checkInterval);
this.disconnect();
if (typeof window !== 'undefined') {
window.removeEventListener('unload', this._unloadHandler);
} else if (typeof process !== 'undefined') {
process.off('exit', this._unloadHandler);
}
this.awareness.off('update', this._awarenessUpdateHandler);
this.doc.off('update', this._updateHandler);
super.destroy();
}
connectBc() {
if (this.disableBc) {
return;
}
if (!this.bcconnected) {
bc.subscribe(this.bcChannel, this._bcSubscriber);
this.bcconnected = true;
}
// send sync step1 to bc
// write sync step 1
const encoderSync = encoding.createEncoder();
encoding.writeVarUint(encoderSync, messageSync);
syncProtocol.writeSyncStep1(encoderSync, this.doc);
bc.publish(this.bcChannel, encoding.toUint8Array(encoderSync), this);
// broadcast local state
const encoderState = encoding.createEncoder();
encoding.writeVarUint(encoderState, messageSync);
syncProtocol.writeSyncStep2(encoderState, this.doc);
bc.publish(this.bcChannel, encoding.toUint8Array(encoderState), this);
// write queryAwareness
const encoderAwarenessQuery = encoding.createEncoder();
encoding.writeVarUint(encoderAwarenessQuery, messageQueryAwareness);
bc.publish(
this.bcChannel,
encoding.toUint8Array(encoderAwarenessQuery),
this
);
// broadcast local awareness state
const encoderAwarenessState = encoding.createEncoder();
encoding.writeVarUint(encoderAwarenessState, messageAwareness);
encoding.writeVarUint8Array(
encoderAwarenessState,
awarenessProtocol.encodeAwarenessUpdate(this.awareness, [
this.doc.clientID,
])
);
bc.publish(
this.bcChannel,
encoding.toUint8Array(encoderAwarenessState),
this
);
}
disconnectBc() {
// broadcast message with local awareness state set to null (indicating disconnect)
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, messageAwareness);
encoding.writeVarUint8Array(
encoder,
awarenessProtocol.encodeAwarenessUpdate(
this.awareness,
[this.doc.clientID],
new Map()
)
);
broadcastMessage(this, encoding.toUint8Array(encoder));
if (this.bcconnected) {
bc.unsubscribe(this.bcChannel, this._bcSubscriber);
this.bcconnected = false;
}
}
disconnect() {
this.shouldConnect = false;
this.disconnectBc();
if (this.ws !== null) {
this.ws.close();
}
}
connect() {
this.shouldConnect = true;
if (!this.wsconnected && this.ws === null) {
setupWS(this);
this.connectBc();
}
}
}

View File

@ -1,72 +0,0 @@
import { BlobSyncState } from '@blocksuite/store';
import { Slot } from '@blocksuite/store';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import type {
BlobId,
BlobProvider,
BlobSyncStateChangeEvent,
BlobURL,
} from '@blocksuite/store/dist/persistence/blob/types';
import * as ipcMethods from '../ipc/methods';
export class IPCBlobProvider implements BlobProvider {
#ipc = ipcMethods;
#workspace: string;
readonly slots = {
onBlobSyncStateChange: new Slot<BlobSyncStateChangeEvent>(),
};
private constructor(workspace: string) {
this.#workspace = workspace;
}
static async init(workspace: string): Promise<IPCBlobProvider> {
const provider = new IPCBlobProvider(workspace);
return provider;
}
get uploading() {
return false;
}
get blobs() {
// TODO: implement blob list in Octobase
return Promise.resolve([]) as Promise<string[]>;
}
async get(id: BlobId): Promise<BlobURL | null> {
const blobArray = await this.#ipc.getBlob({
id,
});
// Make a Blob from the bytes
const blob = new Blob([new Uint8Array(blobArray)], { type: 'image/bmp' });
this.slots.onBlobSyncStateChange.emit({
id,
state: BlobSyncState.Success,
});
return window.URL.createObjectURL(blob);
}
async set(blob: Blob): Promise<BlobId> {
// TODO: skip if already has
const blobID = await this.#ipc.putBlob({
blob: Array.from(new Uint8Array(await blob.arrayBuffer())),
});
this.slots.onBlobSyncStateChange.emit({
id: blobID,
state: BlobSyncState.Success,
});
return blobID;
}
async delete(id: BlobId): Promise<void> {
// TODO: implement blob delete in Octobase
}
async clear(): Promise<void> {
// TODO: implement blob clear in Octobase, use workspace id #workspace
}
}

View File

@ -1,196 +0,0 @@
import type { Workspace as BlocksuiteWorkspace } from '@blocksuite/store';
import assert from 'assert';
import * as Y from 'yjs';
import type { User } from '../../types';
import { applyUpdate } from '../../utils';
import type { WorkspaceUnit } from '../../workspace-unit';
import type {
CreateWorkspaceInfoParams,
ProviderConstructorParams,
} from '../base';
import { LocalProvider } from '../local';
import { loadWorkspaceUnit } from '../local/utils';
import { IPCBlobProvider } from './blocksuite-provider/blob';
import type { IPCMethodsType } from './ipc/methods';
import type { WorkspaceWithPermission } from './ipc/types/workspace';
import { createWorkspaceUnit } from './utils';
/**
* init - createUser - create first workspace and ydoc - loadWorkspace - return the first workspace - wrapWorkspace - #initDocFromIPC - applyUpdate - on('update') - updateYDocument
*
* (init - createUser - error) loadWorkspace - return the first workspace - wrapWorkspace - #initDocFromIPC - applyUpdate - on('update') - updateYDocument
*/
export class TauriIPCProvider extends LocalProvider {
public id = 'tauri-ipc';
static defaultUserEmail = 'xxx@xx.xx';
/**
* // TODO: We only have one user in this version of app client. But may support switch user later.
*/
#userID?: string;
#ipc: IPCMethodsType | undefined;
constructor(params: ProviderConstructorParams) {
super(params);
}
async init(ipc?: IPCMethodsType) {
if (ipc) {
this.#ipc = ipc;
} else {
this.#ipc = await import('./ipc/methods');
}
try {
const user = await this.#ipc?.getUser({
email: TauriIPCProvider.defaultUserEmail,
});
this.#userID = user.id;
} catch (error) {
// maybe user not existed, we create a default user if we don't have one.
try {
const user = await this.#ipc?.createUser({
email: TauriIPCProvider.defaultUserEmail,
name: 'xxx',
password: 'xxx',
avatar_url: '',
});
this.#userID = user.id;
} catch (error) {
// maybe user existed, which can be omited
console.error(error);
}
}
}
/**
* get auth user info
* @returns
*/
public async getUserInfo(): Promise<User | undefined> {
const user = await this.#ipc?.getUser({
email: TauriIPCProvider.defaultUserEmail,
});
if (user?.name !== undefined) {
return {
...user,
avatar: user?.avatar_url || '',
};
}
}
async #initDocFromIPC(
workspaceID: string,
blocksuiteWorkspace: BlocksuiteWorkspace
) {
this._logger.debug(`Loading ${workspaceID}...`);
const result = await this.#ipc?.getYDocument({ id: workspaceID });
if (result) {
const updates = result.updates.map(
binaryUpdate => new Uint8Array(binaryUpdate)
);
const mergedUpdate = Y.mergeUpdates(updates);
await applyUpdate(blocksuiteWorkspace, mergedUpdate);
this._logger.debug(`Loaded: ${workspaceID}`);
}
}
async #connectDocToIPC(
workspaceID: string,
blocksuiteWorkspace: BlocksuiteWorkspace
) {
this._logger.debug(`Connecting yDoc for ${workspaceID}...`);
blocksuiteWorkspace.doc.on('update', async (update: Uint8Array) => {
try {
const binary = Y.encodeStateAsUpdate(blocksuiteWorkspace.doc);
const success = await this.#ipc?.updateYDocument({
update: Array.from(binary),
id: workspaceID,
});
if (!success) {
throw new Error(`YDoc update failed, id: ${workspaceID}`);
}
} catch (error) {
// TODO: write error log to disk, and add button to open them in settings panel
this._logger.error("#yDocument.on('update'", error);
}
});
}
async clear() {
await super.clear();
}
override async warpWorkspace(blocksuiteWorkspace: BlocksuiteWorkspace) {
const { id } = blocksuiteWorkspace;
assert(id);
(await blocksuiteWorkspace.blobs)?.setProvider(
await IPCBlobProvider.init(id)
);
await this.#initDocFromIPC(id, blocksuiteWorkspace);
await this.#connectDocToIPC(id, blocksuiteWorkspace);
return blocksuiteWorkspace;
}
public override async createWorkspace(
meta: CreateWorkspaceInfoParams
): Promise<WorkspaceUnit | undefined> {
this._logger.debug('Creating client app workspace');
assert(this.#ipc);
assert(this.#userID);
const { id } = await this.#ipc.createWorkspace({
name: meta.name,
// TODO: get userID here
user_id: this.#userID,
});
const workspaceUnit = await createWorkspaceUnit({
name: meta.name,
id,
published: false,
avatar: '',
owner: undefined,
syncMode: 'core',
memberCount: 1,
provider: this.id,
});
this._workspaces.add(workspaceUnit);
const doc = workspaceUnit?.blocksuiteWorkspace?.doc;
if (doc) {
const update = Y.encodeStateAsUpdate(doc);
const success = await this.#ipc?.updateYDocument({
update: Array.from(update),
id,
});
if (!success) {
throw new Error(`YDoc update failed, id: ${id}`);
}
}
return workspaceUnit;
}
override async loadWorkspaces(): Promise<WorkspaceUnit[]> {
assert(this.#ipc);
assert(this.#userID);
const { workspaces } = await this.#ipc.getWorkspaces({
user_id: this.#userID,
});
const workspaceUnits = await Promise.all(
workspaces.map((meta: WorkspaceWithPermission) => {
return loadWorkspaceUnit({
...meta,
memberCount: 1,
// TODO: load name here
name: '',
provider: this.id,
syncMode: 'all',
});
})
);
this._workspaces.add(workspaceUnits);
return workspaceUnits;
}
}

View File

@ -1,79 +0,0 @@
import { invoke } from '@tauri-apps/api';
import type { GetBlob, PutBlob } from './types/blob';
import type {
GetDocumentParameter,
GetDocumentResponse,
YDocumentUpdate,
} from './types/document';
import type { CreateUser, GetUserParameters } from './types/user';
import type {
CreateWorkspace,
CreateWorkspaceResult,
GetWorkspace,
GetWorkspaceResult,
GetWorkspaces,
GetWorkspacesResult,
User,
} from './types/workspace';
export interface IPCMethodsType {
updateYDocument: typeof updateYDocument;
getYDocument: typeof getYDocument;
createWorkspace: typeof createWorkspace;
getWorkspaces: typeof getWorkspaces;
getWorkspace: typeof getWorkspace;
putBlob: typeof putBlob;
getBlob: typeof getBlob;
createUser: typeof createUser;
getUser: typeof getUser;
}
export const updateYDocument = async (parameters: YDocumentUpdate) =>
await invoke<boolean>('update_y_document', {
parameters,
});
export const getYDocument = async (parameters: GetDocumentParameter) =>
await invoke<GetDocumentResponse>('get_doc', {
parameters,
});
export const createWorkspace = async (parameters: CreateWorkspace) =>
await invoke<CreateWorkspaceResult>('create_workspace', {
parameters,
});
export const getWorkspaces = async (parameters: GetWorkspaces) =>
await invoke<GetWorkspacesResult>('get_workspaces', {
parameters,
});
export const getWorkspace = async (parameters: GetWorkspace) =>
await invoke<GetWorkspaceResult>('get_workspace', {
parameters,
});
export const putBlob = async (parameters: PutBlob) =>
await invoke<string>('put_blob', {
parameters,
});
export const getBlob = async (parameters: GetBlob) =>
await invoke<number[]>('get_blob', {
parameters,
});
/**
* This will create a private workspace too.
* @returns
*/
export const createUser = async (parameters: CreateUser) =>
await invoke<User>('create_user', {
parameters,
});
export const getUser = async (parameters: GetUserParameters) =>
await invoke<User>('get_user', {
parameters,
});

View File

@ -1,57 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "IBlobParameters",
"oneOf": [
{
"type": "object",
"required": ["Put"],
"properties": {
"Put": {
"$ref": "#/definitions/PutBlob"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["Get"],
"properties": {
"Get": {
"$ref": "#/definitions/GetBlob"
}
},
"additionalProperties": false
}
],
"definitions": {
"GetBlob": {
"type": "object",
"required": ["id"],
"properties": {
"id": {
"type": "string"
},
"workspace_id": {
"type": ["string", "null"]
}
}
},
"PutBlob": {
"type": "object",
"required": ["blob"],
"properties": {
"blob": {
"type": "array",
"items": {
"type": "integer",
"format": "uint8",
"minimum": 0.0
}
},
"workspace_id": {
"type": ["string", "null"]
}
}
}
}
}

View File

@ -1,24 +0,0 @@
/**
* This file was automatically generated by json-schema-to-typescript.
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
* and run json-schema-to-typescript to regenerate this file.
*/
export type IBlobParameters =
| {
Put: PutBlob;
}
| {
Get: GetBlob;
};
export interface PutBlob {
blob: number[];
workspace_id?: string | null;
[k: string]: unknown;
}
export interface GetBlob {
id: string;
workspace_id?: string | null;
[k: string]: unknown;
}

View File

@ -1,103 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "IDocumentParameters",
"oneOf": [
{
"type": "object",
"required": ["YDocumentUpdate"],
"properties": {
"YDocumentUpdate": {
"$ref": "#/definitions/YDocumentUpdate"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["CreateDocumentParameter"],
"properties": {
"CreateDocumentParameter": {
"$ref": "#/definitions/CreateDocumentParameter"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["GetDocumentParameter"],
"properties": {
"GetDocumentParameter": {
"$ref": "#/definitions/GetDocumentParameter"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["GetDocumentResponse"],
"properties": {
"GetDocumentResponse": {
"$ref": "#/definitions/GetDocumentResponse"
}
},
"additionalProperties": false
}
],
"definitions": {
"CreateDocumentParameter": {
"type": "object",
"required": ["workspace_id", "workspace_name"],
"properties": {
"workspace_id": {
"type": "string"
},
"workspace_name": {
"type": "string"
}
}
},
"GetDocumentParameter": {
"type": "object",
"required": ["id"],
"properties": {
"id": {
"type": "string"
}
}
},
"GetDocumentResponse": {
"type": "object",
"required": ["updates"],
"properties": {
"updates": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "integer",
"format": "uint8",
"minimum": 0.0
}
}
}
}
},
"YDocumentUpdate": {
"type": "object",
"required": ["id", "update"],
"properties": {
"id": {
"type": "string"
},
"update": {
"type": "array",
"items": {
"type": "integer",
"format": "uint8",
"minimum": 0.0
}
}
}
}
}
}

View File

@ -1,38 +0,0 @@
/**
* This file was automatically generated by json-schema-to-typescript.
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
* and run json-schema-to-typescript to regenerate this file.
*/
export type IDocumentParameters =
| {
YDocumentUpdate: YDocumentUpdate;
}
| {
CreateDocumentParameter: CreateDocumentParameter;
}
| {
GetDocumentParameter: GetDocumentParameter;
}
| {
GetDocumentResponse: GetDocumentResponse;
};
export interface YDocumentUpdate {
id: string;
update: number[];
[k: string]: unknown;
}
export interface CreateDocumentParameter {
workspace_id: string;
workspace_name: string;
[k: string]: unknown;
}
export interface GetDocumentParameter {
id: string;
[k: string]: unknown;
}
export interface GetDocumentResponse {
updates: number[][];
[k: string]: unknown;
}

View File

@ -1,87 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "IUserParameters",
"oneOf": [
{
"type": "object",
"required": ["CreateUser"],
"properties": {
"CreateUser": {
"$ref": "#/definitions/CreateUser"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["User"],
"properties": {
"User": {
"$ref": "#/definitions/User"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["GetUserParameters"],
"properties": {
"GetUserParameters": {
"$ref": "#/definitions/GetUserParameters"
}
},
"additionalProperties": false
}
],
"definitions": {
"CreateUser": {
"type": "object",
"required": ["email", "name", "password"],
"properties": {
"avatar_url": {
"type": ["string", "null"]
},
"email": {
"type": "string"
},
"name": {
"type": "string"
},
"password": {
"type": "string"
}
}
},
"GetUserParameters": {
"type": "object",
"required": ["email"],
"properties": {
"email": {
"type": "string"
}
}
},
"User": {
"type": "object",
"required": ["created_at", "email", "id", "name"],
"properties": {
"avatar_url": {
"type": ["string", "null"]
},
"created_at": {
"type": "integer",
"format": "int64"
},
"email": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
}
}
}

View File

@ -1,36 +0,0 @@
/**
* This file was automatically generated by json-schema-to-typescript.
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
* and run json-schema-to-typescript to regenerate this file.
*/
export type IUserParameters =
| {
CreateUser: CreateUser;
}
| {
User: User;
}
| {
GetUserParameters: GetUserParameters;
};
export interface CreateUser {
avatar_url?: string | null;
email: string;
name: string;
password: string;
[k: string]: unknown;
}
export interface User {
avatar_url?: string | null;
created_at: number;
email: string;
id: string;
name: string;
[k: string]: unknown;
}
export interface GetUserParameters {
email: string;
[k: string]: unknown;
}

View File

@ -1,240 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "IWorkspaceParameters",
"oneOf": [
{
"type": "object",
"required": ["CreateWorkspace"],
"properties": {
"CreateWorkspace": {
"$ref": "#/definitions/CreateWorkspace"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["GetWorkspace"],
"properties": {
"GetWorkspace": {
"$ref": "#/definitions/GetWorkspace"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["GetWorkspaces"],
"properties": {
"GetWorkspaces": {
"$ref": "#/definitions/GetWorkspaces"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["GetWorkspaceResult"],
"properties": {
"GetWorkspaceResult": {
"$ref": "#/definitions/GetWorkspaceResult"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["GetWorkspacesResult"],
"properties": {
"GetWorkspacesResult": {
"$ref": "#/definitions/GetWorkspacesResult"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["UpdateWorkspace"],
"properties": {
"UpdateWorkspace": {
"$ref": "#/definitions/UpdateWorkspace"
}
},
"additionalProperties": false
},
{
"type": "object",
"required": ["CreateWorkspaceResult"],
"properties": {
"CreateWorkspaceResult": {
"$ref": "#/definitions/CreateWorkspaceResult"
}
},
"additionalProperties": false
}
],
"definitions": {
"CreateWorkspace": {
"type": "object",
"required": ["name", "user_id"],
"properties": {
"name": {
"description": "only set name, avatar is update in datacenter to yDoc directly",
"type": "string"
},
"user_id": {
"type": "string"
}
}
},
"CreateWorkspaceResult": {
"type": "object",
"required": ["id", "name"],
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"GetWorkspace": {
"type": "object",
"required": ["id"],
"properties": {
"id": {
"type": "string"
}
}
},
"GetWorkspaceResult": {
"type": "object",
"required": ["workspace"],
"properties": {
"workspace": {
"$ref": "#/definitions/WorkspaceDetail"
}
}
},
"GetWorkspaces": {
"type": "object",
"required": ["user_id"],
"properties": {
"user_id": {
"type": "string"
}
}
},
"GetWorkspacesResult": {
"type": "object",
"required": ["workspaces"],
"properties": {
"workspaces": {
"type": "array",
"items": {
"$ref": "#/definitions/WorkspaceWithPermission"
}
}
}
},
"PermissionType": {
"type": "integer",
"enum": [0, 1, 10, 99]
},
"UpdateWorkspace": {
"type": "object",
"required": ["id", "public"],
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"public": {
"type": "boolean"
}
}
},
"User": {
"type": "object",
"required": ["created_at", "email", "id", "name"],
"properties": {
"avatar_url": {
"type": ["string", "null"]
},
"created_at": {
"type": "integer",
"format": "int64"
},
"email": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"WorkspaceDetail": {
"type": "object",
"required": ["created_at", "id", "member_count", "public", "type"],
"properties": {
"created_at": {
"type": "integer",
"format": "int64"
},
"id": {
"type": "string"
},
"member_count": {
"type": "integer",
"format": "int64"
},
"owner": {
"anyOf": [
{
"$ref": "#/definitions/User"
},
{
"type": "null"
}
]
},
"public": {
"type": "boolean"
},
"type": {
"$ref": "#/definitions/WorkspaceType"
}
}
},
"WorkspaceType": {
"type": "integer",
"enum": [0, 1]
},
"WorkspaceWithPermission": {
"type": "object",
"required": ["created_at", "id", "permission", "public", "type"],
"properties": {
"created_at": {
"type": "integer",
"format": "int64"
},
"id": {
"type": "string"
},
"permission": {
"$ref": "#/definitions/PermissionType"
},
"public": {
"type": "boolean"
},
"type": {
"$ref": "#/definitions/WorkspaceType"
}
}
}
}
}

View File

@ -1,90 +0,0 @@
/**
* This file was automatically generated by json-schema-to-typescript.
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
* and run json-schema-to-typescript to regenerate this file.
*/
export type IWorkspaceParameters =
| {
CreateWorkspace: CreateWorkspace;
}
| {
GetWorkspace: GetWorkspace;
}
| {
GetWorkspaces: GetWorkspaces;
}
| {
GetWorkspaceResult: GetWorkspaceResult;
}
| {
GetWorkspacesResult: GetWorkspacesResult;
}
| {
UpdateWorkspace: UpdateWorkspace;
}
| {
CreateWorkspaceResult: CreateWorkspaceResult;
};
export type WorkspaceType = 0 | 1;
export type PermissionType = 0 | 1 | 10 | 99;
export interface CreateWorkspace {
/**
* only set name, avatar is update in datacenter to yDoc directly
*/
name: string;
user_id: string;
[k: string]: unknown;
}
export interface GetWorkspace {
id: string;
[k: string]: unknown;
}
export interface GetWorkspaces {
user_id: string;
[k: string]: unknown;
}
export interface GetWorkspaceResult {
workspace: WorkspaceDetail;
[k: string]: unknown;
}
export interface WorkspaceDetail {
created_at: number;
id: string;
member_count: number;
owner?: User | null;
public: boolean;
type: WorkspaceType;
[k: string]: unknown;
}
export interface User {
avatar_url?: string | null;
created_at: number;
email: string;
id: string;
name: string;
[k: string]: unknown;
}
export interface GetWorkspacesResult {
workspaces: WorkspaceWithPermission[];
[k: string]: unknown;
}
export interface WorkspaceWithPermission {
created_at: number;
id: string;
permission: PermissionType;
public: boolean;
type: WorkspaceType;
[k: string]: unknown;
}
export interface UpdateWorkspace {
id: number;
public: boolean;
[k: string]: unknown;
}
export interface CreateWorkspaceResult {
id: string;
name: string;
[k: string]: unknown;
}

View File

@ -1,26 +0,0 @@
import { createBlocksuiteWorkspace } from '../../utils';
import type { WorkspaceUnitCtorParams } from '../../workspace-unit';
import { WorkspaceUnit } from '../../workspace-unit';
import { setDefaultAvatar } from '../utils';
import { IPCBlobProvider } from './blocksuite-provider/blob';
export const createWorkspaceUnit = async (params: WorkspaceUnitCtorParams) => {
const workspaceUnit = new WorkspaceUnit(params);
const blocksuiteWorkspace = createBlocksuiteWorkspace({
id: workspaceUnit.id,
blobOptionsGetter: (k: string) => undefined,
});
blocksuiteWorkspace.meta.setName(workspaceUnit.name);
(await blocksuiteWorkspace.blobs)?.setProvider(
await IPCBlobProvider.init(workspaceUnit.id)
);
if (!workspaceUnit.avatar) {
await setDefaultAvatar(blocksuiteWorkspace);
workspaceUnit.update({ avatar: blocksuiteWorkspace.meta.avatar });
}
workspaceUnit.setBlocksuiteWorkspace(blocksuiteWorkspace);
return workspaceUnit;
};

View File

@ -1,22 +0,0 @@
import type { Workspace as BlocksuiteWorkspace } from '@blocksuite/store';
import assert from 'assert';
import { getDefaultHeadImgBlob } from '../utils';
export const setDefaultAvatar = async (
blocksuiteWorkspace: BlocksuiteWorkspace
) => {
if (typeof document === 'undefined') {
return;
}
const blob = await getDefaultHeadImgBlob(blocksuiteWorkspace.meta.name ?? '');
if (!blob) {
return;
}
const blobStorage = await blocksuiteWorkspace.blobs;
assert(blobStorage, 'No blob storage');
const avatar = await blobStorage.set(blob);
if (avatar) {
blocksuiteWorkspace.meta.setAvatar(avatar);
}
};

View File

@ -1,89 +0,0 @@
import {
clear,
createStore,
del,
entries,
get,
keys,
set,
setMany,
} from 'idb-keyval';
export type ConfigStore<T = any> = {
get: (key: string) => Promise<T | undefined>;
set: (key: string, value: T) => Promise<void>;
setMany: (values: [string, T][]) => Promise<void>;
keys: () => Promise<string[]>;
entries: () => Promise<[string, T][]>;
delete: (key: string) => Promise<void>;
clear: () => Promise<void>;
};
const initialIndexedDB = <T = any>(database: string): ConfigStore<T> => {
const store = createStore(`affine:${database}`, 'database');
return {
get: (key: string) => get<T>(key, store),
set: (key: string, value: T) => set(key, value, store),
setMany: (values: [string, T][]) => setMany(values, store),
keys: () => keys(store),
entries: () => entries(store),
delete: (key: string) => del(key, store),
clear: () => clear(store),
};
};
const scopedIndexedDB = () => {
const idb = initialIndexedDB('global');
const storeCache = new Map<string, Readonly<ConfigStore>>();
return <T = any>(scope: string): Readonly<ConfigStore<T>> => {
if (!storeCache.has(scope)) {
const prefix = `${scope}:`;
const store = {
get: async (key: string) => idb.get(prefix + key),
set: (key: string, value: T) => idb.set(prefix + key, value),
setMany: (values: [string, T][]) =>
idb.setMany(values.map(([k, v]) => [`${scope}:${k}`, v])),
keys: () =>
idb
.keys()
.then(keys =>
keys
.filter(k => k.startsWith(prefix))
.map(k => k.slice(prefix.length))
),
entries: () =>
idb
.entries()
.then(entries =>
entries
.filter(([k]) => k.startsWith(prefix))
.map(([k, v]) => [k.slice(prefix.length), v] as [string, T])
),
delete: (key: string) => idb.delete(prefix + key),
clear: async () => {
await idb
.keys()
.then(keys =>
Promise.all(
keys.filter(k => k.startsWith(prefix)).map(k => del(k))
)
);
},
};
storeCache.set(scope, store);
}
return storeCache.get(scope) as ConfigStore<T>;
};
};
let lazyKVConfigure: ReturnType<typeof scopedIndexedDB> | undefined = undefined;
export const getKVConfigure = (scope: string) => {
if (!lazyKVConfigure) {
lazyKVConfigure = scopedIndexedDB();
}
return lazyKVConfigure(scope);
};

View File

@ -1,34 +0,0 @@
// export type WorkspaceInfo = {
// name: string;
// id: string;
// isPublish?: boolean;
// avatar?: string;
// owner?: User;
// isLocal?: boolean;
// memberCount: number;
// provider: string;
// };
import type { DebugLogger } from '@affine/debug';
declare global {
interface Window {
// eslint-disable-next-line @typescript-eslint/ban-types
__TAURI_IPC__: Function;
}
}
export type User = {
name: string;
id: string;
email: string;
avatar: string;
};
// export type WorkspaceMeta = Pick<WorkspaceInfo, 'name' | 'avatar'>;
export type Logger = DebugLogger;
export type Message = {
code: number;
message: string;
provider: string;
};

View File

@ -1,77 +0,0 @@
import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models';
import type { StoreOptions } from '@blocksuite/store';
import { Workspace as BlocksuiteWorkspace } from '@blocksuite/store';
export const createBlocksuiteWorkspace = (options: StoreOptions) => {
return new BlocksuiteWorkspace({
defaultFlags: {},
isSSR: typeof window === 'undefined',
...options,
})
.register(AffineSchemas)
.register(__unstableSchemas);
};
const DefaultHeadImgColors = [
['#C6F2F3', '#0C6066'],
['#FFF5AB', '#896406'],
['#FFCCA7', '#8F4500'],
['#FFCECE', '#AF1212'],
['#E3DEFF', '#511AAB'],
];
export async function getDefaultHeadImgBlob(
workspaceName: string
): Promise<Blob | null> {
const canvas = document.createElement('canvas');
canvas.height = 100;
canvas.width = 100;
if (!canvas.getContext) {
return Promise.resolve(null);
}
const ctx = canvas.getContext('2d');
return new Promise<Blob>((resolve, reject) => {
if (ctx) {
const randomNumber = Math.floor(Math.random() * 5);
const randomColor = DefaultHeadImgColors[randomNumber];
ctx.fillStyle = randomColor[0];
ctx.fillRect(0, 0, 100, 100);
ctx.font = "600 50px 'PingFang SC', 'Microsoft Yahei'";
ctx.fillStyle = randomColor[1];
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(workspaceName[0], 50, 50);
canvas.toBlob(blob => {
if (blob) {
resolve(blob);
} else {
reject();
}
}, 'image/png');
} else {
reject();
}
});
}
export const applyUpdate = async (
blocksuiteWorkspace: BlocksuiteWorkspace,
updates: Uint8Array
) => {
if (updates && updates.byteLength) {
await new Promise(resolve => {
// FIXME: if we merge two empty doc, there will no update event.
// So we set a timer to cancel update listener.
const doc = blocksuiteWorkspace.doc;
const timer = setTimeout(() => {
doc.off('update', resolve);
resolve(undefined);
}, 1000);
doc.once('update', () => {
clearTimeout(timer);
setTimeout(resolve, 100);
});
BlocksuiteWorkspace.Y.applyUpdate(doc, new Uint8Array(updates));
});
}
};

View File

@ -1,64 +0,0 @@
import { describe, expect, test } from 'vitest';
import { WorkspaceUnit } from './workspace-unit';
import type { WorkspaceUnitCollectionChangeEvent } from './workspace-unit-collection';
import { WorkspaceUnitCollection } from './workspace-unit-collection';
describe('workspace meta collection observable', () => {
const workspaceUnitCollection = new WorkspaceUnitCollection();
const scope = workspaceUnitCollection.createScope();
test('add workspace', () => {
workspaceUnitCollection.once(
'change',
(event: WorkspaceUnitCollectionChangeEvent) => {
expect(event.added?.[0]?.id).toEqual('123');
}
);
const workspaceUnit = new WorkspaceUnit({
id: '123',
name: 'test',
avatar: undefined,
memberCount: 1,
provider: '',
syncMode: 'core',
});
scope.add(workspaceUnit);
});
test('list workspace', () => {
const list = scope.list();
expect(list.length).toEqual(1);
expect(list[0].id).toEqual('123');
});
test('get workspace', () => {
expect(scope.get('123')?.id).toEqual('123');
});
test('update workspace', () => {
workspaceUnitCollection.once(
'change',
(event: WorkspaceUnitCollectionChangeEvent) => {
expect(event.updated?.name).toEqual('demo');
}
);
scope.update('123', { name: 'demo' });
});
test('get workspace form other scope', () => {
const scope1 = workspaceUnitCollection.createScope();
expect(scope1.get('123')).toBeFalsy();
});
test('delete workspace', () => {
workspaceUnitCollection.once(
'change',
(event: WorkspaceUnitCollectionChangeEvent) => {
expect(event.deleted?.[0]?.id).toEqual('123');
}
);
scope.remove('123');
});
});

View File

@ -1,177 +0,0 @@
import { Observable } from 'lib0/observable';
import type {
UpdateWorkspaceUnitParams,
WorkspaceUnit,
} from './workspace-unit';
export interface WorkspaceUnitCollectionScope {
get: (workspaceId: string) => WorkspaceUnit | undefined;
list: () => WorkspaceUnit[];
add: (workspace: WorkspaceUnit | WorkspaceUnit[]) => void;
remove: (workspaceId: string | string[], isUpdate?: boolean) => boolean;
clear: (isUpdate?: boolean) => void;
update: (
workspaceId: string,
workspaceUnit: UpdateWorkspaceUnitParams
) => void;
}
export interface WorkspaceUnitCollectionChangeEvent {
added?: WorkspaceUnit[];
deleted?: WorkspaceUnit[];
updated?: WorkspaceUnit;
}
export class WorkspaceUnitCollection {
private _events = new Observable();
private _workspaceUnitMap = new Map<string, WorkspaceUnit>();
get workspaces(): WorkspaceUnit[] {
return Array.from(this._workspaceUnitMap.values());
}
public on(
type: 'change',
callback: (event: WorkspaceUnitCollectionChangeEvent) => void
) {
this._events.on(type, callback);
}
public off(
type: 'change',
callback: (event: WorkspaceUnitCollectionChangeEvent) => void
) {
this._events.off(type, callback);
}
public once(
type: 'change',
callback: (event: WorkspaceUnitCollectionChangeEvent) => void
) {
this._events.once(type, callback);
}
find(workspaceId: string) {
return this._workspaceUnitMap.get(workspaceId);
}
createScope(): WorkspaceUnitCollectionScope {
const scopedWorkspaceIds = new Set<string>();
const get = (workspaceId: string) => {
if (!scopedWorkspaceIds.has(workspaceId)) {
return;
}
return this._workspaceUnitMap.get(workspaceId);
};
const add = (workspaceUnit: WorkspaceUnit | WorkspaceUnit[]) => {
const workspaceUnits = Array.isArray(workspaceUnit)
? workspaceUnit
: [workspaceUnit];
let added = false;
workspaceUnits.forEach(workspaceUnit => {
if (this._workspaceUnitMap.has(workspaceUnit.id)) {
// FIXME: multiple add same workspace
return;
}
added = true;
this._workspaceUnitMap.set(workspaceUnit.id, workspaceUnit);
scopedWorkspaceIds.add(workspaceUnit.id);
});
if (!added) {
return;
}
this._events.emit('change', [
{
added: workspaceUnits,
} as WorkspaceUnitCollectionChangeEvent,
]);
};
const remove = (workspaceId: string | string[], isUpdate = true) => {
const workspaceIds = Array.isArray(workspaceId)
? workspaceId
: [workspaceId];
const workspaceUnits: WorkspaceUnit[] = [];
workspaceIds.forEach(workspaceId => {
if (!scopedWorkspaceIds.has(workspaceId)) {
return;
}
const workspaceUnit = this._workspaceUnitMap.get(workspaceId);
if (workspaceUnit) {
const ret = this._workspaceUnitMap.delete(workspaceId);
// If deletion failed, return.
if (!ret) {
return;
}
workspaceUnits.push(workspaceUnit);
scopedWorkspaceIds.delete(workspaceId);
}
});
if (!workspaceUnits.length) {
return false;
}
if (isUpdate) {
this._events.emit('change', [
{
deleted: workspaceUnits,
} as WorkspaceUnitCollectionChangeEvent,
]);
}
return true;
};
const clear = (isUpdate = true) => {
remove(Array.from(scopedWorkspaceIds), isUpdate);
};
const update = (workspaceId: string, meta: UpdateWorkspaceUnitParams) => {
if (!scopedWorkspaceIds.has(workspaceId)) {
return true;
}
const workspaceUnit = this._workspaceUnitMap.get(workspaceId);
if (!workspaceUnit) {
return true;
}
workspaceUnit.update(meta);
this._events.emit('change', [
{
updated: workspaceUnit,
} as WorkspaceUnitCollectionChangeEvent,
]);
};
// TODO: need to optimize
const list = () => {
const workspaceUnits: WorkspaceUnit[] = [];
scopedWorkspaceIds.forEach(id => {
const workspaceUnit = this._workspaceUnitMap.get(id);
if (workspaceUnit) {
workspaceUnits.push(workspaceUnit);
}
});
return workspaceUnits;
};
return {
get,
list,
add,
remove,
clear,
update,
};
}
}

View File

@ -1,98 +0,0 @@
import type {
BlobOptionsGetter,
Workspace as BlocksuiteWorkspace,
} from '@blocksuite/store';
import type { User } from './types';
export type SyncMode = 'all' | 'core';
export interface WorkspaceUnitCtorParams {
id: string;
name: string;
avatar?: string;
owner?: User;
published?: boolean;
memberCount: number;
provider: string;
syncMode: SyncMode;
blobOptionsGetter?: BlobOptionsGetter;
blocksuiteWorkspace?: BlocksuiteWorkspace | null;
}
export type UpdateWorkspaceUnitParams = Partial<
Omit<WorkspaceUnitCtorParams, 'id'>
>;
export class WorkspaceUnit {
public readonly id: string;
public name!: string;
public avatar?: string;
public owner?: User;
public published?: boolean;
public memberCount!: number;
public provider!: string;
public syncMode: 'all' | 'core' = 'core';
private _blocksuiteWorkspace?: BlocksuiteWorkspace | null;
constructor(params: WorkspaceUnitCtorParams) {
this.id = params.id;
this.update(params);
}
get isPublish() {
console.error('Suggest changing to published');
return this.published;
}
get isLocal() {
console.error('Suggest changing to syncMode');
return this.syncMode === 'all';
}
get blocksuiteWorkspace() {
return this._blocksuiteWorkspace;
}
setBlocksuiteWorkspace(blocksuiteWorkspace: BlocksuiteWorkspace | null) {
if (blocksuiteWorkspace && blocksuiteWorkspace.id !== this.id) {
throw new Error('Workspace id inconsistent.');
}
this._blocksuiteWorkspace = blocksuiteWorkspace;
}
update(params: UpdateWorkspaceUnitParams) {
Object.assign(this, params);
if (params.blocksuiteWorkspace) {
this.setBlocksuiteWorkspace(params.blocksuiteWorkspace);
}
if (params.blobOptionsGetter && this.blocksuiteWorkspace) {
this.blocksuiteWorkspace.setGettingBlobOptions(params.blobOptionsGetter);
}
}
toJSON(): Omit<
WorkspaceUnitCtorParams,
'blocksuiteWorkspace' | 'blobOptionsGetter'
> {
return {
id: this.id,
name: this.name,
avatar: this.avatar,
owner: this.owner,
published: this.published,
memberCount: this.memberCount,
provider: this.provider,
syncMode: this.syncMode,
};
}
/**
* @internal only for debug use
*/
exportWorkspaceYDoc(): void {
this._blocksuiteWorkspace?.exportYDoc();
}
}

View File

@ -1,4 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["./src"]
}

View File

@ -5,7 +5,8 @@
"./utils": "./src/utils.ts",
"./type": "./src/type.ts",
"./affine/*": "./src/affine/*.ts",
"./affine/api": "./src/affine/api/index.ts"
"./affine/api": "./src/affine/api/index.ts",
"./affine/sync": "./src/affine/sync.js"
},
"dependencies": {
"@affine-test/fixtures": "workspace:^",
@ -18,6 +19,7 @@
"jotai": "^2.0.3",
"js-base64": "^3.7.5",
"ky": "^0.33.3",
"lib0": "^0.2.73",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"zod": "^3.21.4"

View File

@ -3,7 +3,7 @@
*/
import 'fake-indexeddb/auto';
import { MessageCode } from '@affine/datacenter';
import { MessageCode } from '@affine/env/constant';
import userA from '@affine-test/fixtures/userA.json';
import { assertExists } from '@blocksuite/global/utils';
import { Workspace } from '@blocksuite/store';

View File

@ -295,13 +295,14 @@ export function createWorkspaceApis(prefixUrl = '/') {
throw new RequestError(MessageCode.acceptInvitingFailed, e);
});
},
uploadBlob: async (params: { blob: Blob }): Promise<string> => {
uploadBlob: async (workspaceId: string, blob: Blob): Promise<string> => {
const auth = getLoginStorage();
assertExists(auth);
return fetch(prefixUrl + 'api/blob', {
method: 'PUT',
body: params.blob,
body: blob,
headers: {
'Content-Type': blob.type,
Authorization: auth.token,
},
}).then(r => r.text());

View File

@ -77,7 +77,7 @@ export async function loginUser(
) {
await page.evaluate(async token => {
// @ts-ignore
globalThis.AFFINE_APIS.auth.setLogin(token);
globalThis.setLogin(token);
}, token);
}

View File

@ -22,7 +22,6 @@
"./packages/component/src/components/block-suite-editor/index"
],
"@affine/templates/*": ["./packages/templates/src/*"],
"@affine/datacenter": ["./packages/datacenter/src"],
"@affine/i18n": ["./packages/i18n/src"],
"@affine/debug": ["./packages/debug"],
"@affine/env": ["./packages/env"],
@ -42,9 +41,6 @@
{
"path": "./apps/desktop"
},
{
"path": "./packages/data-center"
},
{
"path": "./packages/component"
},

View File

@ -26,10 +26,6 @@ export default defineConfig({
provider: 'istanbul', // or 'c8'
reporter: ['lcov'],
reportsDirectory: '.coverage/store',
exclude: [
// data center will be removed in the future
'packages/data-center',
],
},
},
});

View File

@ -110,30 +110,6 @@ __metadata:
languageName: unknown
linkType: soft
"@affine/datacenter@workspace:*, @affine/datacenter@workspace:packages/data-center":
version: 0.0.0-use.local
resolution: "@affine/datacenter@workspace:packages/data-center"
dependencies:
"@affine/debug": "workspace:*"
"@blocksuite/blocks": 0.5.0-20230324040005-14417c2
"@blocksuite/global": 0.5.0-20230324040005-14417c2
"@blocksuite/store": 0.5.0-20230324040005-14417c2
"@tauri-apps/api": ^1.2.0
encoding: ^0.1.13
fake-indexeddb: 4.0.1
firebase: ^9.18.0
idb-keyval: ^6.2.0
js-base64: ^3.7.5
ky: ^0.33.3
ky-universal: ^0.11.0
lib0: ^0.2.69
lit: ^2.6.1
typescript: ^5.0.2
y-protocols: ^1.0.5
yjs: ^13.5.50
languageName: unknown
linkType: soft
"@affine/debug@workspace:*, @affine/debug@workspace:packages/debug":
version: 0.0.0-use.local
resolution: "@affine/debug@workspace:packages/debug"
@ -219,7 +195,6 @@ __metadata:
resolution: "@affine/web@workspace:apps/web"
dependencies:
"@affine/component": "workspace:*"
"@affine/datacenter": "workspace:*"
"@affine/debug": "workspace:*"
"@affine/env": "workspace:*"
"@affine/i18n": "workspace:*"
@ -285,6 +260,7 @@ __metadata:
jotai: ^2.0.3
js-base64: ^3.7.5
ky: ^0.33.3
lib0: ^0.2.73
react: ^18.2.0
react-dom: ^18.2.0
zod: ^3.21.4
@ -7766,15 +7742,6 @@ __metadata:
languageName: node
linkType: hard
"abort-controller@npm:^3.0.0":
version: 3.0.0
resolution: "abort-controller@npm:3.0.0"
dependencies:
event-target-shim: ^5.0.0
checksum: 170bdba9b47b7e65906a28c8ce4f38a7a369d78e2271706f020849c1bfe0ee2067d4261df8bbb66eb84f79208fd5b710df759d64191db58cfba7ce8ef9c54b75
languageName: node
linkType: hard
"accepts@npm:~1.3.5, accepts@npm:~1.3.8":
version: 1.3.8
resolution: "accepts@npm:1.3.8"
@ -11458,13 +11425,6 @@ __metadata:
languageName: node
linkType: hard
"event-target-shim@npm:^5.0.0":
version: 5.0.1
resolution: "event-target-shim@npm:5.0.1"
checksum: 1ffe3bb22a6d51bdeb6bf6f7cf97d2ff4a74b017ad12284cc9e6a279e727dc30a5de6bb613e5596ff4dc3e517841339ad09a7eec44266eccb1aa201a30448166
languageName: node
linkType: hard
"events@npm:^3.2.0, events@npm:^3.3.0":
version: 3.3.0
resolution: "events@npm:3.3.0"
@ -15007,22 +14967,6 @@ __metadata:
languageName: node
linkType: hard
"ky-universal@npm:^0.11.0":
version: 0.11.0
resolution: "ky-universal@npm:0.11.0"
dependencies:
abort-controller: ^3.0.0
node-fetch: ^3.2.10
peerDependencies:
ky: ">=0.31.4"
web-streams-polyfill: ">=3.2.1"
peerDependenciesMeta:
web-streams-polyfill:
optional: true
checksum: 42e4c91551a0d17465d6a117de2d1fa4efdf38c4e29dbd70af0c5b7ac0ee13994bceca9af2a0ac21a943d1cd22557ea664abe79f25e096d30a6baca0a0265a12
languageName: node
linkType: hard
"ky@npm:^0.33.3":
version: 0.33.3
resolution: "ky@npm:0.33.3"
@ -15084,7 +15028,7 @@ __metadata:
languageName: node
linkType: hard
"lib0@npm:^0.2.35, lib0@npm:^0.2.42, lib0@npm:^0.2.68, lib0@npm:^0.2.69, lib0@npm:^0.2.72":
"lib0@npm:^0.2.35, lib0@npm:^0.2.42, lib0@npm:^0.2.68, lib0@npm:^0.2.69, lib0@npm:^0.2.72, lib0@npm:^0.2.73":
version: 0.2.73
resolution: "lib0@npm:0.2.73"
dependencies:
@ -16343,17 +16287,6 @@ __metadata:
languageName: node
linkType: hard
"node-fetch@npm:^3.2.10":
version: 3.3.1
resolution: "node-fetch@npm:3.3.1"
dependencies:
data-uri-to-buffer: ^4.0.0
fetch-blob: ^3.1.4
formdata-polyfill: ^4.0.10
checksum: 62145fd3ba4770a76110bc31fdc0054ab2f5442b5ce96e9c4b39fc9e94a3d305560eec76e1165d9259eab866e02a8eecf9301062bb5dfc9f08a4d08b69d223dd
languageName: node
linkType: hard
"node-gyp-build@npm:^4.2.1":
version: 4.6.0
resolution: "node-gyp-build@npm:4.6.0"