@avtechno/sfr
v2.1.1
Published
An opinionated way of writing services using ExpressJS.
Readme
Single File Router (SFR)
An opinionated, convention-over-configuration approach to building services in Node.js
Overview
SFR is a declarative routing framework that allows you to define your entire API service—handlers, validators, and controllers—within a single, structured file. By embracing a convention over configuration philosophy, SFR eliminates boilerplate and enforces consistency across your codebase.
Key Features
- Single-File Declaration — Define validators, handlers, and controllers together in one cohesive module
- Multi-Protocol Support — Build REST APIs, WebSocket servers, and Message Queue consumers using the same patterns
- Automatic Validation — Integrate Joi schemas or Multer middleware for request validation
- Dependency Injection — Inject shared dependencies into handlers and controllers via IoC pattern
- Auto-Generated Documentation — Produces OpenAPI 3.0 specs (REST) and AsyncAPI 3.0 specs (MQ/WS) automatically
- File-Based Routing — Directory structure determines endpoint paths, reducing manual configuration
Installation
npm install @avtechno/sfr
# or
yarn add @avtechno/sfrPeer Dependencies
SFR works with the following libraries (install as needed):
npm install express joi amqplib socket.ioQuick Start
import sfr from "@avtechno/sfr";
import express from "express";
const app = express();
const PORT = 3000;
app.use(express.json());
await sfr(
{ root: "dist", path: "api", out: "docs" },
{
title: "My Service",
description: "A sample SFR-powered service",
version: "1.0.0",
meta: {
service: "my-service",
domain: "example-org",
type: "backend",
language: "javascript",
port: PORT
}
},
{ REST: app },
"api"
);
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));Project Structure
SFR expects your API files to be organized by protocol within the specified path:
project/
├── dist/ # Compiled output (cfg.root)
│ └── api/ # API directory (cfg.path)
│ ├── rest/ # REST endpoints
│ │ ├── users.mjs
│ │ └── auth/
│ │ └── login.mjs
│ ├── ws/ # WebSocket handlers
│ │ └── chat.mjs
│ └── mq/ # Message Queue consumers
│ └── notifications.mjs
├── docs/ # Generated OpenAPI/AsyncAPI specs (cfg.out)
└── src/
└── api/
└── ... # Source filesThe file path determines the endpoint URL:
rest/users.mjs→/api/users/*rest/auth/login.mjs→/api/auth/login/*
SFR File Structure
Each SFR module exports a default object created using REST(), WS(), or MQ() template functions:
| Component | Description | |-----------|-------------| | cfg | Configuration options (base directory, public/private access) | | validators | Request validation schemas (Joi objects or Multer middleware) | | handlers | Route handlers organized by HTTP method or MQ pattern | | controllers | Reusable data access functions (database calls, external APIs) |
Creating a REST Endpoint
import Joi from "joi";
import { REST } from "@avtechno/sfr";
export default REST({
cfg: {
base_dir: "users", // Optional: Prepends to endpoint path
public: false // Requires authentication (default: false)
},
validators: {
"get-profile": {
user_id: Joi.string().uuid().required()
},
"update-profile": {
name: Joi.string().min(2).max(100),
email: Joi.string().email()
}
},
handlers: {
GET: {
"get-profile": {
summary: "Retrieve user profile",
description: "Fetches the profile for a given user ID",
tags: ["users", "profile"],
async fn(req, res) {
const { user_id } = req.query;
const profile = await this.fetch_user(user_id);
res.status(200).json({ data: profile });
}
}
},
PUT: {
"update-profile": {
summary: "Update user profile",
description: "Updates profile information for authenticated user",
tags: ["users", "profile"],
deprecated: false,
async fn(req, res) {
const result = await this.update_user(req.body);
res.status(200).json({ data: result });
}
}
}
},
controllers: {
async fetch_user(user_id) {
// Access injected dependencies via `this`
return this.db.users.findById(user_id);
},
async update_user(data) {
return this.db.users.update(data);
}
}
});Resulting Endpoints
| Method | Endpoint | Handler |
|--------|----------|---------|
| GET | /api/users/get-profile | get-profile |
| PUT | /api/users/update-profile | update-profile |
Creating MQ Consumers
SFR supports multiple messaging patterns via RabbitMQ/AMQP:
| Pattern | Description | Class |
|---------|-------------|-------|
| Point-to-Point | Direct queue consumption | TargetedMQ |
| Request-Reply | RPC-style messaging with responses | TargetedMQ |
| Fanout | Broadcast to all subscribers | BroadcastMQ |
| Direct | Route by exact routing key | BroadcastMQ |
| Topic | Route by pattern-matched routing key | BroadcastMQ |
import Joi from "joi";
import { MQ } from "@avtechno/sfr";
export default MQ({
cfg: {
base_dir: "notifications"
},
validators: {
"send-email": {
to: Joi.string().email().required(),
subject: Joi.string().required(),
body: Joi.string().required()
}
},
handlers: {
"Point-to-Point": {
"send-email": {
summary: "Process email notification",
options: { noAck: false },
async fn(msg) {
const { to, subject, body } = msg.content;
await this.send_email(to, subject, body);
this.mq.channel.ack(msg);
}
}
},
"Request-Reply": {
"validate-email": {
summary: "Validate email address",
async fn(msg) {
const result = await this.validate(msg.content.email);
this.mq.reply(msg, { valid: result });
}
}
}
},
controllers: {
async send_email(to, subject, body) {
// Email sending logic
},
async validate(email) {
// Validation logic
return true;
}
}
});Creating WebSocket Handlers
SFR supports real-time WebSocket communication via Socket.IO. WebSocket SFRs follow the same pattern as REST and MQ:
import Joi from "joi";
import { WS } from "@avtechno/sfr";
export default WS({
cfg: {
namespace: "/chat", // Socket.IO namespace (optional, defaults to "/")
public: true // Skip authentication checks
},
validators: {
"send-message": {
room: Joi.string().required(),
message: Joi.string().min(1).max(1000).required(),
sender: Joi.string().required()
},
"join-room": {
room: Joi.string().required(),
username: Joi.string().min(2).max(50).required()
},
"leave-room": {
room: Joi.string().required()
}
},
handlers: {
"send-message": {
summary: "Send a message to a room",
description: "Broadcasts a message to all users in the specified room",
tags: ["chat", "messaging"],
async fn(ctx) {
const { room, message, sender } = ctx.data;
// Process via controller
const processed = await this.process_message(room, sender, message);
// Broadcast to room
ctx.io.to(room).emit("new-message", processed);
// Acknowledge if callback provided
if (ctx.ack) ctx.ack({ success: true });
}
},
"join-room": {
summary: "Join a chat room",
description: "Adds the user to a room and notifies other members",
tags: ["chat", "rooms"],
async fn(ctx) {
const { room, username } = ctx.data;
// Join Socket.IO room
ctx.socket.join(room);
// Notify other room members
ctx.socket.to(room).emit("user-joined", {
username,
socket_id: ctx.socket.id
});
if (ctx.ack) ctx.ack({ success: true, room });
}
},
"leave-room": {
summary: "Leave a chat room",
tags: ["chat", "rooms"],
async fn(ctx) {
const { room } = ctx.data;
ctx.socket.leave(room);
ctx.socket.to(room).emit("user-left", { socket_id: ctx.socket.id });
if (ctx.ack) ctx.ack({ success: true });
}
}
},
controllers: {
async process_message(room, sender, message) {
// Access injected dependencies via `this`
return {
id: `msg_${Date.now()}`,
room,
sender,
message,
timestamp: Date.now()
};
}
}
});WebSocket Handler Context
Each WebSocket handler receives a context object (ctx) with the following properties:
| Property | Type | Description |
|----------|------|-------------|
| io | Server | The Socket.IO server instance |
| socket | Socket | The current client socket connection |
| data | object | The validated event payload |
| ack | function? | Optional acknowledgement callback |
Initializing with WebSocket
import sfr from "@avtechno/sfr";
import express from "express";
import { createServer } from "http";
import { Server } from "socket.io";
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer);
await sfr(
{ root: "dist", path: "api", out: "docs" },
{ /* OAS config */ },
{
REST: app,
WS: io // Pass Socket.IO server
},
"api"
);
httpServer.listen(3000);WebSocket Features
- Namespaces — Use
cfg.namespaceto organize events into logical groups - Rooms — Use
ctx.socket.join()andctx.socket.to()for room-based messaging - Validation — Joi schemas validate incoming event data before handler execution
- Acknowledgements — Support for Socket.IO acknowledgement callbacks via
ctx.ack - Error Handling — Validation errors and handler exceptions emit
{event}:errorevents
Dependency Injection
Inject shared dependencies (database connections, external services, etc.) into handlers and controllers:
import sfr, { inject } from "@avtechno/sfr";
import { PrismaClient } from "@prisma/client";
import Redis from "ioredis";
const db = new PrismaClient();
const redis = new Redis();
// Inject dependencies before initializing SFR
inject({
handlers: {
redis,
logger: console
},
controllers: {
db,
redis
}
});
// Now all handlers can access `this.redis` and `this.logger`
// All controllers can access `this.db` and `this.redis`Accessing Injected Dependencies
// In handlers
async fn(req, res) {
this.logger.info("Request received");
const cached = await this.redis.get(key);
// ...
}
// In controllers
async fetch_data(id) {
return this.db.table.findUnique({ where: { id } });
}Validation
Joi Validation
Define validation schemas as plain objects with Joi validators:
validators: {
"create-user": {
name: Joi.string().min(2).max(50).required(),
email: Joi.string().email().required(),
age: Joi.number().integer().min(18).optional()
}
}- GET requests: Validates
req.query - POST/PUT/PATCH requests: Validates
req.body
Multer Validation (File Uploads)
Pass Multer middleware directly as validators:
import multer from "multer";
const upload = multer({ dest: "uploads/" });
validators: {
"upload-avatar": upload.single("avatar")
}Configuration Reference
Parser Configuration (ParserCFG)
| Property | Type | Description |
|----------|------|-------------|
| root | string | Root directory of compiled source files |
| path | string | Directory containing SFR modules |
| out | string | Output directory for generated API specs |
SFR Configuration (SFRConfig)
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| base_dir | string | "" | Prefix path for all endpoints in the SFR |
| public | boolean | false | Skip authentication/authorization checks |
| rate_limit | RateLimitConfig \| false | Uses global default | Per-route rate limit configuration (see Rate Limiting section) |
OAS Configuration (OASConfig)
| Property | Type | Description |
|----------|------|-------------|
| title | string | Service name |
| description | string | Service description |
| version | string | Semantic version |
| meta.service | string | Service identifier |
| meta.domain | string | Organization/domain name |
| meta.type | "backend" | "frontend" | Service type |
| meta.language | string | Programming language |
| meta.port | number | Service port |
Rate Limiting
SFR includes built-in rate limiting support via express-rate-limit to protect your APIs from abuse and ensure fair resource usage.
Global Configuration
Set default rate limiting for all routes:
import { set_rate_limit_config } from "@avtechno/sfr";
set_rate_limit_config({
default: {
max: 100, // Maximum requests
windowMs: 60000, // Time window (1 minute)
message: "Too many requests, please try again later.",
statusCode: 429,
standardHeaders: true, // Include RateLimit-* headers
legacyHeaders: false // Include X-RateLimit-* headers
}
});Per-Route Configuration
Override the global default in individual SFR files:
import { REST } from "@avtechno/sfr";
export default REST({
cfg: {
base_dir: "api",
// Custom rate limit for this route
rate_limit: {
max: 10, // 10 requests
windowMs: 60000 // per minute
}
},
// ... rest of config
});Disable Rate Limiting
To disable rate limiting for a specific route:
cfg: {
rate_limit: false // Disables rate limiting for this route
}Advanced Configuration
All express-rate-limit options are supported:
import RedisStore from "rate-limit-redis";
import Redis from "ioredis";
const redis = new Redis();
set_rate_limit_config({
default: {
max: 100,
windowMs: 60000,
// Custom key generator (e.g., by user ID)
keyGenerator: (req) => req.user?.id || req.ip,
// Skip rate limiting for certain requests
skip: (req) => req.user?.isAdmin === true,
// Custom store (Redis, MongoDB, etc.)
store: new RedisStore({ client: redis })
}
});Note: Rate limiting middleware is applied before validation and authentication middleware.
Auto-Generated Documentation
SFR automatically generates API documentation in standard formats:
- REST endpoints → OpenAPI 3.0 specification
- MQ consumers → AsyncAPI 3.0 specification
Documentation is written to the cfg.out directory as YAML files, enabling integration with:
- Swagger UI / ReDoc
- Postman / Insomnia import
- API gateways
- SDK generators
- Automated testing tools
Handler Metadata
Enhance generated documentation with metadata:
handlers: {
GET: {
"fetch-items": {
summary: "Short description", // Brief endpoint summary
description: "Detailed explanation", // Full documentation
tags: ["inventory", "read"], // Categorization tags
deprecated: false, // Mark as deprecated
async fn(req, res) { /* ... */ }
}
}
}Error Handling
SFR provides automatic error handling for both handlers and controllers:
| Context | Supported Error Styles |
|---------|------------------------|
| Handlers | throw new Error(), next(error) |
| Controllers | Promise.reject(), return Promise.resolve/reject |
Example
handlers: {
GET: {
"risky-operation": {
async fn(req, res, next) {
try {
const result = await this.dangerous_call();
res.json({ data: result });
} catch (error) {
// Option 1: Throw (SFR catches this)
throw error;
// Option 2: Pass to Express error handler
// next(error);
}
}
}
}
},
controllers: {
async dangerous_call() {
// Rejections are automatically caught
if (bad_condition) {
return Promise.reject(new Error("Operation failed"));
}
return result;
}
}MQ Integration
Initializing with Message Queue
import sfr, { MQLib } from "@avtechno/sfr";
const mq = new MQLib();
await mq.init(process.env.RABBITMQ_URL);
await sfr(
{ root: "dist", path: "api", out: "docs" },
{ /* OAS config */ },
{
REST: app,
MQ: mq.get_channel()
},
"api"
);Using MQ in REST Handlers
When MQ is configured, handlers receive access to messaging patterns:
handlers: {
POST: {
"trigger-job": {
async fn(req, res) {
// Access injected MQ patterns
await this.mq["Point-to-Point"].produce("job-queue", req.body);
res.json({ status: "queued" });
}
}
}
}Debugging
Enable debug output by setting the environment variable:
DEBUG_SFR=true node server.jsThis displays:
- Number of mounted endpoints per protocol
- Resolved paths for each SFR
- Protocol status indicators
API Reference
Exports
| Export | Description |
|--------|-------------|
| default | Main SFR initializer function |
| REST | Template function for REST endpoints |
| WS | Template function for WebSocket handlers |
| MQ | Template function for MQ consumers |
| MQLib | Helper class for MQ connection management |
| BroadcastMQ | MQ class for Fanout/Direct/Topic patterns |
| TargetedMQ | MQ class for Point-to-Point/Request-Reply patterns |
| inject | Dependency injection function |
| set_observability_options | Configure observability settings |
| sfr_logger | Logger with automatic trace context |
| with_span | Create custom tracing spans |
| get_trace_context | Get current trace/span IDs |
Observability (OpenTelemetry)
SFR includes built-in OpenTelemetry-compatible observability with automatic instrumentation for all protocols.
Features
- Tracing: Distributed traces across REST, WebSocket, and MQ handlers
- Metrics: Request counts, latencies, error rates, connection gauges
- Logging: Structured logs with automatic trace context correlation
- Auto-instrumentation: Express, Socket.IO, and AMQP are automatically traced
Quick Start
Observability is enabled by default and uses your OASConfig for service identification:
import sfr from "@avtechno/sfr";
// Service name and version are extracted from oas_cfg automatically
await sfr(
{ root: "dist", path: "api", out: "docs" },
{
title: "My Service",
version: "1.0.0",
meta: {
service: "my-service", // Used as service.name in traces
domain: "example-org",
type: "backend",
language: "javascript",
port: 3000
}
},
{ REST: app }
);Configuration
Configure observability before initializing SFR:
import sfr, { set_observability_options } from "@avtechno/sfr";
set_observability_options({
enabled: true, // Enable/disable (default: true)
otlp_endpoint: "http://localhost:4318", // OTLP collector endpoint
auto_instrumentation: true, // Auto-instrument common libraries
log_format: "json", // 'json' or 'pretty'
debug: false // Enable OTel SDK debug logs
});
await sfr(cfg, oas_cfg, connectors);Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| OTEL_EXPORTER_OTLP_ENDPOINT | OTLP collector endpoint | http://localhost:4318 |
| SFR_TELEMETRY_ENABLED | Enable/disable telemetry | true |
| SFR_LOG_FORMAT | Log format (json/pretty) | pretty (dev), json (prod) |
| DEBUG_SFR | Enable debug logging | false |
Custom Spans in Handlers
Add custom tracing spans for fine-grained observability:
import { REST, with_span } from "@avtechno/sfr";
export default REST({
handlers: {
GET: {
"get-user": {
async fn(req, res) {
// Create a child span for database operation
const user = await with_span({
name: "db:fetch_user",
attributes: { "user.id": req.params.id }
}, async (span) => {
return this.db.users.findById(req.params.id);
});
res.json({ data: user });
}
}
}
}
});Logging with Trace Context
Logs automatically include trace context when a span is active:
import { REST, sfr_logger, create_child_logger } from "@avtechno/sfr";
export default REST({
handlers: {
POST: {
"create-order": {
async fn(req, res) {
// Logs include trace_id and span_id automatically
sfr_logger.info("Creating order", { user_id: req.user.id });
// Create a child logger with additional context
const order_logger = create_child_logger({ order_id: "ord_123" });
order_logger.debug("Processing payment");
res.json({ data: { success: true } });
}
}
}
}
});Graceful Shutdown
Ensure telemetry is flushed before shutdown:
import { shutdown_observability } from "@avtechno/sfr";
process.on("SIGTERM", async () => {
await shutdown_observability();
process.exit(0);
});Metrics Available
| Metric | Type | Description |
|--------|------|-------------|
| sfr.rest.requests.total | Counter | Total REST requests |
| sfr.rest.request.duration | Histogram | Request latency (ms) |
| sfr.rest.errors.total | Counter | Total REST errors |
| sfr.ws.connections.active | Gauge | Active WebSocket connections |
| sfr.ws.events.total | Counter | Total WebSocket events |
| sfr.mq.messages.received | Counter | Total MQ messages received |
| sfr.mq.processing.duration | Histogram | Message processing time (ms) |
📖 For comprehensive OpenTelemetry documentation, including integration guides for Prometheus, Grafana, Loki, Jaeger, and other backends, see OpenTelemetry Documentation.
Roadmap
- [ ] Multer upload validation in OpenAPI specs
- [x] Built-in structured logging
- [x] WebSocket protocol implementation
- [x] Rate limiting middleware integration
- [ ] GraphQL protocol support
- [ ] gRPC protocol support
Now that I'm using a diverse set of communication protocols, I may rewrite SFR into a more pluggable and modular format with each protocol and major feature being treated as an installable extension.
Contributing
Found a bug or have a feature request? Please open an issue on our GitHub Issues page.
License
ISC © Emmanuel Abellana
