Extension Webhook
The webhook extension allows you to connect Hocuspocus to your existing application by triggering webhooks on certain events.
Installation
Install the Webhook package with:
npm install @hocuspocus/extension-webhook
Configuration
import { Server } from "@hocuspocus/server";
import { Webhook, Events } from "@hocuspocus/extension-webhook";
import { TiptapTransformer } from "@hocuspocus/transformer";
const server = new Server({
extensions: [
new Webhook({
// [required] url of your application
url: "https://example.com/api/hocuspocus",
// [required] a random string that will be used to verify the request signature
secret: "459824aaffa928e05f5b1caec411ae5f",
// [required] a transformer for your document
transformer: TiptapTransformer,
// [optional] array of events that will trigger a webhook
// defaults to [ Events.onChange ]
events: [Events.onConnect, Events.onCreate, Events.onChange, Events.onDisconnect],
// [optional] time in ms the change event should be debounced,
// defaults to 2000
debounce: 2000,
// [optional] time in ms after that the webhook will be sent
// regardless of the configured debouncing, defaults to 10000
debounceMaxWait: 10000,
}),
],
});
server.listen();
How it works
The webhook extension listens on up to four configurable events/hooks that will trigger a POST request to the configured url.
onConnect
When a new user connects to the server, the onConnect webhook will be triggered with the following payload:
{
"event": "connect",
"payload": {
"documentName": "example-document",
"requestHeaders": {
"Example-Header": "Example"
},
"requestParameters": {
"example": "12345"
}
}
}
You can respond with a JSON payload that will be set as context throughout the rest of the application. For example:
// authorize the user by the request parameters or headers
if (payload.requestParameters?.get("token") !== "secret-api-token") {
response.writeHead(403, "unauthorized");
return response.end();
}
// return context if authorized
response.writeHead(200, { "Content-Type": "application/json" });
response.end(
JSON.stringify({
user: {
id: 1,
name: "Jane Doe",
},
})
);
onCreate
When a new document is created the onCreate webhook will be triggered with the following payload:
{
"event": "create",
"payload": {
"documentName": "example-document"
}
}
You can use this to import a document into Hocuspocus. The webhook extension will first load the document from the primary storage and only import it if it doesn't already exist in there.
Just respond with all the single documents keyed by their field name. For example:
response.writeHead(200, { "Content-Type": "application/json" });
response.end(
JSON.stringify({
// Document for the "secondary" field
secondary: {},
// Document for the "default" field
default: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "What is love?",
},
],
},
],
},
})
);
onChange
When a document is changed the onChange webhook will be triggered with the following payload including the context you set before:
{
"event": "change",
"payload": {
"documentName": "example-document",
"document": {
"another-field-name": {},
"field-name": {
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "What is love?"
}
]
}
]
}
},
"context": {
"user_id": 1,
"name": "Jane Doe"
}
}
}
Because this happens on every keystroke up to multiple times a second, the webhook is debounced by default. You can
configure this (or shut it off entirely) with the debounce
and debounceMaxWait
configuration options.
onDisconnect
When a user disconnects the onDisconnect webhook will be triggered with the following payload:
{
"event": "disconnect",
"payload": {
"documentName": "example-document",
"context": {
"user_id": 1,
"name": "Jane Doe"
}
}
}
Transformation
The Y-Doc must be serialized into something readable by your application and when importing a document it must be converted into a Y-Doc respectively.
Because Hocuspocus doesn't know how your data is structured, you need to pass a transformer to the Webhook extension.
You can use one of the transformers from the @hocuspocus/transformer
package. Make sure to configure them properly.
In this example we used the TiptapTransformer that needs the list of extensions:
import { Server } from "@hocuspocus/server";
import { Webhook } from "@hocuspocus/extension-webhook";
import { TiptapTransformer } from "@hocuspocus/transformer";
import Document from "@tiptap/extension-document";
import Paragraph from "@tiptap/extension-paragraph";
import Text from "@tiptap/extension-text";
const server = new Server({
extensions: [
new Webhook({
url: "https://example.com/api/webhook",
secret: "459824aaffa928e05f5b1caec411ae5f",
transformer: TiptapTransformer.extensions([Document, Paragraph, Text]),
}),
],
});
server.listen();
Alternatively you can write your own implementation by simply passing functions that convert a Y-Doc to your representation and vice versa:
import { Server } from "@hocuspocus/server";
import { Webhook } from "@hocuspocus/extension-webhook";
import { Doc } from "yjs";
const server = new Server({
extensions: [
new Webhook({
url: "https://example.com/api/webhook",
secret: "459824aaffa928e05f5b1caec411ae5f",
transformer: {
toYdoc(document: any, fieldName: string): Doc {
// convert the given document (from your api) to a ydoc using the provided fieldName
return new Doc();
},
fromYdoc(document: Doc): any {
// convert the ydoc to your representation
return document.toJSON();
},
},
}),
],
});
server.listen();
Verify Request Signature
On your application server you should verify the signature coming from the webhook extension to secure the route.
The extension sends POST requests, and the signature is stored in the X-Hocuspocus-Signature-256
header containing a
message authentication code created with sha256.
Here are some examples how you could do that in different languages:
PHP
use Symfony\Component\HttpFoundation\Request;
function verifySignature(Request $request) {
$secret = '459824aaffa928e05f5b1caec411ae5f';
if (($signature = $request->headers->get('X-Hocuspocus-Signature-256')) == null) {
throw new Exception('Header not set');
}
$parts = explode('=', $signature);
if (count($parts) != 2) {
throw new Exception('Invalid signature format');
}
$digest = hash_hmac('sha256', $request->getContent(), $secret);
return hash_equals($digest, $parts[1]);
}
JavaScript
import { IncomingMessage } from 'http'
const secret = '459824aaffa928e05f5b1caec411ae5f'
const verifySignature = (request: IncomingMessage): boolean => {
const signature = Buffer.from(request.headers['x-hocuspocus-signature-256'] as string)
const hmac = createHmac('sha256', secret)
const digest = Buffer.from(`sha256=${hmac.update(body).digest('hex')}`)
return signature.length !== digest.length || timingSafeEqual(digest, signature)
}