Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

68 lines · 2.6 KBRaw History
1import { checkPublicUrl } from "../imports/options.js";
2import { hmacSha256Hex } from "./util.js";
3
4/** One webhook delivery; failed ones go back on the events queue with a delay. */
5export 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
17export 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 record(repoId: string, hookId: string, status: number): Promise<void>;
23 requeue(r: WebhookRetry, delaySeconds: number): Promise<void>;
24 fetch?: typeof fetch;
25}
26
27// Delays after the first, second, ... failed attempt: about five hours in all.
28export const WEBHOOK_RETRY_DELAYS = [60, 300, 1800, 3600, 3 * 3600];
29
30/** Network errors, timeouts, rate limits and server errors are worth another try. */
31export const retryable = (status: number) => status === 0 || status === 408 || status === 429 || status >= 500;
32
33/** Send one delivery (to `known` when the caller already has the hook) and schedule a retry if it fails. */
34export async function deliverWebhook(deps: WebhookDeps, r: WebhookRetry, known?: { id: string; url: string; secret: string }): Promise<number | null> {
35 const h = known ?? (await deps.hook(r.repoId, r.hookId));
36 if (!h) return null;
37 // A hook saved before URLs were checked may point at an internal or Diffinery's own address: never deliver there.
38 try {
39 checkPublicUrl(h.url, false, "webhook URL", deps.ownHost);
40 } catch {
41 await deps.record(r.repoId, h.id, 0);
42 return 0;
43 }
44 let status = 0;
45 try {
46 const res = await (deps.fetch ?? fetch)(h.url, {
47 method: "POST",
48 headers: {
49 "content-type": "application/json",
50 "user-agent": "Diffinery-Hookshot/1",
51 "x-github-event": r.event,
52 "x-github-delivery": r.delivery,
53 "x-diffinery-event": r.diffineryEvent,
54 "x-hub-signature-256": `sha256=${await hmacSha256Hex(h.secret, r.body)}`,
55 },
56 body: r.body,
57 redirect: "manual",
58 signal: AbortSignal.timeout(10_000),
59 });
60 status = res.status;
61 } catch {
62 status = 0;
63 }
64 await deps.record(r.repoId, h.id, status);
65 const delay = WEBHOOK_RETRY_DELAYS[r.attempt];
66 if (retryable(status) && delay !== undefined) await deps.requeue({ ...r, attempt: r.attempt + 1 }, delay);
67 return status;
68}