Skip to content

Commit d5ffc92

Browse files
authored
feat: allow to specify timeout option in middleware, refactor middleware (#1148)
1 parent 07dc367 commit d5ffc92

14 files changed

+257
-324
lines changed
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import type { WebhookEventName } from "../generated/webhook-identifiers.ts";
2+
3+
import type { Webhooks } from "../index.ts";
4+
import type { WebhookEventHandlerError } from "../types.ts";
5+
import type { MiddlewareOptions } from "./types.ts";
6+
7+
type CreateMiddlewareOptions = {
8+
handleResponse: (
9+
body: string | null,
10+
status?: number,
11+
headers?: Record<string, string>,
12+
response?: any,
13+
) => any;
14+
getPayload: (request: Request) => Promise<string>;
15+
getRequestHeader: <T = string>(request: Request, key: string) => T;
16+
};
17+
18+
const isApplicationJsonRE = /^\s*(application\/json)\s*(?:;|$)/u;
19+
20+
type IncomingMessage = any;
21+
type ServerResponse = any;
22+
23+
const WEBHOOK_HEADERS = [
24+
"x-github-event",
25+
"x-hub-signature-256",
26+
"x-github-delivery",
27+
];
28+
29+
export function createMiddleware(options: CreateMiddlewareOptions) {
30+
const { handleResponse, getRequestHeader, getPayload } = options;
31+
32+
return function middleware(
33+
webhooks: Webhooks,
34+
options: Required<MiddlewareOptions>,
35+
) {
36+
return async function octokitWebhooksMiddleware(
37+
request: IncomingMessage,
38+
response?: ServerResponse,
39+
next?: Function,
40+
) {
41+
let pathname: string;
42+
try {
43+
pathname = new URL(request.url as string, "http://localhost").pathname;
44+
} catch (error) {
45+
return handleResponse(
46+
JSON.stringify({
47+
error: `Request URL could not be parsed: ${request.url}`,
48+
}),
49+
422,
50+
{
51+
"content-type": "application/json",
52+
},
53+
response,
54+
);
55+
}
56+
57+
if (pathname !== options.path) {
58+
next?.();
59+
return handleResponse(null);
60+
} else if (request.method !== "POST") {
61+
return handleResponse(
62+
JSON.stringify({
63+
error: `Unknown route: ${request.method} ${pathname}`,
64+
}),
65+
404,
66+
{
67+
"content-type": "application/json",
68+
},
69+
response,
70+
);
71+
}
72+
73+
// Check if the Content-Type header is `application/json` and allow for charset to be specified in it
74+
// Otherwise, return a 415 Unsupported Media Type error
75+
// See https://github.com/octokit/webhooks.js/issues/158
76+
const contentType = getRequestHeader(request, "content-type");
77+
78+
if (
79+
typeof contentType !== "string" ||
80+
!isApplicationJsonRE.test(contentType)
81+
) {
82+
return handleResponse(
83+
JSON.stringify({
84+
error: `Unsupported "Content-Type" header value. Must be "application/json"`,
85+
}),
86+
415,
87+
{
88+
"content-type": "application/json",
89+
accept: "application/json",
90+
},
91+
response,
92+
);
93+
}
94+
95+
const missingHeaders = WEBHOOK_HEADERS.filter((header) => {
96+
return getRequestHeader(request, header) == undefined;
97+
}).join(", ");
98+
99+
if (missingHeaders) {
100+
return handleResponse(
101+
JSON.stringify({
102+
error: `Required headers missing: ${missingHeaders}`,
103+
}),
104+
400,
105+
{
106+
"content-type": "application/json",
107+
accept: "application/json",
108+
},
109+
response,
110+
);
111+
}
112+
113+
const eventName = getRequestHeader<WebhookEventName>(
114+
request,
115+
"x-github-event",
116+
);
117+
const signature = getRequestHeader(request, "x-hub-signature-256");
118+
const id = getRequestHeader(request, "x-github-delivery");
119+
120+
options.log.debug(`${eventName} event received (id: ${id})`);
121+
122+
// GitHub will abort the request if it does not receive a response within 10s
123+
// See https://github.com/octokit/webhooks.js/issues/185
124+
let didTimeout = false;
125+
let timeout: ReturnType<typeof setTimeout>;
126+
const timeoutPromise = new Promise<Response>((resolve) => {
127+
timeout = setTimeout(() => {
128+
didTimeout = true;
129+
resolve(
130+
handleResponse(
131+
"still processing\n",
132+
202,
133+
{
134+
"Content-Type": "text/plain",
135+
accept: "application/json",
136+
},
137+
response,
138+
),
139+
);
140+
}, options.timeout);
141+
});
142+
143+
const processWebhook = async () => {
144+
try {
145+
const payload = await getPayload(request);
146+
147+
await webhooks.verifyAndReceive({
148+
id,
149+
name: eventName,
150+
payload,
151+
signature,
152+
});
153+
clearTimeout(timeout);
154+
155+
if (didTimeout) return handleResponse(null);
156+
157+
return handleResponse(
158+
"ok\n",
159+
200,
160+
{
161+
"content-type": "text/plain",
162+
accept: "application/json",
163+
},
164+
response,
165+
);
166+
} catch (error) {
167+
clearTimeout(timeout);
168+
169+
if (didTimeout) return handleResponse(null);
170+
171+
const err = Array.from((error as WebhookEventHandlerError).errors)[0];
172+
const errorMessage = err.message
173+
? `${err.name}: ${err.message}`
174+
: "Error: An Unspecified error occurred";
175+
const statusCode =
176+
typeof err.status !== "undefined" ? err.status : 500;
177+
178+
options.log.error(error);
179+
180+
return handleResponse(
181+
JSON.stringify({
182+
error: errorMessage,
183+
}),
184+
statusCode,
185+
{
186+
"content-type": "application/json",
187+
accept: "application/json",
188+
},
189+
response,
190+
);
191+
}
192+
};
193+
194+
return await Promise.race([timeoutPromise, processWebhook()]);
195+
};
196+
};
197+
}

src/middleware/node/get-missing-headers.ts

Lines changed: 0 additions & 15 deletions
This file was deleted.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// https://docs.github.com/en/developers/webhooks-and-events/webhook-events-and-payloads#delivery-headers
2+
export function getRequestHeader<T = string>(request: any, key: string) {
3+
return request.headers[key] as T;
4+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export function handleResponse(
2+
body: string | null,
3+
status = 200 as number,
4+
headers = {} as Record<string, string>,
5+
response?: any,
6+
) {
7+
if (body === null) {
8+
return false;
9+
}
10+
11+
headers["content-length"] = body.length.toString();
12+
response.writeHead(status, headers).end(body);
13+
return true;
14+
}

src/middleware/node/index.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
11
import { createLogger } from "../../create-logger.ts";
22
import type { Webhooks } from "../../index.ts";
3-
import { middleware } from "./middleware.ts";
43
import type { MiddlewareOptions } from "../types.ts";
4+
import { createMiddleware } from "../create-middleware.ts";
5+
import { handleResponse } from "./handle-response.ts";
6+
import { getRequestHeader } from "./get-request-header.ts";
7+
import { getPayload } from "./get-payload.ts";
58

69
export function createNodeMiddleware(
710
webhooks: Webhooks,
811
{
912
path = "/api/github/webhooks",
1013
log = createLogger(),
14+
timeout = 9000,
1115
}: MiddlewareOptions = {},
1216
) {
13-
return middleware.bind(null, webhooks, {
17+
return createMiddleware({
18+
handleResponse,
19+
getRequestHeader,
20+
getPayload,
21+
})(webhooks, {
1422
path,
1523
log,
24+
timeout,
1625
} as Required<MiddlewareOptions>);
1726
}

src/middleware/node/middleware.ts

Lines changed: 0 additions & 127 deletions
This file was deleted.

0 commit comments

Comments
 (0)