Skip to content

feat: adds middleware support for event interception#28

Merged
ortense merged 3 commits intomainfrom
feat/middlewares
Oct 1, 2025
Merged

feat: adds middleware support for event interception#28
ortense merged 3 commits intomainfrom
feat/middlewares

Conversation

@ortense
Copy link
Owner

@ortense ortense commented Sep 30, 2025

Middleware System Implementation

Overview

This PR introduces a middleware system to the Mediator library. Middlewares are configured as part of the mediator creation step, reinforcing their role as part of the mediator’s infrastructure. Middlewares provide a dedicated layer that can observe events, modify the context or pending changes, and even cancel propagation. This ensures more control, observability, and separation of concerns while keeping the API minimalistic.

Features

🎯 Core Functionality

  • Event Interception: Middlewares execute before event listeners.
  • Context Modification: Enrich or modify the current context during event processing.
  • Changes Transformation: Transform or validate pending changes before they are applied.
  • Flow Control: Cancel propagation entirely to stop execution.
  • Order Preservation: Middlewares execute in declared order.
  • Wildcard Support: Apply middlewares to all events using *.

🔧 API Design

Middlewares are declared through the options parameter in createMediator:

export type MediatorMiddlewareConfig<Context extends MediatorContext, EventName extends string = string> = {
  event: EventName | "*";
  handler: MediatorMiddleware<Context, EventName>;
};

export type MediatorOptions<Context extends MediatorContext, EventName extends string = string> = {
  middlewares?: MediatorMiddlewareConfig<Context, EventName>[];
};

export function createMediator<Context extends MediatorContext, EventName extends string = string>(
  initialContext: Context,
  options?: MediatorOptions<Context, EventName>,
): Mediator<Context, EventName>;

Middleware Data Types

// Input data passed to middleware functions
export type MediatorMiddlewareInput<Context extends MediatorContext> = {
  pendingChanges: Nullable<AtLeastOneOf<Context>>; // Pending changes from modifier or middlewares
};

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

// Output data returned from middleware functions
export type MediatorMiddlewareOutput<Context extends MediatorContext> =
  Maybe<MediatorMiddlewareInput<Context> | MediatorCancelEvent>;

// Unified middleware type - can return modified data, cancel, or void
export type MediatorMiddleware<Context extends MediatorContext, EventName extends string = string> = (
  context: Readonly<Context>,
  input: MediatorMiddlewareInput<Context>,
  event: EventName,
) => MediatorMiddlewareOutput<Context>;

Usage Examples

Creating a Mediator with Middlewares

import { createMediator } from "@ortense/mediator";

interface AppContext {
  user: string;
  count: number;
}

// Watcher middleware (only observes, returns void)
const logEvents: MediatorMiddleware<AppContext, string> = (context, input, event) => {
  console.log("[LOG]", event, context, input.pendingChanges);
  // No return value - passes through unchanged
};

// Transformer middleware (modifies pending changes)
const validateCount: MediatorMiddleware<AppContext, string> = (context, input, event) => {
  if (input.pendingChanges && 'count' in input.pendingChanges && input.pendingChanges.count < 0) {
    console.warn("Invalid count change, cancelling");
    return { cancel: true }; // cancel stops propagation
  }
  return input; // return modified data
};

// Middleware that modifies pending changes
const incrementCount: MediatorMiddleware<AppContext, string> = (context, input, event) => {
  const currentCount = 'count' in (input.pendingChanges ?? {}) ? input.pendingChanges!.count : context.count;
  return {
    pendingChanges: { 
      ...(input.pendingChanges ?? {}), 
      count: currentCount + 1 
    },
  };
};

const mediator = createMediator<AppContext>(
  { user: "anonymous", count: 0 },
  {
    middlewares: [
      { event: "*", handler: logEvents },
      { event: "counter:decrement", handler: validateCount },
      { event: "counter:increment", handler: incrementCount },
    ],
  }
);

