Framework-agnostic HTTP middleware for Express and AWS Lambda
npm install @loupeat/fmiddlewareA framework-agnostic HTTP middleware for building APIs that run on both Express.js and AWS Lambda.
- Framework-agnostic: Write your API once, deploy to Express.js or AWS Lambda
- Powerful routing: Path parameters, wildcards, and pattern matching
- Pre-processors: Enrich requests with authentication, context, and validation
- Post-processors: Handle errors, logging, and response transformation
- Built-in validation: JSON Schema validation with custom keywords (uuid, email, json)
- Typed errors: Semantic error classes that map to HTTP status codes
- TypeScript-first: Full type safety for requests and responses
- OpenAPI generation: Generate OpenAPI 3.0 specs automatically from handlers
``bash`
npm install @loupeat/fmiddleware
For AWS Lambda support, also install the types:
`bash`
npm install --save-dev @types/aws-lambda
`typescript
import express from "express";
import { FExpressMiddleware, FRequest } from "@loupeat/fmiddleware";
const app = express();
const api = new FExpressMiddleware();
interface Note {
id: string;
title: string;
}
// Register a simple GET endpoint
api.get("/api/notes", async (request: FRequest
const notes: Note[] = [{ id: "1", title: "Hello World" }];
return api.responses.OK
});
// Use FMiddleware as Express middleware
app.use(express.json());
app.all("*", async (req, res) => {
const response = await api.process(req);
for (const [key, value] of Object.entries(response.headers || {})) {
res.setHeader(key, value as string);
}
res.status(response.statusCode).json(response.body);
});
app.listen(3000);
`
`typescript
import { APIGatewayProxyHandler } from "aws-lambda";
import { FAWSLambdaMiddleware, FRequest } from "@loupeat/fmiddleware";
interface Note {
id: string;
title: string;
}
const api = new FAWSLambdaMiddleware();
api.get("/api/notes", async (request: FRequest
const notes: Note[] = [{ id: "1", title: "Hello World" }];
return api.responses.OK
});
export const handler: APIGatewayProxyHandler = async (event) => {
return api.process(event);
};
`
FMiddleware provides methods for all common HTTP verbs:
`typescript`
api.get(path, handler);
api.post(path, handler, schema?);
api.put(path, handler, schema?);
api.delete(path, handler);
Capture dynamic segments using {paramName} syntax:
`typescript
import { FRequest } from "@loupeat/fmiddleware";
api.get("/api/notes/{noteId}", async (request: FRequest
const noteId = api.pathParameter(request, "noteId");
const note = await notesService.get(noteId);
if (!note) {
return api.responses.NotFound(request, Note ${noteId} not found);
}
return api.responses.OK
});
`
Capture multiple path segments using {paramName+}:
`typescript
interface FilePathResponse {
filepath: string;
}
api.get("/api/files/{filepath+}", async (request: FRequest
// For /api/files/documents/2024/report.pdf
// filepath = "documents/2024/report.pdf"
const filepath = api.pathParameter(request, "filepath");
return api.responses.OK
});
`
> Security Warning: Path parameters can contain traversal sequences like ../. If you use path parameters for file system operations, always validate that the resolved path stays within your intended directory:`
> typescript`
> import * as path from "path";
>
> const baseDir = "/var/uploads";
> const userPath = api.pathParameter(request, "filepath");
> const resolved = path.resolve(baseDir, userPath);
>
> if (!resolved.startsWith(baseDir)) {
> throw new ForbiddenError("Invalid file path");
> }
>
`typescript
api.get("/api/notes/search", async (request: FRequest
// Required parameter - throws ValidationError if missing
const query = api.queryStringParameter(request, "q");
// Optional parameter - returns undefined if missing
const tag = api.queryStringParameterOptional(request, "tag");
const results = await notesService.search(query, tag);
return api.responses.OK
});
`
FMiddleware supports wildcards for pre/post-processors:
| Pattern | Matches |
|---------|---------|
| /api/notes | Exact match |/api/notes/*
| | Single segment wildcard |/api/notes/**
| | Multi-segment wildcard |/api/notes/{id}
| | Path parameter |/api/notes/{path+}
| | Greedy path parameter |
Validate request bodies using JSON Schema (Draft-07):
`typescript
import { FRequest } from "@loupeat/fmiddleware";
interface CreateNoteRequest {
title: string;
content: string;
tags?: string[];
}
const CreateNoteSchema = {
type: "object",
properties: {
title: { type: "string", minLength: 1 },
content: { type: "string" },
tags: {
type: "array",
items: { type: "string" }
}
},
required: ["title", "content"]
};
api.post("/api/notes", async (request: FRequest
// request.body is validated against the schema and typed
const { title, content, tags } = request.body;
const note = await notesService.create({ title, content, tags });
return api.responses.OK
}, CreateNoteSchema);
`
The built-in validator supports custom keywords:
`typescript
// UUID validation
const schema = {
type: "object",
properties: {
id: { type: "string", uuid: true }
}
};
// Email validation
const schema = {
type: "object",
properties: {
email: { type: "string", email: true }
}
};
// JSON string validation
const schema = {
type: "object",
properties: {
metadata: { type: "string", json: true }
}
};
`
Pre-processors run before the handler and can enrich the request context:
`typescript
import {
FMiddleware,
FRequest,
FHandler,
RequestPreProcessor,
AuthenticationError
} from "@loupeat/fmiddleware";
interface User {
id: string;
email: string;
}
const AuthPreProcessor: RequestPreProcessor = {
name: "AuthPreProcessor",
pathPatterns: ["/api/notes/**"],
requestSource: "", // "express", "aws-lambda", or "" for both
process: async (
api: FMiddleware
request: FRequest
handler: FHandler
) => {
const authHeader = request.headers["authorization"];
if (!authHeader) {
throw new AuthenticationError("Missing authorization header");
}
const token = authHeader.replace(/^Bearer /, "");
const user = await authService.verifyToken(token);
// Add user to request context
request.context["user"] = user;
}
};
// Register the pre-processor
api.addRequestPreProcessor(AuthPreProcessor);
// Access context in handlers
api.get("/api/notes", async (request: FRequest
const user = api.context
const notes = await notesService.listByUser(user.id);
return api.responses.OK
});
`
`typescript`
const MyPreProcessor: RequestPreProcessor = {
name: "MyPreProcessor",
pathPatterns: ["/api/**"], // Which paths to match
httpMethods: [FHttpMethod.POST, FHttpMethod.PUT], // Optional: specific methods only
requestSource: "", // "express", "aws-lambda", or ""
process: async (api, request, handler) => {
// Your logic here
}
};
Post-processors run after the handler and can transform responses or handle errors:
`typescript
import { FMiddleware, FResponse, ResponsePostProcessor } from "@loupeat/fmiddleware";
const LoggingPostProcessor: ResponsePostProcessor = {
name: "LoggingPostProcessor",
pathPatterns: ["/**"],
requestSource: "*",
process: async (api: FMiddleware
console.log(${response.request.httpMethod} ${response.request.path} - ${response.statusCode});
if (response.error) {
console.error("Request failed:", response.error);
}
}
};
api.addResponsePostProcessor(LoggingPostProcessor);
`
FMiddleware provides semantic error classes that automatically map to HTTP status codes:
`typescript
import {
FRequest,
ValidationError, // 400 Bad Request
AuthenticationError, // 401 Unauthorized
ForbiddenError, // 403 Forbidden
NotFoundError, // 404 Not Found
ConflictError // 409 Conflict
} from "@loupeat/fmiddleware";
interface User {
id: string;
}
api.get("/api/notes/{noteId}", async (request: FRequest
const noteId = api.pathParameter(request, "noteId");
const user = api.context
const note = await notesService.get(noteId);
if (!note) {
throw new NotFoundError(Note ${noteId} not found);
}
if (note.userId !== user.id) {
throw new ForbiddenError("You don't have access to this note");
}
return api.responses.OK
});
`
`typescript
interface Note {
id: string;
title: string;
}
// 200 OK with typed body
api.responses.OK
// 200 OK with custom headers
api.responses.OK
// 204 No Content
api.responses.NoContent(request);
// 400 Bad Request
api.responses.BadRequest(request, "Invalid input");
// 404 Not Found
api.responses.NotFound(request, "Resource not found");
// Custom status code with typed body
api.responses._
`
Here's a complete example of a Notes API with authentication:
`typescript
import {
FExpressMiddleware,
FMiddleware,
FRequest,
FHandler,
RequestPreProcessor,
AuthenticationError,
NotFoundError,
ForbiddenError,
validator
} from "@loupeat/fmiddleware";
// Types
interface User {
id: string;
email: string;
}
interface Note {
id: string;
userId: string;
title: string;
content: string;
tags: string[];
}
interface CreateNoteRequest {
title: string;
content: string;
tags?: string[];
}
interface UpdateNoteRequest {
title?: string;
content?: string;
tags?: string[];
}
// Schemas
const CreateNoteSchema = {
type: "object",
properties: {
title: { type: "string", minLength: 1 },
content: { type: "string" },
tags: { type: "array", items: { type: "string" } }
},
required: ["title", "content"]
};
const UpdateNoteSchema = {
type: "object",
properties: {
title: { type: "string", minLength: 1 },
content: { type: "string" },
tags: { type: "array", items: { type: "string" } }
}
};
// Initialize middleware
const api = new FExpressMiddleware();
// Authentication pre-processor
const AuthPreProcessor: RequestPreProcessor = {
name: "AuthPreProcessor",
pathPatterns: ["/api/notes/**", "/api/notes"],
requestSource: "*",
process: async (
_api: FMiddleware
request: FRequest
_handler: FHandler
) => {
const authHeader = request.headers["authorization"];
if (!authHeader) {
throw new AuthenticationError("Missing authorization header");
}
const token = authHeader.replace(/^Bearer /, "");
const user = await verifyToken(token); // Your auth logic
request.context["user"] = user;
}
};
api.addRequestPreProcessor(AuthPreProcessor);
// Routes
export function registerNotesApi(api: FExpressMiddleware) {
// List all notes for user
api.get("/api/notes", async (request: FRequest
const user = api.context
const notes = await notesService.listByUser(user.id);
return api.responses.OK
});
// Create a new note
api.post("/api/notes", async (request: FRequest
const user = api.context
const { title, content, tags } = request.body;
const note = await notesService.create({
userId: user.id,
title,
content,
tags: tags || []
});
return api.responses.OK
}, CreateNoteSchema);
// Get a specific note
api.get("/api/notes/{noteId}", async (request: FRequest
const user = api.context
const noteId = api.pathParameter(request, "noteId");
validator.validateUuid(noteId);
const note = await notesService.get(noteId);
if (!note) {
throw new NotFoundError(Note ${noteId} not found);
}
if (note.userId !== user.id) {
throw new ForbiddenError("Access denied");
}
return api.responses.OK
});
// Update a note
api.put("/api/notes/{noteId}", async (request: FRequest
const user = api.context
const noteId = api.pathParameter(request, "noteId");
validator.validateUuid(noteId);
const note = await notesService.get(noteId);
if (!note) {
throw new NotFoundError(Note ${noteId} not found);
}
if (note.userId !== user.id) {
throw new ForbiddenError("Access denied");
}
const updated = await notesService.update(noteId, request.body);
return api.responses.OK
}, UpdateNoteSchema);
// Delete a note
api.delete("/api/notes/{noteId}", async (request: FRequest
const user = api.context
const noteId = api.pathParameter(request, "noteId");
validator.validateUuid(noteId);
const note = await notesService.get(noteId);
if (!note) {
throw new NotFoundError(Note ${noteId} not found);
}
if (note.userId !== user.id) {
throw new ForbiddenError("Access denied");
}
await notesService.delete(noteId);
return api.responses.NoContent(request);
});
// Search notes
api.get("/api/notes/search", async (request: FRequest
const user = api.context
const query = api.queryStringParameterOptional(request, "q") || "";
const tag = api.queryStringParameterOptional(request, "tag");
const notes = await notesService.search(user.id, { query, tag });
return api.responses.OK
});
}
`
FMiddleware works great with serverless frameworks. Here's how to deploy to AWS Lambda.
We recommend using Serverless Framework or AWS CDK for Lambda deployments.
serverless.yml:
`yaml
service: notes-api
plugins:
- serverless-esbuild # For TypeScript bundling
custom:
esbuild:
bundle: true
minify: false
sourcemap: true
target: node20
provider:
name: aws
runtime: nodejs20.x
region: eu-west-1
environment:
LOG_LEVEL: info
functions:
api:
handler: src/handler.main
events:
- http:
method: any
path: "api/{proxy+}"
cors: true
timeout: 15
`
src/handler.ts:
`typescript
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import { FAWSLambdaMiddleware } from "@loupeat/fmiddleware";
import { registerNotesApi } from "./notes-api";
let api: FAWSLambdaMiddleware;
export const main = async (event: APIGatewayProxyEvent): Promise
// Initialize once per cold start
if (!api) {
api = new FAWSLambdaMiddleware();
registerNotesApi(api);
}
return api.process(event);
};
`
A common pattern is to split Lambda functions by authentication context rather than by resource. This approach:
- Optimizes cold starts: Public handlers don't load auth processors
- Improves security: Authentication code is isolated to protected functions
- Enables different configurations: More memory/timeout for authenticated requests
serverless.yml:
`yaml
functions:
# Public endpoints - no authentication
public:
handler: src/lambda.publicHandler
events:
- http:
method: any
path: "api/public/{proxy+}"
cors: true
timeout: 15
memorySize: 256
# Private endpoints - requires JWT
private:
handler: src/lambda.privateHandler
events:
- http:
method: any
path: "api/private/{proxy+}"
cors: true
authorizer:
type: COGNITO_USER_POOLS
authorizerId:
Ref: ApiGatewayAuthorizer
timeout: 30
memorySize: 512
`
src/lambda.ts:
`typescript
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import { FAWSLambdaMiddleware } from "@loupeat/fmiddleware";
import { registerApi } from "./api";
let publicApi: FAWSLambdaMiddleware;
let privateApi: FAWSLambdaMiddleware;
// Public handler - no auth required
export const publicHandler = async (event: APIGatewayProxyEvent): Promise
if (!publicApi) {
publicApi = new FAWSLambdaMiddleware();
publicApi.setPathPrefix("/api/public");
registerApi(publicApi);
}
return publicApi.process(event);
};
// Private handler - JWT validated by API Gateway
export const privateHandler = async (event: APIGatewayProxyEvent): Promise
if (!privateApi) {
privateApi = new FAWSLambdaMiddleware();
privateApi.setPathPrefix("/api/private");
registerApi(privateApi);
}
return privateApi.process(event);
};
`
The setPathPrefix() method ensures each Lambda only registers handlers matching its prefix, reducing initialization time and memory usage.
src/app.ts:
`typescript
import express, { Request, Response } from "express";
import { FExpressMiddleware, FResponse } from "@loupeat/fmiddleware";
import { registerNotesApi } from "./notes-api";
const app = express();
const api = new FExpressMiddleware();
// Register your routes
registerNotesApi(api);
// Parse JSON bodies
app.use(express.json());
// Route all requests through FMiddleware
app.all("*", async (req: Request, res: Response) => {
const response: FResponse
// Set headers
for (const [key, value] of Object.entries(response.headers || {})) {
res.setHeader(key, value as string);
}
// Send response
res.status(response.statusCode).json(response.body);
});
app.listen(3000, () => {
console.log("Server running on http://localhost:3000");
});
`
> Security Note: request.sourceIp in Express comes from request.ip, which respects the X-Forwarded-For header if trust proxy is enabled. If your Express app runs behind a reverse proxy (nginx, load balancer), configure trust proxy correctly. If running without a proxy, ensure trust proxy is disabled to prevent IP spoofing via the X-Forwarded-For header. See Express trust proxy documentation.
Set the log level via environment variable:
`bash`
LOG_LEVEL=debug # debug, info, warn, error
Both FExpressMiddleware and FAWSLambdaMiddleware set a CORS header by default:
`typescript`
{
"Access-Control-Allow-Origin": "*"
}
You can add custom headers to responses:
`typescript`
api.responses.OK
"Cache-Control": "max-age=60",
"X-Custom-Header": "value"
}));
FMiddleware includes built-in support for generating OpenAPI 3.0 specifications from your registered handlers.
You can enrich handlers with OpenAPI metadata for better documentation:
`typescript
import { FExpressMiddleware, OpenAPIMetadata } from "@loupeat/fmiddleware";
const api = new FExpressMiddleware();
// GET with OpenAPI metadata
api.get("/api/notes", async (request) => {
const notes = await notesService.list();
return api.responses.OK(request, notes);
}, {
summary: "List all notes",
description: "Retrieves all notes for the authenticated user",
tags: ["Notes"],
queryParams: [
{ name: "tag", description: "Filter by tag", required: false },
{ name: "limit", description: "Max results", schema: { type: "integer" } }
],
responseSchema: {
type: "array",
items: { $ref: "#/components/schemas/Note" }
}
});
// POST with schema and OpenAPI metadata
api.post("/api/notes", async (request) => {
const note = await notesService.create(request.body);
return api.responses.OK(request, note);
}, CreateNoteSchema, {
summary: "Create a note",
tags: ["Notes"],
requestBodyDescription: "Note to create"
});
`
`typescript`
interface OpenAPIMetadata {
summary?: string; // Brief description
description?: string; // Detailed description
tags?: string[]; // Categorization tags
operationId?: string; // Unique operation ID
deprecated?: boolean; // Mark as deprecated
queryParams?: QueryParamDef[]; // Query parameter definitions
pathParams?: Record
responseSchema?: any; // JSON Schema for response
responses?: Record
requestBodyDescription?: string; // Description for request body
}
Use the OpenApiGenerator class to generate specs from your middleware:
`typescript
import {
FExpressMiddleware,
OpenApiGenerator,
GeneratorConfig
} from "@loupeat/fmiddleware";
import * as fs from "fs";
// Initialize and register handlers
const api = new FExpressMiddleware();
registerAllRoutes(api);
// Configure the generator
const config: GeneratorConfig = {
info: {
title: "My API",
version: "1.0.0",
description: "API description"
},
servers: [
{ url: "https://api.example.com", description: "Production" }
],
tags: [
{ name: "Notes", description: "Note management" }
],
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT"
}
},
// Custom security inference based on path
securityInference: (path) => {
if (path.includes("/public/")) return [];
return [{ bearerAuth: [] }];
},
// Custom tag inference based on path
tagInference: (path) => {
const match = path.match(/\/api\/(\w+)/);
return match ? [match[1]] : ["General"];
}
};
// Generate the spec
const generator = new OpenApiGenerator(api, config);
const spec = generator.generate();
// Write to file
fs.writeFileSync("openapi.json", JSON.stringify(spec, null, 2));
`
`typescript`
interface GeneratorConfig {
info: {
title: string;
version: string;
description?: string;
};
servers?: Array<{ url: string; description?: string }>;
tags?: Array<{ name: string; description?: string }>;
securitySchemes?: Record
securityInference?: (path: string) => any[]; // Custom security logic
tagInference?: (path: string) => string[]; // Custom tag logic
}
The generator automatically infers:
- Path parameters from {param} patterns in routes
- Operation IDs from HTTP method + path
- Summaries from HTTP method + resource name
- Request body schemas from handler schema parameter
- Standard error responses (400, 401, 403, 404, 500)
`typescript
// Request type with generics for original request and body type
FRequest
// Response type with generics
FResponse
// Handler function signature
(request: FRequest
`
| Method | Description |
|--------|-------------|
| get(path, handler, openapi?) | Register GET handler with optional OpenAPI metadata |post(path, handler, schema?, openapi?)
| | Register POST handler with validation and OpenAPI metadata |put(path, handler, schema?, openapi?)
| | Register PUT handler with validation and OpenAPI metadata |delete(path, handler, schema?, openapi?)
| | Register DELETE handler with optional OpenAPI metadata |addRequestPreProcessor(processor)
| | Add a pre-processor |addResponsePostProcessor(processor)
| | Add a post-processor |pathParameter(request, name)
| | Get path parameter value |queryStringParameter(request, name)
| | Get required query parameter |queryStringParameterOptional(request, name)
| | Get optional query parameter |context
| | Get typed value from request context |setPathPrefix(prefix)
| | Only register handlers matching prefix |getHandlers()
| | Get all registered handlers (for OpenAPI generation) |responses.OK
| | Return 200 with typed response |responses.NoContent(request)
| | Return 204 |responses.NotFound(request, message)
| | Return 404 |responses.BadRequest(request, message)
| | Return 400 |responses._(request, status, body)
| | Return custom status code |
| Class | HTTP Status |
|-------|-------------|
| ValidationError | 400 |AuthenticationError
| | 401 |ForbiddenError
| | 403 |NotFoundError
| | 404 |ConflictError
| | 409 |
| Type | Description |
|------|-------------|
| OpenAPIMetadata | Metadata to attach to handlers for documentation |QueryParamDef
| | Query parameter definition |PathParamDef
| | Path parameter description |ResponseDef
| | Response definition |OpenAPISpec
| | Full OpenAPI 3.0 specification |GeneratorConfig
| | Configuration for OpenApiGenerator |OpenApiGenerator` | Class to generate OpenAPI specs from middleware |
|
MIT