hono icon indicating copy to clipboard operation
hono copied to clipboard

feat(middleware): Event Emitter middleware

Open DavidHavl opened this issue 1 year ago • 11 comments

Hi there, I have created Event Emitter functionality/middleware for Hono and have been using it in my projects for a while. It works great so I wanted to contribute it to the Hono itself if you like it.

See usage bellow. For more complete documentation: https://github.com/DavidHavl/hono-event-emitter

Usage

// app.js

import { emitter } from 'hono-event-emitter'
import { Hono } from 'hono'

// Define event handlers
const handlers = {
  'todo:created': [
    (payload, c) => { console.log('New todo created:', payload) }
  ],
  'foo': [
    (payload) => { console.log('Foo:', payload) }
  ]
}

const app = new Hono()

// Register the emitter middleware and provide it with the handlers
app.use('*', emitter(handlers))

app.post('/todo', async (c) => {
  // ...
  // The emitter is available under "emitter" key in the context. Use emit method to emit events
  c.get('emitter').emit('foo', 42)
  // You can also pass along the context
  c.get('emitter').emit('todo:created', { todo, c })
})

export default app

You can also subscribe to events inside middlewares or route handlers. 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.

// Define event handler as named function
const todoCreatedHandler = ({ todo, c }) => {
  console.log('New todo created:', todo)
}
// ...
app.use((c) => {
  // ...
  // Subscribe to event
  c.get('emitter').on('todo:created', todoCreatedHandler)
})

app.post('/todo', async (c) => {
  // ...
    // Emit event
  c.get('emitter').emit('todo:created', { todo, c });
})
// ...

Typescript

// app.ts

import { emitter, type Emitter, type EventHandlers } from 'hono-event-emitter'
import { Hono } from 'hono'

type Todo = {
  id: string,
  title: string,
  completed: boolean
}

type AvailableEvents = {
  // event key: payload type
  'todo:created': { todo: Todo, c: Context };
  'todo:deleted': { id: string };
  'foo': number;
};

const handlers: EventHandlers<AvailableEvents> = {
  'todo:deleted': [
    (payload) => {} // payload will be inferred as { id: string }
  ]
}

const todoCreatedHandler = ({ todo: Todo, c: Context }) => {
  // ...
  console.log('New todo created:', todo)
}

// Initialize the app with emitter type
const app = new Hono<{ Variables: { emitter: Emitter<AvailableEvents> }}>()

// Register the emitter middleware and provide it with the handlers
app.use('*', emitter(handlers))

// And/Or setup event listeners as "named function" inside middleware or route handler
app.use((c) => {
  c.get('emitter').on('todo:created', todoCreatedHandler)
})

app.post('/todo', async (c) => {
  // ...
  // Emit event and pass the payload (todo object) plus context
  c.get('emitter').emit('todo:created', { todo, c })
})

app.delete('/todo/:id', async (c) => {
  // ...
  // Emit event
  c.get('emitter').emit('todo:deleted', { id })
})

export default app

The author should do the following, if applicable

  • [x] Add tests
  • [x] Run tests
  • [x] bun run format:fix && bun run lint:fix to format the code

DavidHavl avatar May 28 '24 14:05 DavidHavl

Hi @DavidHavl !

Sorry for the late reply. This idea is very interesting.

But, I could be wrong, if we can use Node.js's EvetEmitter, is that enough?

yusukebe avatar Jun 01 '24 05:06 yusukebe

Thank you @yusukebe, that is a good question. The main beauty/benefit of using this one instead is the Typescript flow. It helps to prevent many bugs. You specify the names of the events and corresponding handler payload type and the rest is taken care of. See the gifs bellow.

Additionally, when adding event handlers via the .on() method inside of middleware/handler and using named functions the code makes sure there are no duplicates (otherwise caused by calling the same middleware on each request).

Theoretically, I could have extended Node.js Event Emitter class and add similar typings to those methods, but I felt it was a bit of an overkill. I was striving for simplicity, wanted to really only include on(), off() and emit() methods, so all of the other various methods of Event Emitter were redundant and would make the resulting file much larger if I would try to type those as well. Also my code is really not that complex, it’s just a Map for events and handlers.

hono-event-emitter

DavidHavl avatar Jun 03 '24 12:06 DavidHavl

@yusukebe So what do you think?

DavidHavl avatar Jun 17 '24 10:06 DavidHavl

Hi @DavidHavl

Sorry for the late response. This would be great! I have two requests for changing APIs.

What about introducing defineHandlers function to make it easy to define the handlers with type definitions?:

