Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/smart-rats-smell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ortense/mediator": minor
---

middleware system implementation
138 changes: 133 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@
# @ortense/mediator
[![npm version](https://badgen.net/npm/v/@ortense/mediator)](https://bundlephobia.com/package/@ortense/mediator@1.3.0) [![bundle size](https://badgen.net/bundlephobia/minzip/@ortense/mediator)](https://bundlephobia.com/package/@ortense/mediator@1.3.0) [![install size](https://packagephobia.com/badge?p=@ortense/mediator)](https://packagephobia.com/result?p=@ortense/mediator) [![Coverage Status](https://coveralls.io/repos/github/ortense/mediator/badge.svg?branch=github-actions)](https://coveralls.io/github/ortense/mediator?branch=github-actions) [![JSR Score](https://jsr.io/badges/@ortense/mediator/score)](https://jsr.io/@ortense/mediator)


A minimalistic and dependency-free event mediator with internal context for front-end.
Written typescript for a good development experience and really light, just 300 bytes in your bundle!
A minimalistic and dependency-free event mediator with internal context and middleware support for front-end.
Written typescript for a good development experience and incredibly lightweight at less than 550 bytes!

Access the complete documentation at [ortense.github.io/mediator/](https://ortense.github.io/mediator/)

Expand Down Expand Up @@ -60,7 +59,7 @@ export const myMediator = createMediator(initialContext)
The complete setup file should look like this:

```typescript
import { MediatorContext, createMediator } from '@ortense/mediator'
import { MediatorContext, createMediator, MediatorMiddleware } from '@ortense/mediator'

export interface MyContext extends MediatorContext {
value: string
Expand All @@ -78,7 +77,16 @@ const initialContext: MyContext = {
},
}

export const myMediator = createMediator(initialContext)
// Optional: Add middleware for logging
const logger: MediatorMiddleware<MyContext, string> = (context, input, event) => {
console.log(`Event ${event} triggered with context:`, context)
}

export const myMediator = createMediator(initialContext, {
middlewares: [
{ event: '*', handler: logger }
]
})
```

### Events
Expand All @@ -95,6 +103,126 @@ export const myMediator = createMediator<MyContext, MyEvents>(initialContext)

This is a good practice to help developers who will interact with the mediator, providing predictability of the events that can be listened or send.

### Middlewares

Middlewares provide a powerful way to intercept, transform, and control event flow in your mediator. They execute **before** event listeners and can:

- **Observe events**: Log, track, or monitor events without side effects
- **Transform data**: Modify pending changes before they're applied to the context
- **Validate changes**: Ensure data integrity and business rules
- **Cancel propagation**: Stop event processing entirely when needed

Middlewares are configured during mediator creation and run in the order they're declared.

#### Creating a Mediator with Middlewares

```typescript
import { createMediator, MediatorMiddleware } from '@ortense/mediator'

interface AppContext extends MediatorContext {
user: string
count: number
}

// Logger middleware (observes only)
const logEvents: MediatorMiddleware<AppContext, string> = (context, input, event) => {
console.log(`[${event}] Context:`, context, 'Changes:', input.pendingChanges)
// No return - passes through unchanged
}

// Validation middleware
const validateCount: MediatorMiddleware<AppContext, string> = (context, input, event) => {
if (input.pendingChanges && 'count' in input.pendingChanges && input.pendingChanges.count < 0) {
console.warn('Invalid count, cancelling event')
return { cancel: true } // Stop propagation
}
return input // Pass through
}

// Transformation middleware - adds timestamp to all changes
const addTimestamp: MediatorMiddleware<AppContext, string> = (context, input, event) => {
return {
pendingChanges: {
...(input.pendingChanges ?? {}),
timestamp: Date.now()
}
}
}

const mediator = createMediator<AppContext>(
{ user: 'anonymous', count: 0 },
{
middlewares: [
{ event: '*', handler: logEvents }, // Runs for all events
{ event: 'counter:decrement', handler: validateCount },
{ event: 'counter:increment', handler: addTimestamp },
]
}
)
```

#### Middleware Types

```typescript
// Input data passed to middleware functions
type MediatorMiddlewareInput<Context extends MediatorContext> = {
pendingChanges: Nullable<AtLeastOneOf<Context>>
}

// Cancel event propagation
type MediatorCancelEvent = {
cancel: true
}

// Middleware function signature
type MediatorMiddleware<Context extends MediatorContext, EventName extends string = string> = (
context: Readonly<Context>,
input: MediatorMiddlewareInput<Context>,
event: EventName,
) => MediatorMiddlewareInput<Context> | MediatorCancelEvent | void
```

#### Middleware Return Types

```typescript
// Void middleware - observes only, passes through unchanged
const logger: MediatorMiddleware<AppContext, string> = (context, input, event) => {
console.log(`Event ${event} triggered`)
// No return - middleware passes through
}

// Transform middleware - modifies pending changes
const enrichData: MediatorMiddleware<AppContext, string> = (context, input, event) => {
return {
pendingChanges: {
...(input.pendingChanges ?? {}),
timestamp: Date.now()
}
}
}

// Cancel middleware - stops event processing
const authGuard: MediatorMiddleware<AppContext, string> = (context, input, event) => {
if (context.user === 'anonymous') {
return { cancel: true } // Stop processing
}
return input
}
```

#### Execution Flow

1. `mediator.send(event, modifier)` is called
2. A frozen snapshot of the current context is created
3. Modifier generates initial `pendingChanges` if provided
4. Middlewares execute in registration order:
- **Void middlewares** observe and pass through unchanged
- **Return middlewares** may return modified `pendingChanges`
- Any middleware can return `{ cancel: true }` to stop propagation
- **All middlewares receive the same immutable context snapshot**
5. Final context is updated with shallow merge of all `pendingChanges`
6. Event listeners run with the updated context unless propagation was cancelled

### Listening to events

To listen to events use the `.on` method
Expand Down
79 changes: 55 additions & 24 deletions src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,67 +2,98 @@ import type {
Mediator,
MediatorContext,
MediatorEventListener,
MediatorOptions,
} from "./types.ts";

const isCancel = (value: unknown): value is { cancel: true } =>
(value as { cancel?: unknown })?.cancel === true;

const copy = <T>(value: T) => structuredClone(value) as T;

/**
* Creates a Mediator instance with a specific initial context.
* Creates a Mediator instance with a specific initial context and optional middleware configuration.
* @function createMediator
* @param {Context} initialContext - The initial context for the Mediator.
* @param {MediatorOptions<Context, EventName>} [options] - Optional configuration including middlewares.
* @returns {Mediator<Context, EventName>} A Mediator instance with the specified context type and event names.
* @template {@extends MediatorContext} Context - The type of the MediatorContext.
* @template {@extends string} [EventName] - The type of the event names. @defaultValue string
* @template Context - The type of the MediatorContext that extends MediatorContext.
* @template EventName - The type of the event names that extends string. Defaults to string.
* @example
* ```
* type MyEvents = 'item:added' | 'item:removed'
* type MyEvents = 'item:added' | 'item:removed'
*
* interface MyContext extends MediatorContext {
* items: number[]
* }
*
* // Basic usage
* const myMediator = createMediator<MyContext, MyEvents>(initialContext)
*
* // With middlewares
* const myMediator = createMediator<MyContext, MyEvents>(initialContext, {
* middlewares: [
* { event: '*', handler: logger },
* { event: 'item:added', handler: validator }
* ]
* })
* ```
*/
export function createMediator<
Context extends MediatorContext,
EventName extends string = string,
>(initialContext: Context): Mediator<Context, EventName> {
>(
initialContext: Context,
options?: MediatorOptions<Context, EventName>,
): Mediator<Context, EventName> {
const handlers = new Map<
string,
Array<MediatorEventListener<Context, EventName>>
>();
let context = structuredClone(initialContext);

const middlewares = options?.middlewares ?? [];
let context = copy(initialContext);

return {
on: (event, listener) => {
const listeners = handlers.get(event) || [];
handlers.set(event, [...listeners, listener]);
handlers.set(event, [...(handlers.get(event) ?? []), listener]);
},

off: (event, listener) => {
const listeners = handlers.get(event);
if (listeners === undefined) {
return;
}

handlers.set(
event,
listeners.filter((fn) => fn !== listener),
);
const filtered = handlers.get(event)?.filter((fn) => fn !== listener);
if (filtered) handlers.set(event, filtered);
},

send: (event, modifier) => {
if (modifier) {
context = structuredClone({ ...context, ...modifier(context) });
// snapshot readonly for modifier/middlewares
const snapshot = Object.freeze(copy(context)) as Readonly<Context>;

// initial pendingChanges calculated from snapshot
let pendingChanges = modifier?.(snapshot as Context) ?? null;

// execute middlewares in registration order - ALL receive the SAME immutable snapshot
for (const { event: evt, handler } of middlewares) {
if (evt === "*" || evt === event) {
const result = handler(snapshot, { pendingChanges }, event);
if (!result) continue;
if (isCancel(result)) return; // stop without applying anything
pendingChanges = result.pendingChanges ?? null;
}
}

// apply shallow merge and promote to new state
if (pendingChanges) {
context = { ...copy(snapshot), ...pendingChanges };
}

handlers.get(event)?.forEach((fn) => {
fn(context, event);
handlers.get(event)?.forEach((listener) => {
listener(context, event);
});
handlers.get("*")?.forEach((fn) => {
fn(context, event);
// wildcard listeners
handlers.get("*")?.forEach((listener) => {
listener(context, event);
});
},

getContext: () => structuredClone(context),
getContext: () => copy(context),
};
}
Loading