🚧 This project is in active development. APIs may change between versions.
Avleon is a TypeScript-first web framework built on top of Fastify, designed for building scalable, maintainable REST APIs with minimal boilerplate. It provides decorator-based routing, built-in dependency injection, automatic OpenAPI documentation, and first-class validation support.
- Features
- Installation
- Quick Start
- Core Concepts
- Advanced Features
- Route Mapping (Functional Style)
- Testing
- License
- 🎯 Decorator-based routing — define controllers and routes with TypeScript decorators
- 💉 Dependency injection — powered by TypeDI
- 📄 OpenAPI / Swagger — automatic docs with Swagger UI or Scalar
- ✅ Validation — request validation via class-validator
- 🔑 Authentication — pluggable handler that populates
request.user - 🔒 Authorization — flexible middleware-based auth system
- 📁 File uploads — multipart form support out of the box
- 🗄️ Database — TypeORM and Knex integrations
- ⚙️ Config — typed, environment-aware config classes
- 📬 Queues & workers — background jobs on BullMQ
- ⏰ Task scheduling — cron, interval and timeout jobs
- 🗃️ Caching — in-memory or Redis, with tag-based invalidation
- 🔌 WebSocket — Socket.IO integration
- 🧪 Testing — built-in test utilities
Scaffold a new project using the CLI:
npx @avleon/cli new myapp
# or
yarn dlx @avleon/cli new myapp
# or
pnpm dlx @avleon/cli new myappOr install manually:
npm install @avleon/core reflect-metadata class-validator class-transformerAvleon keeps integrations out of the core install. Add only what you use — the package imports fine without any of them, and you get a clear error the moment a feature needs one:
| Feature | Install |
|---|---|
| TypeORM | npm i typeorm + a driver (pg, mysql2, sqlite3, …) |
| Knex | npm i knex + a driver |
| Queues & workers | npm i bullmq ioredis |
| Redis cache | npm i ioredis |
| WebSocket | npm i socket.io fastify-socket.io |
import { Avleon } from '@avleon/core';
const app = Avleon.createApplication();
app.mapGet('/', () => ({ message: 'Hello, Avleon!' }));
app.run(4000);import { Avleon, ApiController, Get } from '@avleon/core';
@ApiController('/')
class HelloController {
@Get()
sayHello() {
return { message: 'Hello, Avleon!' };
}
}
const app = Avleon.createApplication();
app.useControllers([HelloController]);
app.run(4000);import { Avleon } from '@avleon/core';
const app = Avleon.createApplication();
app.useCors({ origin: '*' });
app.useControllers([UserController]);
// Auto-discover controllers from a directory:
// app.useControllers({ auto: true, path: 'src/controllers' });
app.run(4000);import { ApiController, Get, Post, Put, Delete } from '@avleon/core';
@ApiController('/users')
class UserController {
@Get('/')
getAll() { ... }
@Post('/')
create() { ... }
@Put('/:id')
update() { ... }
@Delete('/:id')
remove() { ... }
}| Decorator | HTTP Method |
|---|---|
@Get(path?) |
GET |
@Post(path?) |
POST |
@Put(path?) |
PUT |
@Patch(path?) |
PATCH |
@Delete(path?) |
DELETE |
@Get('/:id')
async getUser(
@Param('id') id: string,
@Query('include') include: string,
@Query() query: UserQuery, // maps full query to a DTO
@Body() body: CreateUserDto,
@Header('authorization') token: string,
@AuthUser() user: CurrentUser,
) {
// ...
}| Decorator | Source |
|---|---|
@Param(key?) |
Route path params |
@Query(key?) |
Query string |
@Body() |
Request body |
@Header(key?) |
Request headers |
@AuthUser() |
Current authenticated user |
import { HttpExceptions, HttpResponse } from '@avleon/core';
@Get('/:id')
async getUser(@Param('id') id: string) {
const user = await this.userService.findById(id);
if (!user) {
throw HttpExceptions.NotFound('User not found');
}
return HttpResponse.Ok(user);
}Available exceptions: NotFound, BadRequest, Unauthorized, Forbidden, InternalServerError.
import { AppMiddleware, AvleonMiddleware, IRequest, UseMiddleware } from '@avleon/core';
@AppMiddleware
class LoggingMiddleware extends AvleonMiddleware {
async invoke(req: IRequest) {
console.log(`${req.method} ${req.url}`);
return req;
}
}
// Apply to entire controller
@UseMiddleware(LoggingMiddleware)
@ApiController('/users')
class UserController { ... }
// Or apply to a specific route
@ApiController('/users')
class UserController {
@UseMiddleware(LoggingMiddleware)
@Get('/')
getAll() { ... }
}Authentication establishes who the caller is. Register one handler and
whatever it returns becomes request.user — the value @AuthUser() injects.
1 — Define your authentication class:
import { AvleonAuthentication, AppService, IRequest } from '@avleon/core';
@AppService
export class JwtAuthentication extends AvleonAuthentication<User> {
async authenticate(request: IRequest) {
const header = request.headers.authorization;
if (!header?.startsWith('Bearer ')) return null;
return verifyToken(header.slice(7)); // becomes request.user
}
}2 — Register with the app:
app.useAuthentication(JwtAuthentication);3 — Read the user in any controller:
@ApiController('/me')
class MeController {
@Get()
whoami(@AuthUser() user: User) {
return user;
}
}Returning null leaves request.user unset — authentication only identifies
the caller. Rejecting anonymous requests is the job of authorization.
1 — Define your authorization class:
import { CanAuthorize, AuthorizeMiddleware, IRequest } from '@avleon/core';
@CanAuthorize
class JwtAuthorization extends AuthorizeMiddleware {
async authorize(req: IRequest, options?: any) {
const token = req.headers['authorization']?.split(' ')[1];
if (!token) throw HttpExceptions.Unauthorized('Missing token');
req.user = verifyToken(token); // attach user to request
}
}2 — Register with the app:
app.useAuthorization(JwtAuthorization);3 — Protect controllers or routes:
// Protect entire controller
@Authorized()
@ApiController('/admin')
class AdminController {
@Get('/')
dashboard(@AuthUser() user: User) {
return user;
}
}
// Protect specific route with roles
@ApiController('/admin')
class AdminController {
@Authorized({ roles: ['admin'] })
@Get('/stats')
stats() { ... }
}Validation is powered by class-validator. Decorate your DTOs and Avleon validates automatically:
import { IsString, IsEmail, IsInt, Min, Max, IsOptional } from 'class-validator';
class CreateUserDto {
@IsString()
@IsNotEmpty()
name: string;
@IsEmail()
email: string;
@IsInt()
@Min(0)
@Max(120)
age: number;
@IsOptional()
@IsString()
role?: string;
}
@Post('/')
async createUser(@Body() body: CreateUserDto) {
return this.userService.create(body);
}Config classes turn environment variables into typed, injectable settings.
Extend AvleonConfig and register with @AppConfig:
import { AppConfig, AvleonConfig, Environment, GetConfig } from '@avleon/core';
type MailSettings = { host: string; port: number };
@AppConfig
export class MailConfig extends AvleonConfig<MailSettings> {
config(env: Environment): MailSettings {
return {
host: env.get('MAIL_HOST') || 'localhost',
port: Number(env.get('MAIL_PORT')) || 1025,
};
}
}
const mail = GetConfig(MailConfig); // MailSettingsA config class must extend AvleonConfig — that is how Avleon recognizes it
when you pass one to useKnex, useTypeORM or useOpenApi.
Inline config:
app.useOpenApi({
info: {
title: 'User API',
version: '1.0.0',
description: 'API for managing users',
},
servers: [{ url: 'http://localhost:4000', description: 'Dev server' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
},
},
});Config class:
import { AppConfig, AvleonConfig, Environment } from '@avleon/core';
@AppConfig
export class OpenApiConfig extends AvleonConfig {
config(env: Environment) {
return {
info: { title: 'My API', version: '1.0.0' },
routePrefix: '/docs',
provider: 'scalar', // or 'default' for Swagger UI
};
}
}
// In app.ts
if (app.isDevelopment()) {
app.useOpenApi(OpenApiConfig);
}Route-level docs with @OpenApi:
import { OpenApi, OpenApiProperty, OpenApiSchema } from '@avleon/core';
@OpenApiSchema()
export class UserQuery {
@OpenApiProperty({ type: 'string', example: 'john', required: false })
@IsOptional()
search?: string;
@OpenApiProperty({ type: 'integer', example: 1, required: false })
@IsOptional()
page?: number;
}
@OpenApi({
summary: 'Get all users',
tags: ['users'],
security: [{ bearerAuth: [] }],
response: {
200: {
description: 'List of users',
type: 'object',
properties: {
data: { type: 'array' },
total: { type: 'integer', example: 100 },
},
},
401: { description: 'Unauthorized' },
},
})
@Get('/')
getAll(@Query() query: UserQuery) { ... }useKnex connects eagerly and verifies the connection, so it returns a promise:
await app.useKnex({
client: 'mysql',
connection: {
host: '127.0.0.1',
port: 3306,
user: 'root',
password: 'password',
database: 'myapp',
},
});Using a config class:
import { AppConfig, AvleonConfig, Environment } from '@avleon/core';
import type { Knex } from 'knex';
@AppConfig
export class KnexConfig extends AvleonConfig<Knex.Config> {
config(env: Environment): Knex.Config {
return {
client: 'mysql',
connection: {
host: env.get('DB_HOST') || '127.0.0.1',
port: Number(env.get('DB_PORT')) || 3306,
user: env.get('DB_USER') || 'root',
password: env.get('DB_PASS') || 'password',
database: env.get('DB_NAME') || 'myapp',
},
};
}
}
await app.useKnex(KnexConfig);Using in a service:
import { KnexDB, AppService } from '@avleon/core';
@AppService
export class UsersService {
constructor(private readonly db: KnexDB) {}
async findAll() {
return this.db.client.select('*').from('users');
}
}useTypeORM initializes the DataSource, so it returns a promise:
await app.useTypeORM({
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'postgres',
password: 'password',
database: 'avleon',
entities: [User],
synchronize: true,
});Using a config class:
import { AppConfig, AvleonConfig, Environment } from '@avleon/core';
import type { DataSourceOptions } from 'typeorm';
@AppConfig
export class DataSourceConfig extends AvleonConfig<DataSourceOptions> {
config(env: Environment): DataSourceOptions {
return {
type: 'postgres',
host: env.get('DB_HOST') || 'localhost',
port: Number(env.get('DB_PORT')) || 5432,
username: env.get('DB_USER') || 'postgres',
password: env.get('DB_PASS') || 'password',
database: env.get('DB_NAME') || 'avleon',
entities: [User],
synchronize: true,
};
}
}
await app.useTypeORM(DataSourceConfig);
useDatasource()is deprecated and will be removed in the next stable version. UseuseTypeORM()instead.
Using in a service:
import { AppService, InjectRepository } from '@avleon/core';
import { Repository } from 'typeorm';
import { User } from './user.entity';
@AppService
export class UserService {
constructor(
@InjectRepository(User)
private readonly userRepo: Repository<User>,
) {}
async findAll() {
return this.userRepo.find();
}
}Background jobs run on BullMQ. Install it alongside a Redis client:
npm i bullmq ioredisA queue both produces and consumes jobs. Name each job with @JobHandler:
import { AvleonQueue, Queue, JobHandler, Job } from '@avleon/core';
type EmailPayload = { userId: number; email: string };
@Queue({
name: 'email',
adapter: { connection: { host: '127.0.0.1', port: 6379 } },
worker: { concurrency: 5 },
})
export class EmailQueue extends AvleonQueue<EmailPayload> {
@JobHandler('welcome')
async sendWelcome(job: Job<EmailPayload>) {
await mailer.send(job.data.email, 'Welcome!');
}
@JobHandler('reminder')
async sendReminder(job: Job<EmailPayload>) {
await mailer.send(job.data.email, 'Don't forget…');
}
}Dispatch jobs from anywhere the queue is injected:
@AppService
export class UsersService {
constructor(private readonly emails: EmailQueue) {}
async register(user: User) {
await this.emails.dispatch('welcome', { userId: user.id, email: user.email });
await this.emails.dispatch('reminder', { userId: user.id, email: user.email }, 86_400_000);
}
}Handlers declared on a base class are inherited, and a subclass may override one by reusing its job name.
Standalone workers. When the consumer runs in its own process, use
AvleonWorkerBase:
import { AvleonWorker, AvleonWorkerBase, Job } from '@avleon/core';
@AvleonWorker({ queue: 'email', concurrency: 5 })
export class EmailWorker extends AvleonWorkerBase<EmailPayload> {
async process(job: Job<EmailPayload>) {
await mailer.send(job.data.email, 'Welcome!');
}
onFailed(job: Job<EmailPayload> | undefined, error: Error) {
logger.error(`Job ${job?.id} failed`, error);
}
}Register workers so they start with the app and close on shutdown:
app.useWorker([EmailQueue, EmailWorker]);Mark a class with @ScheduledTask(), then schedule its methods:
import { ScheduledTask, Cron, Interval, Timeout } from '@avleon/core';
@ScheduledTask()
export class ReportTask {
constructor(private readonly reports: ReportService) {}
@Cron('0 8 * * 1-5', { timezone: 'Asia/Dhaka' })
async sendDailyReport() {
await this.reports.send();
}
@Interval(60_000) // every minute
async pollQueue() { ... }
@Timeout(5_000) // once, 5s after startup
async warmCache() { ... }
}Register tasks explicitly, or auto-discover them:
app.useScheduler([ReportTask]);
// or
app.useScheduler({ path: 'src/tasks' });In-memory by default; pass provider: 'redis' to use Redis instead.
app.useCache({ provider: 'memory' });
// Redis (requires: npm i ioredis)
app.useCache({
provider: 'redis',
redisOptions: { host: '127.0.0.1', port: 6379 },
});Inject CacheManager where you need it. Entries can carry tags, so related
keys are invalidated together:
import { CacheManager, AppService } from '@avleon/core';
@AppService
export class UsersService {
constructor(private readonly cache: CacheManager) {}
async findAll() {
const cached = await this.cache.get<User[]>('users:all');
if (cached) return cached;
const users = await this.repo.find();
await this.cache.set('users:all', users, 3600, ['users']);
return users;
}
async update(user: User) {
await this.repo.save(user);
await this.cache.invalidateTags('users');
}
}// Configure multipart support
app.useMultipart({
destination: path.join(process.cwd(), 'public/uploads'),
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
});import { FileStorage, UploadFile, MultipartFile } from '@avleon/core';
@ApiController('/files')
class FileController {
constructor(private readonly fileStorage: FileStorage) {}
@OpenApi({
description: 'Upload a single file',
body: {
type: 'object',
properties: {
file: { type: 'string', format: 'binary' },
},
required: ['file'],
},
})
@Post('/upload')
async upload(@UploadFile('file') file: MultipartFile) {
const result = await this.fileStorage.save(file);
// optionally rename: this.fileStorage.save(file, { as: 'newname.jpg' })
return result;
// { uploadPath: '/uploads/...', staticPath: '/static/...' }
}
}import path from 'path';
app.useStaticFiles({
path: path.join(process.cwd(), 'public'),
prefix: '/static/',
});app.useSocketIo({ cors: { origin: '*' } });Dispatch events from services:
import { AppService, EventDispatcher } from '@avleon/core';
@AppService
export class UserService {
constructor(private readonly dispatcher: EventDispatcher) {}
async create(data: any) {
const user = await this.save(data);
await this.dispatcher.dispatch('users:created', { userId: user.id });
return user;
}
}For simple routes without a controller class:
app.mapGet('/users', async (req, res) => {
return { users: [] };
});
app.mapPost('/users', async (req, res) => {
return { success: true };
});
app.mapPut('/users/:id', async (req, res) => {
return { success: true };
});
app.mapDelete('/users/:id', async (req, res) => {
return { success: true };
});Add middleware and OpenAPI docs to functional routes:
app
.mapGet('/users', async (req, res) => {
return { users: [] };
})
.useMiddlewares([AuthMiddleware])
.useOpenApi({
summary: 'Get all users',
tags: ['users'],
security: [{ bearerAuth: [] }],
response: {
200: {
description: 'List of users',
type: 'array',
},
},
});Resolve a controller directly, with its dependencies injected:
import { AvleonTest } from '@avleon/core';
import { UserController } from './user.controller';
describe('UserController', () => {
let controller: UserController;
beforeAll(() => {
controller = AvleonTest.getController(UserController);
});
afterAll(() => AvleonTest.clean());
it('should return users', async () => {
const result = await controller.getAll();
expect(Array.isArray(result)).toBe(true);
});
});Or exercise real routes without binding a port:
const app = AvleonTest.createTestApplication({ controllers: [UserController] });
const res = await app.get('/users');
expect(res.statusCode).toBe(200);
const created = await app.post('/users', { payload: { name: 'Tareq' } });
expect(created.statusCode).toBe(201);get, post, put, patch, delete and options are all available, each
taking Fastify's inject options (payload, headers, query, …).
Pass stub dependencies positionally when a controller needs them:
const controller = AvleonTest.getController(UserController, [mockUserService]);ISC © Tareq Hossain