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; } export class Webhook implements Extension { configuration: Configuration = { debounce: 2000, debounceMaxWait: 10000, secret: "", transformer: TiptapTransformer, url: "", events: [Events.onChange], }; debounced: Map = new Map(); /** * Constructor */ constructor(configuration?: Partial) { 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, 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}`); } } }