Docs/Middlewarev1.0.0-rc.1

Composition Utilities

Package: @tagna/udiot v1.0.0-rc.1 Source: src/middleware/context.ts (exported via src/middleware/index.ts) Import:

import {
  compose,
  when,
  parallel,
  tryCatch,
  timeout,
  memoize,
  skip,
  measure
} from '@tagna/udiot';

All utilities accept and return Middleware<TContext> objects (not bare functions). If you have a bare MiddlewareFunction, wrap it:

const mw: Middleware<HttpContext> = { name: 'myMw', handler: myFn };

compose

function compose<TContext extends BaseContext>(
  ...middlewares: Middleware<TContext>[]
): Middleware<TContext>

Chains multiple middleware into a single middleware that executes them sequentially left-to-right. The composed middleware calls next() only after all composed middleware have run.

The returned Middleware is named composed_<name1>_<name2>_....

When to use: Bundling a set of related middleware into a single named unit so they can be treated atomically — added, removed, or replaced together.

import { compose, authMiddleware, validationMiddleware } from '@tagna/udiot';
import type { HttpContext } from '@tagna/udiot';

const securePostMiddleware = compose<HttpContext>(
  authMiddleware({ strategy: 'jwt', secret: process.env.JWT_SECRET! }),
  validationMiddleware({
    body: {
      title: { type: 'string', required: true, min: 1, max: 200 },
      content: { type: 'string', required: true }
    }
  })
);

pipeline.use(securePostMiddleware);

when

function when<TContext extends BaseContext>(
  condition: (context: TContext) => boolean,
  middleware: Middleware<TContext>
): Middleware<TContext>

Wraps middleware so it only executes when condition(context) returns true. When the condition is false the wrapper calls next() directly, skipping the wrapped middleware entirely.

The returned Middleware is named when_<wrappedName>.

When to use: Applying middleware conditionally based on runtime context properties (HTTP method, request path, user role, feature flag).

import { when, loggingMiddleware } from '@tagna/udiot';
import type { HttpContext } from '@tagna/udiot';

// Only log non-health-check requests
const selectiveLogger = when<HttpContext>(
  (ctx) => ctx.request.path !== '/health',
  loggingMiddleware({ level: 'info' })
);

pipeline.use(selectiveLogger);
// Only run expensive validation on POST/PUT/PATCH
const conditionalValidation = when<HttpContext>(
  (ctx) => ['POST', 'PUT', 'PATCH'].includes(ctx.request.method),
  validationMiddleware({ body: createSchema })
);

parallel

function parallel<TContext extends BaseContext>(
  ...middlewares: Middleware<TContext>[]
): Middleware<TContext>

Executes all provided middleware concurrently using Promise.all, then calls next() after all of them complete. Each middleware in the parallel group receives a no-op next — they cannot advance the outer chain themselves.

The returned Middleware is named parallel_<name1>_<name2>_... with category: 'performance'.

When to use: Independent side effects that do not depend on each other's output and that would otherwise introduce unnecessary sequential latency.

Warning: Middleware in the parallel group must not write to the same context fields without coordination, as concurrent writes can race.

import { parallel } from '@tagna/udiot';
import type { HttpContext } from '@tagna/udiot';

const analyticsMiddleware: Middleware<HttpContext> = {
  name: 'analytics',
  handler: async (ctx, _next) => {
    await sendToAnalytics(ctx.request.path, ctx.user?.id);
  }
};

const auditMiddleware: Middleware<HttpContext> = {
  name: 'audit',
  handler: async (ctx, _next) => {
    await writeAuditLog(ctx.request.method, ctx.request.path);
  }
};

// Both fire concurrently; pipeline continues only after both finish
pipeline.use(parallel<HttpContext>(analyticsMiddleware, auditMiddleware));

tryCatch

function tryCatch<TContext extends BaseContext>(
  middleware: Middleware<TContext>,
  errorHandler: (error: Error, context: TContext) => Promise<void> | void
): Middleware<TContext>

