Middleware

Middleware in Sapling provides a powerful way to process requests before they reach your route handlers. They can be used for common tasks like authentication, logging, error handling, and request transformation.

Understanding Middleware

Middleware functions in Sapling receive two parameters:

  • A Context object (c) containing request and response information
  • A next function that calls the next middleware or route handler in the chain
type Next = () => Promise<Response | null>;
type Middleware = (c: Context, next: Next) => Promise<Response | null>;

Each middleware can:

  • Process the request before calling next()
  • Modify the response after calling next()
  • Short-circuit the request by returning a response without calling next()
  • Pass control to the next middleware by calling next()

Important Note About next()

Unlike some other frameworks like Hono, Sapling requires you to explicitly return await next() in your middleware. This is a deliberate design choice that makes the middleware flow more explicit. Sapling wants to know when you are done modifying the response.

// ❌ Wrong - will not work in Sapling
site.use(async (c, next) => {
  console.log('Before');
  await next();  // Response is lost!
  console.log('After');
});

// ✅ Correct - explicitly return the response
site.use(async (c, next) => {
  console.log('Before');
  const response = await next();
  console.log('After');
  return response;  // Must return the response
});

This requirement ensures you're always handling the response properly and makes it clear when middleware is modifying or passing through responses.

This could change in the future, but for now it's a deliberate design choice.

Global Middleware

Global middleware is applied to all routes in your application. Use the use method to register global middleware:

const site = new Sapling();

// Logging middleware
site.use(async (c, next) => {
  console.log(`${c.req.method} ${c.req.url}`);
  const start = Date.now();
  
  const response = await next();
  
  const duration = Date.now() - start;
  console.log(`Request completed in ${duration}ms`);
  
  return response;
});

// Error handling middleware
site.use(async (c, next) => {
  try {
    return await next();
  } catch (error) {
    console.error('Error:', error);
    return c.json({ error: 'Internal Server Error' }, 500);
  }
});

Route-Specific Middleware

You can apply middleware to specific routes by passing them as arguments before the route handler. Sapling fully supports chaining multiple middleware functions for a single route:

// Authentication middleware
async function requireAuth(c: Context, next: Next) {
  const token = c.req.header('Authorization');
  
  if (!token) {
    return c.json({ error: 'Unauthorized' }, 401);
  }
  
  try {
    const user = await verifyToken(token);
    c.set('user', user); // Store user in context state
    return await next();
  } catch (error) {
    return c.json({ error: 'Invalid token' }, 401);
  }
}

// Data validation middleware
async function validatePostData(c: Context, next: Next) {
  const data = await c.req.json();
  if (!data.title || !data.content) {
    return c.json({ error: 'Missing required fields' }, 400);
  }
  c.set('validatedData', data);
  return await next();
}

// Apply single middleware to a route
site.get('/api/profile', 
  requireAuth,
  (c) => {
    const user = c.get('user');
    return c.json({ user });
  }
);

// Chain multiple middleware functions
site.post('/api/posts',
  requireAuth,        // First, check authentication
  validatePostData,   // Then, validate the post data
  async (c) => {      // Finally, handle the request
    const user = c.get('user');
    const data = c.get('validatedData');
    return c.json({ created: true, userId: user.id });
  }
);

Each middleware in the chain must explicitly return the result of await next() or return its own response to short-circuit the chain. The middleware functions are executed in the order they are provided.

Common Middleware Patterns

State Management

Middleware can use the context state to pass data between middleware functions and route handlers:

// Add data to context state
site.use(async (c, next) => {
  c.set('requestId', crypto.randomUUID());
  return await next();
});

// Access state in route handlers
site.get('/api/data', (c) => {
  const requestId = c.get<string>('requestId');
  return c.json({ requestId });
});

Request Transformation

Middleware can transform or validate request data before it reaches your route handlers:

async function validateJson(c: Context, next: Next) {
  if (c.req.method === 'POST' || c.req.method === 'PUT') {
    try {
      const body = await c.req.json();
      c.set('validatedBody', body);
    } catch {
      return c.json({ error: 'Invalid JSON' }, 400);
    }
  }
  return await next();
}

site.post('/api/data', 
  validateJson,
  (c) => {
    const body = c.get('validatedBody');
    return c.json({ received: body });
  }
);

Response Headers

Middleware can modify response headers:

site.use(async (c, next) => {
  // Add security headers
  c.res.headers.set('X-Frame-Options', 'DENY');
  c.res.headers.set('X-Content-Type-Options', 'nosniff');
  
  return await next();
});

CORS Middleware

Example of a CORS middleware implementation:

function cors(origin: string = '*') {
  return async (c: Context, next: Next) => {
    // Handle preflight requests
    if (c.req.method === 'OPTIONS') {
      const headers = new Headers({
        'Access-Control-Allow-Origin': origin,
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
        'Access-Control-Max-Age': '86400',
      });
      return new Response(null, { headers });
    }

    // Handle actual requests
    const response = await next();
    if (response) {
      response.headers.set('Access-Control-Allow-Origin', origin);
    }
    return response;
  };
}

// Apply CORS middleware
site.use(cors('https://example.com'));

Best Practices

  1. Order Matters: Middleware executes in the order it's added. Place middleware that should run first (like logging or error handling) at the beginning.

  2. Error Handling: Always handle potential errors in middleware, especially when dealing with async operations:

site.use(async (c, next) => {
  try {
    return await next();
  } catch (error) {
    console.error(error);
    return c.json({ error: 'Something went wrong' }, 500);
  }
});
  1. Type Safety: Use TypeScript interfaces for better type safety:
interface User {
  id: string;
  role: string;
}

async function requireAdmin(c: Context, next: Next) {
  const user = c.get<User>('user');
  if (!user || user.role !== 'admin') {
    return c.json({ error: 'Forbidden' }, 403);
  }
  return await next();
}
  1. Keep Middleware Focused: Each middleware should have a single responsibility. Break complex middleware into smaller, more focused functions:
// Instead of one large middleware
site.use(async (c, next) => {
  // Don't do authentication, logging, and validation in one middleware
});

// Break it down
site.use(requestLogger);
site.use(authenticate);
site.use(validateRequest);
  1. Performance: Be mindful of performance in middleware, especially for operations that run on every request:
// Cache expensive operations
const cache = new Map();

site.use(async (c, next) => {
  const cacheKey = c.req.url;
  if (cache.has(cacheKey)) {
    return cache.get(cacheKey);
  }
  
  const response = await next();
  if (response && c.req.method === 'GET') {
    cache.set(cacheKey, response.clone());
  }
  return response;
});