From 0c963929e6d744f2e4ca2819e9f7e4ea53ab2a08 Mon Sep 17 00:00:00 2001 From: wolfy1339 Date: Mon, 30 Sep 2024 11:07:24 -0400 Subject: [PATCH 1/2] feat: add new "web" middleware This new middleware should be generic enough for use in the serverless/edge platforms --- src/index.ts | 1 + src/middleware/node/index.ts | 2 +- src/middleware/node/middleware.ts | 2 +- src/middleware/{node => }/types.ts | 2 +- src/middleware/web/get-missing-headers.ts | 10 ++ src/middleware/web/get-payload.ts | 3 + src/middleware/web/index.ts | 17 +++ src/middleware/web/middleware.ts | 133 ++++++++++++++++++ .../web/on-unhandled-request-default.ts | 12 ++ 9 files changed, 179 insertions(+), 3 deletions(-) rename src/middleware/{node => }/types.ts (57%) create mode 100644 src/middleware/web/get-missing-headers.ts create mode 100644 src/middleware/web/get-payload.ts create mode 100644 src/middleware/web/index.ts create mode 100644 src/middleware/web/middleware.ts create mode 100644 src/middleware/web/on-unhandled-request-default.ts diff --git a/src/index.ts b/src/index.ts index b632cf7d..16b4be8a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import type { } from "./types.js"; export { createNodeMiddleware } from "./middleware/node/index.js"; +export { createWebMiddleware } from "./middleware/web/index.js"; export { emitterEventNames } from "./generated/webhook-names.js"; // U holds the return value of `transform` function in Options diff --git a/src/middleware/node/index.ts b/src/middleware/node/index.ts index 52ae744e..dbfb48c7 100644 --- a/src/middleware/node/index.ts +++ b/src/middleware/node/index.ts @@ -1,7 +1,7 @@ import { createLogger } from "../../createLogger.js"; import type { Webhooks } from "../../index.js"; import { middleware } from "./middleware.js"; -import type { MiddlewareOptions } from "./types.js"; +import type { MiddlewareOptions } from "../types.js"; export function createNodeMiddleware( webhooks: Webhooks, diff --git a/src/middleware/node/middleware.ts b/src/middleware/node/middleware.ts index 74991dcf..417b2a1f 100644 --- a/src/middleware/node/middleware.ts +++ b/src/middleware/node/middleware.ts @@ -6,7 +6,7 @@ import type { WebhookEventName } from "../../generated/webhook-identifiers.js"; import type { Webhooks } from "../../index.js"; import type { WebhookEventHandlerError } from "../../types.js"; -import type { MiddlewareOptions } from "./types.js"; +import type { MiddlewareOptions } from "../types.js"; import { getMissingHeaders } from "./get-missing-headers.js"; import { getPayload } from "./get-payload.js"; import { onUnhandledRequestDefault } from "./on-unhandled-request-default.js"; diff --git a/src/middleware/node/types.ts b/src/middleware/types.ts similarity index 57% rename from src/middleware/node/types.ts rename to src/middleware/types.ts index a7138589..5b5ea1e9 100644 --- a/src/middleware/node/types.ts +++ b/src/middleware/types.ts @@ -1,4 +1,4 @@ -import type { Logger } from "../../createLogger.js"; +import type { Logger } from "../createLogger.js"; export type MiddlewareOptions = { path?: string; diff --git a/src/middleware/web/get-missing-headers.ts b/src/middleware/web/get-missing-headers.ts new file mode 100644 index 00000000..30c93aa6 --- /dev/null +++ b/src/middleware/web/get-missing-headers.ts @@ -0,0 +1,10 @@ +const WEBHOOK_HEADERS = [ + "x-github-event", + "x-hub-signature-256", + "x-github-delivery", +]; + +// https://docs.github.com/en/developers/webhooks-and-events/webhook-events-and-payloads#delivery-headers +export function getMissingHeaders(request: Request) { + return WEBHOOK_HEADERS.filter((header) => !request.headers.has(header)); +} diff --git a/src/middleware/web/get-payload.ts b/src/middleware/web/get-payload.ts new file mode 100644 index 00000000..9e7e10e9 --- /dev/null +++ b/src/middleware/web/get-payload.ts @@ -0,0 +1,3 @@ +export function getPayload(request: Request): Promise { + return request.text(); +} diff --git a/src/middleware/web/index.ts b/src/middleware/web/index.ts new file mode 100644 index 00000000..9af5a2fa --- /dev/null +++ b/src/middleware/web/index.ts @@ -0,0 +1,17 @@ +import { createLogger } from "../../createLogger.js"; +import type { Webhooks } from "../../index.js"; +import { middleware } from "./middleware.js"; +import type { MiddlewareOptions } from "../types.js"; + +export function createWebMiddleware( + webhooks: Webhooks, + { + path = "/api/github/webhooks", + log = createLogger(), + }: MiddlewareOptions = {}, +) { + return middleware.bind(null, webhooks, { + path, + log, + } as Required); +} diff --git a/src/middleware/web/middleware.ts b/src/middleware/web/middleware.ts new file mode 100644 index 00000000..30662692 --- /dev/null +++ b/src/middleware/web/middleware.ts @@ -0,0 +1,133 @@ +import type { WebhookEventName } from "../../generated/webhook-identifiers.js"; + +import type { Webhooks } from "../../index.js"; +import type { WebhookEventHandlerError } from "../../types.js"; +import type { MiddlewareOptions } from "../types.js"; +import { getMissingHeaders } from "./get-missing-headers.js"; +import { getPayload } from "./get-payload.js"; +import { onUnhandledRequestDefault } from "./on-unhandled-request-default.js"; + +export async function middleware( + webhooks: Webhooks, + options: Required, + request: Request, +) { + let pathname: string; + try { + pathname = new URL(request.url as string, "http://localhost").pathname; + } catch (error) { + return new Response( + JSON.stringify({ + error: `Request URL could not be parsed: ${request.url}`, + }), + { + status: 422, + headers: { + "content-type": "application/json", + }, + }, + ); + } + + if (pathname !== options.path || request.method !== "POST") { + return onUnhandledRequestDefault(request); + } + + // Check if the Content-Type header is `application/json` and allow for charset to be specified in it + // Otherwise, return a 415 Unsupported Media Type error + // See https://github.com/octokit/webhooks.js/issues/158 + if ( + typeof request.headers.get("content-type") !== "string" || + !request.headers.get("content-type")!.startsWith("application/json") + ) { + return new Response( + JSON.stringify({ + error: `Unsupported "Content-Type" header value. Must be "application/json"`, + }), + { + status: 415, + headers: { + "content-type": "application/json", + }, + }, + ); + } + + const missingHeaders = getMissingHeaders(request).join(", "); + + if (missingHeaders) { + return new Response( + JSON.stringify({ + error: `Required headers missing: ${missingHeaders}`, + }), + { + status: 422, + headers: { + "content-type": "application/json", + }, + }, + ); + } + + const eventName = request.headers.get("x-github-event") as WebhookEventName; + const signatureSHA256 = request.headers.get("x-hub-signature-256") as string; + const id = request.headers.get("x-github-delivery") as string; + + options.log.debug(`${eventName} event received (id: ${id})`); + + // GitHub will abort the request if it does not receive a response within 10s + // See https://github.com/octokit/webhooks.js/issues/185 + let didTimeout = false; + let timeout: ReturnType; + const timeoutPromise = new Promise((resolve) => { + timeout = setTimeout(() => { + didTimeout = true; + resolve( + new Response("still processing\n", { + status: 202, + headers: { "Content-Type": "text/plain" } + }) + ); + }, 9000).unref(); + }); + + const processWebhook = async () => { + try { + const payload = await getPayload(request); + + await webhooks.verifyAndReceive({ + id: id, + name: eventName, + payload, + signature: signatureSHA256, + }); + clearTimeout(timeout); + + if (didTimeout) return new Response(null); + + return new Response("ok\n"); + } catch (error) { + clearTimeout(timeout); + + if (didTimeout) return new Response(null); + + const err = Array.from((error as WebhookEventHandlerError).errors)[0]; + const errorMessage = err.message + ? `${err.name}: ${err.message}` + : "Error: An Unspecified error occurred"; + + options.log.error(error); + + return new Response(JSON.stringify({ + error: errorMessage, + }), { + status: typeof err.status !== "undefined" ? err.status : 500, + headers: { + "content-type": "application/json", + }, + }) + } + } + + return await Promise.race([timeoutPromise, processWebhook()]); +} diff --git a/src/middleware/web/on-unhandled-request-default.ts b/src/middleware/web/on-unhandled-request-default.ts new file mode 100644 index 00000000..9655486c --- /dev/null +++ b/src/middleware/web/on-unhandled-request-default.ts @@ -0,0 +1,12 @@ +export function onUnhandledRequestDefault( + request: Request, +) { + return new Response(JSON.stringify({ + error: `Unknown route: ${request.method} ${request.url}`, + }), { + status: 404, + headers: { + "content-type": "application/json", + }, + }); +} From 59d92036c96810314fc4924879adcd470a7a11f5 Mon Sep 17 00:00:00 2001 From: wolfy1339 Date: Tue, 26 Nov 2024 18:27:22 -0500 Subject: [PATCH 2/2] style: prettier --- src/middleware/web/middleware.ts | 17 ++++++++++------- .../web/on-unhandled-request-default.ts | 13 +++++++------ 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/middleware/web/middleware.ts b/src/middleware/web/middleware.ts index 30662692..7e02b5ab 100644 --- a/src/middleware/web/middleware.ts +++ b/src/middleware/web/middleware.ts @@ -85,8 +85,8 @@ export async function middleware( resolve( new Response("still processing\n", { status: 202, - headers: { "Content-Type": "text/plain" } - }) + headers: { "Content-Type": "text/plain" }, + }), ); }, 9000).unref(); }); @@ -118,16 +118,19 @@ export async function middleware( options.log.error(error); - return new Response(JSON.stringify({ + return new Response( + JSON.stringify({ error: errorMessage, - }), { + }), + { status: typeof err.status !== "undefined" ? err.status : 500, headers: { "content-type": "application/json", }, - }) + }, + ); } - } - + }; + return await Promise.race([timeoutPromise, processWebhook()]); } diff --git a/src/middleware/web/on-unhandled-request-default.ts b/src/middleware/web/on-unhandled-request-default.ts index 9655486c..b29caaf4 100644 --- a/src/middleware/web/on-unhandled-request-default.ts +++ b/src/middleware/web/on-unhandled-request-default.ts @@ -1,12 +1,13 @@ -export function onUnhandledRequestDefault( - request: Request, -) { - return new Response(JSON.stringify({ +export function onUnhandledRequestDefault(request: Request) { + return new Response( + JSON.stringify({ error: `Unknown route: ${request.method} ${request.url}`, - }), { + }), + { status: 404, headers: { "content-type": "application/json", }, - }); + }, + ); }