Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: GitHub Webhooks middleware #883

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
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
5 changes: 5 additions & 0 deletions .changeset/nice-rocks-lay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hono/github-webhooks': major
---

GitHub Webhooks middleware that allows for listening to Webhooks in your Hono app.
24 changes: 24 additions & 0 deletions .github/workflows/ci-github-webhooks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: ci-github-webhooks
on:
push:
branches: [main]
paths:
- 'packages/github-webhooks/**'
pull_request:
branches: ['*']
paths:
- 'packages/github-webhooks/**'

jobs:
ci:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./packages/github-webhooks
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
- run: yarn install --frozen-lockfile
- run: yarn prerelease
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"build:ajv-validator": "yarn workspace @hono/ajv-validator build",
"build:tsyringe": "yarn workspace @hono/tsyringe build",
"build:cloudflare-access": "yarn workspace @hono/cloudflare-access build",
"build:github-webhooks": "yarn workspace @hono/github-webhooks build",
"build": "run-p 'build:*'",
"lint": "eslint 'packages/**/*.{ts,tsx}'",
"lint:fix": "eslint --fix 'packages/**/*.{ts,tsx}'",
Expand Down
42 changes: 42 additions & 0 deletions packages/github-webhooks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# GitHub Webhooks middleware for Hono

Middleware that allows you to receive and process GitHub Webhooks events in your Hono application.
It conveniently validates the incoming requests and provides you with a simple API to handle the
events.

## Installation

```bash
npm i @hono/github-webhooks
```

## Usage

> [!IMPORTANT]
> This middleware requires you to set the `GITHUB_WEBHOOK_SECRET` environment variable. This is the
> secret that GitHub uses to sign the payloads.

```ts
import { Hono } from 'hono'
import { gitHubWebhooksMiddleware } from './dist'

type Env = {
GITHUB_WEBHOOK_SECRET: string
}

const app = new Hono<{ Bindings: Env }>()

app.use('/webhook', gitHubWebhooksMiddleware())

app.post('/webhook', async (c) => {
const webhooks = c.get('webhooks')

webhooks.on('star.created', async ({ id, name, payload }) => {
console.log(`Received ${name} event with id ${id} and payload: ${payload}`)
})
})
```

> [!TIP]
> This middleware builds upon the [GitHub Octokit Webhooks](https://github.com/octokit/webhooks.js)
> library. You can find the list of events and their payloads in the library's documentation.
58 changes: 58 additions & 0 deletions packages/github-webhooks/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"name": "@hono/github-webhooks",
"version": "1.0.0",
"packageManager": "[email protected]",
"description": "Hono middleware that listens for GitHub Webhooks",
"author": "oscarvz <[email protected]> (https://github.com/oscarvz)",
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"test": "vitest --run",
"build": "tsup ./src/index.ts --format esm,cjs --dts",
"prerelease": "yarn build && yarn test",
"release": "yarn publish"
},
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"license": "MIT",
"publishConfig": {
"registry": "https://registry.npmjs.org",
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/honojs/middleware.git"
},
"homepage": "https://github.com/honojs/middleware",
"engines": {
"node": ">=18"
},
"dependencies": {
"@octokit/webhooks": "^13.4.1"
},
"devDependencies": {
"@octokit/types": "^13.6.2",
"@octokit/webhooks-methods": "^5.1.0",
"hono": "^4.6.13",
"tsup": "^8.3.5",
"vitest": "^2.1.8"
},
"peerDependencies": {
"hono": "*"
}
}
81 changes: 81 additions & 0 deletions packages/github-webhooks/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Webhooks } from '@octokit/webhooks';
import type { Options, WebhookEventName } from '@octokit/webhooks/dist-types/types';
import { } from 'hono';
import { env } from 'hono/adapter';
import { createMiddleware } from 'hono/factory';

export type GitHubWebhooksEnv = {
GITHUB_WEBHOOK_SECRET: string
}

declare module 'hono' {
interface ContextVariableMap {
webhooks: InstanceType<typeof Webhooks>
}
}