export const defineHandlers = <T>(handlers: { [K in keyof T]?: EventHandler<T[K]>[] }) => {
  return handlers
}

And make EventHandler receive Context object as a first argument:

type EventHandler<T, E extends Env = Env> = (c: Context<E>, payload: T) => void

Below is the usage of mixing these:

const handlers = defineHandlers<AvailableEvents>({
  'todo:created': [
    (c, payload) => {
      console.log(`${payload.todo.id} is created!`)
    },
  ],
})

I would like to know others' thoughts on whether we should include this middle to the hono package and the APIs. cc: @usualoma @fzn0x @nakasyou @ryuapp @EdamAme-x @NicoPlyley and others.

yusukebe avatar Jun 17 '24 21:06 yusukebe

Hi @DavidHavl I think it's a very good implementation!

However, I don't think there are many use cases for event emitters in web frameworks in general. The Websocket example makes sense as a use case, but I don't see the benefit of "setting an event emitter instance by the middleware". It seems to me that it would be more straightforward to share it in a global variable.

I think it's a very interesting pattern, but "is it a necessary feature of a web framework?" I don't think so.

And if there is an advantage to "setting an event emitter instance by the middleware'", I think it would be in a model where a new event emitter instance is created for each request and destroyed at the end of the request. (Whether or not there is a use case for that pattern, however, is another topic.) In the current code, the first instance created is used all the time.

https://github.com/honojs/hono/pull/2843/files#diff-ddd131fd2c9bb99661cfb53039b3cd5836596271e7565258fad3a3894c2e527bR178-R182

usualoma avatar Jun 18 '24 00:06 usualoma

I'm afraid that it may cause memory leak in your example.

First, from your example,

app.use((c) => {
  c.get('emitter').on('todo:created', todoCreatedHandler)
})

This code throws error, so I created modified code:

app.use(async (c, next) => {
  c.get('emitter').on('todo:created', todoCreatedHandler)
  await next()
})

Then, I wrote test code using Deno.

import { Hono } from 'jsr:@hono/hono'
import { emitter, type EventHandlers, type Emitter } from '../src/middleware/event-emitter/index.ts'

interface AvailableEvents {
  created: { id: string }
}

const handlers: EventHandlers<AvailableEvents> = {
  created: [
    ({ id }) => {}
  ]
}

const app = new Hono<{ Variables: { emitter: Emitter<AvailableEvents> }}>()

app.use(emitter(handlers))
app.use(async (c, next) => {
  c.get('emitter').on('created', () => {
    // created
    // ...
  })
  await next()
})
app.get('/create', (c) => {
  const id = crypto.randomUUID()
  c.get('emitter').emit('created', { id })
  return c.json({ id })
})

console.log('Started', Deno.memoryUsage())
for (let i = 0; i !== 10000; i++) {
  await app.request('/create')
}
console.log('Finished', Deno.memoryUsage())

The result is that:

Started [Object: null prototype] {
  rss: 48918528,
  heapTotal: 5365760,
  heapUsed: 3921624,
  external: 2961103
}
Finished [Object: null prototype] {
  rss: 68493312,
  heapTotal: 16248832,
  heapUsed: 10866824,
  external: 2961103
}

I think that the reason is adding a new handler for each request.

nakasyou avatar Jun 18 '24 02:06 nakasyou

At first, I did not think my input would be of much value because have rarely had to need to use event emitters. I thought I was missing something. Then @usualoma also mentioned use cases and I realized, it's not common that an application needs a publish/subscribe pattern, it adds a level of complexity to projects.

For something that is not a common pattern, but rather opt-in, for web development, is it necessary to have in the core of Hono? I think having it as a library that is opt-in makes more sense.

A good way of thinking about is similar to state management in React, while not an event emitter, it's the same principle of a pub/sub pattern. State management can be done natively in React, but in some projects using what React gives by default would be harder to manage than using Redux. And the same goes the other way where Redux can be overkill for a small project and add a lot more complexity.

All this to say I don't think complex development patterns need to be baked into Hono by default. I think either official middleware or 3rd parties libraries by developers like @DavidHavl would be best. This way we keep the core lightweight and flexible, but can be extendable as needed.

NicoPlyley avatar Jun 19 '24 06:06 NicoPlyley

Thank you @yusukebe, I love your suggestion! This way there would be a unified and typed way to define handlers.

I will add it in, perhaps together with a singular version "defineHandler" method for defining a single handler?

DavidHavl avatar Jun 19 '24 22:06 DavidHavl

Thank you @usualoma for your kind words.

I understand your concerns, I value and respect your opinion.

