Skip to content

Commit

Permalink
feat: add new "web" middleware (#1078)
Browse files Browse the repository at this point in the history
* feat: add new "web" middleware

This new middleware should be generic enough for use in the serverless/edge platforms

* style: prettier
  • Loading branch information
wolfy1339 authored Feb 24, 2025
1 parent c3ef304 commit d0d5268
Show file tree
Hide file tree
Showing 9 changed files with 183 additions and 3 deletions.
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/middleware/node/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/middleware/node/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";

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";
Expand Down
2 changes: 1 addition & 1 deletion src/middleware/node/types.ts → src/middleware/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Logger } from "../../createLogger.js";
import type { Logger } from "../createLogger.js";

export type MiddlewareOptions = {
path?: string;
Expand Down
10 changes: 10 additions & 0 deletions src/middleware/web/get-missing-headers.ts
Original file line number Diff line number Diff line change
@@ -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));
}
3 changes: 3 additions & 0 deletions src/middleware/web/get-payload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function getPayload(request: Request): Promise<string> {
return request.text();
}
17 changes: 17 additions & 0 deletions src/middleware/web/index.ts
Original file line number Diff line number Diff line change
@@ -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<MiddlewareOptions>);
}
136 changes: 136 additions & 0 deletions src/middleware/web/middleware.ts
Original file line number Diff line number Diff line change
@@ -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<MiddlewareOptions>,
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<typeof setTimeout>;
const timeoutPromise = new Promise<Response>((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()]);
}
13 changes: 13 additions & 0 deletions src/middleware/web/on-unhandled-request-default.ts
Original file line number Diff line number Diff line change
@@ -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",
},
},
);
}

0 comments on commit d0d5268

Please sign in to comment.