Hooks

Introduction

Hocuspocus offers hooks to extend its functionality and integrate it into existing applications. Hooks are configured as simple methods the same way as other configuration options are.

Hooks accept a hook payload as first argument. The payload is an object that contains data you can use and manipulate, allowing you to built complex things on top of this simple mechanic, like extensions.

Hooks are required to return a Promise; the easiest way to do that is to mark the function as async (Node.js version must 14+). In this way, you can do things like executing API requests, running DB queries, trigger webhooks or whatever you need to do to integrate it into your application.

Lifecycle

Hooks will be called on different stages of the Hocuspocus lifecycle. For example the onListen hook will be called when you call the listen() method on the server instance.

Some hooks allow you not only to react to those events but also to intercept them. For example the onConnect hook will be fired when a new connection is made to underlying websocket server. By rejecting the Promise in your hook (or throwing an empty exception if using async) you can terminate the connection and stop the chain.

The hook chain

Extensions use hooks to add additional functionality to Hocuspocus. They will be called one after another in the order of their registration with your configuration as the last part of the chain.

If the Promise in a hook is rejected it will not be called for the following extensions or your configuration. It's like a stack of middlewares a request has to go through. Keep that in mind when working with hooks.

By way of illustration, if a user isn’t allowed to connect: Just throw an error in the onAuthenticate() hook. Nice, isn’t it?

Summary Table

Hook Description Link
beforeHandleMessage Before handling a message Read more
onConnect When a connection is established Read more
connected After a connection has been establied Read more
onAuthenticate When authentication is required Read more
onAwarenessUpdate When awareness changed Read more
onLoadDocument During the creation of a new document Read more
afterLoadDocument After a document is created Read more
onChange When a document has changed Read more
onDisconnect When a connection was closed Read more
onListen When the server is initialized Read more
onDestroy When the server will be destroyed Read more
onConfigure When the server has been configured Read more
onRequest When a HTTP request comes in Read more
onStoreDocument When a document has been changed Read more
onUpgrade When the WebSocket connection is upgraded Read more
onStateless When the Stateless message is received Read more
beforeBroadcastStateless Before broadcast a stateless message Read more
afterUnloadDocument When a document is closed Read more

Usage

import { Server } from "@hocuspocus/server";

const server = Server.configure({
  async onAuthenticate({ documentName, token }) {
    // Could be an API call, DB query or whatever …
    // The endpoint should return 200 OK in case the user is authenticated, and an http error
    // in case the user is not.
    return axios.get("/user", {
      headers: {
        Authorization: `Bearer ${token}`,
      },
    });
  },
});

server.listen();

Hooks

beforeHandleMessage

The beforeHandleMessage hooks are called when a message was received by the server, directly before handling / applying it. The hook can be used to reject a message (e.g. if the authentication token has expired), or even to check the update message and reject / accept it based on custom rules. If you throw an error in the hook, the connection will be closed. You can return a custom code / reason by throwing an error that implements CloseEvent (see example below).

Hook payload

The data passed to the beforeHandleMessage hook has the following attributes:

import { IncomingHttpHeaders } from "http";
import { URLSearchParams } from "url";
import { Doc } from "yjs";
import { CloseEvent } from "@hocuspocus/common";

const data = {
  clientsCount: number,
  context: any,
  document: Doc,
  documentName: string,
  instance: Hocuspocus,
  requestHeaders: IncomingHttpHeaders,
  requestParameters: URLSearchParams,
  update: Uint8Array,
  socketId: string,
};

Context contains the data provided in former onConnect hooks.

Example

import { debounce } from "debounce";
import { Server } from "@hocuspocus/server";
import { TiptapTransformer } from "@hocuspocus/transformer";
import { writeFile } from "fs";

let debounced;

const server = Server.configure({
  beforeHandleMessage(data) {
    if (data.context.tokenExpiresAt <= new Date()) {
      const error: CloseEvent = {
        reason: "Token expired",
      };

      throw error;
    }
  },
});

server.listen();

connected

The connected hooks are called after a new connection has been successfully established.

Example

import { Server } from "@hocuspocus/server";

