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..7e02b5ab --- /dev/null +++ b/src/middleware/web/middleware.ts @@ -0,0 +1,136 @@ +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..b29caaf4 --- /dev/null +++ b/src/middleware/web/on-unhandled-request-default.ts @@ -0,0 +1,13 @@ +export function onUnhandledRequestDefault(request: Request) { + return new Response( + JSON.stringify({ + error: `Unknown route: ${request.method} ${request.url}`, + }), + { + status: 404, + headers: { + "content-type": "application/json", + }, + }, + ); +}