@noctuatech/lattice
v1.0.0
Published
A lightweight web framework with dependency injection and decorator-based routing. Hono is the default HTTP server, but the routing layer can be backed by your own server implementation.
Readme
@noctuatech/lattice
A lightweight web framework with dependency injection and decorator-based routing. Hono is the default HTTP server, but the routing layer can be backed by your own server implementation.
Overview
The Lattice framework provides a simple way to build web applications using TypeScript decorators and dependency injection. It ships with a Hono-based server out of the box and exposes an HTTP server contract that you can replace when you need a different runtime or framework.
Core Features
- Decorator-based routing - Use
@get,@post,@put,@patch,@del,@usedecorators for clean route definitions - Dependency injection - Built-in DI support using
@joist/di - Automatic route registration - Controllers are automatically discovered and registered
- Middleware support - Easy middleware integration with
@usedecorator - Custom server support - Swap out the default Hono server by providing your own
HTTP_SERVER - TypeScript-first - Full TypeScript support with type safety
Quick Start
Basic Controller
// hello.controller.ts
import { Injector } from '@joist/di';
import { controller, get } from '@noctuatech/lattice';
import type { Context } from 'hono';
@controller()
export default class HelloController {
@get('/hello')
async sayHello(ctx: Context) {
return ctx.json({ message: 'Hello, World!' });
}
}
// main.ts
const root = new Injector();
const app = root.inject(LatticeApp);
await app.serve();Custom HTTP Server
import { Injector, injectable } from '@joist/di';
import type { AddressInfo } from 'node:net';
import { HTTP_SERVER, LatticeApp, type HttpHandler, type HttpServer } from '@noctuatech/lattice';
@injectable()
class CustomHttpServer implements HttpServer {
get(path: string, handler: HttpHandler) {
// register GET handler with your server
}
post(path: string, handler: HttpHandler) {
// register POST handler with your server
}
put(path: string, handler: HttpHandler) {
// register PUT handler with your server
}
patch(path: string, handler: HttpHandler) {
// register PATCH handler with your server
}
delete(path: string, handler: HttpHandler) {
// register DELETE handler with your server
}
use(path: string, handler: HttpHandler) {
// register middleware with your server
}
listen(port: number): Promise<AddressInfo> {
// start your server and return the bound address
throw new Error('not implemented');
}
}
const root = new Injector({
providers: [[HTTP_SERVER, { use: CustomHttpServer }]],
});
const app = root.inject(LatticeApp);
await app.serve({ port: 8080 });Controller with Base Path
import { controller, get, post } from '@noctuatech/lattice';
import type { Context } from 'hono';
@controller('/api/users')
export default class UserController {
@get()
async getUsers(ctx: Context) {
return ctx.json({ users: [] });
}
@post()
async createUser(ctx: Context) {
const body = await ctx.req.json();
return ctx.json({ user: body }, 201);
}
}Decorators
@controller(path?, opts?)
Marks a class as a controller and optionally sets a base path for all routes in the controller.
Parameters:
path(optional): Base path for all routes in this controlleropts(optional): Dependency injection options
Example:
@controller('/api/v1') // All routes will be prefixed with /api/v1
export default class ApiController {
// Routes will be: /api/v1/users, /api/v1/posts, etc.
}@get(path?, condition?)
Registers a GET route handler.
Parameters:
path: Route path (relative to controller base path)condition(optional): Lifecycle condition for dependency injection
Example:
@get('/users/:id')
async getUser(ctx: Context) {
const id = ctx.req.param('id');
return ctx.json({ id, name: 'John Doe' });
}@post(path, condition?)
Registers a POST route handler.
Parameters:
path: Route path (relative to controller base path)condition(optional): Lifecycle condition for dependency injection
Example:
@post('/users')
async createUser(ctx: Context) {
const body = await ctx.req.json();
return ctx.json({ user: body }, 201);
}@use(path?, condition?)
Registers middleware for the specified path pattern.
Parameters:
path: Path pattern (e.g.,'*'for all routes,'/api/*'for API routes)condition(optional): Lifecycle condition for dependency injection
Example:
@use('*')
async logger(ctx: Context, next: Next) {
console.log(`${ctx.req.method} ${ctx.req.path}`);
await next();
console.log(`Response: ${ctx.res.status}`);
}Dependency Injection
Controllers support dependency injection using @joist/di. You can inject services and other dependencies:
import { inject } from '@joist/di';
import { controller, get } from '@noctuatech/lattice';
import { UserService } from '#services/user.service.js';
@controller('/api/users')
export default class UserController {
#userService = inject(UserService);
@get('/')
async getUsers(ctx: Context) {
const userService = this.#userService();
const users = await userService.getAll();
return ctx.json({ users });
}
}Middleware
Global Middleware
Create middleware controllers that apply to all routes:
import { controller, use } from '@noctuatech/lattice';
import type { Context, Next } from 'hono';
@controller()
export default class LoggerMiddleware {
@use('*')
async logRequests(ctx: Context, next: Next) {
const start = Date.now();
await next();
const duration = Date.now() - start;
console.log(`${ctx.req.method} ${ctx.req.path} - ${ctx.res.status} (${duration}ms)`);
}
}Conditional Middleware
Use lifecycle conditions to enable/disable middleware based on environment or configuration:
@controller()
export default class AuthMiddleware {
@use('*', () => {
return {
enabled: process.env.NODE_ENV !== 'development',
};
})
async requireAuth(ctx: Context, next: Next) {
// Only runs in non-development environments
const token = ctx.req.header('Authorization');
if (!token) {
return ctx.json({ error: 'Unauthorized' }, 401);
}
return next();
}
}Class-Based Middleware
For reusable and type-safe middleware logic, you can define middleware classes. To do this, implement the Middleware interface and decorate the class with @injectable():
import { injectable } from '@joist/di';
import { type Middleware } from '@noctuatech/lattice';
import type { Context, Next } from 'hono';
@injectable()
export class AuthMiddleware implements Middleware {
async middleware(ctx: Context, next: Next) {
const authHeader = ctx.req.header('Authorization');
if (!authHeader) {
return ctx.json({ error: 'Unauthorized' }, 401);
}
await next();
}
}Controller-Level Class Middleware
You can apply class-based middleware to an entire controller using the @use() decorator at the class level. You can pass one or more middleware classes:
import { controller, get, use } from '@noctuatech/lattice';
import { AuthMiddleware } from './auth.middleware.js';
import { LoggingMiddleware } from './logging.middleware.js';
@controller('/api')
@use(LoggingMiddleware, AuthMiddleware)
export class ApiController {
@get('/data')
async getData(ctx: Context) {
return ctx.json({ data: 'secure data' });
}
}Route-Level Class Middleware
You can apply class-based middleware to individual route methods using the @use() decorator on the method. Multiple decorators are evaluated in top-to-bottom order:
import { controller, get, use } from '@noctuatech/lattice';
import { RateLimitMiddleware } from './rate-limit.middleware.js';
import { AuditLogMiddleware } from './audit-log.middleware.js';
@controller('/api')
export class ApiController {
@get('/heavy-operation')
@use(RateLimitMiddleware)
@use(AuditLogMiddleware)
async heavyOp(ctx: Context) {
return ctx.json({ success: true });
}
}Combining Middleware and Execution Order
When combining controller-level and route-level middleware, they are guaranteed to execute in a precise order:
- Controller-Level Middleware (in the exact order specified, left-to-right, within the
@use()decorator) - Route-Level Middleware (in the exact order specified, top-to-bottom, of the route's
@use()decorators) - Route Handler Method
Example:
@controller('/api')
@use(ControllerMw1, ControllerMw2)
export class ApiController {
@get('/data')
@use(RouteMw1)
@use(RouteMw2)
async getData(ctx: Context) {
return ctx.json({ ok: true });
}
}Execution Flow:
ControllerMw1 → ControllerMw2 → RouteMw1 → RouteMw2 → getData route handler
Route Patterns
The framework supports various route patterns:
@controller('/api')
export default class ApiController {
// Static routes
@get('/users')
async getUsers(ctx: Context) {
/* ... */
}
// Parameter routes
@get('/users/:id')
async getUser(ctx: Context) {
const id = ctx.req.param('id');
// ...
}
// Wildcard routes
@get('*')
async catchAll(ctx: Context) {
return ctx.json({ error: 'Not found' }, 404);
}
// Nested parameter routes
@get('/users/:userId/posts/:postId')
async getUserPost(ctx: Context) {
const userId = ctx.req.param('userId');
const postId = ctx.req.param('postId');
// ...
}
}Testing Controllers
Controllers can be easily tested using the framework's testing utilities:
import { HonoService } from '@noctuatech/lattice';
import { Injector } from '@joist/di';
import { assert } from 'chai';
import { test } from 'node:test';
import UserController from '#lib/user.controller.js';
test('UserController', async () => {
const testbed = new Injector();
const hono = testbed.inject(HonoService);
const controller = testbed.inject(UserController);
assert.instanceOf(controller, UserController);
const response = await hono.request('/api/users');
assert.strictEqual(response.status, 200);
});File Naming Convention
Controllers are automatically discovered by the framework. Follow these naming conventions:
- Controllers: Files ending with
.controller.ts - Middleware: Files ending with
.middleware.ts
Example file structure:
src/
├── routes/
│ ├── users/
│ │ └── user.controller.ts
│ └── posts/
│ └── post.controller.ts
└── middleware/
├── auth.middleware.ts
└── logger.middleware.tsBest Practices
- Use descriptive controller names:
UserController,PostController, etc. - Group related routes: Use base paths to organize related endpoints
- Keep controllers focused: Each controller should handle a specific resource or feature
- Use dependency injection: Inject services rather than importing them directly
- Handle errors gracefully: Return appropriate HTTP status codes and error messages
- Write tests: Test your controllers to ensure they work correctly
Dependencies
hono: Fast, lightweight web framework@joist/di: Dependency injection container@hono/node-server: Node.js server adapter for Hono