const server = Server.configure({
  async connected() {
    console.log("connections:", server.getConnectionsCount());
  },
});

server.listen();

onAuthenticate

The onAuthenticate hook will be called when the server receives an authentication request from the client provider. It should return a Promise. Throwing an exception or rejecting the Promise will terminate the connection.

Be aware, the onAuthenticate hook will only be called after the client has sent the Auth message, which won't happen if there is no token provided to HocuspocusProvider.

Hook payload

The data passed to the onAuthenticate hook has the following attributes:

import { IncomingHttpHeaders, IncomingMessage } from "http";
import { URLSearchParams } from "url";
import { Doc } from "yjs";

const data = {
  documentName: string,
  instance: Hocuspocus,
  requestHeaders: IncomingHttpHeaders,
  requestParameters: URLSearchParams,
  request: IncomingMessage,
  socketId: string,
  token: string,
  connection: {
    readOnly: boolean,
  },
};

Example

import { Server } from "@hocuspocus/server";

const server = Server.configure({
  async onAuthenticate(data) {
    const { token } = data;

    // Example test if a user is authenticated using a
    // request parameter
    if (token !== "super-secret-token") {
      throw new Error("Not authorized!");
    }

    // Example to set a document to read only for the current user
    // thus changes will not be accepted and synced to other clients
    if (someCondition === true) {
      data.connection.readOnly = true;
    }

    // You can set contextual data to use it in other hooks
    return {
      user: {
        id: 1234,
        name: "John",
      },
    };
  },
});

server.listen();

Disabling authentication for some users

Once The onAuthenticate hooks are configured, the server will wait for the authentication WebSocket message. If you want to override that behaviour (for some users), you can manually do that in the onConnect hook.

import { Server } from "@hocuspocus/server";

const server = Server.configure({
  async onConnect({ connection }) {
    connection.requiresAuthentication = false;
  },
  async onAuthenticate() {
    // Danger! This won’t be called for that connection attempt.
  },
}).listen();

onAwarenessUpdate

The onAwarenessUpdate hooks are called when awareness changed (Provider Awareness API).

Hook payload

The data passed to the onAwarenessUpdate hook has the following attributes:

import { IncomingHttpHeaders } from 'http'
import { URLSearchParams } from 'url'
import { Awareness } from 'y-protocols/awareness'

const data = {
  clientsCount: number,
  context: any,
  document: Document,
  documentName: string,
  instance: Hocuspocus,
  requestHeaders: IncomingHttpHeaders,
  requestParameters: URLSearchParams,
  update: Uint8Array,
  socketId: string,
  added: number[],
  updated: number[],
  removed: number[],
  awareness: Awareness,
  states: { clientId: number, [key: string | number]: any }[],

}

Example

const provider = new HocuspocusProvider({
  url: "ws://127.0.0.1:1234",
  name: "example-document",
  document: ydoc,
  onAwarenessUpdate: ({ states }) => {
    currentStates = states;
  },
});

onChange

The onChange hooks are called when the document itself has changed. It should return a Promise.

It's important to understand that this hook is called just once per document. You can use it to react to changes by a specific connection, because we're passing context and update in the payload (see below).

It's highly recommended to debounce extensive operations as this hook can be fired up to multiple times a second.

Hook payload

The data passed to the onChange hook has the following attributes:

import { IncomingHttpHeaders } from "http";
import { URLSearchParams } from "url";
import { Doc } from "yjs";

const data = {
  clientsCount: number,
  context: any,
  document: Doc,
  documentName: string,
  instance: Hocuspocus,
  requestHeaders: IncomingHttpHeaders,
  requestParameters: URLSearchParams,
  update: Uint8Array,
  socketId: string,
};

Context contains the data provided in former onConnect hooks.

Example

Use a primary storage

The following example is not intended to be your primary storage as serializing to and deserializing from JSON will not store collaboration history steps but only the resulting document. This example is only meant to store the resulting document for the views of your application. For a primary storage, check out the Database extension.

import { debounce } from "debounce";
import { Server } from "@hocuspocus/server";
import { TiptapTransformer } from "@hocuspocus/transformer";
import { writeFile } from "fs";

let debounced;

