@netless/app-quill
Version:
netless-app-quill
131 lines (108 loc) • 3.76 kB
text/typescript
// The bridge between netless app storage and Yjs.
// For 1.0 implementation see https://github.com/netless-io/y.
import type { NetlessApp } from '@netless/fastboard'
import { disposableStore } from '@wopjs/disposable'
import { ObservableV2 } from 'lib0/observable'
import * as Y from "yjs";
import { uint8ArrayToBase64, base64ToUint8Array } from "uint8array-extras";
export type AppContext = Parameters<NetlessApp['setup']>[0]
export type Storage = AppContext['storage']
export type AnyDict = { [key in PropertyKey]: any }
export interface VectorEvents {
update: (data: any) => void
}
export class Vector extends ObservableV2<VectorEvents> {
readonly dispose = disposableStore()
readonly storage: Storage
readonly clientId: string
clock = 1
constructor(readonly context: AppContext, storage: Storage) {
super()
this.storage = storage;
this.clientId = context.getRoom()?.uid || Math.random().toString(36).slice(2, 8)
this.dispose.add(this.storage.addStateChangedListener((diff) => {
Object.keys(diff).forEach((key) => {
const [clientId, _clock] = key.split("@");
if (clientId === this.clientId) return;
const one = diff[key];
const update = one && one.newValue;
update && this.emit("update", [update]);
});
}))
Object.keys(this.storage.state).forEach((key) => {
const [clientId, clock] = key.split("@");
if (clientId === this.clientId) {
this.clock = Math.max(this.clock, Number(clock) + 1);
}
});
}
get size() {
return Object.keys(this.storage.state).length;
}
forEach(callback: (update: any, index: number) => void) {
const state = this.storage.state;
Object.keys(state).forEach((key, i) => callback(state[key], i));
}
push(update: any) {
if (!this.context.getIsWritable()) return;
this.storage.setState({ [this.clientId + "@" + this.clock++]: update });
}
swap(updates: Array<any>) {
if (!this.context.getIsWritable()) return;
this.storage.emptyStorage();
let newState: Record<string, any> = {};
for (const update of updates) {
newState[this.clientId + "@" + this.clock++] = update;
}
this.storage.setState(newState);
}
override destroy(): void {
this.dispose();
super.destroy();
}
}
export function createVector(context: AppContext, storage: Storage) {
return new Vector(context, storage);
}
export interface ConnectOptions {
/**
* Shrink vector size when it is bigger than this number.Affected by akko's 'illusion queue', the maximum cannot exceed 500
* default: 500
*/
optimizeAt?: number;
}
export function connect(
vector: Vector,
doc: Y.Doc,
{ optimizeAt = 500 }: ConnectOptions = {}
) {
if (optimizeAt <= 0) {
throw new Error("[optimizeAt] must be greater than 0");
}
const remoteOrigin = "remote";
// restore state
vector.forEach((update: string) => {
Y.applyUpdate(doc, base64ToUint8Array(update), remoteOrigin);
});
// doc -> vector -> other clients
function onDocUpdate(update: Uint8Array, origin: unknown) {
if (origin !== remoteOrigin) {
vector.push(uint8ArrayToBase64(update));
if (vector.size > optimizeAt) {
vector.swap([uint8ArrayToBase64(Y.encodeStateAsUpdate(doc))]);
}
}
}
doc.on("update", onDocUpdate);
const disposeDocListener = () => doc.off("update", onDocUpdate);
// other clients -> vector -> doc
function onVectorUpdate(update: string) {
Y.applyUpdate(doc, base64ToUint8Array(update), remoteOrigin);
}
vector.on("update", onVectorUpdate);
const disposeVectorListener = () => vector.off("update", onVectorUpdate);
return function dispose() {
disposeVectorListener();
disposeDocListener();
};
}