Wraps a single middleware in a try/catch block. When the wrapped middleware throws, errorHandler is called with the error and the context. After the error handler completes, the outer chain continues by calling next().

The returned Middleware is named tryCatch_<wrappedName> with category: 'error-handling'.

When to use: Per-middleware error isolation — you want a specific middleware to fail gracefully without halting the pipeline.

import { tryCatch } from '@tagna/udiot';
import type { HttpContext } from '@tagna/udiot';

const safeExternalCall: Middleware<HttpContext> = {
  name: 'externalEnrichment',
  handler: async (ctx, next) => {
    const enriched = await callExternalService(ctx.user?.id);
    ctx.metadata.set('enrichment', enriched);
    await next();
  }
};

const resilientEnrichment = tryCatch<HttpContext>(
  safeExternalCall,
  async (error, ctx) => {
    // Log and continue without enrichment data
    console.warn('Enrichment service unavailable:', error.message);
    ctx.metadata.set('enrichment', null);
  }
);

pipeline.use(resilientEnrichment);

timeout

function timeout<TContext extends BaseContext>(
  middleware: Middleware<TContext>,
  ms: number
): Middleware<TContext>

Wraps middleware in a Promise.race against a timer. If the middleware does not complete within ms milliseconds, the wrapper rejects with Error: Middleware <name> timeout after <ms>ms.

The returned Middleware is named timeout_<wrappedName>.

When to use: Protecting against slow external calls that would block the pipeline indefinitely. Combine with tryCatch to handle the timeout error gracefully.

import { timeout, tryCatch } from '@tagna/udiot';
import type { HttpContext } from '@tagna/udiot';

const slowDbMiddleware: Middleware<HttpContext> = {
  name: 'dbQuery',
  handler: async (ctx, next) => {
    ctx.result = await db.findUser(ctx.params.id);
    await next();
  }
};

// Abort if db takes more than 2 seconds
const timedDbMiddleware = timeout<HttpContext>(slowDbMiddleware, 2000);

// Return 503 on timeout instead of crashing
const resilientDb = tryCatch<HttpContext>(
  timedDbMiddleware,
  async (_error, ctx) => {
    ctx.response.status(503);
    ctx.result = { error: 'Database timeout' };
  }
);

pipeline.use(resilientDb);

memoize

function memoize<TContext extends BaseContext>(
  middleware: Middleware<TContext>,
  keyFn: (context: TContext) => string,
  ttl: number = 60000
): Middleware<TContext>

Caches context.result produced by the wrapped middleware. On subsequent executions where keyFn(context) returns the same key and the cached entry has not expired (TTL in milliseconds), the middleware sets ctx.result from the cache and skips executing the wrapped middleware.

The cache is stored in a closure-scoped Map. It is not shared between different memoize() calls. Entries older than ttl are evicted on cache misses and periodically when the cache exceeds 100 entries.

The returned Middleware is named memoize_<wrappedName> with category: 'performance'.

When to use: Expensive idempotent computations (database lookups, external API calls) whose results are stable for a predictable duration.

import { memoize } from '@tagna/udiot';
import type { HttpContext } from '@tagna/udiot';

const userLookup: Middleware<HttpContext> = {
  name: 'userLookup',
  handler: async (ctx, next) => {
    ctx.result = await db.findUserById(ctx.params.id);
    await next();
  }
};

// Cache user lookups for 5 minutes per user ID
const cachedUserLookup = memoize<HttpContext>(
  userLookup,
  (ctx) => `user:${ctx.params.id}`,
  5 * 60 * 1000
);

skip

function skip<TContext extends BaseContext>(
  middleware: Middleware<TContext>,
  condition: (context: TContext) => boolean
): Middleware<TContext>

The inverse of when. Skips the wrapped middleware when condition(context) returns true; runs it when condition returns false. Implemented internally as when((ctx) => !condition(ctx), middleware).

When to use: When it reads more naturally to express the skip case rather than the run case.

