@hocuspocus/server
Version:
plug & play collaboration backend
264 lines (212 loc) • 5.99 kB
text/typescript
import type WebSocket from "ws";
import {
Awareness,
removeAwarenessStates,
applyAwarenessUpdate,
} from "y-protocols/awareness";
import { applyUpdate, Doc, encodeStateAsUpdate } from "yjs";
import type { AwarenessUpdate } from "./types.ts";
import type Connection from "./Connection.ts";
import { OutgoingMessage } from "./OutgoingMessage.ts";
export class Document extends Doc {
awareness: Awareness;
callbacks = {
// eslint-disable-next-line @typescript-eslint/no-empty-function
onUpdate: (
document: Document,
connection: Connection,
update: Uint8Array,
) => {},
beforeBroadcastStateless: (document: Document, stateless: string) => {},
};
connections: Map<
WebSocket,
{
clients: Set<any>;
connection: Connection;
}
> = new Map();
// The number of direct (non-websocket) connections to this document
directConnectionsCount = 0;
name: string;
isLoading: boolean;
isDestroyed = false;
/**
* Constructor.
*/
constructor(name: string, yDocOptions?: object) {
super(yDocOptions);
this.name = name;
this.awareness = new Awareness(this);
this.awareness.setLocalState(null);
this.awareness.on("update", this.handleAwarenessUpdate.bind(this));
this.on("update", this.handleUpdate.bind(this));
this.isLoading = true;
}
/**
* Check if the Document (XMLFragment or Map) is empty
*/
isEmpty(fieldName: string): boolean {
// eslint-disable-next-line no-underscore-dangle
return !this.get(fieldName)._start && !this.get(fieldName)._map.size;
}
/**
* Merge the given document(s) into this one
*/
merge(documents: Doc | Array<Doc>): Document {
(Array.isArray(documents) ? documents : [documents]).forEach((document) => {
applyUpdate(this, encodeStateAsUpdate(document));
});
return this;
}
/**
* Set a callback that will be triggered when the document is updated
*/
onUpdate(
callback: (
document: Document,
connection: Connection,
update: Uint8Array,
) => void,
): Document {
this.callbacks.onUpdate = callback;
return this;
}
/**
* Set a callback that will be triggered before a stateless message is broadcasted
*/
beforeBroadcastStateless(
callback: (document: Document, stateless: string) => void,
): Document {
this.callbacks.beforeBroadcastStateless = callback;
return this;
}
/**
* Register a connection and a set of clients on this document keyed by the
* underlying websocket connection
*/
addConnection(connection: Connection): Document {
this.connections.set(connection.webSocket, {
clients: new Set(),
connection,
});
return this;
}
/**
* Is the given connection registered on this document
*/
hasConnection(connection: Connection): boolean {
return this.connections.has(connection.webSocket);
}
/**
* Remove the given connection from this document
*/
removeConnection(connection: Connection): Document {
removeAwarenessStates(
this.awareness,
Array.from(this.getClients(connection.webSocket)),
null,
);
this.connections.delete(connection.webSocket);
return this;
}
addDirectConnection(): Document {
this.directConnectionsCount += 1;
return this;
}
removeDirectConnection(): Document {
if (this.directConnectionsCount > 0) {
this.directConnectionsCount -= 1;
}
return this;
}
/**
* Get the number of active connections for this document
*/
getConnectionsCount(): number {
return this.connections.size + this.directConnectionsCount;
}
/**
* Get an array of registered connections
*/
getConnections(): Array<Connection> {
return Array.from(this.connections.values()).map((data) => data.connection);
}
/**
* Get the client ids for the given connection instance
*/
getClients(connectionInstance: WebSocket): Set<any> {
const connection = this.connections.get(connectionInstance);
return connection?.clients === undefined ? new Set() : connection.clients;
}
/**
* Has the document awareness states
*/
hasAwarenessStates(): boolean {
return this.awareness.getStates().size > 0;
}
/**
* Apply the given awareness update
*/
applyAwarenessUpdate(connection: Connection, update: Uint8Array): Document {
applyAwarenessUpdate(this.awareness, update, connection.webSocket);
return this;
}
/**
* Handle an awareness update and sync changes to clients
* @private
*/
private handleAwarenessUpdate(
{ added, updated, removed }: AwarenessUpdate,
connectionInstance: WebSocket,
): Document {
const changedClients = added.concat(updated, removed);
if (connectionInstance !== null) {
const connection = this.connections.get(connectionInstance);
if (connection) {
added.forEach((clientId: any) => connection.clients.add(clientId));
removed.forEach((clientId: any) => connection.clients.delete(clientId));
}
}
this.getConnections().forEach((connection) => {
const awarenessMessage = new OutgoingMessage(
this.name,
).createAwarenessUpdateMessage(this.awareness, changedClients);
connection.send(awarenessMessage.toUint8Array());
});
return this;
}
/**
* Handle an updated document and sync changes to clients
*/
private handleUpdate(update: Uint8Array, connection: Connection): Document {
this.callbacks.onUpdate(this, connection, update);
const message = new OutgoingMessage(this.name)
.createSyncMessage()
.writeUpdate(update);
this.getConnections().forEach((connection) => {
connection.send(message.toUint8Array());
});
return this;
}
/**
* Broadcast stateless message to all connections
*/
public broadcastStateless(
payload: string,
filter?: (conn: Connection) => boolean,
): void {
this.callbacks.beforeBroadcastStateless(this, payload);
const connections = filter
? this.getConnections().filter(filter)
: this.getConnections();
connections.forEach((connection) => {
connection.sendStateless(payload);
});
}
destroy() {
super.destroy();
this.isDestroyed = true;
}
}
export default Document;