File-based routing · Multi-core clustering · Background tasks · Zero boilerplate
EFC is an opinionated backend framework built on Express. Drop files in src/api/ and they become routes. Every CPU core serves traffic automatically. Heavy work goes to a queue-backed task system so your request handlers stay fast.
npx create-efc-app my-api
cd my-api
efc start dev- Why EFC
- Installation
- Project Structure
- File-Based Routing
- Middleware
- Database
- Authentication
- Background Tasks
- Clustering
- Error Handling
- CLI Reference
- Configuration Reference
- Environment Variables
- Roadmap
- Contributing
- License
Most Express apps grow the same way: a working prototype, then a maze of router.get(...) calls spread across files, a clustering setup copy-pasted from a blog post, and background jobs bolted on as an afterthought.
EFC collapses all of that into conventions:
| Pain point | EFC's answer |
|---|---|
| Route registration ceremony | The file tree is the route tree |
| Single-threaded Node under load | Auto-detected CPU count → worker processes |
| Blocking work on the request path | enqueue() ships it off; respond immediately |
| Wiring auth, DB, and middleware by hand | ignite() — one call bootstraps everything |
| Scattered model definitions | defineModel() — typed CRUD with zero ORM ceremony |
| Per-request user context in nested calls | AsyncLocalStorage-backed getCurrentUser() |
Scaffold a new project (recommended):
npx create-efc-app my-apiThe interactive scaffolder asks for language, database, auth strategy, clustering, and task queue — then generates everything including a .env with a real JWT_SECRET.
Add to an existing Express project:
npm install express-file-clusterRequires: Node.js ≥ 20 · TypeScript 5.x (optional but recommended)
my-api/
├── src/
│ ├── api/ # Every file here becomes a route
│ │ ├── health.ts # → GET /v1/api/health
│ │ ├── users/
│ │ │ ├── index.ts # → GET /v1/api/users POST /v1/api/users
│ │ │ └── [id].ts # → GET /v1/api/users/:id DELETE …
│ │ └── posts/
│ │ └── [slug]/
│ │ └── comments.ts # → GET /v1/api/posts/:slug/comments
│ ├── tasks/ # Background job definitions
│ │ ├── SendEmail.ts
│ │ └── ResizeImage.ts
│ ├── models/
│ │ └── User.ts # defineModel() schemas
│ └── index.ts # ignite() entry point
├── efc.config.ts
├── .env # Gitignored — JWT_SECRET auto-generated
└── .env.example
Export uppercase HTTP method names from any file under src/api/. Everything else returns 405 Method Not Allowed automatically.
| File | Route |
|---|---|
api/health.ts |
GET /health |
api/users/index.ts |
GET /users · POST /users |
api/users/[id].ts |
GET /users/:id · DELETE /users/:id |
api/posts/[slug]/comments.ts |
GET /posts/:slug/comments |
// src/api/users/index.ts
import type { Request, Response } from 'express';
import { User } from '../../models/User';
export const GET = async (req: Request, res: Response) => {
const users = await User.find();
res.json(users);
};
export const POST = async (req: Request, res: Response) => {
const user = await User.create(req.body);
res.status(201).json({ id: user.id });
};// src/api/users/[id].ts
import type { Request, Response } from 'express';
import { User } from '../../models/User';
import { HttpError } from 'express-file-cluster';
export const GET = async (req: Request, res: Response) => {
const user = await User.findById(req.params.id);
if (!user) throw new HttpError(404, 'User not found');
res.json(user);
};
export const DELETE = async (req: Request, res: Response) => {
await User.delete(req.params.id);
res.status(204).send();
};Three tiers, each with a clear scope:
// 1. Global — runs on every request (configured in ignite())
ignite({ globalMiddlewares: [rateLimiter()] });
// 2. Route-level — applies to all handlers in this file
export const middlewares = [requireAuth];
// 3. Handler-level — compose() wraps a single handler
import { compose } from 'express-file-cluster';
export const POST = compose(
validateBody(CreateUserSchema),
async (req, res) => {
// req.body is validated here
},
);Declare a typed model with a schema. EFC compiles it to a Mongoose model and wraps it in a clean CRUD surface.
// src/model/User.ts
import { defineModel } from 'express-file-cluster';
interface UserDocument {
name: string;
email: string;
role: 'admin' | 'user';
verifyToken: string;
createdAt?: Date;
}
export const User = defineModel<UserDocument>('User', {
name: { type: 'string', required: true },
email: { type: 'string', required: true, unique: true },
role: { type: 'string', enum: ['admin', 'user'], default: 'user' },
verifyToken: { type: 'string', default: '$uuid' }, // fresh UUID per document
});Instead of a literal or a raw function, pass a sentinel string — EFC resolves it to a fresh per-document value at schema-compile time:
| Code | Resolves to |
|---|---|
'$now' |
new Date() |
'$uuid' |
crypto.randomUUID() |
'$objectId' |
fresh Mongoose ObjectId |
'$timestamp' |
Date.now() (number) |
'$shortId' |
random 16-char base64url string |
'$currentUser' |
full JWT payload from the in-flight request |
'$currentUser.<key>' |
single field from that payload (e.g. '$currentUser.id') |
export const Order = defineModel<OrderDocument>('Order', {
orderNumber: { type: 'number', sequence: true, required: true },
// sequence: 'global.orders' — explicit key to share a counter across models
});sequence is assigned atomically in a pre('validate') hook — it fires before required validation, so the field can be both required: true and auto-filled.
// Disable Mongoose's automatic createdAt/updatedAt
export const Role = defineModel<RoleDocument>('Role', schema, {
timestamps: false,
});
// Or rename them
export const Audit = defineModel<AuditDocument>('Audit', schema, {
timestamps: { createdAt: 'created_at', updatedAt: false },
});await User.find({ role: 'admin' }); // find all matching
await User.findById('66a1...'); // by _id
await User.findOne({ email: 'a@b.com' }); // first match
await User.create({ name: 'Alice', ... }); // insert
await User.update('66a1...', { name: 'Bob' }); // findOneAndUpdate
await User.delete('66a1...'); // remove
await User.count({ role: 'user' }); // count matchingPopulate references:
await Post.find({}, { populate: 'author' });
await Post.findById(id, { populate: ['author', 'comments.user'] });Tokens are stored in HttpOnly; Secure; SameSite=Strict cookies — no JS access, no XSS risk.
import { issueToken, revokeToken, requireAuth } from 'express-file-cluster/auth';
// Login
export const POST = async (req, res) => {
const user = await verifyCredentials(req.body);
issueToken(res, { sub: user.id, role: user.role });
res.json({ ok: true });
};
// Logout
export const DELETE = async (req, res) => {
revokeToken(res);
res.json({ ok: true });
};Token returned in the response body; client attaches Authorization: Bearer <token>.
import { signToken } from 'express-file-cluster/auth';
export const POST = async (req, res) => {
const token = signToken({ sub: user.id });
res.json({ token });
};// Route-level (all handlers in this file)
export const middlewares = [requireAuth];
// Role-gated
export const middlewares = [requireAuth('admin')];
// Handler-level
export const DELETE = compose(requireAuth('admin'), async (req, res) => { ... });import { getCurrentUser } from 'express-file-cluster/auth';
// Available anywhere inside a request (including inside defineModel defaults)
const user = getCurrentUser(); // Record<string, unknown> | undefinedTasks run off the request path — enqueue and respond immediately; the queue handles the rest.
// src/tasks/SendEmail.ts
import { defineTask } from 'express-file-cluster/tasks';
interface Payload { to: string; subject: string; body: string }
export default defineTask<Payload>(async (payload) => {
await mailer.send(payload);
});// src/tasks/ResizeImage.ts — CPU-bound: runs in worker_threads
import { defineTask } from 'express-file-cluster/tasks';
export default defineTask<{ key: string; width: number }>(
{ thread: true, retries: 2, backoff: 'exponential' },
async ({ key, width }) => {
const buf = await sharp(await download(key)).resize(width).toBuffer();
await upload(`${key}@${width}`, buf);
},
);import { enqueue } from 'express-file-cluster/tasks';
export const POST = async (req, res) => {
const user = await User.create(req.body);
await enqueue('SendEmail', { to: user.email, subject: 'Welcome!', body: '...' });
res.status(202).json({ id: user.id, queued: true });
};| Option | Type | Default | Description |
|---|---|---|---|
thread |
boolean |
false |
Run in a worker_threads thread (CPU-bound work) |
retries |
number |
3 |
Attempts before dead-lettering |
backoff |
'fixed' | 'exponential' |
'exponential' |
Retry delay strategy |
concurrency |
number |
tasks.concurrency |
Parallel workers for this task |
EFC uses Node's built-in cluster module. The master process forks one worker per CPU core; each worker runs the full Pre-Flight lifecycle independently.
Master Process
┌────────────────────┐
│ fork × N workers │
│ respawn on crash │
└──┬──────┬──────┬───┘
│ │ │
Worker 1 Worker 2 Worker N
─────────────────────────────
Pre-Flight (per worker):
1. Connect database
2. Configure auth
3. Scan tasksDir → register tasks
4. Start BullMQ backend
5. Scan apiDir → build route map
6. Mount routes on Express
7. server.listen() ← OS load-balances connections
CPU-bound tasks fan out further into worker_threads — the request loop stays unblocked at every layer.
ignite({
// cluster defaults to NODE_ENV === 'production' — only pass it to force one way
// regardless of NODE_ENV (e.g. cluster: false to disable clustering even in prod)
workers: 4, // default: os.cpus().length
onWorkerReady: (id) => console.log(`Worker ${id} ready`),
onWorkerCrash: (id, code) => console.error(`Worker ${id} crashed (${code})`),
});Throw HttpError from any handler — it's caught and formatted automatically.
import { HttpError, isHttpError } from 'express-file-cluster';
export const GET = async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new HttpError(404, 'User not found');
res.json(user);
};
// Factory method for wrapping unknown errors
const err = HttpError.from(someError, 500);Override the global error handler:
ignite({
onError: (err, req, res, next) => {
logger.error(err);
res.status(err.statusCode ?? 500).json({ error: err.message });
},
});# Development
efc start dev # Hot-reload single process (tsx --watch)
# Production
efc build prod # Type-check + compile (tsup, dual CJS/ESM)
efc start prod # Run dist/ with clustering enabled
# Testing
efc run tests # Vitest (--watch, --coverage passthrough)
# Code generation
efc generate route users/[id] # → src/api/users/[id].ts
efc generate task ProcessPayment # → src/tasks/ProcessPayment.ts
efc generate middleware authorize # → src/middlewares/authorize.ts
# Diagnostics
efc routes # Print resolved route table (path → file → methods)
efc tasks # List registered background tasks
efc doctor # Validate config, env vars, DB connectivityEFC uses a two-file convention: all runtime values are read from process.env in efc.config.ts and passed explicitly to ignite(). The framework never reads process.env itself (except NODE_ENV).
// efc.config.ts ← single source of truth for all runtime values
import type { EFCConfig } from 'express-file-cluster';
const corsOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean)
: undefined;
const config: EFCConfig = {
port: process.env.PORT ? Number(process.env.PORT) : undefined,
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
jwtExpiresIn: process.env.JWT_EXPIRES_IN,
cookieDomain: process.env.COOKIE_DOMAIN,
cors: corsOrigins ? { origin: corsOrigins } : true,
authStrategy: 'http-only', // 'http-only' | 'localStorage'
tasks: { backend: 'bullmq', concurrency: 5, redisUrl: process.env.REDIS_URL },
globalMiddlewares: [],
};
export default config;// src/index.ts ← entry point: spread config, ignite
import { ignite, gracefulShutdown } from 'express-file-cluster';
import config from '../efc.config.js';
ignite({
...config,
}).then(gracefulShutdown).catch(console.error);Don't hardcode
cluster: truehere.ignite()'s own default (NODE_ENV === 'production') already gives you clustering in prod and a single process in dev — a literal boolean always wins over that default, socluster: truewould cluster in dev too. Only passclusterexplicitly if you want to force it one way regardless ofNODE_ENV(e.g.cluster: falseto disable clustering even in production).
apiDir and tasksDir are auto-resolved — EFC probes src/api, src/tasks, dist/api, dist/tasks in order. You only need to pass them if your layout differs from the convention.
| Option | Type | Default | Description |
|---|---|---|---|
port |
number |
3000 |
HTTP listen port |
basePath |
string |
'/v1/api' |
URL prefix for all routes |
database |
'mongodb' | 'postgresql' |
auto-detected | Database engine |
databaseUrl |
string |
— | Connection string (MongoDB auto-detected from mongodb:// prefix) |
authStrategy |
'http-only' | 'localStorage' |
'http-only' |
Token delivery method |
jwtSecret |
string |
— | JWT signing secret |
jwtExpiresIn |
string |
'7d' |
Token lifetime |
cookieDomain |
string |
— | Cookie domain (http-only only) |
cluster |
boolean |
true in prod |
Enable multi-core clustering |
workers |
number |
os.cpus().length |
Worker count override |
tasks |
TaskConfig | false |
false |
Background task runtime |
cors |
boolean | CorsConfig |
true |
CORS configuration |
requestTimeout |
number |
— | Request timeout in ms (408 on exceed) |
globalMiddlewares |
RequestHandler[] |
[] |
Applied to every route |
dashboard |
boolean |
true in dev |
Dev route dashboard at / |
onWorkerReady |
(id) => void |
— | Called when a worker boots |
onWorkerCrash |
(id, code) => void |
— | Called before respawn |
onError |
ErrorRequestHandler |
built-in | Override global error handler |
create-efc-app generates .env (gitignored, JWT_SECRET pre-filled) and .env.example (committed, documented). EFC does not auto-load any of these — read them yourself in efc.config.ts and pass them to ignite().
| Variable | Required | Description |
|---|---|---|
PORT |
No (default 3000) |
HTTP listen port |
NODE_ENV |
No | development | production | test — only env var EFC reads directly |
DATABASE_URL |
If using a database | MongoDB or PostgreSQL connection string |
JWT_SECRET |
If using auth | JWT signing key — auto-generated by scaffolder |
JWT_EXPIRES_IN |
No (default 7d) |
Token lifetime |
COOKIE_DOMAIN |
No | Cookie domain for http-only auth |
REDIS_URL |
If using BullMQ | Redis connection for the task queue |
CORS_ORIGINS |
No | Comma-separated allowed origins |
packages/
core/ → express-file-cluster (the framework)
create-efc-app/ → npx create-efc-app (interactive scaffolder)
docs/ → documentation source
client/ → marketing site
git clone https://github.com/pr4shxnt/efc.js.git
cd efc.js
npm install # installs all workspace packages
npm run build # build all packages
npm test # run tests (Vitest)
npm run typecheck # tsc across all packages
npm run lint # ESLint| Phase | Status | Focus |
|---|---|---|
| 1 | 🟡 In progress | Router, clustering, MongoDB, BullMQ, auth, CLI, scaffolder |
| 2 | ⬜ Planned | PostgreSQL (Drizzle), Zod validation, structured logging (pino), cron tasks |
| 3 | ⬜ Planned | Plugins, WebSockets, OpenAPI auto-gen, OpenTelemetry, efc studio |
| 4 | ⬜ Planned | Edge/serverless adapter, gRPC, GraphQL |
See todo.md for the full implementation checklist.
Note: EFC is architecturally incompatible with Vercel's serverless runtime — it relies on Node.js
cluster,server.listen(), and persistent Redis connections. A serverless adapter is planned for Phase 4.
Contributions, issues, and pull requests are welcome!
- Branches:
feat/<topic>·fix/<topic>·docs/<topic> - Commits: Conventional Commits
- PRs: should include tests, pass CI, and reference an issue where applicable
git clone https://github.com/pr4shxnt/efc.js.git
cd efc.js && npm install
npm test && npm run lint