feat: Event Emitter middleware (#615)
* Add event-emitter middleware * Commit yarn.lock * Add author to package.json * Remove CHANGELOG.md * Bump up node version * Adjust initial versionpull/625/head
parent
9bf6c4bb8e
commit
53b4f33190
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@hono/event-emitter': major
|
||||||
|
---
|
||||||
|
|
||||||
|
Full release of Event Emitter middleware for Hono
|
|
@ -0,0 +1,25 @@
|
||||||
|
name: ci-event-emitter
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'packages/event-emitter/**'
|
||||||
|
pull_request:
|
||||||
|
branches: ['*']
|
||||||
|
paths:
|
||||||
|
- 'packages/event-emitter/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./packages/event-emitter
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20.x
|
||||||
|
- run: yarn install --frozen-lockfile
|
||||||
|
- run: yarn build
|
||||||
|
- run: yarn test
|
|
@ -24,6 +24,7 @@
|
||||||
"build:typia-validator": "yarn workspace @hono/typia-validator build",
|
"build:typia-validator": "yarn workspace @hono/typia-validator build",
|
||||||
"build:swagger-ui": "yarn workspace @hono/swagger-ui build",
|
"build:swagger-ui": "yarn workspace @hono/swagger-ui build",
|
||||||
"build:esbuild-transpiler": "yarn workspace @hono/esbuild-transpiler build",
|
"build:esbuild-transpiler": "yarn workspace @hono/esbuild-transpiler build",
|
||||||
|
"build:event-emitter": "yarn workspace @hono/event-emitter build",
|
||||||
"build:oauth-providers": "yarn workspace @hono/oauth-providers build",
|
"build:oauth-providers": "yarn workspace @hono/oauth-providers build",
|
||||||
"build:react-renderer": "yarn workspace @hono/react-renderer build",
|
"build:react-renderer": "yarn workspace @hono/react-renderer build",
|
||||||
"build:auth-js": "yarn workspace @hono/auth-js build",
|
"build:auth-js": "yarn workspace @hono/auth-js build",
|
||||||
|
|
|
@ -0,0 +1,359 @@
|
||||||
|
# Event Emitter middleware for Hono
|
||||||
|
|
||||||
|
Minimal, lightweight and edge compatible Event Emitter middleware for [Hono](https://github.com/honojs/hono).
|
||||||
|
|
||||||
|
It enables event driven logic flow in hono applications (essential in larger projects or projects with a lot of interactions between features).
|
||||||
|
|
||||||
|
Inspired by event emitter concept in other frameworks such
|
||||||
|
as [Adonis.js](https://docs.adonisjs.com/guides/emitter), [Nest.js](https://docs.nestjs.com/techniques/events), [Hapi.js](https://github.com/hapijs/podium), [Laravel](https://laravel.com/docs/11.x/events), [Sails.js](https://sailsjs.com/documentation/concepts/extending-sails/hooks/events), [Meteor](https://github.com/Meteor-Community-Packages/Meteor-EventEmitter) and others.
|
||||||
|
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install @hono/event-emitter
|
||||||
|
# or
|
||||||
|
yarn add @hono/event-emitter
|
||||||
|
# or
|
||||||
|
pnpm add @hono/event-emitter
|
||||||
|
# or
|
||||||
|
bun install @hono/event-emitter
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
#### There are 2 ways you can use this with Hono:
|
||||||
|
|
||||||
|
### 1. As Hono middleware
|
||||||
|
|
||||||
|
```js
|
||||||
|
// event-handlers.js
|
||||||
|
|
||||||
|
// Define event handlers
|
||||||
|
export const handlers = {
|
||||||
|
'user:created': [
|
||||||
|
(c, payload) => {} // c is current Context, payload will be correctly inferred as User
|
||||||
|
],
|
||||||
|
'user:deleted': [
|
||||||
|
(c, payload) => {} // c is current Context, payload will be inferred as string
|
||||||
|
],
|
||||||
|
'foo': [
|
||||||
|
(c, payload) => {} // c is current Context, payload will be inferred as { bar: number }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// You can also define single event handler as named function
|
||||||
|
// export const userCreatedHandler = (c, user) => {
|
||||||
|
// // c is current Context, payload will be inferred as User
|
||||||
|
// // ...
|
||||||
|
// console.log('New user created:', user)
|
||||||
|
// }
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// app.js
|
||||||
|
|
||||||
|
import { emitter } from '@hono/event-emitter'
|
||||||
|
import { handlers, userCreatedHandler } from './event-handlers'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
|
||||||
|
// Initialize the app with emitter type
|
||||||
|
const app = new Hono()
|
||||||
|
|
||||||
|
// Register the emitter middleware and provide it with the handlers
|
||||||
|
app.use('*', emitter(handlers))
|
||||||
|
|
||||||
|
// You can also setup "named function" as event listener inside middleware or route handler
|
||||||
|
// app.use((c, next) => {
|
||||||
|
// c.get('emitter').on('user:created', userCreatedHandler)
|
||||||
|
// return next()
|
||||||
|
// })
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.post('/user', async (c) => {
|
||||||
|
// ...
|
||||||
|
// Emit event and pass current context plus the payload
|
||||||
|
c.get('emitter').emit('user:created', c, user)
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/user/:id', async (c) => {
|
||||||
|
// ...
|
||||||
|
// Emit event and pass current context plus the payload
|
||||||
|
c.get('emitter').emit('user:deleted', c, id)
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app
|
||||||
|
```
|
||||||
|
|
||||||
|
The emitter is available in the context as `emitter` key, and handlers (when using named functions) will only be subscribed to events once, even if the middleware is called multiple times.
|
||||||
|
|
||||||
|
As seen above (commented out) you can also subscribe to events inside middlewares or route handlers, but you can only use named functions to prevent duplicates!
|
||||||
|
|
||||||
|
### 2 Standalone
|
||||||
|
|
||||||
|
|
||||||
|
```js
|
||||||
|
// events.js
|
||||||
|
|
||||||
|
import { createEmitter } from '@hono/event-emitter'
|
||||||
|
|
||||||
|
// Define event handlers
|
||||||
|
export const handlers = {
|
||||||
|
'user:created': [
|
||||||
|
(c, payload) => {} // c is current Context, payload will be whatever you pass to emit method
|
||||||
|
],
|
||||||
|
'user:deleted': [
|
||||||
|
(c, payload) => {} // c is current Context, payload will be whatever you pass to emit method
|
||||||
|
],
|
||||||
|
'foo': [
|
||||||
|
(c, payload) => {} // c is current Context, payload will be whatever you pass to emit method
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize emitter with handlers
|
||||||
|
const emitter = createEmitter(handlers)
|
||||||
|
|
||||||
|
// And you can add more listeners on the fly.
|
||||||
|
// Here you CAN use anonymous or closure function because .on() is only called once.
|
||||||
|
emitter.on('user:updated', (c, payload) => {
|
||||||
|
console.log('User updated:', payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default emitter
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// app.js
|
||||||
|
|
||||||
|
import emitter from './events'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
|
||||||
|
// Initialize the app
|
||||||
|
const app = new Hono()
|
||||||
|
|
||||||
|
app.post('/user', async (c) => {
|
||||||
|
// ...
|
||||||
|
// Emit event and pass current context plus the payload
|
||||||
|
emitter.emit('user:created', c, user)
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/user/:id', async (c) => {
|
||||||
|
// ...
|
||||||
|
// Emit event and pass current context plus the payload
|
||||||
|
emitter.emit('user:deleted', c, id )
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Typescript
|
||||||
|
|
||||||
|
### 1. As hono middleware
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// types.ts
|
||||||
|
|
||||||
|
import type { Emitter } from '@hono/event-emitter'
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
id: string,
|
||||||
|
title: string,
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AvailableEvents = {
|
||||||
|
// event key: payload type
|
||||||
|
'user:created': User;
|
||||||
|
'user:deleted': string;
|
||||||
|
'foo': { bar: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Env = {
|
||||||
|
Bindings: {};
|
||||||
|
Variables: {
|
||||||
|
// Define emitter variable type
|
||||||
|
emitter: Emitter<AvailableEvents>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// event-handlers.ts
|
||||||
|
|
||||||
|
import { defineHandlers } from '@hono/event-emitter'
|
||||||
|
import { AvailableEvents } from './types'
|
||||||
|
|
||||||
|
// Define event handlers
|
||||||
|
export const handlers = defineHandlers<AvailableEvents>({
|
||||||
|
'user:created': [
|
||||||
|
(c, user) => {} // c is current Context, payload will be correctly inferred as User
|
||||||
|
],
|
||||||
|
'user:deleted': [
|
||||||
|
(c, payload) => {} // c is current Context, payload will be inferred as string
|
||||||
|
],
|
||||||
|
'foo': [
|
||||||
|
(c, payload) => {} // c is current Context, payload will be inferred as { bar: number }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// You can also define single event handler as named function using defineHandler to leverage typings
|
||||||
|
// export const userCreatedHandler = defineHandler<AvailableEvents, 'user:created'>((c, user) => {
|
||||||
|
// // c is current Context, payload will be inferred as User
|
||||||
|
// // ...
|
||||||
|
// console.log('New user created:', user)
|
||||||
|
// })
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app.ts
|
||||||
|
|
||||||
|
import { emitter, type Emitter, type EventHandlers } from '@hono/event-emitter'
|
||||||
|
import { handlers, userCreatedHandler } from './event-handlers'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import { Env } from './types'
|
||||||
|
|
||||||
|
// Initialize the app
|
||||||
|
const app = new Hono<Env>()
|
||||||
|
|
||||||
|
// Register the emitter middleware and provide it with the handlers
|
||||||
|
app.use('*', emitter(handlers))
|
||||||
|
|
||||||
|
// You can also setup "named function" as event listener inside middleware or route handler
|
||||||
|
// app.use((c, next) => {
|
||||||
|
// c.get('emitter').on('user:created', userCreatedHandler)
|
||||||
|
// return next()
|
||||||
|
// })
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.post('/user', async (c) => {
|
||||||
|
// ...
|
||||||
|
// Emit event and pass current context plus the payload (User type)
|
||||||
|
c.get('emitter').emit('user:created', c, user)
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/user/:id', async (c) => {
|
||||||
|
// ...
|
||||||
|
// Emit event and pass current context plus the payload (string)
|
||||||
|
c.get('emitter').emit('user:deleted', c, id)
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Standalone:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// types.ts
|
||||||
|
|
||||||
|
type User = {
|
||||||
|
id: string,
|
||||||
|
title: string,
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AvailableEvents = {
|
||||||
|
// event key: payload type
|
||||||
|
'user:created': User;
|
||||||
|
'user:updated': User;
|
||||||
|
'user:deleted': string,
|
||||||
|
'foo': { bar: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// events.ts
|
||||||
|
|
||||||
|
import { createEmitter, defineHandlers, type Emitter, type EventHandlers } from '@hono/event-emitter'
|
||||||
|
import { AvailableEvents } from './types'
|
||||||
|
|
||||||
|
// Define event handlers
|
||||||
|
export const handlers = defineHandlers<AvailableEvents>({
|
||||||
|
'user:created': [
|
||||||
|
(c, user) => {} // c is current Context, payload will be correctly inferred as User
|
||||||
|
],
|
||||||
|
'user:deleted': [
|
||||||
|
(c, payload) => {} // c is current Context, payload will be inferred as string
|
||||||
|
],
|
||||||
|
'foo': [
|
||||||
|
(c, payload) => {} // c is current Context, payload will be inferred as { bar: number }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// You can also define single event handler using defineHandler to leverage typings
|
||||||
|
// export const userCreatedHandler = defineHandler<AvailableEvents, 'user:created'>((c, payload) => {
|
||||||
|
// // c is current Context, payload will be correctly inferred as User
|
||||||
|
// // ...
|
||||||
|
// console.log('New user created:', payload)
|
||||||
|
// })
|
||||||
|
|
||||||
|
// Initialize emitter with handlers
|
||||||
|
const emitter = createEmitter(handlers)
|
||||||
|
|
||||||
|
// emitter.on('user:created', userCreatedHandler)
|
||||||
|
|
||||||
|
// And you can add more listeners on the fly.
|
||||||
|
// Here you can use anonymous or closure function because .on() is only called once.
|
||||||
|
emitter.on('user:updated', (c, payload) => { // Payload will be correctly inferred as User
|
||||||
|
console.log('User updated:', payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default emitter
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app.ts
|
||||||
|
|
||||||
|
import emitter from './events'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
|
||||||
|
// Initialize the app
|
||||||
|
const app = new Hono()
|
||||||
|
|
||||||
|
app.post('/user', async (c) => {
|
||||||
|
// ...
|
||||||
|
// Emit event and pass current context plus the payload (User)
|
||||||
|
emitter.emit('user:created', c, user)
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/user/:id', async (c) => {
|
||||||
|
// ...
|
||||||
|
// Emit event and pass current context plus the payload (string)
|
||||||
|
emitter.emit('user:deleted', c, id )
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### NOTE:
|
||||||
|
|
||||||
|
When assigning event handlers inside of middleware or route handlers, don't use anonymous or closure functions, only named functions!
|
||||||
|
This is because anonymous functions or closures in javascript are created as new object every time and therefore can't be easily checked for equality/duplicates.
|
||||||
|
|
||||||
|
|
||||||
|
For more usage examples, see the [tests](src/index.test.ts) or [Hono REST API starter kit](https://github.com/DavidHavl/hono-rest-api-starter)
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
- David Havl - <https://github.com/DavidHavl>
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
|
@ -0,0 +1,43 @@
|
||||||
|
{
|
||||||
|
"name": "@hono/event-emitter",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "Event Emitter middleware for Hono",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"module": "dist/index.mjs",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest --run",
|
||||||
|
"build": "tsup ./src/index.ts --format esm,cjs --dts",
|
||||||
|
"publint": "publint",
|
||||||
|
"release": "yarn build && yarn test && yarn publint && yarn publish"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.mts",
|
||||||
|
"import": "./dist/index.mjs",
|
||||||
|
"require": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"author": "David Havl <contact@davidhavl.com> (https://github.com/DavidHavl)",
|
||||||
|
"peerDependencies": {
|
||||||
|
"hono": "*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"hono": "^3.11.7",
|
||||||
|
"tsup": "^8.0.1",
|
||||||
|
"vitest": "^1.0.4"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,243 @@
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { expect, vi } from 'vitest';
|
||||||
|
import { emitter, createEmitter, type Emitter, type EventHandlers, defineHandler, defineHandlers } from '../src';
|
||||||
|
|
||||||
|
describe('EventEmitter', () => {
|
||||||
|
describe('Used inside of route handlers', () => {
|
||||||
|
it('Should work when subscribing to events inside of route handler', async () => {
|
||||||
|
type EventHandlerPayloads = {
|
||||||
|
'todo:created': { id: string; text: string };
|
||||||
|
};
|
||||||
|
type Env = { Variables: { emitter: Emitter<EventHandlerPayloads> } };
|
||||||
|
|
||||||
|
const handler = defineHandler<EventHandlerPayloads, 'todo:created'>((_c, _payload) => {});
|
||||||
|
|
||||||
|
const spy = vi.fn(handler);
|
||||||
|
|
||||||
|
const app = new Hono<Env>();
|
||||||
|
|
||||||
|
app.use('*', emitter());
|
||||||
|
|
||||||
|
app.use((c, next) => {
|
||||||
|
c.get('emitter').on('todo:created', spy);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
let currentContext = null;
|
||||||
|
app.post('/todo', (c) => {
|
||||||
|
currentContext = c;
|
||||||
|
c.get('emitter').emit('todo:created', c, { id: '2', text: 'Buy milk' });
|
||||||
|
return c.json({ message: 'Todo created' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request('http://localhost/todo', { method: 'POST' });
|
||||||
|
expect(res).not.toBeNull();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(spy).toHaveBeenCalledWith(currentContext, { id: '2', text: 'Buy milk' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should not subscribe same handler to same event twice inside of route handler', async () => {
|
||||||
|
type EventHandlerPayloads = {
|
||||||
|
'todo:created': { id: string; text: string };
|
||||||
|
};
|
||||||
|
type Env = { Variables: { emitter: Emitter<EventHandlerPayloads> } };
|
||||||
|
|
||||||
|
const handler = defineHandler<EventHandlerPayloads, 'todo:created'>((_c, _payload) => {});
|
||||||
|
|
||||||
|
const spy = vi.fn(handler);
|
||||||
|
|
||||||
|
const app = new Hono<Env>();
|
||||||
|
|
||||||
|
app.use('*', emitter());
|
||||||
|
|
||||||
|
app.use((c, next) => {
|
||||||
|
c.get('emitter').on('todo:created', spy);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/todo', (c) => {
|
||||||
|
c.get('emitter').emit('todo:created', c, { id: '2', text: 'Buy milk' });
|
||||||
|
return c.json({ message: 'Todo created' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.request('http://localhost/todo', { method: 'POST' });
|
||||||
|
await app.request('http://localhost/todo', { method: 'POST' });
|
||||||
|
await app.request('http://localhost/todo', { method: 'POST' });
|
||||||
|
expect(spy).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should work assigning event handlers via middleware', async () => {
|
||||||
|
type EventHandlerPayloads = {
|
||||||
|
'todo:created': { id: string; text: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type Env = { Variables: { emitter: Emitter<EventHandlerPayloads> } };
|
||||||
|
|
||||||
|
const handlers = defineHandlers<EventHandlerPayloads>({
|
||||||
|
'todo:created': [vi.fn((_c, _payload) => {})],
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = new Hono<Env>();
|
||||||
|
|
||||||
|
app.use('*', emitter(handlers));
|
||||||
|
|
||||||
|
let currentContext = null;
|
||||||
|
app.post('/todo', (c) => {
|
||||||
|
currentContext = c;
|
||||||
|
c.get('emitter').emit('todo:created', c, { id: '2', text: 'Buy milk' });
|
||||||
|
return c.json({ message: 'Todo created' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request('http://localhost/todo', { method: 'POST' });
|
||||||
|
expect(res).not.toBeNull();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(handlers['todo:created']?.[0]).toHaveBeenCalledWith(currentContext, { id: '2', text: 'Buy milk' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Used as standalone', () => {
|
||||||
|
it('Should work assigning event handlers via createEmitter function param', async () => {
|
||||||
|
type EventHandlerPayloads = {
|
||||||
|
'todo:created': { id: string; text: string };
|
||||||
|
'todo:deleted': { id: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type Env = { Variables: { emitter: Emitter<EventHandlerPayloads> } };
|
||||||
|
|
||||||
|
const handlers: EventHandlers<EventHandlerPayloads> = {
|
||||||
|
'todo:created': [vi.fn((_payload) => {})],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ee = createEmitter<EventHandlerPayloads>(handlers);
|
||||||
|
|
||||||
|
const todoDeletedHandler = vi.fn(defineHandler<EventHandlerPayloads, 'todo:deleted'>((_c, _payload) => {}));
|
||||||
|
|
||||||
|
ee.on('todo:deleted', todoDeletedHandler);
|
||||||
|
|
||||||
|
const app = new Hono<Env>();
|
||||||
|
|
||||||
|
let todoCreatedContext = null;
|
||||||
|
app.post('/todo', (c) => {
|
||||||
|
todoCreatedContext = c;
|
||||||
|
ee.emit('todo:created', c, { id: '2', text: 'Buy milk' });
|
||||||
|
return c.json({ message: 'Todo created' });
|
||||||
|
});
|
||||||
|
|
||||||
|
let todoDeletedContext = null;
|
||||||
|
app.delete('/todo/123', (c) => {
|
||||||
|
todoDeletedContext = c;
|
||||||
|
ee.emit('todo:deleted', c, { id: '3' });
|
||||||
|
return c.json({ message: 'Todo deleted' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request('http://localhost/todo', { method: 'POST' });
|
||||||
|
expect(res).not.toBeNull();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(handlers['todo:created']?.[0]).toHaveBeenCalledWith(todoCreatedContext, { id: '2', text: 'Buy milk' });
|
||||||
|
const res2 = await app.request('http://localhost/todo/123', { method: 'DELETE' });
|
||||||
|
expect(res2).not.toBeNull();
|
||||||
|
expect(res2.status).toBe(200);
|
||||||
|
expect(todoDeletedHandler).toHaveBeenCalledWith(todoDeletedContext, { id: '3' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should work assigning event handlers via standalone on()', async () => {
|
||||||
|
type EventHandlerPayloads = {
|
||||||
|
'todo:created': { id: string; text: string };
|
||||||
|
'todo:deleted': { id: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type Env = { Variables: { emitter: Emitter<EventHandlerPayloads> } };
|
||||||
|
|
||||||
|
const ee = createEmitter<EventHandlerPayloads>();
|
||||||
|
|
||||||
|
const todoDeletedHandler = defineHandler<EventHandlerPayloads, 'todo:deleted'>(
|
||||||
|
(_c, _payload: EventHandlerPayloads['todo:deleted']) => {},
|
||||||
|
);
|
||||||
|
|
||||||
|
const spy = vi.fn(todoDeletedHandler);
|
||||||
|
|
||||||
|
ee.on('todo:deleted', spy);
|
||||||
|
|
||||||
|
const app = new Hono<Env>();
|
||||||
|
|
||||||
|
let currentContext = null;
|
||||||
|
app.delete('/todo/123', (c) => {
|
||||||
|
currentContext = c;
|
||||||
|
ee.emit('todo:deleted', c, { id: '2' });
|
||||||
|
return c.json({ message: 'Todo created' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request('http://localhost/todo/123', { method: 'DELETE' });
|
||||||
|
expect(res).not.toBeNull();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(spy).toHaveBeenCalledWith(currentContext, { id: '2' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should work removing event handlers via off() method', async () => {
|
||||||
|
type EventHandlerPayloads = {
|
||||||
|
'todo:created': { id: string; text: string };
|
||||||
|
'todo:deleted': { id: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type Env = { Variables: { emitter: Emitter<EventHandlerPayloads> } };
|
||||||
|
|
||||||
|
const ee = createEmitter<EventHandlerPayloads>();
|
||||||
|
|
||||||
|
const todoDeletedHandler = defineHandler<EventHandlerPayloads, 'todo:deleted'>(
|
||||||
|
(_c, _payload: EventHandlerPayloads['todo:deleted']) => {},
|
||||||
|
);
|
||||||
|
|
||||||
|
const spy = vi.fn(todoDeletedHandler);
|
||||||
|
|
||||||
|
ee.on('todo:deleted', spy);
|
||||||
|
|
||||||
|
const app = new Hono<Env>();
|
||||||
|
|
||||||
|
app.post('/todo', (c) => {
|
||||||
|
ee.emit('todo:deleted', c, { id: '2' });
|
||||||
|
ee.off('todo:deleted', spy);
|
||||||
|
return c.json({ message: 'Todo created' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.request('http://localhost/todo', { method: 'POST' });
|
||||||
|
await app.request('http://localhost/todo', { method: 'POST' });
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should work removing all event handlers via off() method not providing handler as second argument', async () => {
|
||||||
|
type EventHandlerPayloads = {
|
||||||
|
'todo:deleted': { id: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type Env = { Variables: { emitter: Emitter<EventHandlerPayloads> } };
|
||||||
|
|
||||||
|
const ee = createEmitter<EventHandlerPayloads>();
|
||||||
|
|
||||||
|
const todoDeletedHandler = defineHandler<EventHandlerPayloads, 'todo:deleted'>(
|
||||||
|
(_c, _payload: EventHandlerPayloads['todo:deleted']) => {},
|
||||||
|
);
|
||||||
|
const todoDeletedHandler2 = defineHandler<EventHandlerPayloads, 'todo:deleted'>(
|
||||||
|
(_c, _payload: EventHandlerPayloads['todo:deleted']) => {},
|
||||||
|
);
|
||||||
|
|
||||||
|
const spy = vi.fn(todoDeletedHandler);
|
||||||
|
const spy2 = vi.fn(todoDeletedHandler2);
|
||||||
|
|
||||||
|
ee.on('todo:deleted', spy);
|
||||||
|
ee.on('todo:deleted', spy2);
|
||||||
|
|
||||||
|
const app = new Hono<Env>();
|
||||||
|
|
||||||
|
app.post('/todo', (c) => {
|
||||||
|
ee.emit('todo:deleted', c, { id: '2' });
|
||||||
|
ee.off('todo:deleted');
|
||||||
|
return c.json({ message: 'Todo created' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.request('http://localhost/todo', { method: 'POST' });
|
||||||
|
await app.request('http://localhost/todo', { method: 'POST' });
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(spy2).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,234 @@
|
||||||
|
/**
|
||||||
|
* @module
|
||||||
|
* Event Emitter Middleware for Hono.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Context, Env, MiddlewareHandler } from 'hono';
|
||||||
|
import { createMiddleware } from 'hono/factory'
|
||||||
|
|
||||||
|
export type EventKey = string | symbol;
|
||||||
|
export type EventHandler<T, E extends Env = Env> = (c: Context<E>, payload: T) => void | Promise<void>;
|
||||||
|
export type EventHandlers<T> = { [K in keyof T]?: EventHandler<T[K]>[] };
|
||||||
|
|
||||||
|
export interface Emitter<EventHandlerPayloads> {
|
||||||
|
on<Key extends keyof EventHandlerPayloads>(key: Key, handler: EventHandler<EventHandlerPayloads[Key]>): void;
|
||||||
|
off<Key extends keyof EventHandlerPayloads>(key: Key, handler?: EventHandler<EventHandlerPayloads[Key]>): void;
|
||||||
|
emit<Key extends keyof EventHandlerPayloads>(key: Key, c: Context, payload: EventHandlerPayloads[Key]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to define fully typed event handler.
|
||||||
|
* @param {EventHandler} handler - The event handlers.
|
||||||
|
* @returns The event handler.
|
||||||
|
*/
|
||||||
|
export const defineHandler = <T, K extends keyof T, E extends Env = Env>(
|
||||||
|
handler: EventHandler<T[K], E>,
|
||||||
|
): EventHandler<T[K], E> => {
|
||||||
|
return handler;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to define fully typed event handlers.
|
||||||
|
* @param {EventHandler[]} handlers - An object where each key is an event type and the value is an array of event handlers.
|
||||||
|
* @returns The event handlers.
|
||||||
|
*/
|
||||||
|
export const defineHandlers = <T, E extends Env = Env>(handlers: { [K in keyof T]?: EventHandler<T[K], E>[] }) => {
|
||||||
|
return handlers;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Event Emitter instance.
|
||||||
|
*
|
||||||
|
* @param {EventHandlers} eventHandlers - Event handlers to be registered.
|
||||||
|
* @returns {Emitter} The EventEmitter instance.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```js
|
||||||
|
* // Define event handlers
|
||||||
|
* const handlers: {
|
||||||
|
* 'foo': [
|
||||||
|
* (c, payload) => { console.log('Foo:', payload) }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Initialize emitter with handlers
|
||||||
|
* const ee = createEmitter(handlers)
|
||||||
|
*
|
||||||
|
* // AND/OR add more listeners on the fly.
|
||||||
|
* ee.on('bar', (c, payload) => {
|
||||||
|
* c.get('logger').log('Bar:', payload.item.id)
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* // Use the emitter to emit events.
|
||||||
|
* ee.emit('foo', c, 42)
|
||||||
|
* ee.emit('bar', c, { item: { id: '12345678' } })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* type AvailableEvents = {
|
||||||
|
* // event key: payload type
|
||||||
|
* 'foo': number;
|
||||||
|
* 'bar': { item: { id: string } };
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* // Define event handlers
|
||||||
|
* const handlers: defineHandlers<AvailableEvents>({
|
||||||
|
* 'foo': [
|
||||||
|
* (c, payload) => { console.log('Foo:', payload) } // payload will be inferred as number
|
||||||
|
* ]
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* // Initialize emitter with handlers
|
||||||
|
* const ee = createEmitter(handlers)
|
||||||
|
*
|
||||||
|
* // AND/OR add more listeners on the fly.
|
||||||
|
* ee.on('bar', (c, payload) => {
|
||||||
|
* c.get('logger').log('Bar:', payload.item.id)
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* // Use the emitter to emit events.
|
||||||
|
* ee.emit('foo', c, 42) // Payload will be expected to be of a type number
|
||||||
|
* ee.emit('bar', c, { item: { id: '12345678' }, c }) // Payload will be expected to be of a type { item: { id: string }, c: Context }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export const createEmitter = <EventHandlerPayloads>(
|
||||||
|
eventHandlers?: EventHandlers<EventHandlerPayloads>,
|
||||||
|
): Emitter<EventHandlerPayloads> => {
|
||||||
|
// A map of event keys and their corresponding event handlers.
|
||||||
|
const handlers: Map<EventKey, EventHandler<unknown>[]> = eventHandlers
|
||||||
|
? new Map(Object.entries(eventHandlers))
|
||||||
|
: new Map();
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Add an event handler for the given event key.
|
||||||
|
* @param {string|symbol} key Type of event to listen for
|
||||||
|
* @param {Function} handler Function that is invoked when the specified event occurs
|
||||||
|
*/
|
||||||
|
on<Key extends keyof EventHandlerPayloads>(key: Key, handler: EventHandler<EventHandlerPayloads[Key]>) {
|
||||||
|
if (!handlers.has(key as EventKey)) {
|
||||||
|
handlers.set(key as EventKey, []);
|
||||||
|
}
|
||||||
|
const handlerArray = handlers.get(key as EventKey) as Array<EventHandler<EventHandlerPayloads[Key]>>;
|
||||||
|
if (!handlerArray.includes(handler)) {
|
||||||
|
handlerArray.push(handler);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an event handler for the given event key.
|
||||||
|
* If `handler` is undefined, all handlers for the given key are removed.
|
||||||
|
* @param {string|symbol} key Type of event to unregister `handler` from
|
||||||
|
* @param {Function} handler - Handler function to remove
|
||||||
|
*/
|
||||||
|
off<Key extends keyof EventHandlerPayloads>(key: Key, handler?: EventHandler<EventHandlerPayloads[Key]>) {
|
||||||
|
if (!handler) {
|
||||||
|
handlers.delete(key as EventKey);
|
||||||
|
} else {
|
||||||
|
const handlerArray = handlers.get(key as EventKey);
|
||||||
|
if (handlerArray) {
|
||||||
|
handlers.set(
|
||||||
|
key as EventKey,
|
||||||
|
handlerArray.filter((h) => h !== handler),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit an event with the given event key and payload.
|
||||||
|
* Triggers all event handlers associated with the specified key.
|
||||||
|
* @param {string|symbol} key - The event key
|
||||||
|
* @param {Context} c - The current context object
|
||||||
|
* @param {EventHandlerPayloads[keyof EventHandlerPayloads]} payload - Data passed to each invoked handler
|
||||||
|
*/
|
||||||
|
emit<Key extends keyof EventHandlerPayloads>(key: Key, c: Context, payload: EventHandlerPayloads[Key]) {
|
||||||
|
const handlerArray = handlers.get(key as EventKey);
|
||||||
|
if (handlerArray) {
|
||||||
|
for (const handler of handlerArray) {
|
||||||
|
handler(c, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event Emitter Middleware for Hono.
|
||||||
|
*
|
||||||
|
* @see {@link https://hono.dev/middleware/builtin/event-emitter}
|
||||||
|
*
|
||||||
|
* @param {EventHandlers} eventHandlers - Event handlers to be registered.
|
||||||
|
* @returns {MiddlewareHandler} The middleware handler function.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```js
|
||||||
|
*
|
||||||
|
* // Define event handlers
|
||||||
|
* const handlers: {
|
||||||
|
* 'foo': [
|
||||||
|
* (c, payload) => { console.log('Foo:', payload) }
|
||||||
|
* ]
|
||||||
|
* 'bar': [
|
||||||
|
* (c, payload) => { console.log('Bar:', payload.item.id) }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* const app = new Hono()
|
||||||
|
*
|
||||||
|
* // Register the emitter middleware and provide it with the handlers
|
||||||
|
* app.use('\*', emitter(handlers))
|
||||||
|
*
|
||||||
|
* // Use the emitter in route handlers to emit events.
|
||||||
|
* app.post('/foo', async (c) => {
|
||||||
|
* // The emitter is available under "emitter" key in the context.
|
||||||
|
* c.get('emitter').emit('foo', c, 42)
|
||||||
|
* c.get('emitter').emit('bar', c, { item: { id: '12345678' } })
|
||||||
|
* return c.text('Success')
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* type AvailableEvents = {
|
||||||
|
* // event key: payload type
|
||||||
|
* 'foo': number;
|
||||||
|
* 'bar': { item: { id: string } };
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* type Env = { Bindings: {}; Variables: { emitter: Emitter<AvailableEvents> }; }
|
||||||
|
*
|
||||||
|
* // Define event handlers
|
||||||
|
* const handlers: defineHandlers<AvailableEvents>({
|
||||||
|
* 'foo': [
|
||||||
|
* (c, payload) => { console.log('Foo:', payload) } // payload will be inferred as number
|
||||||
|
* ]
|
||||||
|
* 'bar': [
|
||||||
|
* (c, payload) => { console.log('Bar:', payload.item.id) } // payload will be inferred as { item: { id: string } }
|
||||||
|
* ]
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* const app = new Hono<Env>()
|
||||||
|
*
|
||||||
|
* // Register the emitter middleware and provide it with the handlers
|
||||||
|
* app.use('\*', emitter(handlers))
|
||||||
|
*
|
||||||
|
* // Use the emitter in route handlers to emit events.
|
||||||
|
* app.post('/foo', async (c) => {
|
||||||
|
* // The emitter is available under "emitter" key in the context.
|
||||||
|
* c.get('emitter').emit('foo', c, 42) // Payload will be expected to be of a type number
|
||||||
|
* c.get('emitter').emit('bar', c, { item: { id: '12345678' } }) // Payload will be expected to be of a type { item: { id: string } }
|
||||||
|
* return c.text('Success')
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const emitter = <EventHandlerPayloads>(
|
||||||
|
eventHandlers?: EventHandlers<EventHandlerPayloads>,
|
||||||
|
): MiddlewareHandler => {
|
||||||
|
// Create new instance to share with any middleware and handlers
|
||||||
|
const instance = createEmitter<EventHandlerPayloads>(eventHandlers);
|
||||||
|
return createMiddleware(async (c, next) => {
|
||||||
|
c.set('emitter', instance);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
};
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
/// <reference types="vitest" />
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
})
|
12
yarn.lock
12
yarn.lock
|
@ -1880,6 +1880,18 @@ __metadata:
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
linkType: soft
|
||||||
|
|
||||||
|
"@hono/event-emitter@workspace:packages/event-emitter":
|
||||||
|
version: 0.0.0-use.local
|
||||||
|
resolution: "@hono/event-emitter@workspace:packages/event-emitter"
|
||||||
|
dependencies:
|
||||||
|
hono: "npm:^3.11.7"
|
||||||
|
tsup: "npm:^8.0.1"
|
||||||
|
vitest: "npm:^1.0.4"
|
||||||
|
peerDependencies:
|
||||||
|
hono: "*"
|
||||||
|
languageName: unknown
|
||||||
|
linkType: soft
|
||||||
|
|
||||||
"@hono/firebase-auth@workspace:packages/firebase-auth":
|
"@hono/firebase-auth@workspace:packages/firebase-auth":
|
||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@hono/firebase-auth@workspace:packages/firebase-auth"
|
resolution: "@hono/firebase-auth@workspace:packages/firebase-auth"
|
||||||
|
|
Loading…
Reference in New Issue