Source: util/webhooks.js

'use strict';

const { URL } = require('url');
const debug = require('./debug');

const DEFAULT_TIMEOUT_MS = 10_000;

/**
 * Lightweight webhook dispatcher that posts JSON payloads to configured URLs.
 *
 * @param {Object.<string, string[]>} endpointsByEvent
 *  Mapping of event names to arrays of webhook URLs.
 * @param {Object} [options]
 * @param {Function} [options.fetch] override for fetch (defaults to globalThis.fetch)
 * @param {number} [options.timeoutMs] request timeout in milliseconds
 */
class WebHookDispatcher {
  constructor(endpointsByEvent = {}, options = {}) {
    if (!endpointsByEvent || typeof endpointsByEvent !== 'object')
      throw new TypeError('Webhook configuration must be an object keyed by event name.');

    this._fetch = options.fetch || globalThis.fetch;
    if (typeof this._fetch !== 'function')
      throw new TypeError('A fetch implementation must be provided.');

    this._timeoutMs = typeof options.timeoutMs === 'number' ? options.timeoutMs : DEFAULT_TIMEOUT_MS;
    this._logger = options.logger || debug.webhooks;

    this._endpoints = new Map();
    for (const [eventName, urls] of Object.entries(endpointsByEvent)) {
      if (!Array.isArray(urls) || urls.length === 0)
        continue;

      const normalized = urls
        .filter(url => typeof url === 'string' && url.trim().length)
        .map(url => url.trim());

      if (normalized.length)
        this._endpoints.set(eventName, normalized);
    }
  }

  /**
   * Trigger a webhook event and POST the payload to all configured URLs.
   *
   * @param {string} eventName - The webhook event identifier.
   * @param {Object} payload - Payload to serialise as JSON.
   * @param {Object} [headers] - Additional HTTP headers for the request.
   * @returns {Promise<{event: string, deliveries: Array<{url: string, ok: boolean, status: (number|undefined), error: (string|undefined)}>}>}
   *  Summary of delivery attempts (always resolves, never rejects).
   */
  async trigger(eventName, payload, headers = {}) {
    const endpoints = this._endpoints.get(eventName) || [];
    if (!endpoints.length)
      return { event: eventName, deliveries: [] };

    const mergedHeaders = Object.assign({ 'Content-Type': 'application/json' }, headers);

    const deliveries = await Promise.all(endpoints.map(url =>
      this._deliver(url, payload, mergedHeaders)
    ));

    return { event: eventName, deliveries };
  }

  async _deliver(url, payload, headers) {
    const delivery = { url, ok: false };

    try {
      // Validate URL before attempting request to catch obvious misconfiguration.
      // eslint-disable-next-line no-new
      new URL(url);

      const response = await this._fetch(url, {
        method: 'POST',
        headers,
        body: JSON.stringify(payload),
        signal: AbortSignal.timeout(this._timeoutMs)
      });

      delivery.status = response.status;
      delivery.ok = response.ok;

      if (response.ok) {
        this._logger(`Webhook to ${url} succeeded (status ${response.status}).`);
      } else {
        this._logger(`Webhook to ${url} responded with ${response.status}.`);
      }
    } catch (error) {
      delivery.error = error && error.message ? error.message : String(error);
      this._logger(`Webhook to ${url} failed: ${delivery.error}`);
    }

    return delivery;
  }
}

module.exports = WebHookDispatcher;