EYHN
2024-03-22 16:43:26 +00:00
parent 05c44db5a9
commit 34703a3b7d
85 changed files with 3248 additions and 2286 deletions
@@ -1,5 +1,5 @@
import { DebugLogger } from '@affine/debug';
import type { AwarenessProvider, RejectByVersion } from '@toeverything/infra';
import type { AwarenessProvider } from '@toeverything/infra';
import {
applyAwarenessUpdate,
type Awareness,
@@ -135,7 +135,7 @@ export class AffineCloudAwarenessProvider implements AwarenessProvider {
);
};
handleReject = (_msg: RejectByVersion) => {
handleReject = () => {
this.socket.off('server-version-rejected', this.handleReject);
this.disconnect();
this.socket.disconnect();
@@ -0,0 +1,24 @@
import { fetchWithTraceReport } from '@affine/graphql';
export class AffineStaticDocStorage {
name = 'affine-cloud-static';
constructor(private readonly workspaceId: string) {}
async pull(
docId: string
): Promise<{ data: Uint8Array; state?: Uint8Array | undefined } | null> {
const response = await fetchWithTraceReport(
`/api/workspaces/${this.workspaceId}/docs/${docId}`,
{
priority: 'high',
}
);
if (response.ok) {
const arrayBuffer = await response.arrayBuffer();
return { data: new Uint8Array(arrayBuffer) };
}
return null;
}
}
@@ -0,0 +1,183 @@
import { DebugLogger } from '@affine/debug';
import { type DocServer, throwIfAborted } from '@toeverything/infra';
import type { Socket } from 'socket.io-client';
import { getIoManager } from '../utils/affine-io';
import { base64ToUint8Array, uint8ArrayToBase64 } from '../utils/base64';
(window as any)._TEST_SIMULATE_SYNC_LAG = Promise.resolve();
const logger = new DebugLogger('affine-cloud-doc-engine-server');
export class AffineCloudDocEngineServer implements DocServer {
socket = null as unknown as Socket;
interruptCb: ((reason: string) => void) | null = null;
SEND_TIMEOUT = 30000;
constructor(private readonly workspaceId: string) {}
private async clientHandShake() {
await this.socket.emitWithAck('client-handshake-sync', {
workspaceId: this.workspaceId,
version: runtimeConfig.appVersion,
});
}
async pullDoc(docId: string, state: Uint8Array) {
// for testing
await (window as any)._TEST_SIMULATE_SYNC_LAG;
const stateVector = state ? await uint8ArrayToBase64(state) : undefined;
const response:
| { error: any }
| { data: { missing: string; state: string; timestamp: number } } =
await this.socket.timeout(this.SEND_TIMEOUT).emitWithAck('doc-load-v2', {
workspaceId: this.workspaceId,
guid: docId,
stateVector,
});
if ('error' in response) {
// TODO: result `EventError` with server
if (response.error.code === 'DOC_NOT_FOUND') {
return null;
} else {
throw new Error(response.error.message);
}
} else {
return {
data: base64ToUint8Array(response.data.missing),
stateVector: response.data.state
? base64ToUint8Array(response.data.state)
: undefined,
serverClock: response.data.timestamp,
};
}
}
async pushDoc(docId: string, data: Uint8Array) {
const payload = await uint8ArrayToBase64(data);
const response: {
// TODO: reuse `EventError` with server
error?: any;
data: { timestamp: number };
} = await this.socket
.timeout(this.SEND_TIMEOUT)
.emitWithAck('client-update-v2', {
workspaceId: this.workspaceId,
guid: docId,
updates: [payload],
});
// TODO: raise error with different code to users
if (response.error) {
logger.error('client-update-v2 error', {
workspaceId: this.workspaceId,
guid: docId,
response,
});
throw new Error(response.error);
}
return { serverClock: response.data.timestamp };
}
async loadServerClock(after: number): Promise<Map<string, number>> {
const response: {
// TODO: reuse `EventError` with server
error?: any;
data: Record<string, number>;
} = await this.socket
.timeout(this.SEND_TIMEOUT)
.emitWithAck('client-pre-sync', {
workspaceId: this.workspaceId,
timestamp: after,
});
if (response.error) {
logger.error('client-pre-sync error', {
workspaceId: this.workspaceId,
response,
});
throw new Error(response.error);
}
return new Map(Object.entries(response.data));
}
async subscribeAllDocs(
cb: (updates: {
docId: string;
data: Uint8Array;
serverClock: number;
}) => void
): Promise<() => void> {
const handleUpdate = async (message: {
workspaceId: string;
guid: string;
updates: string[];
timestamp: number;
}) => {
if (message.workspaceId === this.workspaceId) {
message.updates.forEach(update => {
cb({
docId: message.guid,
data: base64ToUint8Array(update),
serverClock: message.timestamp,
});
});
}
};
this.socket.on('server-updates', handleUpdate);
return () => {
this.socket.off('server-updates', handleUpdate);
};
}
async waitForConnectingServer(signal: AbortSignal): Promise<void> {
const socket = getIoManager().socket('/');
this.socket = socket;
this.socket.on('server-version-rejected', this.handleVersionRejected);
this.socket.on('disconnect', this.handleDisconnect);
throwIfAborted(signal);
if (this.socket.connected) {
await this.clientHandShake();
} else {
this.socket.connect();
await new Promise<void>((resolve, reject) => {
this.socket.on('connect', () => {
resolve();
});
signal.addEventListener('abort', () => {
reject('aborted');
});
});
throwIfAborted(signal);
await this.clientHandShake();
}
}
disconnectServer(): void {
if (!this.socket) {
return;
}
this.socket.emit('client-leave-sync', this.workspaceId);
this.socket.off('server-version-rejected', this.handleVersionRejected);
this.socket.off('disconnect', this.handleDisconnect);
this.socket = null as unknown as Socket;
}
onInterrupted = (cb: (reason: string) => void) => {
this.interruptCb = cb;
};
handleInterrupted = (reason: string) => {
this.interruptCb?.(reason);
};
handleDisconnect = (reason: Socket.DisconnectReason) => {
this.interruptCb?.(reason);
};
handleVersionRejected = () => {
this.interruptCb?.('Client version rejected');
};
}
@@ -1,6 +1,4 @@
export * from './awareness';
export * from './blob';
export * from './consts';
export { AffineCloudBlobStorage } from './blob';
export { CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY } from './consts';
export * from './list';
export * from './sync';
export * from './workspace-factory';
@@ -10,7 +10,6 @@ import { DocCollection } from '@blocksuite/store';
import type { WorkspaceListProvider } from '@toeverything/infra';
import {
type BlobStorage,
type SyncStorage,
type WorkspaceInfo,
type WorkspaceMetadata,
} from '@toeverything/infra';
@@ -21,10 +20,10 @@ import { applyUpdate, encodeStateAsUpdate } from 'yjs';
import { IndexedDBBlobStorage } from '../local/blob-indexeddb';
import { SQLiteBlobStorage } from '../local/blob-sqlite';
import { IndexedDBSyncStorage } from '../local/sync-indexeddb';
import { SQLiteSyncStorage } from '../local/sync-sqlite';
import { IndexedDBDocStorage } from '../local/doc-indexeddb';
import { SqliteDocStorage } from '../local/doc-sqlite';
import { CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY } from './consts';
import { AffineStaticSyncStorage } from './sync';
import { AffineStaticDocStorage } from './doc-static';
async function getCloudWorkspaceList() {
try {
@@ -94,17 +93,20 @@ export class CloudWorkspaceListProvider implements WorkspaceListProvider {
const blobStorage = environment.isDesktop
? new SQLiteBlobStorage(workspaceId)
: new IndexedDBBlobStorage(workspaceId);
const syncStorage = environment.isDesktop
? new SQLiteSyncStorage(workspaceId)
: new IndexedDBSyncStorage(workspaceId);
const docStorage = environment.isDesktop
? new SqliteDocStorage(workspaceId)
: new IndexedDBDocStorage(workspaceId);
// apply initial state
await initial(docCollection, blobStorage);
// save workspace to local storage, should be vary fast
await syncStorage.push(workspaceId, encodeStateAsUpdate(docCollection.doc));
await docStorage.doc.set(
workspaceId,
encodeStateAsUpdate(docCollection.doc)
);
for (const subdocs of docCollection.doc.getSubdocs()) {
await syncStorage.push(subdocs.guid, encodeStateAsUpdate(subdocs));
await docStorage.doc.set(subdocs.guid, encodeStateAsUpdate(subdocs));
}
// notify all browser tabs, so they can update their workspace list
@@ -155,13 +157,13 @@ export class CloudWorkspaceListProvider implements WorkspaceListProvider {
// get information from both cloud and local storage
// we use affine 'static' storage here, which use http protocol, no need to websocket.
const cloudStorage: SyncStorage = new AffineStaticSyncStorage(id);
const localStorage = environment.isDesktop
? new SQLiteSyncStorage(id)
: new IndexedDBSyncStorage(id);
const cloudStorage = new AffineStaticDocStorage(id);
const docStorage = environment.isDesktop
? new SqliteDocStorage(id)
: new IndexedDBDocStorage(id);
// download root doc
const localData = await localStorage.pull(id, new Uint8Array([]));
const cloudData = await cloudStorage.pull(id, new Uint8Array([]));
const localData = await docStorage.doc.get(id);
const cloudData = await cloudStorage.pull(id);
if (!cloudData && !localData) {
return;
@@ -172,7 +174,7 @@ export class CloudWorkspaceListProvider implements WorkspaceListProvider {
schema: globalBlockSuiteSchema,
});
if (localData) applyUpdate(bs.doc, localData.data);
if (localData) applyUpdate(bs.doc, localData);
if (cloudData) applyUpdate(bs.doc, cloudData.data);
return {
@@ -1,208 +0,0 @@
import { DebugLogger } from '@affine/debug';
import { fetchWithTraceReport } from '@affine/graphql';
import {
type RejectByVersion,
type SyncErrorMessage,
type SyncStorage,
} from '@toeverything/infra';
import type { CleanupService } from '@toeverything/infra/lifecycle';
import { getIoManager } from '../utils/affine-io';
import { base64ToUint8Array, uint8ArrayToBase64 } from '../utils/base64';
const logger = new DebugLogger('affine:storage:socketio');
(window as any)._TEST_SIMULATE_SYNC_LAG = Promise.resolve();
export class AffineSyncStorage implements SyncStorage {
name = 'affine-cloud';
SEND_TIMEOUT = 30000;
socket = getIoManager().socket('/');
errorMessage?: SyncErrorMessage;
constructor(
private readonly workspaceId: string,
cleanupService: CleanupService
) {
this.socket.on('connect', this.handleConnect);
this.socket.on('server-version-rejected', this.handleReject);
if (this.socket.connected) {
this.handleConnect();
} else {
this.socket.connect();
}
cleanupService.add(() => {
this.cleanup();
});
}
handleConnect = () => {
this.socket.emit(
'client-handshake-sync',
{
workspaceId: this.workspaceId,
version: runtimeConfig.appVersion,
},
(res: any) => {
logger.debug('client handshake finished', res);
}
);
};
handleReject = (message: RejectByVersion) => {
this.socket.off('server-version-rejected', this.handleReject);
this.cleanup();
this.socket.disconnect();
this.errorMessage = { type: 'outdated', message };
};
async pull(
docId: string,
state: Uint8Array
): Promise<{ data: Uint8Array; state?: Uint8Array } | null> {
// for testing
await (window as any)._TEST_SIMULATE_SYNC_LAG;
const stateVector = state ? await uint8ArrayToBase64(state) : undefined;
logger.debug('doc-load-v2', {
workspaceId: this.workspaceId,
guid: docId,
stateVector,
});
const response:
| { error: any }
| { data: { missing: string; state: string } } = await this.socket
.timeout(this.SEND_TIMEOUT)
.emitWithAck('doc-load-v2', {
workspaceId: this.workspaceId,
guid: docId,
stateVector,
});
logger.debug('doc-load callback', {
workspaceId: this.workspaceId,
guid: docId,
stateVector,
response,
});
if ('error' in response) {
// TODO: result `EventError` with server
if (response.error.code === 'DOC_NOT_FOUND') {
return null;
} else {
throw new Error(response.error.message);
}
} else {
return {
data: base64ToUint8Array(response.data.missing),
state: response.data.state
? base64ToUint8Array(response.data.state)
: undefined,
};
}
}
async push(docId: string, update: Uint8Array) {
logger.debug('client-update-v2', {
workspaceId: this.workspaceId,
guid: docId,
update,
});
const payload = await uint8ArrayToBase64(update);
const response: {
// TODO: reuse `EventError` with server
error?: any;
data: any;
} = await this.socket
.timeout(this.SEND_TIMEOUT)
.emitWithAck('client-update-v2', {
workspaceId: this.workspaceId,
guid: docId,
updates: [payload],
});
// TODO: raise error with different code to users
if (response.error) {
logger.error('client-update-v2 error', {
workspaceId: this.workspaceId,
guid: docId,
response,
});
throw new Error(response.error);
}
}
async subscribe(
cb: (docId: string, data: Uint8Array) => void,
disconnect: (reason: string) => void
) {
const handleUpdate = async (message: {
workspaceId: string;
guid: string;
updates: string[];
}) => {
if (message.workspaceId === this.workspaceId) {
message.updates.forEach(update => {
cb(message.guid, base64ToUint8Array(update));
});
}
};
const handleDisconnect = (reason: string) => {
this.socket.off('server-updates', handleUpdate);
disconnect(reason);
};
this.socket.on('server-updates', handleUpdate);
this.socket.on('disconnect', handleDisconnect);
return () => {
this.socket.off('server-updates', handleUpdate);
this.socket.off('disconnect', handleDisconnect);
};
}
cleanup() {
this.socket.emit('client-leave-sync', this.workspaceId);
this.socket.off('connect', this.handleConnect);
}
}
export class AffineStaticSyncStorage implements SyncStorage {
name = 'affine-cloud-static';
constructor(private readonly workspaceId: string) {}
async pull(
docId: string
): Promise<{ data: Uint8Array; state?: Uint8Array | undefined } | null> {
const response = await fetchWithTraceReport(
`/api/workspaces/${this.workspaceId}/docs/${docId}`,
{
priority: 'high',
}
);
if (response.ok) {
const arrayBuffer = await response.arrayBuffer();
return { data: new Uint8Array(arrayBuffer) };
}
return null;
}
push(): Promise<void> {
throw new Error('Method not implemented.');
}
subscribe(): Promise<() => void> {
throw new Error('Method not implemented.');
}
}
@@ -3,19 +3,19 @@ import type { WorkspaceFactory } from '@toeverything/infra';
import {
AwarenessContext,
AwarenessProvider,
DocServerImpl,
RemoteBlobStorage,
RemoteSyncStorage,
WorkspaceIdContext,
WorkspaceScope,
} from '@toeverything/infra';
import type { ServiceCollection } from '@toeverything/infra/di';
import { CleanupService } from '@toeverything/infra/lifecycle';
import { LocalWorkspaceFactory } from '../local';
import { IndexedDBBlobStorage, SQLiteBlobStorage } from '../local';
import { IndexedDBBlobStorage } from '../local/blob-indexeddb';
import { SQLiteBlobStorage } from '../local/blob-sqlite';
import { AffineCloudAwarenessProvider } from './awareness';
import { AffineCloudBlobStorage } from './blob';
import { AffineSyncStorage } from './sync';
import { AffineCloudDocEngineServer } from './doc';
export class CloudWorkspaceFactory implements WorkspaceFactory {
name = WorkspaceFlavour.AFFINE_CLOUD;
@@ -28,10 +28,7 @@ export class CloudWorkspaceFactory implements WorkspaceFactory {
.addImpl(RemoteBlobStorage('affine-cloud'), AffineCloudBlobStorage, [
WorkspaceIdContext,
])
.addImpl(RemoteSyncStorage('affine-cloud'), AffineSyncStorage, [
WorkspaceIdContext,
CleanupService,
])
.addImpl(DocServerImpl, AffineCloudDocEngineServer, [WorkspaceIdContext])
.addImpl(
AwarenessProvider('affine-cloud'),
AffineCloudAwarenessProvider,