Skip to content

Commit d0d5268

Browse files
authored
feat: add new "web" middleware (#1078)
* feat: add new "web" middleware This new middleware should be generic enough for use in the serverless/edge platforms * style: prettier
1 parent c3ef304 commit d0d5268

File tree

9 files changed

+183
-3
lines changed

9 files changed

+183
-3
lines changed

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type {
1818
} from "./types.js";
1919

2020
export { createNodeMiddleware } from "./middleware/node/index.js";
21+
export { createWebMiddleware } from "./middleware/web/index.js";
2122
export { emitterEventNames } from "./generated/webhook-names.js";
2223

2324
// U holds the return value of `transform` function in Options

src/middleware/node/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createLogger } from "../../createLogger.js";
22
import type { Webhooks } from "../../index.js";
33
import { middleware } from "./middleware.js";
4-
import type { MiddlewareOptions } from "./types.js";
4+
import type { MiddlewareOptions } from "../types.js";
55

66
export function createNodeMiddleware(
77
webhooks: Webhooks,

src/middleware/node/middleware.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
44

55
import type { Webhooks } from "../../index.js";
66
import type { WebhookEventHandlerError } from "../../types.js";
7-
import type { MiddlewareOptions } from "./types.js";
7+
import type { MiddlewareOptions } from "../types.js";
88
import { getMissingHeaders } from "./get-missing-headers.js";
99
import { getPayload } from "./get-payload.js";
1010
import { onUnhandledRequestDefault } from "./on-unhandled-request-default.js";

src/middleware/node/types.ts renamed to src/middleware/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Logger } from "../../createLogger.js";
1+
import type { Logger } from "../createLogger.js";
22

33
export type MiddlewareOptions = {
44
path?: string;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
const WEBHOOK_HEADERS = [
2+
"x-github-event",
3+
"x-hub-signature-256",
4+
"x-github-delivery",
5+
];
6+
7+
// https://docs.github.com/en/developers/webhooks-and-events/webhook-events-and-payloads#delivery-headers
8+
export function getMissingHeaders(request: Request) {
9+
return WEBHOOK_HEADERS.filter((header) => !request.headers.has(header));
10+
}

src/middleware/web/get-payload.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function getPayload(request: Request): Promise<string> {
2+
return request.text();
3+
}

src/middleware/web/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { createLogger } from "../../createLogger.js";
2+
import type { Webhooks } from "../../index.js";
3+
import { middleware } from "./middleware.js";
4+
import type { MiddlewareOptions } from "../types.js";
5+
6+
export function createWebMiddleware(
7+
webhooks: Webhooks,
8+
{
9+
path = "/api/github/webhooks",
10+
log = createLogger(),
11+
}: MiddlewareOptions = {},
12+
) {
13+
return middleware.bind(null, webhooks, {
14+
path,
15+
log,
16+
} as Required<MiddlewareOptions>);
17+
}

src/middleware/web/middleware.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import type { WebhookEventName } from "../../generated/webhook-identifiers.js";
2+
3+
import type { Webhooks } from "../../index.js";
4+
import type { WebhookEventHandlerError } from "../../types.js";
5+
import type { MiddlewareOptions } from "../types.js";
6+
import { getMissingHeaders } from "./get-missing-headers.js";
7+
import { getPayload } from "./get-payload.js";
8+
import { onUnhandledRequestDefault } from "./on-unhandled-request-default.js";
9+
10+
export async function middleware(
11+
webhooks: Webhooks,
12+
options: Required<MiddlewareOptions>,
13+
request: Request,
14+
) {
15+
let pathname: string;
16+
try {
17+
pathname = new URL(request.url as string, "http://localhost").pathname;
18+
} catch (error) {
19+
return new Response(
20+
JSON.stringify({
21+
error: `Request URL could not be parsed: ${request.url}`,
22+
}),
23+
{
24+
status: 422,
25+
headers: {
26+
"content-type": "application/json",
27+
},
28+
},
29+
);
30+
}
31+
32+
if (pathname !== options.path || request.method !== "POST") {
33+
return onUnhandledRequestDefault(request);
34+
}
35+
36+
// Check if the Content-Type header is `application/json` and allow for charset to be specified in it
37+
// Otherwise, return a 415 Unsupported Media Type error
38+
// See https://github.com/octokit/webhooks.js/issues/158
39+
if (
40+
typeof request.headers.get("content-type") !== "string" ||
41+
!request.headers.get("content-type")!.startsWith("application/json")
42+
) {
43+
return new Response(
44+
JSON.stringify({
45+
error: `Unsupported "Content-Type" header value. Must be "application/json"`,
46+
}),
47+
{
48+
status: 415,
49+
headers: {
50+
"content-type": "application/json",
51+
},
52+
},
53+
);
54+
}
55+
56+
const missingHeaders = getMissingHeaders(request).join(", ");
57+
58+
if (missingHeaders) {
59+
return new Response(
60+
JSON.stringify({
61+
error: `Required headers missing: ${missingHeaders}`,
62+
}),
63+
{
64+
status: 422,
65+
headers: {
66+
"content-type": "application/json",
67+
},
68+
},
69+
);
70+
}
71+
72+
const eventName = request.headers.get("x-github-event") as WebhookEventName;
73+
const signatureSHA256 = request.headers.get("x-hub-signature-256") as string;
74+
const id = request.headers.get("x-github-delivery") as string;
75+
76+
options.log.debug(`${eventName} event received (id: ${id})`);
77+
78+
// GitHub will abort the request if it does not receive a response within 10s
79+
// See https://github.com/octokit/webhooks.js/issues/185
80+
let didTimeout = false;
81+
let timeout: ReturnType<typeof setTimeout>;
82+
const timeoutPromise = new Promise<Response>((resolve) => {
83+
timeout = setTimeout(() => {
84+
didTimeout = true;
85+
resolve(
86+
new Response("still processing\n", {
87+
status: 202,
88+
headers: { "Content-Type": "text/plain" },
89+
}),
90+
);
91+
}, 9000).unref();
92+
});
93+
94+
const processWebhook = async () => {
95+
try {
96+
const payload = await getPayload(request);
97+
98+
await webhooks.verifyAndReceive({
99+
id: id,
100+
name: eventName,
101+
payload,
102+
signature: signatureSHA256,
103+
});
104+
clearTimeout(timeout);
105+
106+
if (didTimeout) return new Response(null);
107+
108+
return new Response("ok\n");
109+
} catch (error) {
110+
clearTimeout(timeout);
111+
112+
if (didTimeout) return new Response(null);
113+
114+
const err = Array.from((error as WebhookEventHandlerError).errors)[0];
115+
const errorMessage = err.message
116+
? `${err.name}: ${err.message}`
117+
: "Error: An Unspecified error occurred";
118+
119+
options.log.error(error);
120+
121+
return new Response(
122+
JSON.stringify({
123+
error: errorMessage,
124+
}),
125+
{
126+
status: typeof err.status !== "undefined" ? err.status : 500,
127+
headers: {
128+
"content-type": "application/json",
129+
},
130+
},
131+
);
132+
}
133+
};
134+
135+
return await Promise.race([timeoutPromise, processWebhook()]);
136+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export function onUnhandledRequestDefault(request: Request) {
2+
return new Response(
3+
JSON.stringify({
4+
error: `Unknown route: ${request.method} ${request.url}`,
5+
}),
6+
{
7+
status: 404,
8+
headers: {
9+
"content-type": "application/json",
10+
},
11+
},
12+
);
13+
}

0 commit comments

Comments
 (0)