const server = Server.configure({
  async onChange(data) {
    const save = () => {
      // Convert the y-doc to something you can actually use in your views.
      // In this example we use the TiptapTransformer to get JSON from the given
      // ydoc.
      const prosemirrorJSON = TiptapTransformer.fromYdoc(data.document);

      // Save your document. In a real-world app this could be a database query
      // a webhook or something else
      writeFile(`/path/to/your/documents/${data.documentName}.json`, prosemirrorJSON);

      // Maybe you want to store the user who changed the document?
      // Guess what, you have access to your custom context from the
      // onConnect hook here. See authorization & authentication for more
      // details
      console.log(`Document ${data.documentName} changed by ${data.context.user.name}`);
    };

    debounced?.clear();
    debounced = debounce(save, 4000);
    debounced();
  },
});

server.listen();

onConfigure

The onConfigure hooks are called after the server was configured using the configure method. It should return a Promise.

Default configuration

If configure() is never called, you can get the default configuration by importing it:

import { defaultConfiguration } from "@hocuspocus/server";

Hook payload

The data passed to the onConfigure hook has the following attributes:

import { Configuration } from "@hocuspocus/server";

const data = {
  configuration: Configuration,
  version: string,
  instance: Hocuspocus,
};

Example

import { Server } from "@hocuspocus/server";

const server = Server.configure({
  async onConfigure(data) {
    // Output some information
    console.log(`Server was configured!`);
  },
});

server.listen();

onConnect

The onConnect hook will be called when a new connection is established. It should return a Promise. Throwing an exception or rejecting the Promise will terminate the connection.

Hook payload

The data passed to the onConnect hook has the following attributes:

import { IncomingHttpHeaders } from "http";
import { URLSearchParams } from "url";
import { Doc } from "yjs";

const data = {
  documentName: string,
  instance: Hocuspocus,
  request: IncomingMessage,
  requestHeaders: IncomingHttpHeaders,
  requestParameters: URLSearchParams,
  socketId: string,
  connection: {
    readOnly: boolean,
  },
};

Example

import { Server } from "@hocuspocus/server";

const server = Server.configure({
  async onConnect(data) {
    // Output some information
    console.log(`New websocket connection`);
  },
});

server.listen();

onDestroy

The onDestroy hooks are called after the server was shut down using the destroy method. It should return a Promise.

Hook payload

The data passed to the onDestroy hook has the following attributes:

const data = {
  instance: Hocuspocus,
};

Example

import { Server } from "@hocuspocus/server";

const server = Server.configure({
  async onDestroy(data) {
    // Output some information
    console.log(`Server was shut down!`);
  },
});

server.listen();

onDisconnect

The onDisconnect hooks are called when a connection is terminated. It should return a Promise.

Hook payload

The data passed to the onDisconnect hook has the following attributes:

import { IncomingHttpHeaders } from "http";
import { URLSearchParams } from "url";
import { Doc } from "yjs";

const data = {
  clientsCount: number,
  context: any,
  document: Document,
  documentName: string,
  instance: Hocuspocus,
  requestHeaders: IncomingHttpHeaders,
  requestParameters: URLSearchParams,
  socketId: string,
};

Context contains the data provided in former onConnect hooks.

Example

import { Server } from "@hocuspocus/server";

const server = Server.configure({
  async onDisconnect(data) {
    // Output some information
    console.log(`"${data.context.user.name}" has disconnected.`);
  },
});

server.listen();

onListen

The onListen hooks are called after the server is started and accepts connections. It should return a Promise.

Hook payload

The data passed to the onListen hook has the following attributes:

const data = {
  port: number,
};

Example

import { Server } from "@hocuspocus/server";

const server = Server.configure({
  async onListen(data) {
    // Output some information
    console.log(`Server is listening on port "${data.port}"!`);
  },
});

server.listen();

onLoadDocument

The onLoadDocument hooks are called to fetch existing data from your storage. You are probably used to loading some JSON/HTML document in your application, but that’s not the Y.js-way. For Y.js to work properly, we’ll need to store the history of changes. Only then changes from multiple sources can be merged.

You still can store a JSON/HTML document, but see it more as a “view” on your data, not as your data source.

