@octokit/webhooks
Version:
GitHub webhook events toolset for Node.js
578 lines (559 loc) • 18.4 kB
JavaScript
import AggregateError from 'aggregate-error';
import { sign as sign$1, verify as verify$1 } from '@octokit/webhooks-methods';
const createLogger = (logger) => ({
debug: () => { },
info: () => { },
warn: console.warn.bind(console),
error: console.error.bind(console),
...logger,
});
// THIS FILE IS GENERATED - DO NOT EDIT DIRECTLY
// make edits in scripts/generate-types.ts
const emitterEventNames = [
"branch_protection_rule",
"branch_protection_rule.created",
"branch_protection_rule.deleted",
"branch_protection_rule.edited",
"check_run",
"check_run.completed",
"check_run.created",
"check_run.requested_action",
"check_run.rerequested",
"check_suite",
"check_suite.completed",
"check_suite.requested",
"check_suite.rerequested",
"code_scanning_alert",
"code_scanning_alert.appeared_in_branch",
"code_scanning_alert.closed_by_user",
"code_scanning_alert.created",
"code_scanning_alert.fixed",
"code_scanning_alert.reopened",
"code_scanning_alert.reopened_by_user",
"commit_comment",
"commit_comment.created",
"create",
"delete",
"deploy_key",
"deploy_key.created",
"deploy_key.deleted",
"deployment",
"deployment.created",
"deployment_status",
"deployment_status.created",
"discussion",
"discussion.answered",
"discussion.category_changed",
"discussion.created",
"discussion.deleted",
"discussion.edited",
"discussion.labeled",
"discussion.locked",
"discussion.pinned",
"discussion.transferred",
"discussion.unanswered",
"discussion.unlabeled",
"discussion.unlocked",
"discussion.unpinned",
"discussion_comment",
"discussion_comment.created",
"discussion_comment.deleted",
"discussion_comment.edited",
"fork",
"github_app_authorization",
"github_app_authorization.revoked",
"gollum",
"installation",
"installation.created",
"installation.deleted",
"installation.new_permissions_accepted",
"installation.suspend",
"installation.unsuspend",
"installation_repositories",
"installation_repositories.added",
"installation_repositories.removed",
"issue_comment",
"issue_comment.created",
"issue_comment.deleted",
"issue_comment.edited",
"issues",
"issues.assigned",
"issues.closed",
"issues.deleted",
"issues.demilestoned",
"issues.edited",
"issues.labeled",
"issues.locked",
"issues.milestoned",
"issues.opened",
"issues.pinned",
"issues.reopened",
"issues.transferred",
"issues.unassigned",
"issues.unlabeled",
"issues.unlocked",
"issues.unpinned",
"label",
"label.created",
"label.deleted",
"label.edited",
"marketplace_purchase",
"marketplace_purchase.cancelled",
"marketplace_purchase.changed",
"marketplace_purchase.pending_change",
"marketplace_purchase.pending_change_cancelled",
"marketplace_purchase.purchased",
"member",
"member.added",
"member.edited",
"member.removed",
"membership",
"membership.added",
"membership.removed",
"meta",
"meta.deleted",
"milestone",
"milestone.closed",
"milestone.created",
"milestone.deleted",
"milestone.edited",
"milestone.opened",
"org_block",
"org_block.blocked",
"org_block.unblocked",
"organization",
"organization.deleted",
"organization.member_added",
"organization.member_invited",
"organization.member_removed",
"organization.renamed",
"package",
"package.published",
"package.updated",
"page_build",
"ping",
"project",
"project.closed",
"project.created",
"project.deleted",
"project.edited",
"project.reopened",
"project_card",
"project_card.converted",
"project_card.created",
"project_card.deleted",
"project_card.edited",
"project_card.moved",
"project_column",
"project_column.created",
"project_column.deleted",
"project_column.edited",
"project_column.moved",
"projects_v2_item",
"projects_v2_item.archived",
"projects_v2_item.converted",
"projects_v2_item.created",
"projects_v2_item.deleted",
"projects_v2_item.edited",
"projects_v2_item.reordered",
"projects_v2_item.restored",
"public",
"pull_request",
"pull_request.assigned",
"pull_request.auto_merge_disabled",
"pull_request.auto_merge_enabled",
"pull_request.closed",
"pull_request.converted_to_draft",
"pull_request.edited",
"pull_request.labeled",
"pull_request.locked",
"pull_request.opened",
"pull_request.ready_for_review",
"pull_request.reopened",
"pull_request.review_request_removed",
"pull_request.review_requested",
"pull_request.synchronize",
"pull_request.unassigned",
"pull_request.unlabeled",
"pull_request.unlocked",
"pull_request_review",
"pull_request_review.dismissed",
"pull_request_review.edited",
"pull_request_review.submitted",
"pull_request_review_comment",
"pull_request_review_comment.created",
"pull_request_review_comment.deleted",
"pull_request_review_comment.edited",
"pull_request_review_thread",
"pull_request_review_thread.resolved",
"pull_request_review_thread.unresolved",
"push",
"release",
"release.created",
"release.deleted",
"release.edited",
"release.prereleased",
"release.published",
"release.released",
"release.unpublished",
"repository",
"repository.archived",
"repository.created",
"repository.deleted",
"repository.edited",
"repository.privatized",
"repository.publicized",
"repository.renamed",
"repository.transferred",
"repository.unarchived",
"repository_dispatch",
"repository_import",
"repository_vulnerability_alert",
"repository_vulnerability_alert.create",
"repository_vulnerability_alert.dismiss",
"repository_vulnerability_alert.reopen",
"repository_vulnerability_alert.resolve",
"secret_scanning_alert",
"secret_scanning_alert.created",
"secret_scanning_alert.reopened",
"secret_scanning_alert.resolved",
"security_advisory",
"security_advisory.performed",
"security_advisory.published",
"security_advisory.updated",
"security_advisory.withdrawn",
"sponsorship",
"sponsorship.cancelled",
"sponsorship.created",
"sponsorship.edited",
"sponsorship.pending_cancellation",
"sponsorship.pending_tier_change",
"sponsorship.tier_changed",
"star",
"star.created",
"star.deleted",
"status",
"team",
"team.added_to_repository",
"team.created",
"team.deleted",
"team.edited",
"team.removed_from_repository",
"team_add",
"watch",
"watch.started",
"workflow_dispatch",
"workflow_job",
"workflow_job.completed",
"workflow_job.in_progress",
"workflow_job.queued",
"workflow_run",
"workflow_run.completed",
"workflow_run.requested",
];
function handleEventHandlers(state, webhookName, handler) {
if (!state.hooks[webhookName]) {
state.hooks[webhookName] = [];
}
state.hooks[webhookName].push(handler);
}
function receiverOn(state, webhookNameOrNames, handler) {
if (Array.isArray(webhookNameOrNames)) {
webhookNameOrNames.forEach((webhookName) => receiverOn(state, webhookName, handler));
return;
}
if (["*", "error"].includes(webhookNameOrNames)) {
const webhookName = webhookNameOrNames === "*" ? "any" : webhookNameOrNames;
const message = `Using the "${webhookNameOrNames}" event with the regular Webhooks.on() function is not supported. Please use the Webhooks.on${webhookName.charAt(0).toUpperCase() + webhookName.slice(1)}() method instead`;
throw new Error(message);
}
if (!emitterEventNames.includes(webhookNameOrNames)) {
state.log.warn(`"${webhookNameOrNames}" is not a known webhook name (https://developer.github.com/v3/activity/events/types/)`);
}
handleEventHandlers(state, webhookNameOrNames, handler);
}
function receiverOnAny(state, handler) {
handleEventHandlers(state, "*", handler);
}
function receiverOnError(state, handler) {
handleEventHandlers(state, "error", handler);
}
// Errors thrown or rejected Promises in "error" event handlers are not handled
// as they are in the webhook event handlers. If errors occur, we log a
// "Fatal: Error occurred" message to stdout
function wrapErrorHandler(handler, error) {
let returnValue;
try {
returnValue = handler(error);
}
catch (error) {
console.log('FATAL: Error occurred in "error" event handler');
console.log(error);
}
if (returnValue && returnValue.catch) {
returnValue.catch((error) => {
console.log('FATAL: Error occurred in "error" event handler');
console.log(error);
});
}
}
// @ts-ignore to address #245
function getHooks(state, eventPayloadAction, eventName) {
const hooks = [state.hooks[eventName], state.hooks["*"]];
if (eventPayloadAction) {
hooks.unshift(state.hooks[`${eventName}.${eventPayloadAction}`]);
}
return [].concat(...hooks.filter(Boolean));
}
// main handler function
function receiverHandle(state, event) {
const errorHandlers = state.hooks.error || [];
if (event instanceof Error) {
const error = Object.assign(new AggregateError([event]), {
event,
errors: [event],
});
errorHandlers.forEach((handler) => wrapErrorHandler(handler, error));
return Promise.reject(error);
}
if (!event || !event.name) {
throw new AggregateError(["Event name not passed"]);
}
if (!event.payload) {
throw new AggregateError(["Event payload not passed"]);
}
// flatten arrays of event listeners and remove undefined values
const hooks = getHooks(state, "action" in event.payload ? event.payload.action : null, event.name);
if (hooks.length === 0) {
return Promise.resolve();
}
const errors = [];
const promises = hooks.map((handler) => {
let promise = Promise.resolve(event);
if (state.transform) {
promise = promise.then(state.transform);
}
return promise
.then((event) => {
return handler(event);
})
.catch((error) => errors.push(Object.assign(error, { event })));
});
return Promise.all(promises).then(() => {
if (errors.length === 0) {
return;
}
const error = new AggregateError(errors);
Object.assign(error, {
event,
errors,
});
errorHandlers.forEach((handler) => wrapErrorHandler(handler, error));
throw error;
});
}
function removeListener(state, webhookNameOrNames, handler) {
if (Array.isArray(webhookNameOrNames)) {
webhookNameOrNames.forEach((webhookName) => removeListener(state, webhookName, handler));
return;
}
if (!state.hooks[webhookNameOrNames]) {
return;
}
// remove last hook that has been added, that way
// it behaves the same as removeListener
for (let i = state.hooks[webhookNameOrNames].length - 1; i >= 0; i--) {
if (state.hooks[webhookNameOrNames][i] === handler) {
state.hooks[webhookNameOrNames].splice(i, 1);
return;
}
}
}
function createEventHandler(options) {
const state = {
hooks: {},
log: createLogger(options && options.log),
};
if (options && options.transform) {
state.transform = options.transform;
}
return {
on: receiverOn.bind(null, state),
onAny: receiverOnAny.bind(null, state),
onError: receiverOnError.bind(null, state),
removeListener: removeListener.bind(null, state),
receive: receiverHandle.bind(null, state),
};
}
/**
* GitHub sends its JSON with an indentation of 2 spaces and a line break at the end
*/
function toNormalizedJsonString(payload) {
const payloadString = JSON.stringify(payload);
return payloadString.replace(/[^\\]\\u[\da-f]{4}/g, (s) => {
return s.substr(0, 3) + s.substr(3).toUpperCase();
});
}
async function sign(secret, payload) {
return sign$1(secret, typeof payload === "string" ? payload : toNormalizedJsonString(payload));
}
async function verify(secret, payload, signature) {
return verify$1(secret, typeof payload === "string" ? payload : toNormalizedJsonString(payload), signature);
}
async function verifyAndReceive(state, event) {
// verify will validate that the secret is not undefined
const matchesSignature = await verify$1(state.secret, typeof event.payload === "object"
? toNormalizedJsonString(event.payload)
: event.payload, event.signature);
if (!matchesSignature) {
const error = new Error("[@octokit/webhooks] signature does not match event payload and secret");
return state.eventHandler.receive(Object.assign(error, { event, status: 400 }));
}
return state.eventHandler.receive({
id: event.id,
name: event.name,
payload: typeof event.payload === "string"
? JSON.parse(event.payload)
: event.payload,
});
}
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
function getMissingHeaders(request) {
return WEBHOOK_HEADERS.filter((header) => !(header in request.headers));
}
// @ts-ignore to address #245
function getPayload(request) {
// If request.body already exists we can stop here
// See https://github.com/octokit/webhooks.js/pull/23
if (request.body)
return Promise.resolve(request.body);
return new Promise((resolve, reject) => {
let data = "";
request.setEncoding("utf8");
// istanbul ignore next
request.on("error", (error) => reject(new AggregateError([error])));
request.on("data", (chunk) => (data += chunk));
request.on("end", () => {
try {
resolve(JSON.parse(data));
}
catch (error) {
error.message = "Invalid JSON";
error.status = 400;
reject(new AggregateError([error]));
}
});
});
}
async function middleware(webhooks, options, request, response, next) {
let pathname;
try {
pathname = new URL(request.url, "http://localhost").pathname;
}
catch (error) {
response.writeHead(422, {
"content-type": "application/json",
});
response.end(JSON.stringify({
error: `Request URL could not be parsed: ${request.url}`,
}));
return;
}
const isUnknownRoute = request.method !== "POST" || pathname !== options.path;
const isExpressMiddleware = typeof next === "function";
if (isUnknownRoute) {
if (isExpressMiddleware) {
return next();
}
else {
return options.onUnhandledRequest(request, response);
}
}
const missingHeaders = getMissingHeaders(request).join(", ");
if (missingHeaders) {
response.writeHead(400, {
"content-type": "application/json",
});
response.end(JSON.stringify({
error: `Required headers missing: ${missingHeaders}`,
}));
return;
}
const eventName = request.headers["x-github-event"];
const signatureSHA256 = request.headers["x-hub-signature-256"];
const id = request.headers["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;
const timeout = setTimeout(() => {
didTimeout = true;
response.statusCode = 202;
response.end("still processing\n");
}, 9000).unref();
try {
const payload = await getPayload(request);
await webhooks.verifyAndReceive({
id: id,
name: eventName,
payload: payload,
signature: signatureSHA256,
});
clearTimeout(timeout);
if (didTimeout)
return;
response.end("ok\n");
}
catch (error) {
clearTimeout(timeout);
if (didTimeout)
return;
const statusCode = Array.from(error)[0].status;
response.statusCode = typeof statusCode !== "undefined" ? statusCode : 500;
response.end(String(error));
}
}
function onUnhandledRequestDefault(request, response) {
response.writeHead(404, {
"content-type": "application/json",
});
response.end(JSON.stringify({
error: `Unknown route: ${request.method} ${request.url}`,
}));
}
function createNodeMiddleware(webhooks, { path = "/api/github/webhooks", onUnhandledRequest = onUnhandledRequestDefault, log = createLogger(), } = {}) {
return middleware.bind(null, webhooks, {
path,
onUnhandledRequest,
log,
});
}
// U holds the return value of `transform` function in Options
class Webhooks {
constructor(options) {
if (!options || !options.secret) {
throw new Error("[@octokit/webhooks] options.secret required");
}
const state = {
eventHandler: createEventHandler(options),
secret: options.secret,
hooks: {},
log: createLogger(options.log),
};
this.sign = sign.bind(null, options.secret);
this.verify = verify.bind(null, options.secret);
this.on = state.eventHandler.on;
this.onAny = state.eventHandler.onAny;
this.onError = state.eventHandler.onError;
this.removeListener = state.eventHandler.removeListener;
this.receive = state.eventHandler.receive;
this.verifyAndReceive = verifyAndReceive.bind(null, state);
}
}
export { Webhooks, createEventHandler, createNodeMiddleware, emitterEventNames };
//# sourceMappingURL=index.js.map