import type { IncomingMessage, ServerResponse } from "node:http"; import { registerPluginHttpRoute } from "../plugins/http-registry.js"; import type { FixedWindowRateLimiter } from "./webhook-memory-guards.js"; import { normalizeWebhookPath } from "./webhook-path.js"; import { beginWebhookRequestPipelineOrReject, type WebhookInFlightLimiter, } from "./webhook-request-guards.js"; export type RegisteredWebhookTarget = { target: T; unregister: () => void; }; export type RegisterWebhookTargetOptions = { onFirstPathTarget?: (params: { path: string; target: T }) => void | (() => void); onLastPathTargetRemoved?: (params: { path: string }) => void; }; type RegisterPluginHttpRouteParams = Parameters[0]; export type RegisterWebhookPluginRouteOptions = Omit< RegisterPluginHttpRouteParams, "path" | "fallbackPath" >; export function registerWebhookTargetWithPluginRoute(params: { targetsByPath: Map; target: T; route: RegisterWebhookPluginRouteOptions; onLastPathTargetRemoved?: RegisterWebhookTargetOptions["onLastPathTargetRemoved"]; }): RegisteredWebhookTarget { return registerWebhookTarget(params.targetsByPath, params.target, { onFirstPathTarget: ({ path }) => registerPluginHttpRoute({ ...params.route, path, replaceExisting: params.route.replaceExisting ?? true, }), onLastPathTargetRemoved: params.onLastPathTargetRemoved, }); } const pathTeardownByTargetMap = new WeakMap, Map void>>(); function getPathTeardownMap(targetsByPath: Map): Map void> { const mapKey = targetsByPath as unknown as Map; const existing = pathTeardownByTargetMap.get(mapKey); if (existing) { return existing; } const created = new Map void>(); pathTeardownByTargetMap.set(mapKey, created); return created; } export function registerWebhookTarget( targetsByPath: Map, target: T, opts?: RegisterWebhookTargetOptions, ): RegisteredWebhookTarget { const key = normalizeWebhookPath(target.path); const normalizedTarget = { ...target, path: key }; const existing = targetsByPath.get(key) ?? []; if (existing.length === 0) { const onFirstPathResult = opts?.onFirstPathTarget?.({ path: key, target: normalizedTarget, }); if (typeof onFirstPathResult === "function") { getPathTeardownMap(targetsByPath).set(key, onFirstPathResult); } } targetsByPath.set(key, [...existing, normalizedTarget]); let isActive = true; const unregister = () => { if (!isActive) { return; } isActive = false; const updated = (targetsByPath.get(key) ?? []).filter((entry) => entry !== normalizedTarget); if (updated.length > 0) { targetsByPath.set(key, updated); return; } targetsByPath.delete(key); const teardown = getPathTeardownMap(targetsByPath).get(key); if (teardown) { getPathTeardownMap(targetsByPath).delete(key); teardown(); } opts?.onLastPathTargetRemoved?.({ path: key }); }; return { target: normalizedTarget, unregister }; } export function resolveWebhookTargets( req: IncomingMessage, targetsByPath: Map, ): { path: string; targets: T[] } | null { const url = new URL(req.url ?? "/", "http://localhost"); const path = normalizeWebhookPath(url.pathname); const targets = targetsByPath.get(path); if (!targets || targets.length === 0) { return null; } return { path, targets }; } export async function withResolvedWebhookRequestPipeline(params: { req: IncomingMessage; res: ServerResponse; targetsByPath: Map; allowMethods?: readonly string[]; rateLimiter?: FixedWindowRateLimiter; rateLimitKey?: string; nowMs?: number; requireJsonContentType?: boolean; inFlightLimiter?: WebhookInFlightLimiter; inFlightKey?: string | ((args: { req: IncomingMessage; path: string; targets: T[] }) => string); inFlightLimitStatusCode?: number; inFlightLimitMessage?: string; handle: (args: { path: string; targets: T[] }) => Promise | boolean | void; }): Promise { const resolved = resolveWebhookTargets(params.req, params.targetsByPath); if (!resolved) { return false; } const inFlightKey = typeof params.inFlightKey === "function" ? params.inFlightKey({ req: params.req, path: resolved.path, targets: resolved.targets }) : (params.inFlightKey ?? `${resolved.path}:${params.req.socket?.remoteAddress ?? "unknown"}`); const requestLifecycle = beginWebhookRequestPipelineOrReject({ req: params.req, res: params.res, allowMethods: params.allowMethods, rateLimiter: params.rateLimiter, rateLimitKey: params.rateLimitKey, nowMs: params.nowMs, requireJsonContentType: params.requireJsonContentType, inFlightLimiter: params.inFlightLimiter, inFlightKey, inFlightLimitStatusCode: params.inFlightLimitStatusCode, inFlightLimitMessage: params.inFlightLimitMessage, }); if (!requestLifecycle.ok) { return true; } try { await params.handle(resolved); return true; } finally { requestLifecycle.release(); } } export type WebhookTargetMatchResult = | { kind: "none" } | { kind: "single"; target: T } | { kind: "ambiguous" }; function updateMatchedWebhookTarget( matched: T | undefined, target: T, ): { ok: true; matched: T } | { ok: false; result: WebhookTargetMatchResult } { if (matched) { return { ok: false, result: { kind: "ambiguous" } }; } return { ok: true, matched: target }; } function finalizeMatchedWebhookTarget(matched: T | undefined): WebhookTargetMatchResult { if (!matched) { return { kind: "none" }; } return { kind: "single", target: matched }; } export function resolveSingleWebhookTarget( targets: readonly T[], isMatch: (target: T) => boolean, ): WebhookTargetMatchResult { let matched: T | undefined; for (const target of targets) { if (!isMatch(target)) { continue; } const updated = updateMatchedWebhookTarget(matched, target); if (!updated.ok) { return updated.result; } matched = updated.matched; } return finalizeMatchedWebhookTarget(matched); } export async function resolveSingleWebhookTargetAsync( targets: readonly T[], isMatch: (target: T) => Promise, ): Promise> { let matched: T | undefined; for (const target of targets) { if (!(await isMatch(target))) { continue; } const updated = updateMatchedWebhookTarget(matched, target); if (!updated.ok) { return updated.result; } matched = updated.matched; } return finalizeMatchedWebhookTarget(matched); } export async function resolveWebhookTargetWithAuthOrReject(params: { targets: readonly T[]; res: ServerResponse; isMatch: (target: T) => boolean | Promise; unauthorizedStatusCode?: number; unauthorizedMessage?: string; ambiguousStatusCode?: number; ambiguousMessage?: string; }): Promise { const match = await resolveSingleWebhookTargetAsync(params.targets, async (target) => Boolean(await params.isMatch(target)), ); return resolveWebhookTargetMatchOrReject(params, match); } export function resolveWebhookTargetWithAuthOrRejectSync(params: { targets: readonly T[]; res: ServerResponse; isMatch: (target: T) => boolean; unauthorizedStatusCode?: number; unauthorizedMessage?: string; ambiguousStatusCode?: number; ambiguousMessage?: string; }): T | null { const match = resolveSingleWebhookTarget(params.targets, params.isMatch); return resolveWebhookTargetMatchOrReject(params, match); } function resolveWebhookTargetMatchOrReject( params: { res: ServerResponse; unauthorizedStatusCode?: number; unauthorizedMessage?: string; ambiguousStatusCode?: number; ambiguousMessage?: string; }, match: WebhookTargetMatchResult, ): T | null { if (match.kind === "single") { return match.target; } if (match.kind === "ambiguous") { params.res.statusCode = params.ambiguousStatusCode ?? 401; params.res.end(params.ambiguousMessage ?? "ambiguous webhook target"); return null; } params.res.statusCode = params.unauthorizedStatusCode ?? 401; params.res.end(params.unauthorizedMessage ?? "unauthorized"); return null; } export function rejectNonPostWebhookRequest(req: IncomingMessage, res: ServerResponse): boolean { if (req.method === "POST") { return false; } res.statusCode = 405; res.setHeader("Allow", "POST"); res.end("Method Not Allowed"); return true; }