Create a Y.js document from JSON/HTML (once)

You can create a Y.js document from your existing data, for example JSON. You should use this to migrate data only, not as a permanent way to store your data.

To do this, you can use the Transformer package. For Tiptap-compatible JSON it would look like this:

import { Server } from "@hocuspocus/server";
import { TiptapTransformer } from "@hocuspocus/transformer";
import Document from "@tiptap/extension-document";
import Paragraph from "@tiptap/extension-paragraph";
import Text from "@tiptap/extension-text";

const ydoc = TiptapTransformer.toYdoc(
  // the actual JSON
  json,
  // the `field` you’re using in Tiptap. If you don’t know what that is, use 'default'.
  "default",
  // The Tiptap extensions you’re using. Those are important to create a valid schema.
  [Document, Paragraph, Text]
);

If you want to import HTML, you have to convert it to Tiptap-compatible JSON first

However, we expect you to return a Y.js document from the onLoadDocument hook, no matter where it’s from.

import { Server } from '@hocuspocus/server'

const server = Server.configure({
  async onLoadDocument(data) {
    // fetch the Y.js document from somewhere
    const ydoc =

    return ydoc
  },
})

server.listen()

Fetch your Y.js documents (recommended)

There are multiple ways to store your Y.js documents (and their history) wherever you like. Basically, you should use the onStoreDocument hook, which is debounced and executed every few seconds for changed documents. It gives you the current Y.js document and it’s up to you to store that somewhere. No worries, we provide some convenient ways for you.

If you just want to to get it working, have a look at the SQLite extension for local development, and the generic Database extension for a convenient way to fetch and store documents.

Hook payload

The data passed to the onLoadDocument hook has the following attributes:

import { Doc } from "yjs";

const data = {
  context: any,
  document: Doc,
  documentName: string,
  instance: Hocuspocus,
  requestHeaders: IncomingHttpHeaders,
  requestParameters: URLSearchParams,
  socketId: string,
};

Context contains the data provided in former onConnect hooks.

afterLoadDocument

The afterLoadDocument hooks are called after a document is successfully loaded. This is different to the onLoadDocument hooks which are part of the document creation process and could potentially fail if for instance the document cannot be found in the database.

Because afterLoadDocument only runs after all onLoadDocument hooks are successful at this point you know the document is considered open on the server.

Hook payload

The data passed to the afterLoadDocument hook has the following attributes:

import { Doc } from "yjs";

const data = {
  context: any,
  document: Doc,
  documentName: string,
  instance: Hocuspocus,
  requestHeaders: IncomingHttpHeaders,
  requestParameters: URLSearchParams,
  socketId: string,
};

onRequest

The onRequest hooks are called when the HTTP server inside Hocuspocus receives a new request. It should return a Promise. If you throw an empty exception or reject the returned Promise the following hooks in the chain will not run and thus enable you to respond to the request yourself. It's similar to the concept of request middlewares.

This is useful if you want to create custom routes on the same port Hocuspocus runs on.

Hook payload

The data passed to the onRequest hook has the following attributes:

import { IncomingMessage, ServerResponse } from "http";

const data = {
  request: IncomingMessage,
  response: ServerResponse,
  instance: Hocuspocus,
};

Example

import { Server } from "@hocuspocus/server";

const server = Server.configure({
  onRequest(data) {
    return new Promise((resolve, reject) => {
      const { request, response } = data;

      // Check if the request hits your custom route
      if (request.url?.split("/")[1] === "custom-route") {
        // Respond with your custom content
        response.writeHead(200, { "Content-Type": "text/plain" });
        response.end("This is my custom response, yay!");

        // Rejecting the promise will stop the chain and no further
        // onRequest hooks are run
        return reject();
      }

      resolve();
    });
  },
});

server.listen();

onStoreDocument

The onStoreDocument hooks are called after the document has been changed (after the onChange hook) and can be used to store the changed document to a persistent storage. Calls to onStoreDocument are debounced by default (see debounce and maxDebounce configuration options).

The easiest way to implement this functionality is by extending the extension extension-database and implementing fetch() and store() methods, as we did that in extension-sqlite. You can implement the onStoreDocument yourself with the hook directly, just make sure to apply / encode the states of the yDoc as we did in extension-database.

