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
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:
1666
packages/extension-webhook/CHANGELOG.md
Normal file
1666
packages/extension-webhook/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
44
packages/extension-webhook/README.md
Normal file
44
packages/extension-webhook/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# @hocuspocus/extension-webhook
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-webhook)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@hocuspocus/extension-webhook)
|
||||
[](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).
|
||||
47
packages/extension-webhook/package.json
Normal file
47
packages/extension-webhook/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
227
packages/extension-webhook/src/index.ts
Normal file
227
packages/extension-webhook/src/index.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user