Webhooks(网络钩子)

版本下载量许可聊天

Webhook 扩展允许你通过在特定事件触发 Webhook,将 Hocuspocus 连接到你现有的应用程序。

安装

使用以下命令安装 Webhook 包:

npm install @hocuspocus/extension-webhook

配置

import { Server } from "@hocuspocus/server";
import { Webhook, Events } from "@hocuspocus/extension-webhook";
import { TiptapTransformer } from "@hocuspocus/transformer";

const server = new Server({
  extensions: [
    new Webhook({
      // [必填] 你的应用程序 URL
      url: "https://example.com/api/hocuspocus",

      // [必填] 用于验证请求签名的随机字符串
      secret: "459824aaffa928e05f5b1caec411ae5f",

      // [必填] 你的文档转换器
      transformer: TiptapTransformer,

      // [可选] 触发 webhook 的事件数组
      // 默认为 [ Events.onChange ]
      events: [Events.onConnect, Events.onCreate, Events.onChange, Events.onDisconnect],

      // [可选] change 事件的防抖时间(ms)
      // 默认为 2000
      debounce: 2000,

      // [可选] 无论防抖情况,最大等待时间(ms)后发送 webhook
      // 默认为 10000
      debounceMaxWait: 10000,
    }),
  ],
});

server.listen();

工作原理

Webhook 扩展监听最多四个可配置事件/钩子,当事件触发时,向配置的 URL 发送 POST 请求。

onConnect

当新用户连接服务器时,onConnect webhook 会被触发,携带以下负载:

{
  "event": "connect",
  "payload": {
    "documentName": "example-document",
    "requestHeaders": {
      "Example-Header": "Example"
    },
    "requestParameters": {
      "example": "12345"
    }
  }
}

你可以返回一个 JSON 负载,该负载将在整个应用程序中作为上下文使用。例如:

// 根据请求参数或头信息授权用户
if (payload.requestParameters?.get("token") !== "secret-api-token") {
  response.writeHead(403, "unauthorized");
  return response.end();
}

// 授权成功则返回上下文
response.writeHead(200, { "Content-Type": "application/json" });
response.end(
  JSON.stringify({
    user: {
      id: 1,
      name: "Jane Doe",
    },
  })
);

onCreate

当新文档被创建时,onCreate webhook 会被触发,携带以下负载:

{
  "event": "create",
  "payload": {
    "documentName": "example-document"
  }
}

你可以利用此事件将文档导入 Hocuspocus。Webhook 扩展首先会从主存储加载文档,且仅在文档不存在时导入它。

只需返回以字段名键控的所有单个文档。例如:

response.writeHead(200, { "Content-Type": "application/json" });
response.end(
  JSON.stringify({
    // “secondary” 字段的文档
    secondary: {},
    // “default” 字段的文档
    default: {
      type: "doc",
      content: [
        {
          type: "paragraph",
          content: [
            {
              type: "text",
              text: "What is love?",
            },
          ],
        },
      ],
    },
  })
);

onChange

当文档被更改时,onChange webhook 会被触发,携带包含你先前设置上下文的以下负载:

{
  "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"
    }
  }
}

由于该事件可能在每次敲击键盘时触发多次,默认启用了防抖处理。你可以通过 debouncedebounceMaxWait 配置项调整甚至关闭该功能。

onDisconnect

当用户断开连接时,onDisconnect webhook 会被触发,负载如下:

{
  "event": "disconnect",
  "payload": {
    "documentName": "example-document",
    "context": {
      "user_id": 1,
      "name": "Jane Doe"
    }
  }
}

转换

Y-Doc 必须被序列化成你的应用可读的格式,导入文档时也必须相应地转换回 Y-Doc。

由于 Hocuspocus 无法预知你的数据结构,你需要向 Webhook 扩展传入转换器。你可以使用 @hocuspocus/transformer 包中的转换器。请确保正确配置它们。在此示例中,我们用到了需要扩展列表的 TiptapTransformer:

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();

或者你也可以自行实现转换函数,通过传入将 Y-Doc 转换为你数据表示以及反向转换的方法:

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 {
          // 使用给定的文档(来自你的 API)和字段名转换为 Y-Doc
          return new Doc();
        },
        fromYdoc(document: Doc): any {
          // 将 Y-Doc 转换为你的表示形式
          return document.toJSON();
        },
      },
    }),
  ],
});

server.listen();

验证请求签名

在你的应用服务器上,你应验证来自 webhook 扩展的签名以保证路由安全。

扩展发送 POST 请求,签名存储在 X-Hocuspocus-Signature-256 头,包含用 sha256 生成的消息认证码。

下面是不同语言中如何进行验证的示例:

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)
}