
Product
PHP and Composer Support Is Now in Beta
Socket’s PHP and Composer support is now in Beta for all customers, with PHP reachability analysis generally available.
unconventional
Advanced tools
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.
unconventional-pg-queries@prop, @index, and @timestamp decoratorscreatedAt and updatedAt managementnpm install unconventional
Unconventional requires the following peer dependencies:
npm install hono@^4 unconventional-pg-queries@^1.6.0
Note: 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 recordsexport 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 filterconst 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 savingDefine 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.
git checkout -b feature/AmazingFeature)git commit -m 'Add some AmazingFeature')git push origin feature/AmazingFeature)This project is licensed under the MIT License - see the LICENSE file for details.
Dean Mauro
Made with ❤️ for the Cloudflare ecosystem
FAQs
A complete server built on Cloudflare Workers/Pages
We found that unconventional demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Product
Socket’s PHP and Composer support is now in Beta for all customers, with PHP reachability analysis generally available.

Product
Socket is bringing experimental protection to Firefox, scanning 97,000+ extensions in Mozilla's official directory for malware and risky updates.

Research
/Security News
Three compromised Rust crates pulled in a malicious dependency that downloaded and executed cross-platform malware during Cargo builds.