Skip to content

Middleware

Context accumulation with type-safe middleware

Middleware extends the handler context with new properties. Each .use() call adds to what came before — TypeScript tracks the accumulated context so your handler sees exactly what's available.

Inline Middleware

The simplest middleware is an inline function that returns an object. The returned properties are merged into the context:

inline-middleware.tsTypeScript
import { Get } from '@justscale/http';

Get('/tickets')
  .use(() => ({ requestedAt: Date.now() }))
  .handle(({ requestedAt, res }) => {
    // requestedAt is typed as number — added by the middleware
    res.json({ requestedAt });
  });

Context Accumulation

Multiple .use() calls accumulate context. Each middleware sees everything that came before it:

accumulation.tsTypeScript
import { Get } from '@justscale/http';
import { auth } from '@justscale/auth';

Get('/tickets/:ticket')
  .use(auth)                  // ctx now has: { user }
  .use(async ({ user }) => {  // can access user from previous .use()
    const role = user.email.endsWith('@support.com') ? 'agent' : 'customer';
    return { role };           // adds: { role }
  })
  .handle(({ user, role, params, res }) => {
    // Both user and role are available and typed
    if (role === 'agent') {
      res.json({ tickets: 'all' });
    } else {
      res.json({ tickets: 'own' });
    }
  });

Async Middleware

Middleware can be async — useful for loading data that the handler needs:

async-middleware.tsTypeScript
import { Get } from '@justscale/http';
import { auth } from '@justscale/auth';

// Async middleware — fetches additional data before the handler runs
Get('/dashboard')
  .use(auth)
  .use(async ({ user }) => {
    // Fetch preferences from an external API
    const res = await fetch(`https://api.example.com/prefs/${user.email}`);
    const preferences = await res.json();
    return { preferences };  // adds { preferences } to context
  })
  .handle(({ user, preferences, res }) => {
    // Both user (from auth) and preferences (from async middleware) available
    res.json({ user: user.email, theme: preferences.theme });
  });

DI Middleware (MiddlewareDef)

For middleware that needs injected services, use createMiddleware. This lets middleware participate in the DI system:

Files
src/middleware/audit-log.tsTypeScript
import { createMiddleware, Logger } from '@justscale/core';

// Middleware with DI — Logger is injected automatically
export const auditLog = createMiddleware({
  inject: { logger: Logger },
  handler: ({ logger }) => async (ctx: { user?: { email: string } }) => {
    const who = ctx.user?.email ?? 'anonymous';
    logger.info('Request', { user: who });
    return { auditedAt: new Date() };
  },
});

The auth Middleware

The most common middleware is auth from @justscale/auth. It validates the session token and adds user to the context:

auth-usage.tsTypeScript
import { Get, Post } from '@justscale/http';
import { auth } from '@justscale/auth';

// After .use(auth), the handler has { user: Persistent<User> }
Get('/tickets')
  .use(auth)
  .handle(({ user, res }) => {
    // user.email, user.name — typed fields from the User model
    res.json({ loggedInAs: user.email });
  });

// Without auth — no user in context
Get('/health').handle(({ res }) => {
  res.json({ status: 'ok' });
});

The permissions Middleware

Stack permissions from @justscale/permission after auth when a route uses permission-scoped .returns(). It resolves the caller's principals, stores them for downstream query filtering, and wraps res with a .permission discriminant matching the rule that fired.

permissions-usage.tsTypeScript
import { Get } from '@justscale/http';
import { auth } from '@justscale/auth';
import { permissions, assertNever } from '@justscale/permission';
import { Employee } from '../models/employee';
import { EmployeeFull, EmployeeLimited } from '../schemas/employee';

Get('/employees/:employee')
  .types({ Employee })
  .use(auth)
  .use(permissions)
  .guard(Employee.can.view)
  .returns(200, EmployeeFull,    Employee.can.fullAccess)
  .returns(200, EmployeeLimited, Employee.can.view)
  .handle(({ params, res }) => {
    switch (res.permission) {
      case 'fullAccess': res.json(params.employee); return;
      case 'view':       res.json({ name: params.employee.name }); return;
      default: assertNever(res);
    }
  });

See Permissions for the full picture — declaring rules on models and querying with them.

Best Practices

  • Middleware adds, never removes — each .use() extends the context. It can't remove properties added by previous middleware
  • Order matters — later middleware can access earlier additions. .use(auth).use(loadProfile) works; the reverse doesn't
  • Use DI middleware for shared logic — if middleware needs services, use createMiddleware instead of closures
  • Throw to stop — if middleware throws, execution stops and the error is returned to the client