1
19import { assertPublicUrl } from "@factory/shared";
20import { RetryableError, FatalError } from "./classify.js";
21import { interpolateDeep, runWithTimeout } from "./helpers.js";
22import type { ActionExecutor, ActionResult } from "./types.js";
23
24const MAX_REDIRECTS = 3;
25const TIMEOUT_MS = 30_000;
26
27const HOP_HEADERS = new Set([
28 "connection",
29 "keep-alive",
30 "proxy-authenticate",
31 "proxy-authorization",
32 "te",
33 "trailers",
34 "transfer-encoding",
35 "upgrade",
36 "host",
37 "content-length"
38]);
39
40function hopHeaders(headers: Record<string, string>, location: URL): Record<string, string> {
41 const out: Record<string, string> = {};
42 for (const [k, v] of Object.entries(headers)) {
43 if (!HOP_HEADERS.has(k.toLowerCase())) out[k] = v;
44 }
45 out.host = location.host;
46 return out;
47}
48
49async function post(
50 url: string,
51 headers: Record<string, string>,
52 body: string,
53 redirectsLeft: number
54): Promise<{ status: number; body: string }> {
55 const res = await fetch(url, {
56 method: "POST",
57 headers: {
58 "content-type": "application/json",
59 ...headers,
60 accept: "application/json, */*"
61 },
62 body,
63 redirect: "manual",
64 signal: AbortSignal.timeout(TIMEOUT_MS)
65 });
66
67
68 if (res.status >= 300 && res.status < 400 && res.headers.get("location")) {
69 if (redirectsLeft <= 0) throw new FatalError("WEBHOOK_TOO_MANY_REDIRECTS", `webhook exceeded ${MAX_REDIRECTS} redirects`);
70 const next = new URL(res.headers.get("location")!, url);
71 await res.body?.cancel();
72 await assertPublicUrl(next.toString());
73 return post(next.toString(), hopHeaders(headers, next), body, redirectsLeft - 1);
74 }
75
76 const text = await res.text();
77 return { status: res.status, body: text.slice(0, 4096) };
78}
79
80const executor: ActionExecutor = {
81 type: "SEND_WEBHOOK",
82 async execute(cfg, ctx, deps): Promise<ActionResult> {
83 const warnings: string[] = [];
84 const url = cfg.url as string;
85
86 await assertPublicUrl(url);
87
88 const headers = (cfg.headers ?? {}) as Record<string, string>;
89 const payloadObj = (cfg.payload ?? {}) as Record<string, unknown>;
90 const payload = JSON.stringify(interpolateDeep(payloadObj, ctx.vars, warnings));
91
92 const result = await runWithTimeout(
93 TIMEOUT_MS,
94 () => post(url, headers, payload, MAX_REDIRECTS)
95 );
96
97 if (result.status === 429 || result.status >= 500) {
98 throw new RetryableError("WEBHOOK_HTTP_RETRIABLE", `webhook responded ${result.status}`);
99 }
100 if (result.status >= 400) {
101 throw new FatalError("WEBHOOK_HTTP_ERROR", `webhook responded ${result.status}: ${result.body.slice(0, 200)}`);
102 }
103
104 return {
105 output: { url, status: result.status, response: result.body.slice(0, 1024) },
106 emittedEvents: [],
107 audit: { action: "automation.webhook_sent", resourceType: "automation", resourceId: ctx.automationId, metadata: { url: new URL(url).hostname, status: result.status } }
108 };
109 }
110};
111
112export default executor;
113