/**
* Middleware to receive & validate GitHub webhook requests by verifying their signatures. It
* exposes the `webhooks` instance in the context variable map, and allows you to listen to specific
* events using the `webhooks.on`, `webhooks.onAny`, or `webhooks.onError` methods.
*
* @see [Octokit Webhooks documentation](https://github.com/octokit/webhooks.js)
*
* The webhooks instance can be accessed via `c.get('webhooks')` in the route handler.
*
* @example
* type Env = {
* GITHUB_WEBHOOK_SECRET: string
* }
*
* const app = new Hono<{ Bindings: Env }>()
*
* app.use("/webhook", GitHubWebhooksMiddleware())
*
* app.post("/webhook", async (c) => {
* const webhooks = c.get("webhooks")
*
* webhooks.on("star.created", async ({ id, name, payload }) => {
* console.log(`Received ${name} event with id ${id} and payload: ${payload}`)
* })
* })
*/
export const gitHubWebhooksMiddleware = (options?: Options) =>
createMiddleware(async (c, next) => {
const { GITHUB_WEBHOOK_SECRET } = env<GitHubWebhooksEnv>(c)
const { secret, ...rest } = options || {
secret: GITHUB_WEBHOOK_SECRET,
}

if (!secret) {
return c.text('Missing GitHub Webhook secret key', 403)
}

const webhooks = new Webhooks({ secret, ...rest })

c.set('webhooks', webhooks)

await next()

const id = c.req.header('x-github-delivery')
const signature = c.req.header('x-hub-signature-256')
const name = c.req.header('x-github-event') as WebhookEventName | undefined

if (!(id && name && signature)) {
return c.text('Invalid webhook request', 403)
}

const payload = await c.req.text()

try {
await webhooks.verifyAndReceive({
id,
name,
signature,
payload,
})
return c.text('Webhook received & verified', 201)
} catch (error) {
return c.text(`Failed to verify GitHub Webhook request: ${error}`, 400)
}
})
116 changes: 116 additions & 0 deletions packages/github-webhooks/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { sign } from '@octokit/webhooks-methods'
import { Hono } from 'hono'
import { describe, expect, test, vi } from 'vitest'

import { gitHubWebhooksMiddleware } from '../src'

const ENV_VARS = {
GITHUB_WEBHOOK_SECRET: 'GITHUB_WEBHOOK_SECRET',
}

describe('GitHub Webhooks Middleware', () => {
test('rejects incoming request when the GitHub Webhook secret key is missing', async () => {
const app = new Hono()

app.use('/webhook', gitHubWebhooksMiddleware())

const res = await app.request('/webhook', {
method: 'POST',
})

expect(res.status).toBe(403)
})

test('rejects incoming request when the signed secret is incorrect', async () => {
const app = new Hono()

app.use('/webhook', gitHubWebhooksMiddleware({ secret: ENV_VARS.GITHUB_WEBHOOK_SECRET }))

const FAULTY_SIGNATURE = 'sha256=faulty-signature'

const res = await app.request('/webhook', {
method: 'POST',
headers: {
'x-github-delivery': 'random-id-assigned-by-github',
'x-hub-signature-256': FAULTY_SIGNATURE,
'x-github-event': 'star',
'content-type': 'application/json',
},
body: JSON.stringify({ action: 'created' }),
})

expect(res.status).toBe(400)
})

test("webhooks.on('star.created') is called when repo star payload is verified", async () => {
// Simple payload for testing purposes
const body = JSON.stringify({
action: 'created',
})

// We sign the payload with the secret to simulate a real GitHub webhook request
const signature = await sign(ENV_VARS.GITHUB_WEBHOOK_SECRET, body)
const starCreationHandler = vi.fn()
const irrelevantHandler = vi.fn()

const app = new Hono()

app.use('/webhook', gitHubWebhooksMiddleware({ secret: ENV_VARS.GITHUB_WEBHOOK_SECRET }))

app.post('/webhook', async (c) => {
const webhooks = c.var.webhooks
webhooks.on('star.created', starCreationHandler)
webhooks.on('discussion.created', irrelevantHandler)
})

const res = await app.request('/webhook', {
method: 'POST',
headers: {
'x-github-delivery': 'random-id-assigned-by-github',
'x-hub-signature-256': signature,
'x-github-event': 'star',
'content-type': 'application/json',
},
body: JSON.stringify({ action: 'created' }),
})

expect(res.status).toBe(201)
expect(starCreationHandler).toHaveBeenCalledOnce()
expect(irrelevantHandler).not.toHaveBeenCalled()
})

test("webhooks.on('issues.opened') is called when repo issue payload is verified", async () => {
const body = JSON.stringify({
action: 'opened',
})

const signature = await sign(ENV_VARS.GITHUB_WEBHOOK_SECRET, body)
const issuesOpenedHandler = vi.fn()
const irrelevantHandler = vi.fn()

const app = new Hono()

app.use('/webhook', gitHubWebhooksMiddleware({ secret: ENV_VARS.GITHUB_WEBHOOK_SECRET }))

app.post('/webhook', async (c) => {
const webhooks = c.var.webhooks
webhooks.on('issues.opened', issuesOpenedHandler)
webhooks.on('project.closed', irrelevantHandler)
})

const res = await app.request('/webhook', {
method: 'POST',
headers: {
'x-github-delivery': 'random-id-assigned-by-github',
'x-hub-signature-256': signature,
'x-github-event': 'issues',
'content-type': 'application/json',
},
body: JSON.stringify({ action: 'opened' }),
})

expect(res.status).toBe(201)
expect(issuesOpenedHandler).toHaveBeenCalledOnce()
expect(irrelevantHandler).not.toHaveBeenCalled()
})
})
8 changes: 8 additions & 0 deletions packages/github-webhooks/test/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"module": "Preserve",
"moduleResolution": "bundler"
},
"include": ["./*.test.ts"],
}
Loading