To answer your questions,

I very much do think there are use cases for event emitters (aka. observer pattern or pub/sub pattern or event driven architecture/application flow) in backend applications. They are a crucial part of more complex applications with complex relationships or data flow in my opinion. Frankly, in my career (22 years) I have designed or worked with only very few server applications/apis that did not need event driven logic and that was because they were too small and it would be an overkill.

For simple applications it does not matter, you can cramp everything to only few files or even one file (hopefully nobody does that :-) ) but imagine a fun example, an api for project management system where each user needs to have at least one team they belong to straight from the go. After new user signs up, a new team is automatically created in the database and assigned to the user. Also other common stuff happens like email template is produced and email sent to the new user email, log record is created, or whatever you can think of happening in paralel… In that example you would be dealing with code that belongs to several domains/features, the Team feature, User feature and many others (email feature, logging,…). Now, in your code you could of course just import files containing code (like service or functions) for team creation into the file where you get new user info, but that just goes against so many design principles and eventually makes the backend application code hard to maintain and scale. So instead, using event emitter, in the Team feature you would just subscribe to an event ‘user.created’ and when new user signs up it emits that event with user id (or data) as payload. Team feature reacts on it, and does all it needs to do without too much of tight coupling to User feature. Same goes for email or logging or other things. Great separation of concerns, scalable and maintainable.

I actually have one repo Hono REST API starter kit that has the mentioned flow in there (please be kind, it's still very much a work in progress).

So yes I see this pattern as extremely important for backend applications or apis.

Whether it belongs to core or not,… I am not entirely sure! I mean many other established framework contributors think so ( Nest.js, Adonis.js, Hapi.js, Laravel, Sails.js, Meteor,…) in some form or another, however, I would love to see Hono staying lean and having other things as add-ons to be honest.

To answer another concern that you have: You may be right that it would be more straightforward to use it as a global variable or outside of middleware (as I describe here ). Having it as a middleware is simply just an extra nice way of accessing the emitter instance in request handlers.

I did not find a better way to add event emitter feature to the Hono ecosystem except through middleware. Please suggest if you have better placement ideas. My first instinct was to create it as third-party middleware (and I did), but I figured there are actually no dependencies to third-party code and I wanted to see what you guys suggest first in here.

As for reusing the same instance for all requests, well that is kind of the point. It is just a map of events and their handlers and as such does not need to be created each time for new request. There is not persisting state or session, the handlers just receive data and act on them, no matter what request it came from.

Should there be a need to create new instance for each request, I could add that as an option, that should not be a problem.

Sorry for a long write out, I got carried away a little bit :).

Again, thank you for your comments.

DavidHavl avatar Jun 19 '24 23:06 DavidHavl

@nakasyou Thank you for your effort, I appreciate it. Good catch with the async and next, I omitted it in the write-up above.

As for the memory leak, however, you have a mistake in your test code!

You are using an anonymous function as a handler! c.get('emitter').on('created', () => {...} You can only use named functions as handlers when subscribing to an event INSIDE of middleware or route handler. Anonymous functions can be used but outside of middleware. This is because anonymous functions by nature are created every time as different objects in memory so it is not possible to check for duplicates. I write about this here.

DavidHavl avatar Jun 20 '24 00:06 DavidHavl

Thank you @NicoPlyley for your comment. I appreciate your insights into the topic very much!

I think I have answered most of the concerns in the above reply to @usualoma but, as mentioned, from experience, I find event-driven application logic invaluable when dealing with larger code bases. But indeed it is an overkill for something more simple.

I love your analogy with Redux or other state management libraries in React. You are totally right, it helps only after reaching a certain level of complexity!

I agree, I would also love to keep HONO as slim as possible, so this would probably be better as an official add-on/middleware. If @yusukebe and others prefer that, I will create a pull request for it in the middleware repo.

DavidHavl avatar Jun 20 '24 00:06 DavidHavl

Hi @DavidHavl

Sorry for my super late reply. I was considering we have to merge this to Hono core (honojs/hono). But I decided we don't merge this Event Emitter Middleare into honojs/hono. The reason is simple. As mentioned above, some developers can't imagine many use cases. Honestly, I also don't have ideas for how to use it. However, this middleware is simple and has cool TypeScript support. So, it will be nice to host this middleware in our honojs/middleware mono repo. Thank you!!

yusukebe avatar Jul 01 '24 09:07 yusukebe

Thank you for your consideration @yusukebe. As suggested, I have now opened a pull request in the honojs/middleware repo instead.

DavidHavl avatar Jul 03 '24 21:07 DavidHavl