silverbullet/server/collab.ts

193 lines
6.1 KiB
TypeScript

import { Hocuspocus } from "npm:@hocuspocus/server@2.0.6";
import { nanoid } from "https://esm.sh/nanoid@4.0.0";
import { race, timeout } from "../common/async_util.ts";
import { Application } from "./deps.ts";
import { SpacePrimitives } from "../common/spaces/space_primitives.ts";
type CollabPage = {
clients: Set<string>; // clientIds
collabId?: string;
};
const pingInterval = 5000;
export class CollabServer {
clients: Map<string, { openPage: string; lastPing: number }> = new Map();
pages: Map<string, CollabPage> = new Map();
constructor(private spacePrimitives: SpacePrimitives) {
}
start() {
setInterval(() => {
this.cleanup(3 * pingInterval);
}, pingInterval);
}
ping(clientId: string, currentPage: string): { collabId?: string } {
let clientState = this.clients.get(clientId);
let collabId: string | undefined;
if (!clientState) {
clientState = {
openPage: "",
lastPing: Date.now(),
};
} else {
clientState.lastPing = Date.now();
}
if (currentPage !== clientState.openPage) {
// Client switched pages
// Update last page record
const lastCollabPage = this.pages.get(clientState.openPage);
if (lastCollabPage) {
lastCollabPage.clients.delete(clientId);
if (lastCollabPage.clients.size === 0) {
// Cleanup
this.pages.delete(clientState.openPage);
} else {
if (lastCollabPage.clients.size === 1) {
delete lastCollabPage.collabId;
}
this.pages.set(clientState.openPage, lastCollabPage);
}
}
// Update new page
let nextCollabPage = this.pages.get(currentPage);
if (!nextCollabPage) {
nextCollabPage = {
clients: new Set(),
};
}
nextCollabPage.clients.add(clientId);
// console.log(
// "Current number of clients for",
// currentPage,
// "is",
// nextCollabPage.clients.size,
// nextCollabPage.collabId,
// );
if (nextCollabPage.clients.size > 1 && !nextCollabPage.collabId) {
// Create a new collabId
nextCollabPage.collabId = nanoid();
}
clientState.openPage = currentPage;
this.pages.set(currentPage, nextCollabPage);
collabId = nextCollabPage.collabId;
} else {
// Page didn't change
collabId = this.pages.get(currentPage)?.collabId;
}
this.clients.set(clientId, clientState);
if (collabId) {
return { collabId };
} else {
return {};
}
}
cleanup(timeout: number) {
// Clean up clients that haven't pinged for some time
for (const [clientId, clientState] of this.clients) {
if (Date.now() - clientState.lastPing > timeout) {
console.log("[Collab]", "Ejecting client", clientId);
this.clients.delete(clientId);
const collabPage = this.pages.get(clientState.openPage);
if (collabPage) {
collabPage.clients.delete(clientId);
if (collabPage.clients.size === 0) {
this.pages.delete(clientState.openPage);
} else {
if (collabPage.clients.size === 1) {
delete collabPage.collabId;
}
this.pages.set(clientState.openPage, collabPage);
}
}
}
}
}
route(app: Express.Application) {
const hocuspocus = new Hocuspocus({
quiet: true,
onLoadDocument: async (doc) => {
console.log("[Hocuspocus]", "Requesting doc load", doc.documentName);
const [collabId, pageName] = splitCollabId(doc.documentName);
const collabPage = this.pages.get(pageName);
if (!collabPage || collabPage.collabId !== collabId) {
// This can happen after a server restart (or a multi-server setup which we don't yet support),
// where old clients are still trying to continue on an old session
// This will self-correct when the client discovers that the collabId has changed
// Until then: HARD PASS (meaning: don't send a document)
console.warn(
"[Hocuspocus]",
"Client tried to connect to old session",
doc.documentName,
);
return;
}
try {
const yText = doc.document.getText("codemirror");
// Read document from space and load it into Yjs
const { data } = await this.spacePrimitives.readFile(
`${pageName}.md`,
);
yText.insert(0, new TextDecoder().decode(data));
console.log("[Hocuspocus]", "Loaded document from space");
return doc.document;
} catch (e) {
console.error("Error loading doc", e);
}
},
onStoreDocument: async (data) => {
const [_, pageName] = splitCollabId(data.documentName);
const path = `${pageName}.md`;
const text = data.document.getText("codemirror").toString();
console.log(
"[Hocuspocus]",
"Persisting",
pageName,
"to space on server",
);
const meta = await this.spacePrimitives.writeFile(
path,
new TextEncoder().encode(text),
);
// Broadcast new persisted lastModified date
data.document.broadcastStateless(
JSON.stringify({
type: "persisted",
path,
lastModified: meta.lastModified,
}),
);
return;
},
onDisconnect: (client) => {
console.log("[Hocuspocus]", "Client disconnected", client.clientsCount);
if (client.clientsCount === 0) {
console.log(
"[Hocuspocus]",
"Last client disconnected from",
client.documentName,
"purging from memory",
);
hocuspocus.documents.delete(client.documentName);
}
return Promise.resolve();
},
});
// hocuspocus.listen();
app.ws("/.ws-collab", (ws, req) => {
hocuspocus.handleConnection(ws, req);
});
}
function splitCollabId(documentName: string): [string, string] {
const [collabId, ...pageNamePieces] = documentName.split("/");
const pageName = pageNamePieces.join("/");
return [collabId, pageName];
}