Hook payload

The data passed to the onStoreDocument hook has the following attributes:

import { IncomingHttpHeaders } from "http";
import { URLSearchParams } from "url";
import { Doc } from "yjs";

const data = {
  clientsCount: number,
  context: any,
  document: Doc,
  documentName: string,
  instance: Hocuspocus,
  requestHeaders: IncomingHttpHeaders,
  requestParameters: URLSearchParams,
  socketId: string,
};

onUpgrade

The onUpgrade hooks are called when the HTTP server inside Hocuspocus receives a new upgrade request. It should return a Promise. If you throw an empty exception or reject the returned Promise the following hooks in the chain will not run and thus enable you to respond and upgrade the request yourself. It's similar to the concept of request middlewares.

This is useful if you want to create custom websocket routes on the same port Hocuspocus runs on.

Hook payload

The data passed to the onUpgrade hook has the following attributes:

import { IncomingMessage } from "http";
import { Socket } from "net";

const data = {
  head: any,
  request: IncomingMessage,
  socket: Socket,
  instance: Hocuspocus,
};

Example

import { Server } from "@hocuspocus/server";
import WebSocket, { WebSocketServer } from "ws";

const server = Server.configure({
  onUpgrade(data) {
    return new Promise((resolve, reject) => {
      const { request, socket, head } = data;

      // Check if the request hits your custom route
      if (request.url?.split("/")[1] === "custom-route") {
        // Create your own websocket server to upgrade the request, make
        // sure noServer is set to true, because we're handling the upgrade
        // ourselves
        const websocketServer = new WebSocketServer({ noServer: true });
        websocketServer.on("connection", (connection: WebSocket, request: IncomingMessage) => {
          // Put your application logic here to respond to new connections
          // and subscribe to incoming messages
          console.log("A new connection to our websocket server!");
        });

        // Handle the upgrade request within your own websocket server
        websocketServer.handleUpgrade(request, socket, head, (ws) => {
          websocketServer.emit("connection", ws, request);
        });

        // Rejecting the promise will stop the chain and no further
        // onUpgrade hooks are run
        return reject();
      }

      resolve();
    });
  },
});

server.listen();

onStateless

The onStateless hooks are called after the server has received a stateless message. It should return a Promise.

Hook payload

The data passed to the onListen hook has the following attributes:

const data = {
  connection: Connection,
  documentName: string,
  document: Document,
  payload: string,
}

Example

import { Server } from '@hocuspocus/server'

const server = Server.configure({
  async onStateless({ payload, document, connection }) {
    // Output some information
    console.log(`Server has received a stateless message "${payload}"!`)
    // Broadcast a stateless message to all connections based on document
    document.broadcastStateless('This is a broadcast message.')
    // Send a stateless message to a specific connection
    connection.sendStateless('This is a specific message.')
  },
})

server.listen()

beforeBroadcastStateless

The beforeBroadcastStateless hooks are called before the server broadcast a stateless message.

Hook payload

The data passed to the beforeBroadcastStateless hook has the following attributes:

import { Doc } from 'yjs'

const data = {
  documentName: string,
  document: Doc,
  payload: string,
}

Example

import { Server } from '@hocuspocus/server'

const server = Server.configure({
  beforeBroadcastStateless({ payload }) {
    console.log(`Server will broadcast a stateless message: "${payload}"!`)
  },
})

server.listen()

afterUnloadDocument

The afterUnloadDocument hooks are called after a document was closed on the server. You can no longer access the document at this point as it has been destroyed but you may notify anything that was subscribed to the document.

Note: afterUnloadDocument may be called even if afterLoadDocument never was for a given document as an extension may have aborted the loading of the document during the onLoadDocument phase.

Hook payload

The data passed to the onDestroy hook has the following attributes:

const data = {
  instance: Hocuspocus,
  documentName: string,
};

Example

import { Server } from "@hocuspocus/server";

const server = Server.configure({
  async afterUnloadDocument(data) {
    // Output some information
    console.log(`Document ${data.documentName} was closed`);
  },
});

server.listen();