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:
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