311 lines
10 KiB
Markdown
311 lines
10 KiB
Markdown
# Rettime
|
|
|
|
A type-safe marriage of `EventTarget` and `EventEmitter`.
|
|
|
|
## Features
|
|
|
|
- 🎯 **Event-based**. Control event flow: prevent defaults, stop propagation, cancel events. Something your common `Emitter` can't do.
|
|
- 🗼 **Emitter-inspired**. Emit event types and data, don't bother with creating `Event` instances. A bit less verbosity than a common `EventTarget`.
|
|
- ⛑️ **Type-safe**. Describe the exact event types and payloads accepted by the emitter. Never emit or listen to unknown events.
|
|
- 🧰 **Convenience methods** like `.emitAsPromise()` and `.emitAsGenerator()` to build more complex event-driven systems.
|
|
- 🐙 **Tiny**. 700B gzipped.
|
|
|
|
> [!WARNING]
|
|
> This library **does not** have performance as the end goal. In fact, since it operates on events and supports event cancellation, it will likely be _slower_ than the emitters that don't do that.
|
|
|
|
## Motivation
|
|
|
|
### Why not just `EventTarget`?
|
|
|
|
The `EventTarget` API is fantastic. It works in the browser and in Node.js, dispatches actual events, supports cancellation, etc. At the same time, it has a number of flaws that prevent me from using it for anything serious:
|
|
|
|
- Complete lack of type safety. The `type` in `new Event(type)` is not a type argument in `lib.dom.ts`. It's always `string`. It means it's impossible to narrow it down to a literal string type to achieve type safety.
|
|
- No concept of `.prependListener()`. There is no way to add a listener to run _first_, before other existing listeners.
|
|
- No concept of `.removeAllListeners()`. You have to remove each individual listener by hand. Good if you own the listeners, not so good if you don't.
|
|
- No concept of `.listenerCount()` or knowing if a dispatched event had any listeners (the `boolean` returned from `.dispatch()` indicates if the event has been prevented, not whether it had any listeners).
|
|
- (Opinionated) Verbose. I prefer `.on()` over `.addEventListener()`. I prefer passing data than constructing `new MessageEvent()` all the time.
|
|
|
|
### Why not just `Emitter` (in Node.js)?
|
|
|
|
The `Emitter` API in Node.js is great, but it has its own downsides:
|
|
|
|
- Node.js-specific. `Emitter` does not work in the browser.
|
|
- Lacks any type safety.
|
|
- No concept of `.stopPropagation()` and `.stopImmediatePropagation()`. Those methods are defined but literally do nothing.
|
|
|
|
## Install
|
|
|
|
```sh
|
|
npm install rettime
|
|
```
|
|
|
|
## API
|
|
|
|
### `TypedEvent`
|
|
|
|
`TypedEvent` is a subset of `MessageEvent` that allows for type-safe event declaration.
|
|
|
|
```ts
|
|
new TypedEvent<DataType, ReturnType, EventType>(type: EventType, { data: DataType })
|
|
```
|
|
|
|
> The `data` argument depends on the `DataType` of your event. Use `void` if the event must not send any data.
|
|
|
|
#### Custom events
|
|
|
|
You can implement custom events by extending the default `TypedEvent` class and forwarding the type arguments that it expects:
|
|
|
|
```ts
|
|
class GreetingEvent<
|
|
DataType = void,
|
|
ReturnType = any,
|
|
EventType extends string = string,
|
|
> extends TypedEvent<DataType, ReturnType, EventType> {
|
|
public id: string
|
|
}
|
|
|
|
const emitter = new Emitter<{ greeting: GreetingEvent<'john'> }>()
|
|
|
|
emitter.on('greeting', (event) => {
|
|
console.log(event instanceof GreetingEvent) // true
|
|
console.log(event instanceof TypedEvent) // true
|
|
console.log(event instanceof MessageEvent) // true
|
|
|
|
console.log(event.type) // "greeting"
|
|
console.log(event.data) // "john"
|
|
console.log(event.id) // string
|
|
})
|
|
```
|
|
|
|
### `Emitter`
|
|
|
|
```ts
|
|
new Emitter<EventMap>()
|
|
```
|
|
|
|
The `EventMap` type argument allows you describe the supported event types, their payload, and the return type of their event listeners.
|
|
|
|
```ts
|
|
import { Emitter, TypedEvent } from 'rettime'
|
|
|
|
const emitter = new Emitter<{ hello: TypedEvent<string, number> }>()
|
|
|
|
emitter.on('hello', () => 1) // ✅
|
|
emitter.on('hello', () => 'oops') // ❌ string not assignable to type number
|
|
|
|
emitter.emit(new TypedEvent('hello', { data: 'John' })) // ✅
|
|
emitter.emit(new TypedEvent('hello', { data: 123 })) // ❌ number is not assignable to type string
|
|
emitter.emit(new TypedEvent('hello')) // ❌ missing data argument of type string
|
|
|
|
emitter.emit(new TypedEvent('unknown')) // ❌ "unknown" does not satisfy "hello"
|
|
```
|
|
|
|
#### Describing events
|
|
|
|
The `Emitter` class requires a type argument that describes the event map. If you do not provide that argument, adding listeners or emitting events will produce a type error as your emitter doesn't have an event map defined.
|
|
|
|
An event map is an object of the following shape:
|
|
|
|
```ts
|
|
{
|
|
[type: string]: TypedEvent
|
|
}
|
|
```
|
|
|
|
The `type` is a string indicating the event type (e.g. `greet` or `ping`). The array it accepts has two members: `args` describes the arguments accepted by this event (can also be `never` for events without arguments) and `returnValue` is an optional type for the data returned from the listeners for this event.
|
|
|
|
Let's say you want to define a `greet` event that expects a user name as data and returns a greeting string:
|
|
|
|
```ts
|
|
import { Emitter, TypedEvent } from 'rettime'
|
|
|
|
const emitter = new Emitter<{ greet: TypedEvent<string, string> }>()
|
|
|
|
emitter.on('greet', (event) => {
|
|
console.log(`Hello, ${event.data}!`)
|
|
})
|
|
emitter.emit(new TypedEvent('greet', { data: 'John' }))
|
|
// "Hello, John!"
|
|
```
|
|
|
|
Here's another example where we define a `ping` event that has no arguments but returns a timestamp for each ping:
|
|
|
|
```ts
|
|
const emitter = new Emitter<{ ping: TypedEvent<void, number> }>()
|
|
emitter.on('ping', () => Date.now())
|
|
const results = await emitter.emitAsPromise(new TypedEvent('ping'))
|
|
// [1745658424732]
|
|
```
|
|
|
|
> [!IMPORTANT]
|
|
> When providing type arguments to your `TypedEvents`, you **do not** need to provide the `EventType` argument—it will be inferred from your event map.
|
|
|
|
### `.on(type, listener[, options])`
|
|
|
|
Adds an event listener for the given event type.
|
|
|
|
```ts
|
|
import { Emitter, TypedEvent } from 'rettime'
|
|
|
|
const emitter = new Emitter<{ hello: TypedEvent<string> }>()
|
|
|
|
emitter.on('hello', (event) => {
|
|
// `event` is a `TypedEvent` instance derived from `MessageEvent`.
|
|
console.log(event.data)
|
|
})
|
|
```
|
|
|
|
All methods that add new listeners return an `AbortController` instance bound to that listener. You can use that controller to cancel the event handling, including mid-air:
|
|
|
|
```ts
|
|
const controller = emitter.on('hello', listener)
|
|
controller.abort(reason)
|
|
```
|
|
|
|
All methods that add new listeners also accept an optional `options` argument. You can use it to configure event handling behavior. For example, you can provide an existing `AbortController` signal as the `options.signal` value so the attached listener abides by your controller:
|
|
|
|
```ts
|
|
emitter.on('hello', listener, { signal: controller.signal })
|
|
```
|
|
|
|
> Both the public controller of the event and your custom controller are combined using `AbortSignal.any()`.
|
|
|
|
### `.once(type, listener[, options])`
|
|
|
|
Adds a one-time event listener for the given event type.
|
|
|
|
### `.earlyOn(type, listener[, options])`
|
|
|
|
Prepends a listener for the given event type.
|
|
|
|
```ts
|
|
import { Emitter, TypedEvent } from 'rettime'
|
|
|
|
const emitter = new Emitter<{ hello: TypedEvent<void, number> }>()
|
|
|
|
emitter.on('hello', () => 1)
|
|
emitter.earlyOn('hello', () => 2)
|
|
|
|
const results = await emitter.emitAsPromise(new TypedEvent('hello'))
|
|
// [2, 1]
|
|
```
|
|
|
|
### `.earlyOnce(type, listener[, options])`
|
|
|
|
Prepends a one-time listener for the given event type.
|
|
|
|
### `.emit(type[, data])`
|
|
|
|
Emits the given event with optional data.
|
|
|
|
```ts
|
|
import { Emitter, TypedEvent } from 'rettime'
|
|
|
|
const emitter = new Emitter<{ hello: TypedEvent<string> }>()
|
|
|
|
emitter.on('hello', (event) => console.log(event.data))
|
|
|
|
emitter.emit(new TypedEvent('hello', 'John'))
|
|
```
|
|
|
|
### `.emitAsPromise(type[, data])`
|
|
|
|
Emits the given event and returns a Promise that resolves with the returned data of all matching event listeners, or rejects whenever any of the matching event listeners throws an error.
|
|
|
|
```ts
|
|
import { Emitter, TypedEvent } from 'rettime'
|
|
|
|
const emitter = new Emitter<{ hello: TypedEvent<number, Promise<number>> }>()
|
|
|
|
emitter.on('hello', async (event) => {
|
|
await sleep(100)
|
|
return event.data + 1
|
|
})
|
|
emitter.on('hello', async (event) => event.data + 2)
|
|
|
|
const values = await emitter.emitAsPromise(new TypedEvent('hello', { data: 1 }))
|
|
// [2, 3]
|
|
```
|
|
|
|
> Unlike `.emit()`, the `.emitAsPromise()` method _awaits asynchronous listeners_.
|
|
|
|
### `.emitAsGenerator(type[, data])`
|
|
|
|
Emits the given event and returns a generator function that exhausts all matching event listeners. Using a generator gives you granular control over what listeners are called.
|
|
|
|
```ts
|
|
import { Emitter, TypedEvent } from 'rettime'
|
|
|
|
const emitter = new Emitter<{ hello: TypedEvent<string, number> }>()
|
|
|
|
emitter.on('hello', () => 1)
|
|
emitter.on('hello', () => 2)
|
|
|
|
for (const listenerResult of emitter.emitAsGenerator(
|
|
new TypedEvent('hello', { data: 'John' }),
|
|
)) {
|
|
// Stop event emission if a listener returns a particular value.
|
|
if (listenerResult === 1) {
|
|
break
|
|
}
|
|
}
|
|
```
|
|
|
|
### `.listeners([type])`
|
|
|
|
Returns the list of all event listeners matching the given event type. If no event `type` is provided, returns the list of all existing event listeners.
|
|
|
|
### `.listenerCount([type])`
|
|
|
|
Returns the number of the event listeners matching the given event type. If no event `type` is provided, returns the total number of existing listeners.
|
|
|
|
### `.removeListener(type, listener)`
|
|
|
|
Removes the event listener for the given event type.
|
|
|
|
### `.removeAllListeners([type])`
|
|
|
|
Removes all event listeners for the given event type. If no event `type` is provided, removes all existing event listeners.
|
|
|
|
## Types
|
|
|
|
This library also comes with a set of helper types to make your life easier.
|
|
|
|
### `Emitter.EventType`
|
|
|
|
Returns the `Event` type (or its subtype) representing the given listener.
|
|
|
|
```ts
|
|
import { Emitter, TypedEvent } from 'rettime'
|
|
|
|
const emitter = new Emitter<{ greeting: TypedEvent<'john'> }>()
|
|
type GreetingEvent = Emitter.EventType<typeof emitter, 'greeting'>
|
|
// TypedEvent<'john'>
|
|
```
|
|
|
|
### `Emitter.ListenerType`
|
|
|
|
Returns the type of the given event's listener.
|
|
|
|
```ts
|
|
import { Emitter, TypedEvent } from 'rettime'
|
|
|
|
const emitter = new Emitter<{ greeting: TypedEvent<string, number[]> }>()
|
|
type GreetingListener = Emitter.ListenerType<typeof emitter, 'greeting'>
|
|
// (event: TypedEvent<string>) => number[]
|
|
```
|
|
|
|
> The `ListenerType` helper is in itself type-safe, allowing only known event types as the second argument.
|
|
|
|
### `Emitter.ListenerReturnType`
|
|
|
|
Returns the return type of the given event's listener.
|
|
|
|
```ts
|
|
import { Emitter, TypedEvent } from 'rettime'
|
|
|
|
const emitter = new Emitter<{ getTotalPrice: TypedEvent<Cart, number> }>()
|
|
type CartTotal = Emitter.ListenerReturnType<typeof emitter, 'getTotalPrice'>
|
|
// number
|
|
```
|