mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00:00
feat(nbstore): add awareness storage&sync&frontend (#9016)
This commit is contained in:
128
packages/common/nbstore/src/impls/broadcast-channel/awareness.ts
Normal file
128
packages/common/nbstore/src/impls/broadcast-channel/awareness.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import {
|
||||
type AwarenessRecord,
|
||||
AwarenessStorage,
|
||||
} from '../../storage/awareness';
|
||||
import { BroadcastChannelConnection } from './channel';
|
||||
|
||||
type ChannelMessage =
|
||||
| {
|
||||
type: 'awareness-update';
|
||||
docId: string;
|
||||
bin: Uint8Array;
|
||||
origin?: string;
|
||||
}
|
||||
| {
|
||||
type: 'awareness-collect';
|
||||
docId: string;
|
||||
collectId: string;
|
||||
}
|
||||
| {
|
||||
type: 'awareness-collect-fallback';
|
||||
docId: string;
|
||||
bin: Uint8Array;
|
||||
collectId: string;
|
||||
};
|
||||
|
||||
export class BroadcastChannelAwarenessStorage extends AwarenessStorage {
|
||||
override readonly storageType = 'awareness';
|
||||
override readonly connection = new BroadcastChannelConnection(this.options);
|
||||
get channel() {
|
||||
return this.connection.inner;
|
||||
}
|
||||
|
||||
private readonly subscriptions = new Map<
|
||||
string,
|
||||
Set<{
|
||||
onUpdate: (update: AwarenessRecord, origin?: string) => void;
|
||||
onCollect: () => AwarenessRecord;
|
||||
}>
|
||||
>();
|
||||
|
||||
override update(record: AwarenessRecord, origin?: string): Promise<void> {
|
||||
const subscribers = this.subscriptions.get(record.docId);
|
||||
if (subscribers) {
|
||||
subscribers.forEach(subscriber => subscriber.onUpdate(record, origin));
|
||||
}
|
||||
this.channel.postMessage({
|
||||
type: 'awareness-update',
|
||||
docId: record.docId,
|
||||
bin: record.bin,
|
||||
origin,
|
||||
} satisfies ChannelMessage);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
override subscribeUpdate(
|
||||
id: string,
|
||||
onUpdate: (update: AwarenessRecord, origin?: string) => void,
|
||||
onCollect: () => AwarenessRecord
|
||||
): () => void {
|
||||
const subscribers = this.subscriptions.get(id) ?? new Set();
|
||||
subscribers.forEach(subscriber => {
|
||||
const fallback = subscriber.onCollect();
|
||||
onUpdate(fallback);
|
||||
});
|
||||
|
||||
const collectUniqueId = nanoid();
|
||||
|
||||
const onChannelMessage = (message: MessageEvent<ChannelMessage>) => {
|
||||
if (
|
||||
message.data.type === 'awareness-update' &&
|
||||
message.data.docId === id
|
||||
) {
|
||||
onUpdate(
|
||||
{
|
||||
docId: message.data.docId,
|
||||
bin: message.data.bin,
|
||||
},
|
||||
message.data.origin
|
||||
);
|
||||
}
|
||||
if (
|
||||
message.data.type === 'awareness-collect' &&
|
||||
message.data.docId === id
|
||||
) {
|
||||
const fallback = onCollect();
|
||||
if (fallback) {
|
||||
this.channel.postMessage({
|
||||
type: 'awareness-collect-fallback',
|
||||
docId: message.data.docId,
|
||||
bin: fallback.bin,
|
||||
collectId: collectUniqueId,
|
||||
} satisfies ChannelMessage);
|
||||
}
|
||||
}
|
||||
if (
|
||||
message.data.type === 'awareness-collect-fallback' &&
|
||||
message.data.docId === id &&
|
||||
message.data.collectId === collectUniqueId
|
||||
) {
|
||||
onUpdate({
|
||||
docId: message.data.docId,
|
||||
bin: message.data.bin,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
this.channel.addEventListener('message', onChannelMessage);
|
||||
this.channel.postMessage({
|
||||
type: 'awareness-collect',
|
||||
docId: id,
|
||||
collectId: collectUniqueId,
|
||||
} satisfies ChannelMessage);
|
||||
|
||||
const subscriber = {
|
||||
onUpdate,
|
||||
onCollect,
|
||||
};
|
||||
subscribers.add(subscriber);
|
||||
this.subscriptions.set(id, subscribers);
|
||||
|
||||
return () => {
|
||||
subscribers.delete(subscriber);
|
||||
this.channel.removeEventListener('message', onChannelMessage);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Connection } from '../../connection';
|
||||
import type { StorageOptions } from '../../storage';
|
||||
|
||||
export class BroadcastChannelConnection extends Connection<BroadcastChannel> {
|
||||
readonly channelName = `channel:${this.opts.peer}:${this.opts.type}:${this.opts.id}`;
|
||||
|
||||
constructor(private readonly opts: StorageOptions) {
|
||||
super();
|
||||
}
|
||||
|
||||
override async doConnect() {
|
||||
return new BroadcastChannel(this.channelName);
|
||||
}
|
||||
|
||||
override async doDisconnect() {
|
||||
this.close();
|
||||
}
|
||||
|
||||
private close(error?: Error) {
|
||||
this.maybeConnection?.close();
|
||||
this.setStatus('closed', error);
|
||||
}
|
||||
}
|
||||
148
packages/common/nbstore/src/impls/cloud/awareness.ts
Normal file
148
packages/common/nbstore/src/impls/cloud/awareness.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { SocketOptions } from 'socket.io-client';
|
||||
|
||||
import { share } from '../../connection';
|
||||
import {
|
||||
type AwarenessRecord,
|
||||
AwarenessStorage,
|
||||
type AwarenessStorageOptions,
|
||||
} from '../../storage/awareness';
|
||||
import {
|
||||
base64ToUint8Array,
|
||||
SocketConnection,
|
||||
uint8ArrayToBase64,
|
||||
} from './socket';
|
||||
|
||||
interface CloudAwarenessStorageOptions extends AwarenessStorageOptions {
|
||||
socketOptions: SocketOptions;
|
||||
}
|
||||
|
||||
export class CloudAwarenessStorage extends AwarenessStorage<CloudAwarenessStorageOptions> {
|
||||
connection = share(
|
||||
new SocketConnection(this.peer, this.options.socketOptions)
|
||||
);
|
||||
|
||||
private get socket() {
|
||||
return this.connection.inner;
|
||||
}
|
||||
|
||||
override async connect(): Promise<void> {
|
||||
await super.connect();
|
||||
}
|
||||
|
||||
override async update(record: AwarenessRecord): Promise<void> {
|
||||
const encodedUpdate = await uint8ArrayToBase64(record.bin);
|
||||
this.socket.emit('space:update-awareness', {
|
||||
spaceType: this.spaceType,
|
||||
spaceId: this.spaceId,
|
||||
docId: record.docId,
|
||||
awarenessUpdate: encodedUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
override subscribeUpdate(
|
||||
id: string,
|
||||
onUpdate: (update: AwarenessRecord, origin?: string) => void,
|
||||
onCollect: () => AwarenessRecord
|
||||
): () => void {
|
||||
// leave awareness
|
||||
const leave = () => {
|
||||
this.socket.emit('space:leave-awareness', {
|
||||
spaceType: this.spaceType,
|
||||
spaceId: this.spaceId,
|
||||
docId: id,
|
||||
});
|
||||
};
|
||||
|
||||
// join awareness, and collect awareness from others
|
||||
const joinAndCollect = async () => {
|
||||
await this.socket.emitWithAck('space:join-awareness', {
|
||||
spaceType: this.spaceType,
|
||||
spaceId: this.spaceId,
|
||||
docId: id,
|
||||
clientVersion: BUILD_CONFIG.appVersion,
|
||||
});
|
||||
this.socket.emit('space:load-awarenesses', {
|
||||
spaceType: this.spaceType,
|
||||
spaceId: this.spaceId,
|
||||
docId: id,
|
||||
});
|
||||
};
|
||||
|
||||
joinAndCollect().catch(err => console.error('awareness join failed', err));
|
||||
|
||||
const unsubscribeConnectionStatusChanged = this.connection.onStatusChanged(
|
||||
status => {
|
||||
if (status === 'connected') {
|
||||
joinAndCollect().catch(err =>
|
||||
console.error('awareness join failed', err)
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const handleCollectAwareness = ({
|
||||
spaceId,
|
||||
spaceType,
|
||||
docId,
|
||||
}: {
|
||||
spaceId: string;
|
||||
spaceType: string;
|
||||
docId: string;
|
||||
}) => {
|
||||
if (
|
||||
spaceId === this.spaceId &&
|
||||
spaceType === this.spaceType &&
|
||||
docId === id
|
||||
) {
|
||||
(async () => {
|
||||
const record = onCollect();
|
||||
const encodedUpdate = await uint8ArrayToBase64(record.bin);
|
||||
this.socket.emit('space:update-awareness', {
|
||||
spaceType: this.spaceType,
|
||||
spaceId: this.spaceId,
|
||||
docId: record.docId,
|
||||
awarenessUpdate: encodedUpdate,
|
||||
});
|
||||
})().catch(err => console.error('awareness upload failed', err));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBroadcastAwarenessUpdate = ({
|
||||
spaceType,
|
||||
spaceId,
|
||||
docId,
|
||||
awarenessUpdate,
|
||||
}: {
|
||||
spaceType: string;
|
||||
spaceId: string;
|
||||
docId: string;
|
||||
awarenessUpdate: string;
|
||||
}) => {
|
||||
if (
|
||||
spaceId === this.spaceId &&
|
||||
spaceType === this.spaceType &&
|
||||
docId === id
|
||||
) {
|
||||
onUpdate({
|
||||
bin: base64ToUint8Array(awarenessUpdate),
|
||||
docId: id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
this.socket.on('space:collect-awareness', handleCollectAwareness);
|
||||
this.socket.on(
|
||||
'space:broadcast-awareness-update',
|
||||
handleBroadcastAwarenessUpdate
|
||||
);
|
||||
return () => {
|
||||
leave();
|
||||
this.socket.off('space:collect-awareness', handleCollectAwareness);
|
||||
this.socket.off(
|
||||
'space:broadcast-awareness-update',
|
||||
handleBroadcastAwarenessUpdate
|
||||
);
|
||||
unsubscribeConnectionStatusChanged();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { noop } from 'lodash-es';
|
||||
import type { SocketOptions } from 'socket.io-client';
|
||||
|
||||
import { share } from '../../connection';
|
||||
@@ -33,7 +32,9 @@ export class CloudDocStorage extends DocStorage<CloudDocStorageOptions> {
|
||||
await super.connect();
|
||||
this.connection.onStatusChanged(status => {
|
||||
if (status === 'connected') {
|
||||
this.join().catch(noop);
|
||||
this.join().catch(err => {
|
||||
console.error('doc storage join failed', err);
|
||||
});
|
||||
this.socket.on('space:broadcast-doc-update', this.onServerUpdate);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './awareness';
|
||||
export * from './blob';
|
||||
export * from './doc';
|
||||
|
||||
@@ -29,6 +29,19 @@ interface ServerEvents {
|
||||
timestamp: number;
|
||||
editor: string;
|
||||
};
|
||||
|
||||
'space:collect-awareness': {
|
||||
spaceType: string;
|
||||
spaceId: string;
|
||||
docId: string;
|
||||
};
|
||||
|
||||
'space:broadcast-awareness-update': {
|
||||
spaceType: string;
|
||||
spaceId: string;
|
||||
docId: string;
|
||||
awarenessUpdate: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ClientEvents {
|
||||
@@ -52,6 +65,19 @@ interface ClientEvents {
|
||||
docId: string;
|
||||
};
|
||||
|
||||
'space:update-awareness': {
|
||||
spaceType: string;
|
||||
spaceId: string;
|
||||
docId: string;
|
||||
awarenessUpdate: string;
|
||||
};
|
||||
|
||||
'space:load-awarenesses': {
|
||||
spaceType: string;
|
||||
spaceId: string;
|
||||
docId: string;
|
||||
};
|
||||
|
||||
'space:push-doc-update': [
|
||||
{ spaceType: string; spaceId: string; docId: string; updates: string },
|
||||
{ timestamp: number },
|
||||
|
||||
@@ -34,7 +34,7 @@ export class IDBConnection extends Connection<{
|
||||
this.setStatus('error', new Error('Blocked by other tabs.'));
|
||||
},
|
||||
}),
|
||||
channel: new BroadcastChannel(this.dbName),
|
||||
channel: new BroadcastChannel('idb:' + this.dbName),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user