snapshot of https://github.com/ueberdosis/hocuspocus.git @ cf3cfa355b4d3bb61c6b6c80071f71783e32470e (2026-09-15)
Some checks failed
build / lint (22) (push) Failing after 17s
build / test (22) (push) Failing after 8s
build / lint (23) (push) Failing after 2s
build / build (22) (push) Has been skipped
build / build (23) (push) Has been skipped
build / test (23) (push) Failing after 4s
docsearch / docsearch (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-15 17:21:35 +05:00
commit e0c588ca1c
265 changed files with 55259 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
# @hocuspocus/extension-webhook
[![Version](https://img.shields.io/npm/v/@hocuspocus/extension-webhook.svg?label=version)](https://www.npmjs.com/package/@hocuspocus/extension-webhook)
[![Downloads](https://img.shields.io/npm/dm/@hocuspocus/extension-webhook.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@hocuspocus/extension-webhook.svg)](https://www.npmjs.com/package/@hocuspocus/extension-webhook)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
Send HTTP webhooks for [Hocuspocus](https://github.com/ueberdosis/hocuspocus) document lifecycle events — document changes, connects, disconnects, and creation. Lets you integrate the collaboration backend with your own services (search indexing, audit logs, downstream data pipelines) without writing a custom extension.
## Installation
```bash
npm install @hocuspocus/extension-webhook
```
## Usage
```js
import { Server } from "@hocuspocus/server"
import { Webhook, Events } from "@hocuspocus/extension-webhook"
const server = new Server({
extensions: [
new Webhook({
url: "https://example.com/hocuspocus-webhook",
secret: "your-signing-secret",
events: [Events.onChange, Events.onConnect, Events.onDisconnect, Events.onCreate],
}),
],
})
server.listen()
```
Requests are signed with HMAC-SHA256 using your `secret`, sent in the `X-Hocuspocus-Signature-256` header so the receiver can verify authenticity.
`onChange` events are debounced (default `2000ms`, max wait `10000ms`) to avoid a request per keystroke — tune with the `debounce` and `debounceMaxWait` options.
## Documentation
Payload shapes, signature verification, and debounce tuning: [tiptap.dev/docs/hocuspocus/server/extensions/webhook](https://tiptap.dev/docs/hocuspocus/server/extensions/webhook).
## License
MIT — see [LICENSE.md](https://github.com/ueberdosis/hocuspocus/blob/main/LICENSE.md).

View File

@@ -0,0 +1,47 @@
{
"name": "@hocuspocus/extension-webhook",
"version": "4.7.0",
"description": "hocuspocus webhook extension",
"homepage": "https://hocuspocus.dev",
"keywords": [
"hocuspocus",
"webhook",
"api",
"rest",
"yjs"
],
"license": "MIT",
"type": "module",
"main": "dist/hocuspocus-webhook.cjs",
"module": "dist/hocuspocus-webhook.esm.js",
"types": "dist/index.d.ts",
"exports": {
"source": {
"import": "./src/index.ts"
},
"default": {
"import": "./dist/hocuspocus-webhook.esm.js",
"require": "./dist/hocuspocus-webhook.cjs",
"types": "./dist/index.d.ts"
}
},
"files": [
"src",
"dist"
],
"dependencies": {
"@hocuspocus/common": "workspace:^",
"@hocuspocus/server": "workspace:^",
"@hocuspocus/transformer": "workspace:^"
},
"peerDependencies": {
"yjs": "^13.6.8"
},
"gitHead": "b3454a4ca289a84ddfb7fa5607a2d4b8d5c37e9d",
"repository": {
"url": "https://github.com/ueberdosis/hocuspocus"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,227 @@
import { createHmac } from "node:crypto";
import { Forbidden } from "@hocuspocus/common";
import type {
Extension,
onChangePayload,
onConnectPayload,
onDisconnectPayload,
onLoadDocumentPayload,
} from "@hocuspocus/server";
import type { Transformer } from "@hocuspocus/transformer";
import { TiptapTransformer } from "@hocuspocus/transformer";
import type { Doc } from "yjs";
export enum Events {
onChange = "change",
onConnect = "connect",
onCreate = "create",
onDisconnect = "disconnect",
}
export interface Configuration {
debounce: number | false | null;
debounceMaxWait: number;
secret: string;
transformer:
| Transformer
| {
toYdoc: (document: any) => Doc;
fromYdoc: (document: Doc) => any;
};
url: string;
events: Array<Events>;
}
export class Webhook implements Extension {
configuration: Configuration = {
debounce: 2000,
debounceMaxWait: 10000,
secret: "",
transformer: TiptapTransformer,
url: "",
events: [Events.onChange],
};
debounced: Map<string, { timeout: NodeJS.Timeout; start: number }> =
new Map();
/**
* Constructor
*/
constructor(configuration?: Partial<Configuration>) {
this.configuration = {
...this.configuration,
...configuration,
};
if (!this.configuration.url) {
throw new Error("url is required!");
}
}
/**
* Create a signature for the response body
*/
createSignature(body: string): string {
const hmac = createHmac("sha256", this.configuration.secret);
return `sha256=${hmac.update(body).digest("hex")}`;
}
/**
* debounce the given function, using the given identifier
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
debounce(id: string, func: Function) {
const old = this.debounced.get(id);
const start = old?.start || Date.now();
const run = () => {
this.debounced.delete(id);
func();
};
if (old?.timeout) clearTimeout(old.timeout);
if (Date.now() - start >= this.configuration.debounceMaxWait) return run();
this.debounced.set(id, {
start,
timeout: setTimeout(run, <number>this.configuration.debounce),
});
}
/**
* Send a request to the given url containing the given data
*/
async sendRequest(event: Events, payload: any) {
const json = JSON.stringify({ event, payload });
const response = await fetch(this.configuration.url, {
method: "POST",
body: json,
headers: {
"X-Hocuspocus-Signature-256": this.createSignature(json),
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(
`Webhook request to ${this.configuration.url} failed with status ${response.status}`,
);
}
const text = await response.text();
const contentType = response.headers.get("content-type") ?? "";
const data =
contentType.includes("application/json") && text
? JSON.parse(text)
: text;
return { status: response.status, data };
}
/**
* onChange hook
*/
async onChange(data: onChangePayload) {
if (!this.configuration.events.includes(Events.onChange)) {
return;
}
const save = async () => {
try {
await this.sendRequest(Events.onChange, {
document: this.configuration.transformer.fromYdoc(data.document),
documentName: data.documentName,
context: data.context,
requestHeaders: data.requestHeaders,
requestParameters: Object.fromEntries(
data.requestParameters.entries(),
),
});
} catch (e) {
console.error(`Caught error in extension-webhook: ${e}`);
}
};
if (!this.configuration.debounce) {
return save();
}
this.debounce(data.documentName, save);
}
/**
* onLoadDocument hook
*/
async onLoadDocument(data: onLoadDocumentPayload) {
if (!this.configuration.events.includes(Events.onCreate)) {
return;
}
try {
const response = await this.sendRequest(Events.onCreate, {
documentName: data.documentName,
requestHeaders: data.requestHeaders,
requestParameters: Object.fromEntries(data.requestParameters.entries()),
});
const document = response.data;
if (!document) return;
// eslint-disable-next-line guard-for-in,no-restricted-syntax
for (const fieldName in document) {
if (data.document.isEmpty(fieldName)) {
data.document.merge(
this.configuration.transformer.toYdoc(
document[fieldName],
fieldName,
),
);
}
}
} catch (e) {
console.error(`Caught error in extension-webhook: ${e}`);
}
}
/**
* onConnect hook
*/
async onConnect(data: onConnectPayload) {
if (!this.configuration.events.includes(Events.onConnect)) {
return;
}
try {
const response = await this.sendRequest(Events.onConnect, {
documentName: data.documentName,
requestHeaders: data.requestHeaders,
requestParameters: Object.fromEntries(data.requestParameters.entries()),
});
return response.data;
} catch (e) {
console.error(`Caught error in extension-webhook: ${e}`);
throw Forbidden;
}
}
async onDisconnect(data: onDisconnectPayload) {
if (!this.configuration.events.includes(Events.onDisconnect)) {
return;
}
try {
await this.sendRequest(Events.onDisconnect, {
documentName: data.documentName,
requestHeaders: data.requestHeaders,
requestParameters: Object.fromEntries(data.requestParameters.entries()),
context: data.context,
});
} catch (e) {
console.error(`Caught error in extension-webhook: ${e}`);
}
}
}