import { skip, compressionMiddleware } from '@tagna/udiot';
import type { HttpContext } from '@tagna/udiot';

// Skip compression for small health-check responses
const conditionalCompression = skip<HttpContext>(
  compressionMiddleware({ threshold: 1024 }),
  (ctx) => ctx.request.path === '/health'
);

measure

function measure<TContext extends BaseContext>(
  middleware: Middleware<TContext>,
  onComplete?: (duration: number, context: TContext) => void
): Middleware<TContext>

Wraps middleware to record its wall-clock execution time. The duration in milliseconds is stored in ctx.metadata under the key <middlewareName>_duration. Optionally calls onComplete(duration, context) after the wrapped middleware finishes.

The returned Middleware is named measure_<wrappedName> with category: 'observability'.

When to use: Per-middleware timing outside the pipeline's built-in trackPerformance option — for example, when reporting to an external metrics system with custom dimensions.

import { measure } from '@tagna/udiot';
import type { HttpContext } from '@tagna/udiot';

const businessLogic: Middleware<HttpContext> = {
  name: 'businessLogic',
  handler: async (ctx, next) => {
    ctx.result = await processOrder(ctx.body);
    await next();
  }
};

const timedBusinessLogic = measure<HttpContext>(
  businessLogic,
  (duration, ctx) => {
    metrics.histogram('order_processing_ms', duration, {
      path: ctx.request.path,
      userId: ctx.user?.id
    });
  }
);

Complex Composition Example

The following example composes several utilities to build a robust, observable, resilient middleware for an external enrichment call:

import {
  compose,
  when,
  tryCatch,
  timeout,
  memoize,
  measure,
  loggingMiddleware,
  authMiddleware
} from '@tagna/udiot';
import type { HttpContext, Middleware } from '@tagna/udiot';

// Base enrichment middleware
const enrichmentFetch: Middleware<HttpContext> = {
  name: 'enrichmentFetch',
  handler: async (ctx, next) => {
    const data = await externalService.enrich(ctx.user?.id);
    ctx.metadata.set('enrichment', data);
    await next();
  }
};

// 1. Cache results for 2 minutes per user
const cachedEnrichment = memoize<HttpContext>(
  enrichmentFetch,
  (ctx) => `enrich:${ctx.user?.id}`,
  2 * 60 * 1000
);

// 2. Abort if it takes more than 1 second
const timedEnrichment = timeout<HttpContext>(cachedEnrichment, 1000);

// 3. Degrade gracefully on timeout or network error
const resilientEnrichment = tryCatch<HttpContext>(
  timedEnrichment,
  async (err, ctx) => {
    console.warn('Enrichment skipped:', err.message);
    ctx.metadata.set('enrichment', null);
  }
);

// 4. Record the duration to metrics
const measuredEnrichment = measure<HttpContext>(
  resilientEnrichment,
  (ms) => metrics.histogram('enrichment_ms', ms)
);

// 5. Only run for authenticated users
const conditionalEnrichment = when<HttpContext>(
  (ctx) => ctx.user != null,
  measuredEnrichment
);

// 6. Compose the full pipeline for a protected route
const protectedApiPipeline = compose<HttpContext>(
  loggingMiddleware({ level: 'info', logHeaders: false }),
  authMiddleware({ strategy: 'jwt', secret: process.env.JWT_SECRET! }),
  conditionalEnrichment,
  { name: 'handler', handler: myRouteHandler }
);

pipeline.use(protectedApiPipeline);

Execution order for an authenticated request:

  1. loggingMiddleware — logs the request, attaches requestId/traceId to metadata
  2. authMiddleware — validates JWT, sets ctx.user
  3. conditionalEnrichment — ctx.user is set, so enrichment runs
  4. timedEnrichment — races against 1 second timer
  5. cachedEnrichment — checks cache; if miss, calls external service
  6. myRouteHandler — processes the request with enrichment data in metadata
From the canonical UDIOT public documentation.Try it in the playground ↗