@hocuspocus/server
Version:
plug & play collaboration backend
411 lines (355 loc) • 12 kB
text/typescript
import type { IncomingHttpHeaders, IncomingMessage } from "node:http";
import type { URLSearchParams } from "node:url";
import {
type CloseEvent,
ConnectionTimeout,
Forbidden,
ResetConnection,
Unauthorized,
WsReadyStates,
} from "@hocuspocus/common";
import * as decoding from "lib0/decoding";
import { v4 as uuid } from "uuid";
import type WebSocket from "ws";
import Connection from "./Connection.ts";
import type Document from "./Document.ts";
import type { Hocuspocus } from "./Hocuspocus.ts";
import { IncomingMessage as SocketIncomingMessage } from "./IncomingMessage.ts";
import { OutgoingMessage } from "./OutgoingMessage.ts";
import type {
ConnectionConfiguration,
beforeHandleMessagePayload,
beforeSyncPayload,
onDisconnectPayload,
} from "./types.ts";
import { MessageType } from "./types.ts";
import { getParameters } from "./util/getParameters.ts";
/**
* The `ClientConnection` class is responsible for handling an incoming WebSocket
*
* TODO-refactor:
* - use event handlers instead of calling hooks directly, hooks should probably be called from Hocuspocus.ts
*/
export class ClientConnection {
// this map indicates whether a `Connection` instance has already taken over for incoming message for the key (i.e. documentName)
private readonly documentConnections: Record<string, Connection> = {};
// While the connection will be establishing messages will
// be queued and handled later.
private readonly incomingMessageQueue: Record<string, Uint8Array[]> = {};
// While the connection is establishing, kee
private readonly documentConnectionsEstablished = new Set<string>();
// hooks payload by Document
private readonly hookPayloads: Record<
string,
{
instance: Hocuspocus;
request: IncomingMessage;
requestHeaders: IncomingHttpHeaders;
requestParameters: URLSearchParams;
socketId: string;
connectionConfig: ConnectionConfiguration;
context: any;
}
> = {};
private readonly callbacks = {
onClose: [(document: Document, payload: onDisconnectPayload) => {}],
};
// Every new connection gets a unique identifier.
private readonly socketId = uuid();
timeout: number;
pingInterval: NodeJS.Timeout;
pongReceived = true;
/**
* The `ClientConnection` class receives incoming WebSocket connections,
* runs all hooks:
*
* - onConnect for all connections
* - onAuthenticate only if required
*
* … and if nothings fails it’ll fully establish the connection and
* load the Document then.
*/
constructor(
private readonly websocket: WebSocket,
private readonly request: IncomingMessage,
private readonly documentProvider: {
createDocument: Hocuspocus["createDocument"];
},
// TODO: change to events
private readonly hooks: Hocuspocus["hooks"],
private readonly opts: {
timeout: number;
},
private readonly defaultContext: any = {},
) {
this.timeout = opts.timeout;
this.pingInterval = setInterval(this.check, this.timeout);
websocket.on("pong", this.handlePong);
websocket.on("message", this.messageHandler);
websocket.once("close", this.handleWebsocketClose);
}
private handleWebsocketClose = (code: number, reason: Buffer) => {
this.close({ code, reason: reason.toString() });
this.websocket.removeListener("message", this.messageHandler);
this.websocket.removeListener("pong", this.handlePong);
clearInterval(this.pingInterval);
};
close(event?: CloseEvent) {
Object.values(this.documentConnections).forEach((connection) =>
connection.close(event),
);
}
handlePong = () => {
this.pongReceived = true;
};
/**
* Check if pong was received and close the connection otherwise
* @private
*/
private check = () => {
if (!this.pongReceived) {
return this.close(ConnectionTimeout);
}
this.pongReceived = false;
try {
this.websocket.ping();
} catch (error) {
this.close(ConnectionTimeout);
}
};
/**
* Set a callback that will be triggered when the connection is closed
*/
public onClose(
callback: (document: Document, payload: onDisconnectPayload) => void,
): ClientConnection {
this.callbacks.onClose.push(callback);
return this;
}
/**
* Create a new connection by the given request and document
*/
private createConnection(
connection: WebSocket,
document: Document,
): Connection {
const hookPayload = this.hookPayloads[document.name];
const instance = new Connection(
connection,
hookPayload.request,
document,
hookPayload.socketId,
hookPayload.context,
hookPayload.connectionConfig.readOnly,
);
instance.onClose(async (document, event) => {
const disconnectHookPayload: onDisconnectPayload = {
instance: this.documentProvider as Hocuspocus, // TODO, this will be removed when we use events instead of hooks for this class
clientsCount: document.getConnectionsCount(),
context: hookPayload.context,
document,
socketId: hookPayload.socketId,
documentName: document.name,
requestHeaders: hookPayload.request.headers,
requestParameters: getParameters(hookPayload.request),
};
await this.hooks("onDisconnect", disconnectHookPayload);
this.callbacks.onClose.forEach((callback) =>
callback(document, disconnectHookPayload),
);
});
instance.onStatelessCallback(async (payload) => {
try {
return await this.hooks("onStateless", payload);
} catch (error: any) {
if (error?.message) {
// if a hook rejects and the error is empty, do nothing
// this is only meant to prevent later hooks and the
// default handler to do something. if an error is present
// just rethrow it
throw error;
}
}
});
instance.beforeHandleMessage((connection, update) => {
const beforeHandleMessagePayload: beforeHandleMessagePayload = {
instance: this.documentProvider as Hocuspocus, // TODO, this will be removed when we use events instead of hooks for this class
clientsCount: document.getConnectionsCount(),
context: hookPayload.context,
document,
socketId: hookPayload.socketId,
connection,
documentName: document.name,
requestHeaders: hookPayload.request.headers,
requestParameters: getParameters(hookPayload.request),
update,
};
return this.hooks("beforeHandleMessage", beforeHandleMessagePayload);
});
instance.beforeSync((connection, payload) => {
const beforeSyncPayload: beforeSyncPayload = {
clientsCount: document.getConnectionsCount(),
context: hookPayload.context,
document,
documentName: document.name,
connection,
type: payload.type,
payload: payload.payload,
};
return this.hooks("beforeSync", beforeSyncPayload);
});
return instance;
}
// Once all hooks are run, we’ll fully establish the connection:
private setUpNewConnection = async (documentName: string) => {
const hookPayload = this.hookPayloads[documentName];
// If no hook interrupts, create a document and connection
const document = await this.documentProvider.createDocument(
documentName,
hookPayload.request,
hookPayload.socketId,
hookPayload.connectionConfig,
hookPayload.context,
);
const connection = this.createConnection(this.websocket, document);
connection.onClose((document, event) => {
delete this.hookPayloads[documentName];
delete this.documentConnections[documentName];
delete this.incomingMessageQueue[documentName];
this.documentConnectionsEstablished.delete(documentName);
});
this.documentConnections[documentName] = connection;
// If the WebSocket has already disconnected (wow, that was fast) – then
// immediately call close to cleanup the connection and document in memory.
if (
this.websocket.readyState === WsReadyStates.Closing ||
this.websocket.readyState === WsReadyStates.Closed
) {
this.close();
return;
}
// There’s no need to queue messages anymore.
// Let’s work through queued messages.
this.incomingMessageQueue[documentName].forEach((input) => {
this.websocket.emit("message", input);
});
await this.hooks("connected", {
...hookPayload,
documentName,
context: hookPayload.context,
connection,
});
};
// This listener handles authentication messages and queues everything else.
private handleQueueingMessage = async (data: Uint8Array) => {
try {
const tmpMsg = new SocketIncomingMessage(data);
const documentName = decoding.readVarString(tmpMsg.decoder);
const type = decoding.readVarUint(tmpMsg.decoder);
if (
!(
type === MessageType.Auth &&
!this.documentConnectionsEstablished.has(documentName)
)
) {
this.incomingMessageQueue[documentName].push(data);
return;
}
// Okay, we’ve got the authentication message we’re waiting for:
this.documentConnectionsEstablished.add(documentName);
// The 2nd integer contains the submessage type
// which will always be authentication when sent from client -> server
decoding.readVarUint(tmpMsg.decoder);
const token = decoding.readVarString(tmpMsg.decoder);
try {
const hookPayload = this.hookPayloads[documentName];
await this.hooks(
"onConnect",
{ ...hookPayload, documentName },
(contextAdditions: any) => {
// merge context from all hooks
hookPayload.context = {
...hookPayload.context,
...contextAdditions,
};
},
);
await this.hooks(
"onAuthenticate",
{
token,
...hookPayload,
documentName,
},
(contextAdditions: any) => {
// Hooks are allowed to give us even more context and we’ll merge everything together.
// We’ll pass the context to other hooks then.
hookPayload.context = {
...hookPayload.context,
...contextAdditions,
};
},
);
// All `onAuthenticate` hooks passed.
hookPayload.connectionConfig.isAuthenticated = true;
// Let the client know that authentication was successful.
const message = new OutgoingMessage(documentName).writeAuthenticated(
hookPayload.connectionConfig.readOnly,
);
this.websocket.send(message.toUint8Array());
// Time to actually establish the connection.
await this.setUpNewConnection(documentName);
} catch (err: any) {
const error = err || Forbidden;
const message = new OutgoingMessage(documentName).writePermissionDenied(
error.reason ?? "permission-denied",
);
this.websocket.send(message.toUint8Array());
}
// Catch errors due to failed decoding of data
} catch (error) {
console.error(error);
this.websocket.close(ResetConnection.code, ResetConnection.reason);
}
};
private messageHandler = async (data: Uint8Array) => {
try {
const tmpMsg = new SocketIncomingMessage(data);
const documentName = decoding.readVarString(tmpMsg.decoder);
const connection = this.documentConnections[documentName];
if (connection) {
// forward the message to the connection
connection.handleMessage(data);
// we already have a `Connection` set up for this document
return;
}
const isFirst = this.incomingMessageQueue[documentName] === undefined;
if (isFirst) {
this.incomingMessageQueue[documentName] = [];
if (this.hookPayloads[documentName]) {
throw new Error("first message, but hookPayloads exists");
}
const hookPayload = {
instance: this.documentProvider as Hocuspocus,
request: this.request,
connectionConfig: {
readOnly: false,
isAuthenticated: false,
},
requestHeaders: this.request.headers,
requestParameters: getParameters(this.request),
socketId: this.socketId,
context: {
...this.defaultContext,
},
};
this.hookPayloads[documentName] = hookPayload;
}
this.handleQueueingMessage(data);
} catch (closeError) {
// catch is needed in case an invalid payload crashes the parsing of the Uint8Array
console.error(closeError);
this.websocket.close(Unauthorized.code, Unauthorized.reason);
}
};
}