| 1 | import { checkPublicUrl } from "../imports/options.js"; |
| 2 | import { hmacSha256Hex } from "./util.js"; |
| 3 | |
| 4 | /** One webhook delivery; failed ones go back on the events queue with a delay. */ |
| 5 | export interface WebhookRetry { |
| 6 | type: "webhook_retry"; |
| 7 | repoId: string; |
| 8 | hookId: string; |
| 9 | event: string; |
| 10 | diffineryEvent: string; |
| 11 | body: string; |
| 12 | delivery: string; |
| 13 | /** Attempts made so far. */ |
| 14 | attempt: number; |
| 15 | } |
| 16 | |
| 17 | export interface WebhookDeps { |
| 18 | /** The hook as it is now (it may have been deleted, disabled or re-keyed), or null. */ |
| 19 | hook(repoId: string, hookId: string): Promise<{ id: string; url: string; secret: string } | null>; |
| 20 | /** Diffinery's own host name: deliveries never go there. */ |
| 21 | ownHost?: string; |
| 22 | /** A Worker on this machine only (auth/dev.ts localOnly): deliveries may go to this machine, for the end-to-end tests. */ |
| 23 | local?: boolean; |
| 24 | record(repoId: string, hookId: string, status: number): Promise<void>; |
| 25 | requeue(r: WebhookRetry, delaySeconds: number): Promise<void>; |
| 26 | fetch?: typeof fetch; |
| 27 | } |
| 28 | |
| 29 | // Delays after the first, second, ... failed attempt: about five hours in all. |
| 30 | export const WEBHOOK_RETRY_DELAYS = [60, 300, 1800, 3600, 3 * 3600]; |
| 31 | |
| 32 | /** Network errors, timeouts, rate limits and server errors are worth another try. */ |
| 33 | export const retryable = (status: number) => status === 0 || status === 408 || status === 429 || status >= 500; |
| 34 | |
| 35 | /** Send one delivery (to `known` when the caller already has the hook) and schedule a retry if it fails. */ |
| 36 | export async function deliverWebhook(deps: WebhookDeps, r: WebhookRetry, known?: { id: string; url: string; secret: string }): Promise<number | null> { |
| 37 | const h = known ?? (await deps.hook(r.repoId, r.hookId)); |
| 38 | if (!h) return null; |
| 39 | // A hook saved before URLs were checked may point at an internal or Diffinery's own address: never deliver there. |
| 40 | try { |
| 41 | checkPublicUrl(h.url, !!deps.local, "webhook URL", deps.local ? undefined : deps.ownHost); |
| 42 | } catch { |
| 43 | await deps.record(r.repoId, h.id, 0); |
| 44 | return 0; |
| 45 | } |
| 46 | let status = 0; |
| 47 | try { |
| 48 | const res = await (deps.fetch ?? fetch)(h.url, { |
| 49 | method: "POST", |
| 50 | headers: { |
| 51 | "content-type": "application/json", |
| 52 | "user-agent": "Diffinery-Hookshot/1", |
| 53 | "x-github-event": r.event, |
| 54 | "x-github-delivery": r.delivery, |
| 55 | "x-diffinery-event": r.diffineryEvent, |
| 56 | "x-hub-signature-256": `sha256=${await hmacSha256Hex(h.secret, r.body)}`, |
| 57 | }, |
| 58 | body: r.body, |
| 59 | redirect: "manual", |
| 60 | signal: AbortSignal.timeout(10_000), |
| 61 | }); |
| 62 | status = res.status; |
| 63 | } catch { |
| 64 | status = 0; |
| 65 | } |
| 66 | await deps.record(r.repoId, h.id, status); |
| 67 | const delay = WEBHOOK_RETRY_DELAYS[r.attempt]; |
| 68 | if (retryable(status) && delay !== undefined) await deps.requeue({ ...r, attempt: r.attempt + 1 }, delay); |
| 69 | return status; |
| 70 | } |