Execution Flow

  1. mediator.send(event, modifier) is called.

  2. A frozen snapshot of the current context is created (Object.freeze(structuredClone(context))).

  3. Modifier generates initial pendingChanges if provided.

  4. Middlewares execute in registration order:

    • Void middlewares (no return) observe and pass through unchanged.
    • Return middlewares may return modified pendingChanges.
    • Any middleware can return { cancel: true } to stop propagation immediately.
    • All middlewares receive the same immutable context snapshot - no incremental context updates.
  5. Final context is updated with shallow merge (Object.assign) of all pendingChanges.

  6. Event listeners run with the updated context unless propagation was cancelled.

  7. Wildcard listeners (*) also execute after specific listeners.


Benefits

🚀 Clarity & Simplicity

  • Static Infrastructure: Middlewares are always part of mediator creation.
  • Unified Interface: Single middleware type handles all scenarios (observe, transform, cancel).
  • Clear Separation: Distinct separation between middlewares and listeners.

🛡️ Strong Guarantees

  • Flow Control: Events can be safely cancelled.
  • Predictable Execution: Chains run deterministically.
  • Immutability: Context cannot be accidentally mutated by middlewares.
  • Consistency: All middlewares receive the same context snapshot.

🔍 Observability

  • Void Middleware Support: Add logging, analytics, and debugging with no side effects.
  • Transform Support: Enforce validations or enrich data by returning modified pendingChanges.
  • Cancel Support: Stop event processing when validation fails or conditions aren't met.

Advanced Examples

Middleware Return Types

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

// Pending changes modification middleware
const enrichChanges: MediatorMiddleware<AppContext, string> = (context, input, event) => {
  return {
    pendingChanges: { 
      ...(input.pendingChanges ?? {}), 
      timestamp: Date.now() 
    },
  };
};

// Changes validation middleware
const validateChanges: MediatorMiddleware<AppContext, string> = (context, input, event) => {
  if (input.pendingChanges && 'count' in input.pendingChanges && input.pendingChanges.count < 0) {
    return {
      pendingChanges: { count: 0 }, // Override invalid changes
    };
  }
  return input;
};

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

Accumulating Pending Changes

// Middlewares accumulate changes through pendingChanges
const middleware1: MediatorMiddleware<AppContext, string> = (context, input, event) => {
  const currentCount = 'count' in (input.pendingChanges ?? {}) ? input.pendingChanges!.count : context.count;
  return {
    pendingChanges: { 
      ...(input.pendingChanges ?? {}), 
      count: currentCount + 1 
    },
  };
};

const middleware2: MediatorMiddleware<AppContext, string> = (context, input, event) => {
  const currentCount = 'count' in (input.pendingChanges ?? {}) ? input.pendingChanges!.count : context.count;
  return {
    pendingChanges: { 
      ...(input.pendingChanges ?? {}), 
      count: currentCount + 2 
    },
  };
};

// middleware2 receives accumulated pendingChanges from middleware1
// All middlewares receive the same immutable context snapshot

Wildcard vs Specific Events

const mediator = createMediator<AppContext>(initialContext, {
  middlewares: [
    { event: "*", handler: logger },           // Runs for all events
    { event: "user:login", handler: authGuard }, // Runs only for user:login
  ],
});

Key Design Decisions

🔒 Immutable Context

  • Context is frozen: Object.freeze(structuredClone(context)) ensures true immutability
  • Same snapshot for all: All middlewares receive the identical context snapshot
  • No accidental mutations: Impossible to accidentally modify the context from within middleware

🔄 Pending Changes Accumulation

  • Transformations via pendingChanges: Middlewares modify state through pendingChanges only
  • Shallow merge: Final state uses Object.assign for shallow merging
  • Composable: Multiple middlewares can accumulate changes safely

🎯 Simple API Design

  • Three parameters: (context, input, event) - clear separation of concerns
  • Context as parameter: Passed as readonly parameter for immutability
  • Event as parameter: Explicit event name parameter for clarity

Future Enhancements

  • Async Middleware: Support asynchronous operations.
  • Conditional Middlewares: Run only in specific environments.
  • Priorities: Explicit control over execution order.

This middleware system proposes a powerful yet minimalistic way to control event flow in Mediator. Middlewares can watch, transform, or cancel events, providing security, observability, and flexibility while maintaining the library's lightweight philosophy.

@ortense ortense force-pushed the feat/middlewares branch 9 times, most recently from 0d126c5 to 5d90b17 Compare October 1, 2025 16:18
@ortense ortense merged commit 6d1ac1d into main Oct 1, 2025
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant