From 53b4f331906f3da57da81e87d461889d0aff5cb4 Mon Sep 17 00:00:00 2001 From: David Havl Date: Sun, 7 Jul 2024 09:03:10 +0200 Subject: [PATCH] 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 version --- .changeset/olive-lies-cheat.md | 5 + .github/workflows/ci-event-emitter.yml | 25 ++ package.json | 1 + packages/event-emitter/README.md | 359 +++++++++++++++++++++++ packages/event-emitter/package.json | 43 +++ packages/event-emitter/src/index.test.ts | 243 +++++++++++++++ packages/event-emitter/src/index.ts | 234 +++++++++++++++ packages/event-emitter/tsconfig.json | 10 + packages/event-emitter/vitest.config.ts | 8 + yarn.lock | 12 + 10 files changed, 940 insertions(+) create mode 100644 .changeset/olive-lies-cheat.md create mode 100644 .github/workflows/ci-event-emitter.yml create mode 100644 packages/event-emitter/README.md create mode 100644 packages/event-emitter/package.json create mode 100644 packages/event-emitter/src/index.test.ts create mode 100644 packages/event-emitter/src/index.ts create mode 100644 packages/event-emitter/tsconfig.json create mode 100644 packages/event-emitter/vitest.config.ts diff --git a/.changeset/olive-lies-cheat.md b/.changeset/olive-lies-cheat.md new file mode 100644 index 00000000..d6cf1589 --- /dev/null +++ b/.changeset/olive-lies-cheat.md @@ -0,0 +1,5 @@ +--- +'@hono/event-emitter': major +--- + +Full release of Event Emitter middleware for Hono diff --git a/.github/workflows/ci-event-emitter.yml b/.github/workflows/ci-event-emitter.yml new file mode 100644 index 00000000..0239f5a2 --- /dev/null +++ b/.github/workflows/ci-event-emitter.yml @@ -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 diff --git a/package.json b/package.json index b986daf7..26fc8658 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "build:typia-validator": "yarn workspace @hono/typia-validator build", "build:swagger-ui": "yarn workspace @hono/swagger-ui 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:react-renderer": "yarn workspace @hono/react-renderer build", "build:auth-js": "yarn workspace @hono/auth-js build", diff --git a/packages/event-emitter/README.md b/packages/event-emitter/README.md new file mode 100644 index 00000000..38b1ff2d --- /dev/null +++ b/packages/event-emitter/README.md @@ -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; + }; +}; + + +``` + +```ts +// event-handlers.ts + +import { defineHandlers } from '@hono/event-emitter' +import { AvailableEvents } from './types' + +// Define event handlers +export const handlers = defineHandlers({ + '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((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() + +// 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({ + '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((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 - + +## License + +MIT diff --git a/packages/event-emitter/package.json b/packages/event-emitter/package.json new file mode 100644 index 00000000..bc7ba0fd --- /dev/null +++ b/packages/event-emitter/package.json @@ -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 (https://github.com/DavidHavl)", + "peerDependencies": { + "hono": "*" + }, + "devDependencies": { + "hono": "^3.11.7", + "tsup": "^8.0.1", + "vitest": "^1.0.4" + } +} diff --git a/packages/event-emitter/src/index.test.ts b/packages/event-emitter/src/index.test.ts new file mode 100644 index 00000000..5c922b5f --- /dev/null +++ b/packages/event-emitter/src/index.test.ts @@ -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 } }; + + const handler = defineHandler((_c, _payload) => {}); + + const spy = vi.fn(handler); + + const app = new Hono(); + + 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 } }; + + const handler = defineHandler((_c, _payload) => {}); + + const spy = vi.fn(handler); + + const app = new Hono(); + + 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 } }; + + const handlers = defineHandlers({ + 'todo:created': [vi.fn((_c, _payload) => {})], + }); + + const app = new Hono(); + + 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 } }; + + const handlers: EventHandlers = { + 'todo:created': [vi.fn((_payload) => {})], + }; + + const ee = createEmitter(handlers); + + const todoDeletedHandler = vi.fn(defineHandler((_c, _payload) => {})); + + ee.on('todo:deleted', todoDeletedHandler); + + const app = new Hono(); + + 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 } }; + + const ee = createEmitter(); + + const todoDeletedHandler = defineHandler( + (_c, _payload: EventHandlerPayloads['todo:deleted']) => {}, + ); + + const spy = vi.fn(todoDeletedHandler); + + ee.on('todo:deleted', spy); + + const app = new Hono(); + + 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 } }; + + const ee = createEmitter(); + + const todoDeletedHandler = defineHandler( + (_c, _payload: EventHandlerPayloads['todo:deleted']) => {}, + ); + + const spy = vi.fn(todoDeletedHandler); + + ee.on('todo:deleted', spy); + + const app = new Hono(); + + 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 } }; + + const ee = createEmitter(); + + const todoDeletedHandler = defineHandler( + (_c, _payload: EventHandlerPayloads['todo:deleted']) => {}, + ); + const todoDeletedHandler2 = defineHandler( + (_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(); + + 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); + }); + }); +}); diff --git a/packages/event-emitter/src/index.ts b/packages/event-emitter/src/index.ts new file mode 100644 index 00000000..d119ff6e --- /dev/null +++ b/packages/event-emitter/src/index.ts @@ -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 = (c: Context, payload: T) => void | Promise; +export type EventHandlers = { [K in keyof T]?: EventHandler[] }; + +export interface Emitter { + on(key: Key, handler: EventHandler): void; + off(key: Key, handler?: EventHandler): void; + emit(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 = ( + handler: EventHandler, +): EventHandler => { + 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 = (handlers: { [K in keyof T]?: EventHandler[] }) => { + 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({ + * '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 = ( + eventHandlers?: EventHandlers, +): Emitter => { + // A map of event keys and their corresponding event handlers. + const handlers: Map[]> = 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: Key, handler: EventHandler) { + if (!handlers.has(key as EventKey)) { + handlers.set(key as EventKey, []); + } + const handlerArray = handlers.get(key as EventKey) as Array>; + 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: Key, handler?: EventHandler) { + 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: 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 }; } + * + * // Define event handlers + * const handlers: defineHandlers({ + * '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() + * + * // 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 = ( + eventHandlers?: EventHandlers, +): MiddlewareHandler => { + // Create new instance to share with any middleware and handlers + const instance = createEmitter(eventHandlers); + return createMiddleware(async (c, next) => { + c.set('emitter', instance); + await next(); + }); +}; diff --git a/packages/event-emitter/tsconfig.json b/packages/event-emitter/tsconfig.json new file mode 100644 index 00000000..acfcd843 --- /dev/null +++ b/packages/event-emitter/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + }, + "include": [ + "src/**/*.ts" + ], +} \ No newline at end of file diff --git a/packages/event-emitter/vitest.config.ts b/packages/event-emitter/vitest.config.ts new file mode 100644 index 00000000..17b54e48 --- /dev/null +++ b/packages/event-emitter/vitest.config.ts @@ -0,0 +1,8 @@ +/// +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + }, +}) diff --git a/yarn.lock b/yarn.lock index 2f175131..b1115319 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1880,6 +1880,18 @@ __metadata: languageName: unknown 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": version: 0.0.0-use.local resolution: "@hono/firebase-auth@workspace:packages/firebase-auth"