A complete backend framework built on Cloudflare Workers/Pages and Hono
Unconventional is a powerful, production-ready backend framework designed for Cloudflare Workers and Pages. It provides a complete set of tools for building RESTful APIs with minimal boilerplate, including automatic CRUD operations, database management, caching, security features, and more.
- π Complete CRUD Operations - Automatic REST endpoints for all your models
- ποΈ PostgreSQL Integration - Built-in support via
unconventional-pg-queries - π¨ Decorator-Based Models - Define models with
@prop,@index, and@timestampdecorators - β° Automatic Timestamps - Built-in
createdAtandupdatedAtmanagement - πΎ Cloudflare KV Caching - Intelligent caching layer with automatic invalidation
- π Security First - IDOR protection and private field masking out of the box
- π Relationship Support - HasOne, HasMany, BelongsTo, and ManyToMany relations
- π‘οΈ Middleware Support - Extensible middleware system for authentication and more
- π Advanced Querying - Pagination, filtering, sorting, and query expansion
- π Flexible IDs - Support for both numeric IDs and UUIDs
- π CORS Ready - Pre-configured CORS with sensible defaults
- β‘ Edge Optimized - Built for Cloudflare's edge network
npm install unconventionalUnconventional requires the following peer dependencies:
npm install hono@^4 unconventional-pg-queries@^1.6.0Note: TypeScript is required for this package.
import { BaseModel, prop, timestamp } from 'unconventional';
@timestamp()
export class User extends BaseModel {
public static collection = 'users';
public static db: DB;
@prop({ required: true, unique: true })
public email!: string;
@prop({ required: true })
public name!: string;
@prop({ private: true })
public passwordHash?: string;
}import { AbstractBaseController } from 'unconventional';
import { User } from './models/user';
export class UserController extends AbstractBaseController<typeof User> {
constructor() {
super(User);
}
protected async maskPrivateFields(req: RequestContext, response: User | User[]): Promise<void> {
User.maskPrivate(response);
}
protected removeSystemFields(req: RequestContext): void {
delete req.body.id;
delete req.body.createdAt;
delete req.body.updatedAt;
}
protected async preventIDOR(req: RequestContext): Promise<void> {
// Implement your IDOR prevention logic
// For example, check if user owns the resource
}
}import { BackendServer, PGFactory } from 'unconventional';
import { UserController } from './controllers/user.controller';
const server = new BackendServer({
basePath: '/api',
getDB: PGFactory,
cors: {
origin: ['https://example.com'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
},
});
const userController = new UserController();
server.app
.get('/users', userController.getAll())
.get('/users/:id', userController.get())
.post('/users', userController.create())
.put('/users/:id', userController.update())
.delete('/users/:id', userController.delete());
export default server.start();In your wrangler.json:
{
"name": "my-api",
"compatibility_date": "2024-01-01",
"kv_namespaces": [
{
"binding": "CACHE",
"id": "your-kv-namespace-id"
}
],
"services": [
{
"binding": "DB_PROXY",
"service": "your-postgres-service"
}
]
}The foundation of Unconventional. BaseModel provides static methods for database operations and instance methods for data manipulation.
create(data, upsertConfig?)- Create a new recordcreateMany(data[], upsertConfig?)- Create multiple recordsfindById(id, config?)- Find by ID or keyfindOne(config)- Find a single record matching criteriafindMany(config?)- Find multiple records with paginationupdate(id, data)- Update a recordupdateMany(data[])- Update multiple recordsdelete(id)- Delete a recorddeleteMany(config?)- Delete multiple recordscount()- Count all recordsincrement(id, data)- Increment numeric fieldstruncate()- Delete all records
export class Product extends BaseModel {
public static collection = 'products'; // This model's database table
public static db: DB; // The connection to the database.
// Recommend setting on BaseModel
public static idField = 'id'; // The table's id column. Default: id
public static idType = IdType.UUID; // What column type id is (number or UUID)
public static keyField = 'slug'; // Optional column used for lookups
public static ownerField = 'userId'; // For ownership checks
}The service layer provides a clean interface for business logic with built-in caching support.
import { BaseService } from 'unconventional';
const userService = BaseService.for(User);
// Create
const user = await userService.create({ email: 'user@example.com', name: 'John' }, {
cache: ctx.env.CACHE,
cacheTTL: 3600,
});
// Get with caching
const user = await userService.get(userId, {
cache: ctx.env.CACHE,
expand: 'profile,posts',
});
// Update
const updated = await userService.update(userId, { name: 'Jane' }, {
cache: ctx.env.CACHE,
});
// Delete
await userService.delete(userId, { cache: ctx.env.CACHE });Provides automatic CRUD endpoints with hooks for customization.
create(options?)- POST endpointcreateMany(options?)- POST endpoint for bulk creationget(options?)- GET endpoint for single resourcegetAll(options?)- GET endpoint for paginated listupdate(options?)- PUT endpointupdateMany(options?)- PUT endpoint for bulk updatesdelete(options?)- DELETE endpointdeleteMany(options?)- DELETE endpoint with filter
const controller = new MyController();
server.app.get('/users/:id', controller.get({
before: async (req) => {
// Pre-processing hook
},
after: async (req, response) => {
// Post-processing hook
},
cache: true, // Enable caching for this route
cacheTTL: 1800, // Custom TTL
download: async (req, response) => {
// Return file download instead of JSON
return { filename: 'export.json', buffer: Buffer.from(JSON.stringify(response)) };
},
}));The main server class that wraps Hono with Unconventional's defaults.
import { BackendServer, PGFactory } from 'unconventional';
const server = new BackendServer({
name: 'My API',
basePath: '/api/v1',
getDB: PGFactory, // Database factory function
cors: { // CORS configuration
origin: ['https://example.com'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowHeaders: ['Content-Type', 'Authorization'],
},
middleware: [ // Middleware to run on all routes
async (ctx, next) => {
// Your middleware logic
return next();
},
],
});
// Access the underlying Hono app
server.app.get('/health', (ctx) => ctx.json({ status: 'ok' }));
export default server.start();Define model properties with validation and options.
export class Post extends BaseModel {
@prop({ required: true })
public title!: string;
@prop({ unique: true })
public slug!: string;
@prop({ default: 'draft' })
public status!: string;
@prop({ default: () => new Date() })
public publishedAt?: Date;
@prop({ private: true })
public internalNotes?: string;
@prop({ system: true })
public systemField?: string;
@prop({
relation: {
type: RelationType.BelongsTo,
model: User,
from: 'userId',
to: 'id',
},
})
public author?: User;
}Prop Options:
required- Field is requiredunique- Field has a unique index in the DBdefault- Default value (can be a function)private- Hide from non-privileged users (can be a function)system- Prevent editing by non-privileged users (can be a function)relation- Define relationshipspreFormat- Transform value before saving
Define database indexes for performance.
@index({ email: 1 }, { unique: true })
@index({ createdAt: -1, status: 1 })
export class User extends BaseModel {
// ...
}Automatically manage createdAt and updatedAt fields.
@timestamp()
export class Post extends BaseModel {
@prop()
public createdAt?: Date;
@prop()
public updatedAt?: Date;
}
// Custom field names
@timestamp({ createdField: 'created_at', updatedField: 'updated_at' })
export class Article extends BaseModel {
// ...
}Unconventional supports four types of relationships:
export class Comment extends BaseModel {
@prop({
relation: {
type: RelationType.BelongsTo,
model: Post,
from: 'postId',
to: 'id',
},
})
public post?: Post;
}export class User extends BaseModel {
@prop({
relation: {
type: RelationType.HasOne,
model: Profile,
from: 'id',
to: 'userId',
},
})
public profile?: Profile;
@prop({
relation: {
type: RelationType.HasMany,
model: Post,
from: 'id',
to: 'authorId',
},
})
public posts?: Post[];
}export class User extends BaseModel {
@prop({
relation: {
type: RelationType.ManyToMany,
model: Role,
from: 'id',
to: 'id',
through: {
model: UserRole,
from: 'userId',
to: 'roleId',
},
},
})
public roles?: Role[];
}Load relationships using the expand query parameter:
GET /api/users/123?expand=profile,posts,posts.comments
Unconventional provides intelligent caching with Cloudflare KV.
const controller = new UserController();
// Enable caching at controller level
const controller = new UserController({
cache: true,
cacheTTL: 3600, // 1 hour
});
// Or per-route
server.app.get('/users/:id', controller.get({ cache: true }));import { BaseCache } from 'unconventional';
// Set a model in cache
await BaseCache.setModel(cache, user, undefined, 3600);
// Get a model from cache
const cached = await BaseCache.getModel(cache, User, userId);
// Clear cache
await BaseCache.clearModel(cache, user);
await BaseCache.clearPage(cache, User);Implement ownership checks in your controller:
protected async preventIDOR(req: RequestContext): Promise<void> {
const ownerId = req.get(BaseEnvKey.ownerId);
const isPrivileged = req.get(BaseEnvKey.isPrivileged);
if (isPrivileged) return;
// For updates/deletes, check ownership
if (req.params.id) {
const resource = await User.findById(req.params.id);
if (resource?.userId !== ownerId) {
throw APIError.errForbidden('Access denied');
}
}
// For creates, ensure user can't set someone else as owner
if (req.body.userId && req.body.userId !== ownerId) {
throw APIError.errForbidden('Cannot create resource for another user');
}
}Fields marked with private: true are automatically masked:
@prop({ private: true })
public passwordHash?: string;
@prop({ private: (model, field) => !model.isPublic })
public internalData?: string;Fields marked with system: true cannot be edited by non-privileged users:
@prop({ system: true })
public systemField?: string;
protected removeSystemFields(req: RequestContext): void {
const isPrivileged = req.get(BaseEnvKey.isPrivileged);
if (!isPrivileged) {
delete req.body.systemField;
}
}// Using query parameters
GET /api/users?filter=status eq 'active' AND age gt 18
// In code
const users = await User.findMany({
filter: 'status eq \'active\' AND age gt 18',
});// Query parameter
GET /api/users?orderBy=createdAt desc
// In code
const users = await User.findMany({
sort: 'createdAt desc
});// Cursor-based pagination
GET /api/users?limit=20&cursor=123
// In code
const users = await User.findMany({
limit: 20,
cursor: '123',
});
// Get all (limit < 0)
const allUsers = await User.findMany({ limit: -1 });import { AbstractBaseMiddleware } from 'unconventional';
export class UserMiddleware extends AbstractBaseMiddleware<typeof User> {
constructor() {
super(User);
}
async findOwner(id: string | number): Promise<User | null> {
return this.findAncestor(id, (model) => model === User);
}
}Unconventional includes a comprehensive error handling system:
import { APIError } from 'unconventional';
// Predefined errors
throw APIError.errNotFound('User not found');
throw APIError.errBadRequest('Invalid input');
throw APIError.errUnauthorized('Authentication required');
throw APIError.errForbidden('Access denied');
throw APIError.errResourceCreationFailed('Failed to create user');// Create or update based on unique constraint
const user = await User.create(
{ email: 'user@example.com', name: 'John' },
{
action: ConflictResolution.doUpdate,
constraint: 'email',
}
);// models/user.ts
import { BaseModel, prop, timestamp, RelationType } from 'unconventional';
import { Profile } from './profile';
@timestamp()
export class User extends BaseModel {
public static collection = 'users';
public static db: DB;
@prop({ required: true, unique: true })
public email!: string;
@prop({ required: true })
public name!: string;
@prop({ private: true })
public passwordHash?: string;
@prop({
relation: {
type: RelationType.HasOne,
model: Profile,
from: 'id',
to: 'userId',
},
})
public profile?: Profile;
}
// controllers/user.controller.ts
import { AbstractBaseController, APIError, BaseEnvKey } from 'unconventional';
import { User } from '../models/user';
export class UserController extends AbstractBaseController<typeof User> {
constructor() {
super(User, {
cache: true,
cacheTTL: 3600,
});
}
protected async maskPrivateFields(req: RequestContext, response: User | User[]): Promise<void> {
User.maskPrivate(response);
}
protected async preventIDOR(req: RequestContext): Promise<void> {
const ownerId = req.get(BaseEnvKey.ownerId);
const isPrivileged = req.get(BaseEnvKey.isPrivileged);
if (isPrivileged) return;
if (req.params.id) {
const user = await User.findById(req.params.id);
if (!user || user.id !== ownerId) {
throw APIError.errForbidden('Access denied');
}
}
}
}
// server.ts
import { BackendServer, PGFactory } from 'unconventional';
import { UserController } from './controllers/user.controller';
const server = new BackendServer({
basePath: '/api',
getDB: PGFactory,
cors: {
origin: ['https://example.com'],
},
});
const userController = new UserController();
server.app
.get('/users', userController.getAll())
.get('/users/:id', userController.get())
.post('/users', userController.create())
.put('/users/:id', userController.update())
.delete('/users/:id', userController.delete());
export default server.start();| Method | Description | Returns |
|---|---|---|
create(data, upsertConfig?) |
Create a new record | Promise<InstanceType<T> | null> |
createMany(data[], upsertConfig?) |
Create multiple records | Promise<InstanceType<T>[] | null> |
findById(id, config?) |
Find by ID or key | Promise<InstanceType<T> | null> |
findOne(config) |
Find a single record | Promise<InstanceType<T> | null> |
findMany(config?) |
Find multiple records | Promise<InstanceType<T>[]> |
update(id, data) |
Update a record | Promise<InstanceType<T> | null> |
updateMany(data[]) |
Update multiple records | Promise<InstanceType<T>[] | null> |
delete(id) |
Delete a record | Promise<InstanceType<T> | null> |
deleteMany(config?) |
Delete multiple records | Promise<InstanceType<T>[] | null> |
count() |
Count all records | Promise<number | null> |
increment(id, data) |
Increment numeric fields | Promise<InstanceType<T> | null> |
truncate() |
Delete all records | Promise<void> |
maskPrivate(data) |
Mask private fields | void |
| Method | Description | Returns |
|---|---|---|
$id() |
Get the ID field value | string | number |
$key() |
Get the key field value | string | undefined |
$owner() |
Get the owner field value | string | number | undefined |
| Method | Description | Returns |
|---|---|---|
create(data, config?) |
Create with caching | Promise<InstanceType<M>> |
createMany(data[], config?) |
Create many with caching | Promise<InstanceType<M>[]> |
get(identifier, config?) |
Get with caching | Promise<InstanceType<M>> |
getAll(config?) |
Get all with pagination | Promise<InstanceType<M>[]> |
update(identifier, data, config?) |
Update with cache invalidation | Promise<InstanceType<M>> |
updateMany(data[], config?) |
Update many | Promise<InstanceType<M>[]> |
delete(identifier, config?) |
Delete with cache invalidation | Promise<InstanceType<M>> |
deleteMany(config?) |
Delete many | Promise<InstanceType<M>[]> |
interface ServerConfig {
name?: string;
basePath?: string;
getDB: (ctx: Context) => DB;
cors?: CorsOptions;
middleware?: Middleware[];
}interface ControllerOptions {
cache?: boolean;
cacheTTL?: number;
}interface ServiceConfig<M> {
cache?: KVNamespace;
cacheTTL?: number;
upsertConfig?: UpsertConfig<M>;
}Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/AmazingFeature) - Commit your changes (
git commit -m 'Add some AmazingFeature') - Push to the branch (
git push origin feature/AmazingFeature) - Open a Pull Request
This project is licensed under the MIT License - see the LICENSE file for details.
Dean Mauro
- GitHub: @cloudflare-extension
- Repository: unconventional
- Built on Hono - Ultrafast web framework
- Powered by Cloudflare Workers - Edge computing platform
- Database queries via unconventional-pg-queries
Made with β€οΈ for the Cloudflare ecosystem