feat(nbstore): add awareness storage&sync&frontend (#9016)

This commit is contained in:
EYHN
2024-12-17 04:37:15 +00:00
parent 36ac79351f
commit ffa0231cf5
21 changed files with 572 additions and 26 deletions

View 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);
};
}
}

View File

@@ -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);
}
}

View 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();
};
}
}

View File

@@ -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);
}
});

View File

@@ -1,2 +1,3 @@
export * from './awareness';
export * from './blob';
export * from './doc';

View File

@@ -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 },

View File

@@ -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),
};
}