From 277c78249c3658e8b305363945d5414c34d6ed5b Mon Sep 17 00:00:00 2001 From: Marcus Ortense Date: Fri, 26 Sep 2025 13:30:46 -0300 Subject: [PATCH 1/2] feat: adds middleware support for event interception * include vitest config file --- .changeset/smart-rats-smell.md | 5 + README.md | 140 +++++++- src/factory.ts | 75 ++-- src/middleware.spec.ts | 604 +++++++++++++++++++++++++++++++++ src/types.ts | 147 +++++++- vitest.config.ts | 10 + 6 files changed, 939 insertions(+), 42 deletions(-) create mode 100644 .changeset/smart-rats-smell.md create mode 100644 src/middleware.spec.ts create mode 100644 vitest.config.ts diff --git a/.changeset/smart-rats-smell.md b/.changeset/smart-rats-smell.md new file mode 100644 index 0000000..d11cecf --- /dev/null +++ b/.changeset/smart-rats-smell.md @@ -0,0 +1,5 @@ +--- +"@ortense/mediator": minor +--- + +middleware system implementation diff --git a/README.md b/README.md index 122bcaa..451efe6 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ ![Mediator banner - the mediator mascot generated by dall-e 2](https://raw.githubusercontent.com/ortense/mediator/main/media/mediator.jpg) # @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) -[![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) - -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/) @@ -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 @@ -78,7 +77,16 @@ const initialContext: MyContext = { }, } -export const myMediator = createMediator(initialContext) +// Optional: Add middleware for logging +const logger: MediatorMiddleware = (context, input, event) => { + console.log(`Event ${event} triggered with context:`, context) +} + +export const myMediator = createMediator(initialContext, { + middlewares: [ + { event: '*', handler: logger } + ] +}) ``` ### Events @@ -95,6 +103,126 @@ export const myMediator = createMediator(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 = (context, input, event) => { + console.log(`[${event}] Context:`, context, 'Changes:', input.pendingChanges) + // No return - passes through unchanged +} + +// Validation middleware +const validateCount: MediatorMiddleware = (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 = (context, input, event) => { + return { + pendingChanges: { + ...(input.pendingChanges ?? {}), + timestamp: Date.now() + } + } +} + +const mediator = createMediator( + { 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 = { + pendingChanges: Nullable> +} + +// Cancel event propagation +type MediatorCancelEvent = { + cancel: true +} + +// Middleware function signature +type MediatorMiddleware = ( + context: Readonly, + input: MediatorMiddlewareInput, + event: EventName, +) => MediatorMiddlewareInput | MediatorCancelEvent | void +``` + +#### Middleware Return Types + +```typescript +// Void middleware - observes only, passes through unchanged +const logger: MediatorMiddleware = (context, input, event) => { + console.log(`Event ${event} triggered`) + // No return - middleware passes through +} + +// Transform middleware - modifies pending changes +const enrichData: MediatorMiddleware = (context, input, event) => { + return { + pendingChanges: { + ...(input.pendingChanges ?? {}), + timestamp: Date.now() + } + } +} + +// Cancel middleware - stops event processing +const authGuard: MediatorMiddleware = (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 diff --git a/src/factory.ts b/src/factory.ts index fd17118..14dc6a8 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -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 = (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} [options] - Optional configuration including middlewares. * @returns {Mediator} 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 * @example * ``` - * type MyEvents = 'item:added' | 'item:removed' + * type MyEvents = 'item:added' | 'item:removed' * * interface MyContext extends MediatorContext { * items: number[] * } * + * // Basic usage * const myMediator = createMediator(initialContext) + * + * // With middlewares + * const myMediator = createMediator(initialContext, { + * middlewares: [ + * { event: '*', handler: logger }, + * { event: 'item:added', handler: validator } + * ] + * }) * ``` */ export function createMediator< Context extends MediatorContext, EventName extends string = string, ->(initialContext: Context): Mediator { +>( + initialContext: Context, + options?: MediatorOptions, +): Mediator { const handlers = new Map< string, Array> >(); - 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; + + // 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), }; } diff --git a/src/middleware.spec.ts b/src/middleware.spec.ts new file mode 100644 index 0000000..7aedb43 --- /dev/null +++ b/src/middleware.spec.ts @@ -0,0 +1,604 @@ +import { afterEach, describe, expect, it, vitest } from "vitest"; +import { createMediator } from "./factory"; +import type { MediatorMiddleware, MediatorMiddlewareInput } from "./types"; + +type Context = { count: number; message: string }; +const initial: Context = { count: 0, message: "hello" } as const; + +// Helper function to safely get count from pendingChanges or context +const getCount = (ctx: Context, pendingChanges: unknown): number => { + if ( + pendingChanges && + typeof pendingChanges === "object" && + "count" in pendingChanges + ) { + return (pendingChanges as { count: number }).count; + } + return ctx.count; +}; + +describe("Mediator Middleware", () => { + afterEach(() => { + vitest.clearAllMocks(); + }); + + describe("execution order", () => { + let order: string[] = []; + const middleware1: MediatorMiddleware = + vitest.fn((ctx, input) => { + order.push("middleware1"); + return { + pendingChanges: { + ...(input.pendingChanges ?? {}), + count: getCount(ctx, input.pendingChanges) + 1, + message: "middleware1", + }, + }; + }); + const middleware2: MediatorMiddleware = + vitest.fn((ctx, input) => { + order.push("middleware2"); + return { + pendingChanges: { + ...(input.pendingChanges ?? {}), + count: getCount(ctx, input.pendingChanges) + 2, + message: "middleware2", + }, + }; + }); + const wildcardMiddleware: MediatorMiddleware = + vitest.fn((ctx, input) => { + order.push("wildcard"); + return { + pendingChanges: { + ...(input.pendingChanges ?? {}), + count: getCount(ctx, input.pendingChanges) + 10, + message: "wildcard", + }, + }; + }); + + const createExecutionMediator = () => + createMediator( + { count: 0, message: "initial" }, + { + middlewares: [ + { event: "test", handler: middleware1 }, + { event: "test", handler: middleware2 }, + { event: "*", handler: wildcardMiddleware }, + ], + }, + ); + + let executionMediator = createExecutionMediator(); + + afterEach(() => { + order = []; + executionMediator = createExecutionMediator(); + }); + + it("should execute middleware before event listeners", () => { + const listener = vitest.fn(); + executionMediator.on("test", listener); + + executionMediator.send("test"); + + expect(middleware1).toHaveBeenCalledTimes(1); + expect(middleware2).toHaveBeenCalledTimes(1); + expect(wildcardMiddleware).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledTimes(1); + expect(order).toEqual(["middleware1", "middleware2", "wildcard"]); + }); + + it("should execute multiple middlewares in registration order", () => { + executionMediator.send("test"); + + expect(middleware1).toHaveBeenCalledTimes(1); + expect(middleware2).toHaveBeenCalledTimes(1); + expect(wildcardMiddleware).toHaveBeenCalledTimes(1); + expect(order).toEqual(["middleware1", "middleware2", "wildcard"]); + expect(executionMediator.getContext()).toEqual({ + count: 13, // 1 (middleware1) + 2 (middleware2) + 10 (wildcard) - accumulated + message: "wildcard", // Last middleware wins + }); + }); + + it("should execute wildcard middleware for all events", () => { + executionMediator.send("test"); + executionMediator.send("other"); + + expect(middleware1).toHaveBeenCalledTimes(1); // Only for "test" + expect(middleware2).toHaveBeenCalledTimes(1); // Only for "test" + expect(wildcardMiddleware).toHaveBeenCalledTimes(2); // For both events + expect(order).toEqual([ + "middleware1", + "middleware2", + "wildcard", + "wildcard", + ]); + }); + + it("should execute specific middleware only for matching events", () => { + executionMediator.send("other"); + + expect(middleware1).toHaveBeenCalledTimes(0); // Not for "other" + expect(middleware2).toHaveBeenCalledTimes(0); // Not for "other" + expect(wildcardMiddleware).toHaveBeenCalledTimes(1); // Only wildcard for "other" + expect(order).toEqual(["wildcard"]); + expect(executionMediator.getContext()).toEqual({ + count: 10, // Only wildcard middleware + message: "wildcard", + }); + }); + }); + + describe("event cancellation", () => { + it("should cancel event processing when middleware returns cancel: true", () => { + const cancelMiddleware: MediatorMiddleware = vitest.fn( + (_ctx, _input) => ({ + cancel: true, + }), + ); + const listener = vitest.fn(); + + const cancelMediator = createMediator(initial, { + middlewares: [{ event: "test", handler: cancelMiddleware }], + }); + cancelMediator.on("test", listener); + + cancelMediator.send("test", () => ({ count: 5 })); + + expect(cancelMiddleware).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledTimes(0); + expect(cancelMediator.getContext()).toEqual(initial); + }); + + it("should prevent event listeners from executing when cancelled", () => { + const cancelMiddleware: MediatorMiddleware = vitest.fn( + (_ctx, _input) => ({ + cancel: true, + }), + ); + const specificListener = vitest.fn(); + const wildcardListener = vitest.fn(); + + const cancelMediator = createMediator(initial, { + middlewares: [{ event: "test", handler: cancelMiddleware }], + }); + cancelMediator.on("test", specificListener); + cancelMediator.on("*", wildcardListener); + + cancelMediator.send("test"); + + expect(cancelMiddleware).toHaveBeenCalledTimes(1); + expect(specificListener).toHaveBeenCalledTimes(0); + expect(wildcardListener).toHaveBeenCalledTimes(0); + }); + + it("should not modify context when event is cancelled", () => { + const cancelMiddleware: MediatorMiddleware = vitest.fn( + (_ctx, _input) => ({ + cancel: true, + }), + ); + + const cancelMediator = createMediator(initial, { + middlewares: [{ event: "test", handler: cancelMiddleware }], + }); + + cancelMediator.send("test", () => ({ count: 10, message: "modified" })); + + expect(cancelMediator.getContext()).toEqual(initial); + }); + + it("should handle cancel middleware at different positions in chain", () => { + const modifyMiddleware: MediatorMiddleware = vitest.fn( + (ctx, input) => ({ + pendingChanges: { + ...(input.pendingChanges ?? {}), + count: getCount(ctx, input.pendingChanges) + 1, + }, + }), + ); + const cancelMiddleware: MediatorMiddleware = vitest.fn( + (_ctx, _input) => ({ + cancel: true, + }), + ); + const listener = vitest.fn(); + + // Cancel as first middleware + const firstCancelMediator = createMediator(initial, { + middlewares: [ + { event: "test", handler: cancelMiddleware }, + { event: "test", handler: modifyMiddleware }, + ], + }); + firstCancelMediator.on("test", listener); + firstCancelMediator.send("test"); + + expect(cancelMiddleware).toHaveBeenCalledTimes(1); + expect(modifyMiddleware).toHaveBeenCalledTimes(0); + expect(listener).toHaveBeenCalledTimes(0); + expect(firstCancelMediator.getContext()).toEqual(initial); + + vitest.clearAllMocks(); + + // Cancel as last middleware + const lastCancelMediator = createMediator(initial, { + middlewares: [ + { event: "test", handler: modifyMiddleware }, + { event: "test", handler: cancelMiddleware }, + ], + }); + lastCancelMediator.on("test", listener); + lastCancelMediator.send("test"); + + expect(modifyMiddleware).toHaveBeenCalledTimes(1); + expect(cancelMiddleware).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledTimes(0); + expect(lastCancelMediator.getContext()).toEqual(initial); + }); + }); + + describe("context modification", () => { + it("should apply middleware context changes incrementally", () => { + const contextMiddleware: MediatorMiddleware = vitest.fn( + (ctx, input) => ({ + pendingChanges: { + ...(input.pendingChanges ?? {}), + count: getCount(ctx, input.pendingChanges) + 5, + }, + }), + ); + const listener = vitest.fn(); + + const contextMediator = createMediator(initial, { + middlewares: [{ event: "test", handler: contextMiddleware }], + }); + contextMediator.on("test", listener); + + contextMediator.send("test", () => ({ count: 10 })); + + expect(contextMiddleware).toHaveBeenCalledWith( + { count: 0, message: "hello" }, + { pendingChanges: { count: 10 } }, + "test", + ); + expect(listener).toHaveBeenCalledWith( + { count: 15, message: "hello" }, // 10 (modifier) + 5 (middleware) + "test", + ); + }); + + it("should handle incremental context updates between middlewares", () => { + const firstMiddleware: MediatorMiddleware = vitest.fn( + (ctx, input) => ({ + pendingChanges: { + ...(input.pendingChanges ?? {}), + count: getCount(ctx, input.pendingChanges) + 2, + }, + }), + ); + const secondMiddleware: MediatorMiddleware = vitest.fn( + (ctx, input) => ({ + pendingChanges: { + ...(input.pendingChanges ?? {}), + count: getCount(ctx, input.pendingChanges) + 3, + }, + }), + ); + const listener = vitest.fn(); + + const incrementalMediator = createMediator(initial, { + middlewares: [ + { event: "test", handler: firstMiddleware }, + { event: "test", handler: secondMiddleware }, + ], + }); + incrementalMediator.on("test", listener); + + incrementalMediator.send("test"); + + expect(firstMiddleware).toHaveBeenCalledWith( + { count: 0, message: "hello" }, + { pendingChanges: null }, + "test", + ); + expect(secondMiddleware).toHaveBeenCalledWith( + { count: 0, message: "hello" }, // Same context for all middlewares + { pendingChanges: { count: 2 } }, + "test", + ); + expect(listener).toHaveBeenCalledWith( + { count: 5, message: "hello" }, // 2 (first) + 3 (second) + "test", + ); + }); + }); + + describe("event matching", () => { + it("should not execute middlewares for non-matching events", () => { + const nonMatchingMiddleware: MediatorMiddleware< + Context, + "test" | "other" + > = vitest.fn(); + const listener = vitest.fn(); + + const matchingMediator = createMediator( + initial, + { + middlewares: [{ event: "other", handler: nonMatchingMiddleware }], + }, + ); + matchingMediator.on("test", listener); + + matchingMediator.send("test"); + + expect(nonMatchingMiddleware).toHaveBeenCalledTimes(0); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("should execute wildcard middleware for any event", () => { + const wildcardMiddleware: MediatorMiddleware = + vitest.fn((ctx, input) => ({ + pendingChanges: { + ...(input.pendingChanges ?? {}), + count: getCount(ctx, input.pendingChanges) + 1, + }, + })); + + const wildcardMediator = createMediator( + initial, + { + middlewares: [{ event: "*", handler: wildcardMiddleware }], + }, + ); + + wildcardMediator.send("test"); + wildcardMediator.send("other"); + + expect(wildcardMiddleware).toHaveBeenCalledTimes(2); + expect(wildcardMediator.getContext()).toEqual({ + count: 2, + message: "hello", + }); + }); + + it("should execute specific middleware only for exact event match", () => { + const specificMiddleware: MediatorMiddleware = + vitest.fn((ctx, input) => ({ + pendingChanges: { + ...(input.pendingChanges ?? {}), + count: getCount(ctx, input.pendingChanges) + 5, + }, + })); + + const specificMediator = createMediator( + initial, + { + middlewares: [{ event: "test", handler: specificMiddleware }], + }, + ); + + specificMediator.send("test"); + expect(specificMediator.getContext()).toEqual({ + count: 5, + message: "hello", + }); + + specificMediator.send("other"); + expect(specificMediator.getContext()).toEqual({ + count: 5, + message: "hello", + }); // Unchanged + + expect(specificMiddleware).toHaveBeenCalledTimes(1); // Only for "test" + }); + }); + + describe("middleware return types", () => { + it("should allow void middleware for pass-through behavior", () => { + const voidMiddleware: MediatorMiddleware = vitest.fn(); // Returns undefined + const listener = vitest.fn(); + + const voidMediator = createMediator(initial, { + middlewares: [{ event: "test", handler: voidMiddleware }], + }); + voidMediator.on("test", listener); + + voidMediator.send("test", () => ({ count: 5 })); + + expect(voidMiddleware).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith( + { count: 5, message: "hello" }, + "test", + ); + }); + + it("should allow middleware to override modifier changes with its own changes", () => { + // Middleware that defines specific changes, ignoring what the modifier tries to do + const overrideMiddleware: MediatorMiddleware = vitest.fn( + (_ctx, _input) => ({ + pendingChanges: { count: 100, message: "overridden by middleware" }, + }), + ); + const listener = vitest.fn(); + + const overrideMediator = createMediator(initial, { + middlewares: [{ event: "test", handler: overrideMiddleware }], + }); + overrideMediator.on("test", listener); + + // Modifier tries to increment count by 5 + overrideMediator.send("test", ({ count }) => ({ count: count + 5 })); + + expect(overrideMiddleware).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledTimes(1); + // Middleware changes have precedence over modifier changes + expect(listener).toHaveBeenCalledWith( + { count: 100, message: "overridden by middleware" }, + "test", + ); + }); + + it("should cancel processing when middleware returns cancel: true", () => { + const cancelMiddleware: MediatorMiddleware = vitest.fn( + (_ctx, _input) => ({ + cancel: true, + }), + ); + const listener = vitest.fn(); + + const cancelMediator = createMediator(initial, { + middlewares: [{ event: "test", handler: cancelMiddleware }], + }); + cancelMediator.on("test", listener); + + cancelMediator.send("test", () => ({ count: 5 })); + + expect(cancelMiddleware).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledTimes(0); + expect(cancelMediator.getContext()).toEqual(initial); + }); + + it("should handle mixed void and return middlewares", () => { + const voidMiddleware: MediatorMiddleware = vitest.fn(); // Returns undefined + const returnMiddleware: MediatorMiddleware = vitest.fn( + (ctx, input) => ({ + pendingChanges: { + ...(input.pendingChanges ?? {}), + count: getCount(ctx, input.pendingChanges) + 1, + }, + }), + ); + const listener = vitest.fn(); + + const mixedMediator = createMediator(initial, { + middlewares: [ + { event: "test", handler: voidMiddleware }, + { event: "test", handler: returnMiddleware }, + ], + }); + mixedMediator.on("test", listener); + + mixedMediator.send("test"); + + expect(voidMiddleware).toHaveBeenCalledTimes(1); + expect(returnMiddleware).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith( + { count: 1, message: "hello" }, + "test", + ); + }); + }); + + describe("error handling", () => { + it("should handle middleware that throws errors", () => { + const errorMiddleware: MediatorMiddleware = vitest.fn( + (_ctx, _input) => { + throw new Error("Middleware error"); + }, + ); + const listener = vitest.fn(); + + const errorMediator = createMediator(initial, { + middlewares: [{ event: "test", handler: errorMiddleware }], + }); + errorMediator.on("test", listener); + + expect(() => errorMediator.send("test")).toThrow("Middleware error"); + expect(listener).toHaveBeenCalledTimes(0); + }); + + it("should handle invalid middleware return values", () => { + const invalidMiddleware: MediatorMiddleware = vitest.fn( + (_ctx, _input) => + "invalid" as unknown as MediatorMiddlewareInput, + ); + const listener = vitest.fn(); + + const invalidMediator = createMediator(initial, { + middlewares: [{ event: "test", handler: invalidMiddleware }], + }); + invalidMediator.on("test", listener); + + // Should not throw but also not process the invalid return + expect(() => invalidMediator.send("test")).not.toThrow(); + expect(listener).toHaveBeenCalledTimes(1); + // When middleware returns invalid value, context remains unchanged + expect(listener).toHaveBeenCalledWith( + { count: 0, message: "hello" }, + "test", + ); + }); + }); + + describe("performance", () => { + it("should efficiently skip non-matching middleware", () => { + const matchingMiddleware: MediatorMiddleware = + vitest.fn(); + const nonMatchingMiddleware: MediatorMiddleware< + Context, + "test" | "other" + > = vitest.fn(); + const listener = vitest.fn(); + + const performanceMediator = createMediator( + initial, + { + middlewares: [ + { event: "test", handler: matchingMiddleware }, + { event: "other", handler: nonMatchingMiddleware }, + ], + }, + ); + performanceMediator.on("test", listener); + + performanceMediator.send("test"); + + expect(matchingMiddleware).toHaveBeenCalledTimes(1); + expect(nonMatchingMiddleware).toHaveBeenCalledTimes(0); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("should handle large numbers of middlewares", () => { + const middlewares: Array<{ + event: "test"; + handler: MediatorMiddleware; + }> = []; + const listener = vitest.fn(); + + // Create 10 middlewares + for (let i = 0; i < 10; i++) { + middlewares.push({ + event: "test", + handler: vitest.fn((ctx, input) => ({ + pendingChanges: { + ...(input.pendingChanges ?? {}), + count: getCount(ctx, input.pendingChanges) + 1, + }, + })), + }); + } + + const largeMediator = createMediator(initial, { + middlewares, + }); + largeMediator.on("test", listener); + + largeMediator.send("test"); + + // All middlewares should be called + middlewares.forEach(({ handler }) => { + expect(handler).toHaveBeenCalledTimes(1); + }); + expect(listener).toHaveBeenCalledWith( + { count: 10, message: "hello" }, + "test", + ); + }); + }); +}); diff --git a/src/types.ts b/src/types.ts index 606cc3a..f1bd0d6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,33 +1,152 @@ -/** Represents a JSON primitive serializable value that can be a string, number, boolean, or null. */ -export type JSONPrimitive = string | number | boolean | null; - -/** Represents a JSON serializable value that can be a primitive, a array, or a object. */ -export type JSONValue = JSONPrimitive | JSONArray | JSONObject; +// === UTILITY TYPES === -/** Represents a array of of JSON serializable values. */ -export type JSONArray = JSONValue[]; +/** Represents a value that can be T or undefined */ +export type Maybe = T | undefined; -/** Represents a dictionary (object) with string keys and values of type JSONValue. */ -export type JSONObject = { [member: string]: JSONValue }; +/** Represents a nullable value that can be T or null */ +export type Nullable = T | null; -/** Requires at least one property of T. */ +/** Requires at least one property of T */ export type AtLeastOneOf = { [K in keyof T]-?: Required>; }[keyof T]; -/** Represents a context for the Mediator, which is a JSON serializable object. */ +// === JSON TYPES === + +/** Represents a JSON primitive serializable value that can be a string, number, boolean, or null */ +export type JSONPrimitive = string | number | boolean | null; + +/** Represents a array of of JSON serializable values */ +export type JSONArray = JSONValue[]; + +/** Represents a dictionary (object) with string keys and values of type JSONValue */ +export type JSONObject = { [member: string]: JSONValue }; + +/** Represents a JSON serializable value that can be a primitive, a array, or a object */ +export type JSONValue = JSONPrimitive | JSONArray | JSONObject; + +// === MEDIATOR CORE TYPES === + +/** Represents a context for the Mediator, which is a JSON serializable object */ export type MediatorContext = JSONObject; -/** Represents an event that acts as a wildcard, matching any event. */ +/** Represents an event that acts as a wildcard, matching any event */ export type WildcardEvent = "*"; -/** Represents a listener function for Mediator events. */ +// === MIDDLEWARE TYPES === + +/** + * Represents the data passed to middleware functions. + * @template Context - The type of the MediatorContext. + */ +export type MediatorMiddlewareInput = { + /** The pending changes that will be applied to the context */ + pendingChanges: Nullable>; +}; + +/** + * Represents a cancel event propagation type. + * When returned from a middleware, it stops the event processing entirely. + */ +export type MediatorCancelEvent = { + /** Indicates that the event should be cancelled */ + cancel: true; +}; + +/** + * Represents the output data returned from middleware functions. + * Can be undefined (pass through), modified input data, or a cancel event. + * @template Context - The type of the MediatorContext. + */ +export type MediatorMiddlewareOutput = Maybe< + MediatorMiddlewareInput | MediatorCancelEvent +>; + +/** + * Represents a middleware function for Mediator events. + * Middlewares execute before event listeners and can observe, transform, or cancel events. + * @template Context - The type of the MediatorContext. + * @template EventName - The type of the event names. + * @param context - The immutable context snapshot (same for all middlewares). + * @param input - The input data containing pending changes. + * @param event - The event name being processed. + * @returns Either undefined (pass through), modified input data, or a cancel event. + * @example + * ``` + * // Logger middleware (observes only) + * const logger: MediatorMiddleware = (context, input, event) => { + * console.log(`Event ${event} triggered`) + * // No return - passes through unchanged + * } + * + * // Validation middleware (can cancel) + * const validator: MediatorMiddleware = (context, input, event) => { + * if (input.pendingChanges && 'count' in input.pendingChanges && input.pendingChanges.count < 0) { + * return { cancel: true } // Stop processing + * } + * return input // Pass through + * } + * + * // Transform middleware (modifies data) + * const transformer: MediatorMiddleware = (context, input, event) => { + * return { + * pendingChanges: { + * ...(input.pendingChanges ?? {}), + * timestamp: Date.now() + * } + * } + * } + * ``` + */ +export type MediatorMiddleware< + Context extends MediatorContext, + EventName extends string = string, +> = ( + context: Readonly, + input: MediatorMiddlewareInput, + event: EventName, +) => MediatorMiddlewareOutput; + +// === CONFIGURATION TYPES === + +/** + * Represents a middleware configuration option. + * Defines which events a middleware should handle and the handler function. + * @template Context - The type of the MediatorContext. + * @template EventName - The type of the event names. + */ +export type MediatorMiddlewareConfig< + Context extends MediatorContext, + EventName extends string = string, +> = { + /** The event name or wildcard (*) to match */ + event: EventName | WildcardEvent; + /** The middleware handler function */ + handler: MediatorMiddleware; +}; + +/** + * Represents the options for creating a Mediator instance. + * @template Context - The type of the MediatorContext. + * @template EventName - The type of the event names. + */ +export type MediatorOptions< + Context extends MediatorContext, + EventName extends string = string, +> = { + /** Optional array of middleware configurations */ + middlewares?: MediatorMiddlewareConfig[]; +}; + +// === FUNCTION TYPES === + +/** Represents a listener function for Mediator events */ export type MediatorEventListener< Context extends MediatorContext, EventName extends string, > = (ctx: Readonly, eventName: EventName) => void; -/** Represents a context modifier function for Mediator events. */ +/** Represents a context modifier function for Mediator events */ export type MediatorContextModifier = ( ctx: Readonly, ) => AtLeastOneOf; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..919daad --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + coverage: { + all: false, + provider: "v8", + }, + }, +}); From 927a3928a925d0fea186ca5aab76881bdbc00b44 Mon Sep 17 00:00:00 2001 From: Marcus Ortense Date: Wed, 1 Oct 2025 13:27:30 -0300 Subject: [PATCH 2/2] fix: warning types from typedoc --- src/factory.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/factory.ts b/src/factory.ts index 14dc6a8..48ebf3e 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -16,8 +16,8 @@ const copy = (value: T) => structuredClone(value) as T; * @param {Context} initialContext - The initial context for the Mediator. * @param {MediatorOptions} [options] - Optional configuration including middlewares. * @returns {Mediator} 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'