UNPKG

@hocuspocus/server

Version:

plug & play collaboration backend

268 lines (228 loc) 6.55 kB
import type { Server as HTTPServer, IncomingMessage, ServerResponse, } from "node:http"; import { createServer } from "node:http"; import type { ListenOptions } from "node:net"; import kleur from "kleur"; import type WebSocket from "ws"; import { WebSocketServer } from "ws"; import type { AddressInfo, ServerOptions } from "ws"; import meta from "../package.json" assert { type: "json" }; import { Hocuspocus, defaultConfiguration } from "./Hocuspocus.ts"; import type { Configuration, onListenPayload } from "./types.ts"; export interface ServerConfiguration extends Configuration { port?: number; address?: string; stopOnSignals?: boolean; } export const defaultServerConfiguration = { port: 80, address: "0.0.0.0", stopOnSignals: true, }; export class Server { httpServer: HTTPServer; webSocketServer: WebSocketServer; hocuspocus: Hocuspocus; configuration: ServerConfiguration = { ...defaultConfiguration, ...defaultServerConfiguration, extensions: [], }; constructor( configuration?: Partial<ServerConfiguration>, websocketOptions: ServerOptions = {}, ) { if (configuration) { this.configuration = { ...this.configuration, ...configuration, }; } this.hocuspocus = new Hocuspocus(this.configuration); this.hocuspocus.server = this; this.httpServer = createServer(this.requestHandler); this.webSocketServer = new WebSocketServer({ noServer: true, ...websocketOptions, }); this.setupWebsocketConnection(); this.setupHttpUpgrade(); } setupWebsocketConnection = () => { this.webSocketServer.on( "connection", async (incoming: WebSocket, request: IncomingMessage) => { incoming.on("error", (error) => { /** * Handle a ws instance error, which is required to prevent * the server from crashing when one happens * See https://github.com/websockets/ws/issues/1777#issuecomment-660803472 * @private */ console.error("Error emitted from webSocket instance:"); console.error(error); }); this.hocuspocus.handleConnection(incoming, request); }, ); }; setupHttpUpgrade = () => { this.httpServer.on("upgrade", async (request, socket, head) => { try { await this.hocuspocus.hooks("onUpgrade", { request, socket, head, instance: this.hocuspocus, }); // let the default websocket server handle the connection if // prior hooks don't interfere this.webSocketServer.handleUpgrade(request, socket, head, (ws) => { this.webSocketServer.emit("connection", ws, request); }); } catch (error) { // 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 a error is present // just rethrow it if (error) { throw error; } } }); }; requestHandler = async ( request: IncomingMessage, response: ServerResponse, ) => { try { await this.hocuspocus.hooks("onRequest", { request, response, instance: this.hocuspocus, }); // default response if all prior hooks don't interfere response.writeHead(200, { "Content-Type": "text/plain" }); response.end("Welcome to Hocuspocus!"); } catch (error) { // 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 a error is present // just rethrow it if (error) { throw error; } } }; async listen(port?: number, callback: any = null): Promise<Hocuspocus> { if (port) { this.configuration.port = port; } if (typeof callback === "function") { this.hocuspocus.configuration.extensions.push({ onListen: callback, }); } if (this.configuration.stopOnSignals) { const signalHandler = async () => { await this.destroy(); process.exit(0); }; process.on("SIGINT", signalHandler); process.on("SIGQUIT", signalHandler); process.on("SIGTERM", signalHandler); } // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type return new Promise((resolve: Function, reject: Function) => { this.httpServer.listen( { port: this.configuration.port, address: this.configuration.address, } as ListenOptions, async () => { if ( !this.configuration.quiet && String(process.env.NODE_ENV) !== "testing" ) { this.showStartScreen(); } const onListenPayload = { instance: this.hocuspocus, configuration: this.configuration, port: this.address.port, } as onListenPayload; try { await this.hocuspocus.hooks("onListen", onListenPayload); resolve(this.hocuspocus); } catch (e) { reject(e); } }, ); }); } get address(): AddressInfo { return (this.httpServer.address() || { port: this.configuration.port, address: this.configuration.address, family: "IPv4", }) as AddressInfo; } async destroy(): Promise<any> { await new Promise(async (resolve) => { this.httpServer.close(); try { this.configuration.extensions.push({ async afterUnloadDocument({ instance }) { if (instance.getDocumentsCount() === 0) resolve(""); }, }); this.webSocketServer.close(); if (this.hocuspocus.getDocumentsCount() === 0) resolve(""); this.hocuspocus.closeConnections(); } catch (error) { console.error(error); } }); await this.hocuspocus.hooks("onDestroy", { instance: this.hocuspocus }); } get URL(): string { return `${this.configuration.address}:${this.address.port}`; } get webSocketURL(): string { return `ws://${this.URL}`; } get httpURL(): string { return `http://${this.URL}`; } private showStartScreen() { const name = this.configuration.name ? ` (${this.configuration.name})` : ""; console.log(); console.log( ` ${kleur.cyan(`Hocuspocus v${meta.version}${name}`)}${kleur.green(" running at:")}`, ); console.log(); console.log(` > HTTP: ${kleur.cyan(`${this.httpURL}`)}`); console.log(` > WebSocket: ${this.webSocketURL}`); const extensions = this.configuration?.extensions .map((extension) => { return extension.extensionName ?? extension.constructor?.name; }) .filter((name) => name) .filter((name) => name !== "Object"); if (!extensions.length) { return; } console.log(); console.log(" Extensions:"); extensions.forEach((name) => { console.log(` - ${name}`); }); console.log(); console.log(` ${kleur.green("Ready.")}`); console.log(); } }