Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions src/middleware/create-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import type { WebhookEventName } from "../generated/webhook-identifiers.ts";

import type { Webhooks } from "../index.ts";
import type { WebhookEventHandlerError } from "../types.ts";
import type { MiddlewareOptions } from "./types.ts";

type CreateMiddlewareOptions = {
handleResponse: (
body: string | null,
status?: number,
headers?: Record<string, string>,
response?: any,
) => any;
getPayload: (request: Request) => Promise<string>;
getRequestHeader: <T = string>(request: Request, key: string) => T;
};

const isApplicationJsonRE = /^\s*(application\/json)\s*(?:;|$)/u;

type IncomingMessage = any;
type ServerResponse = any;

const WEBHOOK_HEADERS = [
"x-github-event",
"x-hub-signature-256",
"x-github-delivery",
];

export function createMiddleware(options: CreateMiddlewareOptions) {
const { handleResponse, getRequestHeader, getPayload } = options;

return function middleware(
webhooks: Webhooks,
options: Required<MiddlewareOptions>,
) {
return async function octokitWebhooksMiddleware(
request: IncomingMessage,
response?: ServerResponse,
next?: Function,
) {
let pathname: string;
try {
pathname = new URL(request.url as string, "http://localhost").pathname;
} catch (error) {
return handleResponse(
JSON.stringify({
error: `Request URL could not be parsed: ${request.url}`,
}),
422,
{
"content-type": "application/json",
},
response,
);
}

if (pathname !== options.path) {
next?.();
return handleResponse(null);
} else if (request.method !== "POST") {
return handleResponse(
JSON.stringify({
error: `Unknown route: ${request.method} ${pathname}`,
}),
404,
{
"content-type": "application/json",
},
response,
);
}

// 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
const contentType = getRequestHeader(request, "content-type");

if (
typeof contentType !== "string" ||
!isApplicationJsonRE.test(contentType)
) {
return handleResponse(
JSON.stringify({
error: `Unsupported "Content-Type" header value. Must be "application/json"`,
}),
415,
{
"content-type": "application/json",
accept: "application/json",
},
response,
);
}

const missingHeaders = WEBHOOK_HEADERS.filter((header) => {
return getRequestHeader(request, header) == undefined;
}).join(", ");

if (missingHeaders) {
return handleResponse(
JSON.stringify({
error: `Required headers missing: ${missingHeaders}`,
}),
400,
{
"content-type": "application/json",
accept: "application/json",
},
response,
);
}

const eventName = getRequestHeader<WebhookEventName>(
request,
"x-github-event",
);
const signature = getRequestHeader(request, "x-hub-signature-256");
const id = getRequestHeader(request, "x-github-delivery");

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(
handleResponse(
"still processing\n",
202,
{
"Content-Type": "text/plain",
accept: "application/json",
},
response,
),
);
}, options.timeout);
});

const processWebhook = async () => {
try {
const payload = await getPayload(request);

await webhooks.verifyAndReceive({
id,
name: eventName,
payload,
signature,
});
clearTimeout(timeout);

if (didTimeout) return handleResponse(null);

return handleResponse(
"ok\n",
200,
{
"content-type": "text/plain",
accept: "application/json",
},
response,
);
} catch (error) {
clearTimeout(timeout);

if (didTimeout) return handleResponse(null);

const err = Array.from((error as WebhookEventHandlerError).errors)[0];
const errorMessage = err.message
? `${err.name}: ${err.message}`
: "Error: An Unspecified error occurred";
const statusCode =
typeof err.status !== "undefined" ? err.status : 500;

options.log.error(error);

return handleResponse(
JSON.stringify({
error: errorMessage,
}),
statusCode,
{
"content-type": "application/json",
accept: "application/json",
},
response,
);
}
};

return await Promise.race([timeoutPromise, processWebhook()]);
};
};
}
15 changes: 0 additions & 15 deletions src/middleware/node/get-missing-headers.ts

This file was deleted.

4 changes: 4 additions & 0 deletions src/middleware/node/get-request-header.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// https://docs.github.com/en/developers/webhooks-and-events/webhook-events-and-payloads#delivery-headers
export function getRequestHeader<T = string>(request: any, key: string) {
return request.headers[key] as T;
}
14 changes: 14 additions & 0 deletions src/middleware/node/handle-response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export function handleResponse(
body: string | null,
status = 200 as number,
headers = {} as Record<string, string>,
response?: any,
) {
if (body === null) {
return false;
}

headers["content-length"] = body.length.toString();
response.writeHead(status, headers).end(body);
return true;
}
13 changes: 11 additions & 2 deletions src/middleware/node/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
import { createLogger } from "../../create-logger.ts";
import type { Webhooks } from "../../index.ts";
import { middleware } from "./middleware.ts";
import type { MiddlewareOptions } from "../types.ts";
import { createMiddleware } from "../create-middleware.ts";
import { handleResponse } from "./handle-response.ts";
import { getRequestHeader } from "./get-request-header.ts";
import { getPayload } from "./get-payload.ts";

export function createNodeMiddleware(
webhooks: Webhooks,
{
path = "/api/github/webhooks",
log = createLogger(),
timeout = 9000,
}: MiddlewareOptions = {},
) {
return middleware.bind(null, webhooks, {
return createMiddleware({
handleResponse,
getRequestHeader,
getPayload,
})(webhooks, {
path,
log,
timeout,
} as Required<MiddlewareOptions>);
}
127 changes: 0 additions & 127 deletions src/middleware/node/middleware.ts

This file was deleted.

Loading