express-file-cluster 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +367 -0
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# express-file-cluster · `efc`
|
|
2
|
+
|
|
3
|
+
**File-based routing. Multi-core clustering. Background tasks. Zero boilerplate.**
|
|
4
|
+
|
|
5
|
+
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 subsystem so requests stay fast.
|
|
6
|
+
|
|
7
|
+
> **Status: v0.2.1 (Beta).** The router, clustering, and auth are implemented. The MongoDB adapter and task queue backend are in active development.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Why EFC
|
|
12
|
+
|
|
13
|
+
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:
|
|
14
|
+
|
|
15
|
+
| Problem | EFC's answer |
|
|
16
|
+
|---|---|
|
|
17
|
+
| Route registration ceremony | The file tree **is** the route tree |
|
|
18
|
+
| Single-threaded Node under load | Auto-detected CPU count → worker processes |
|
|
19
|
+
| Blocking work on the request path | `enqueue()` ships it to a queue; respond immediately |
|
|
20
|
+
| Wiring auth, DB, and middleware by hand | `ignite()` — one call bootstraps everything |
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx create-efc-app my-api
|
|
28
|
+
cd my-api
|
|
29
|
+
efc start dev
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The interactive scaffolder asks for language, database, auth strategy, and whether you want clustering and background tasks — then writes the boilerplate, generates a `.env` with a real `JWT_SECRET`, and runs `npm install`.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Project Structure
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
my-api/
|
|
40
|
+
├── src/
|
|
41
|
+
│ ├── api/ # Every file here is a route
|
|
42
|
+
│ │ ├── health.ts # GET /health
|
|
43
|
+
│ │ ├── users/
|
|
44
|
+
│ │ │ ├── index.ts # GET /users • POST /users
|
|
45
|
+
│ │ │ └── [id].ts # GET /users/:id • DELETE /users/:id
|
|
46
|
+
│ │ └── posts/
|
|
47
|
+
│ │ └── [slug]/
|
|
48
|
+
│ │ └── comments.ts # GET /posts/:slug/comments
|
|
49
|
+
│ ├── tasks/ # Background jobs
|
|
50
|
+
│ │ ├── SendEmail.ts
|
|
51
|
+
│ │ └── ResizeImage.ts
|
|
52
|
+
│ ├── models/ # Engine-agnostic models
|
|
53
|
+
│ │ └── User.ts
|
|
54
|
+
│ └── index.ts # Framework entry point
|
|
55
|
+
├── efc.config.ts
|
|
56
|
+
├── .env # Gitignored — JWT_SECRET auto-filled
|
|
57
|
+
└── .env.example
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Routing rules:
|
|
61
|
+
|
|
62
|
+
| File | URL |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `api/health.ts` | `/health` |
|
|
65
|
+
| `api/users/index.ts` | `/users` |
|
|
66
|
+
| `api/users/[id].ts` | `/users/:id` |
|
|
67
|
+
| `api/posts/[slug]/comments.ts` | `/posts/:slug/comments` |
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Route Handlers
|
|
72
|
+
|
|
73
|
+
Export uppercase HTTP method names. Anything not exported returns **405 Method Not Allowed** automatically.
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
// src/api/users/index.ts
|
|
77
|
+
import type { Request, Response } from 'express';
|
|
78
|
+
import { User } from '../../models/User';
|
|
79
|
+
|
|
80
|
+
export const GET = async (req: Request, res: Response) => {
|
|
81
|
+
const users = await User.find();
|
|
82
|
+
res.json(users);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const POST = async (req: Request, res: Response) => {
|
|
86
|
+
const user = await User.create(req.body);
|
|
87
|
+
res.status(201).json({ id: user.id });
|
|
88
|
+
};
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
// src/api/users/[id].ts
|
|
93
|
+
import type { Request, Response } from 'express';
|
|
94
|
+
import { User } from '../../models/User';
|
|
95
|
+
import { HttpError } from 'express-file-cluster';
|
|
96
|
+
|
|
97
|
+
export const GET = async (req: Request, res: Response) => {
|
|
98
|
+
const user = await User.findById(req.params.id);
|
|
99
|
+
if (!user) throw new HttpError(404, 'User not found');
|
|
100
|
+
res.json(user);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const DELETE = async (req: Request, res: Response) => {
|
|
104
|
+
await User.delete(req.params.id);
|
|
105
|
+
res.status(204).send();
|
|
106
|
+
};
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Middleware
|
|
112
|
+
|
|
113
|
+
Three tiers, each with a clear scope:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
// 1. Global — applies to every request
|
|
117
|
+
// CORS is built-in — configure it via CORS_ORIGINS in .env, not a separate package
|
|
118
|
+
ignite({ globalMiddlewares: [rateLimiter()] });
|
|
119
|
+
|
|
120
|
+
// 2. Route-level — applies to every handler in this file
|
|
121
|
+
export const middlewares = [requireAuth];
|
|
122
|
+
|
|
123
|
+
// 3. Handler-level — applies to one handler via compose()
|
|
124
|
+
import { compose } from 'express-file-cluster';
|
|
125
|
+
|
|
126
|
+
export const POST = compose(
|
|
127
|
+
validateBody(CreateUserSchema),
|
|
128
|
+
async (req, res) => { /* req.body is validated */ },
|
|
129
|
+
);
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Background Tasks
|
|
135
|
+
|
|
136
|
+
Tasks run off the request path — respond immediately, let the queue handle the work.
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
// src/tasks/SendEmail.ts
|
|
140
|
+
import { defineTask } from 'express-file-cluster/tasks';
|
|
141
|
+
|
|
142
|
+
interface Payload { to: string; subject: string; body: string }
|
|
143
|
+
|
|
144
|
+
export default defineTask<Payload>(async (payload) => {
|
|
145
|
+
await mailer.send(payload);
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
// src/tasks/ResizeImage.ts — CPU-bound: runs in a worker_threads thread
|
|
151
|
+
import { defineTask } from 'express-file-cluster/tasks';
|
|
152
|
+
|
|
153
|
+
export default defineTask<{ key: string; width: number }>(
|
|
154
|
+
{ thread: true },
|
|
155
|
+
async ({ key, width }) => {
|
|
156
|
+
const buf = await sharp(await download(key)).resize(width).toBuffer();
|
|
157
|
+
await upload(`${key}@${width}`, buf);
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
// Trigger from a route handler
|
|
164
|
+
import { enqueue } from 'express-file-cluster/tasks';
|
|
165
|
+
|
|
166
|
+
export const POST = async (req, res) => {
|
|
167
|
+
const user = await User.create(req.body);
|
|
168
|
+
await enqueue('SendEmail', { to: user.email, subject: 'Welcome!', body: '...' });
|
|
169
|
+
res.status(202).json({ id: user.id, queued: true });
|
|
170
|
+
};
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Task options:
|
|
174
|
+
|
|
175
|
+
| Option | Default | Description |
|
|
176
|
+
|---|---|---|
|
|
177
|
+
| `thread` | `false` | Run in a `worker_threads` thread (CPU-bound work) |
|
|
178
|
+
| `retries` | `3` | Retry attempts before dead-lettering |
|
|
179
|
+
| `backoff` | `'exponential'` | Delay strategy between retries |
|
|
180
|
+
| `concurrency` | `tasks.concurrency` | Parallel jobs for this task |
|
|
181
|
+
| `schedule` | — | Cron expression for recurring tasks |
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Authentication
|
|
186
|
+
|
|
187
|
+
### `http-only` (recommended for SSR/SSG)
|
|
188
|
+
|
|
189
|
+
Tokens stored in `HttpOnly + Secure + SameSite=Strict` cookies.
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
import { issueToken, revokeToken, requireAuth } from 'express-file-cluster/auth';
|
|
193
|
+
|
|
194
|
+
// src/api/auth/login.ts
|
|
195
|
+
export const POST = async (req, res) => {
|
|
196
|
+
const user = await verifyCredentials(req.body);
|
|
197
|
+
issueToken(res, { sub: user.id, role: user.role });
|
|
198
|
+
res.json({ message: 'Logged in' });
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// Protect a route
|
|
202
|
+
export const middlewares = [requireAuth];
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### `localStorage` (SPA-friendly)
|
|
206
|
+
|
|
207
|
+
Token returned in body; client attaches `Authorization: Bearer <token>`.
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
import { signToken } from 'express-file-cluster/auth';
|
|
211
|
+
|
|
212
|
+
export const POST = async (req, res) => {
|
|
213
|
+
const token = signToken({ sub: user.id });
|
|
214
|
+
res.json({ token });
|
|
215
|
+
};
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Bootstrapper
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
// src/index.ts
|
|
224
|
+
import { ignite } from 'express-file-cluster';
|
|
225
|
+
import path from 'path';
|
|
226
|
+
import { fileURLToPath } from 'url';
|
|
227
|
+
|
|
228
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
229
|
+
|
|
230
|
+
ignite({
|
|
231
|
+
port: Number(process.env.PORT) || 3000,
|
|
232
|
+
apiDir: path.join(__dirname, 'api'),
|
|
233
|
+
tasksDir: path.join(__dirname, 'tasks'),
|
|
234
|
+
|
|
235
|
+
database: 'mongodb',
|
|
236
|
+
databaseUrl: process.env.DATABASE_URL,
|
|
237
|
+
|
|
238
|
+
authStrategy: 'http-only',
|
|
239
|
+
jwtSecret: process.env.JWT_SECRET,
|
|
240
|
+
|
|
241
|
+
cluster: true, // false → single process (auto-disabled in dev)
|
|
242
|
+
workers: 4, // defaults to os.cpus().length
|
|
243
|
+
|
|
244
|
+
tasks: {
|
|
245
|
+
backend: 'bullmq',
|
|
246
|
+
redisUrl: process.env.REDIS_URL,
|
|
247
|
+
concurrency: 5,
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
globalMiddlewares: [],
|
|
251
|
+
onWorkerReady: (id) => console.log(`Worker ${id} ready`),
|
|
252
|
+
onWorkerCrash: (id, code) => console.error(`Worker ${id} crashed (${code})`),
|
|
253
|
+
});
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### `ignite()` options
|
|
257
|
+
|
|
258
|
+
| Option | Type | Default | Description |
|
|
259
|
+
|---|---|---|---|
|
|
260
|
+
| `port` | `number` | `3000` | HTTP listen port |
|
|
261
|
+
| `apiDir` | `string` | — | Path to route modules |
|
|
262
|
+
| `tasksDir` | `string` | — | Path to task modules |
|
|
263
|
+
| `database` | `'mongodb' \| 'postgresql'` | — | Database engine |
|
|
264
|
+
| `databaseUrl` | `string` | `DATABASE_URL` | Connection string |
|
|
265
|
+
| `authStrategy` | `'http-only' \| 'localStorage'` | — | Token delivery |
|
|
266
|
+
| `jwtSecret` | `string` | `JWT_SECRET` | JWT signing secret |
|
|
267
|
+
| `cluster` | `boolean` | `true` | Enable multi-core clustering |
|
|
268
|
+
| `workers` | `number` | `os.cpus().length` | Worker count override |
|
|
269
|
+
| `tasks` | `TaskConfig \| false` | `false` | Background task runtime |
|
|
270
|
+
| `cors` | `boolean \| CorsConfig` | `true` | CORS — origins driven by `CORS_ORIGINS` env var |
|
|
271
|
+
| `globalMiddlewares` | `RequestHandler[]` | `[]` | Applied to every route |
|
|
272
|
+
| `onWorkerReady` | `(id) => void` | — | Called when a worker boots |
|
|
273
|
+
| `onWorkerCrash` | `(id, code) => void` | — | Called before respawn |
|
|
274
|
+
| `onError` | `ErrorRequestHandler` | built-in | Override global error handler |
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## CLI Reference
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
# Development
|
|
282
|
+
efc start dev # Hot-reload single process, source maps, pretty logs
|
|
283
|
+
|
|
284
|
+
# Production
|
|
285
|
+
efc build prod # Type-check + compile to dist/ (tsup, dual CJS/ESM)
|
|
286
|
+
efc start prod # Run dist/ with clustering enabled
|
|
287
|
+
|
|
288
|
+
# Tests
|
|
289
|
+
efc run tests # Vitest (--watch, --coverage passthrough)
|
|
290
|
+
|
|
291
|
+
# Code generation
|
|
292
|
+
efc generate route users/[id] # → src/api/users/[id].ts
|
|
293
|
+
efc generate task ProcessPayment # → src/tasks/ProcessPayment.ts
|
|
294
|
+
efc generate middleware authorize # → src/middlewares/authorize.ts
|
|
295
|
+
|
|
296
|
+
# Diagnostics
|
|
297
|
+
efc routes # Print resolved route table (path → file → methods)
|
|
298
|
+
efc tasks # List registered background tasks
|
|
299
|
+
efc doctor # Validate config, env vars, DB connectivity
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## Clustering Architecture
|
|
305
|
+
|
|
306
|
+
```
|
|
307
|
+
Master Process
|
|
308
|
+
┌────────────────────┐
|
|
309
|
+
│ fork × N workers │
|
|
310
|
+
│ respawn on crash │
|
|
311
|
+
└──┬──────┬──────┬───┘
|
|
312
|
+
│ │ │
|
|
313
|
+
Worker 1 Worker 2 Worker N
|
|
314
|
+
Pre-Flight lifecycle per worker:
|
|
315
|
+
1. Connect DB
|
|
316
|
+
2. Configure auth
|
|
317
|
+
3. Scan apiDir → route map
|
|
318
|
+
4. Register tasks
|
|
319
|
+
5. Mount routes on Express
|
|
320
|
+
6. Listen (OS round-robins connections)
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
CPU-bound tasks fan out further into `worker_threads` — the request loop stays unblocked at every layer.
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
## Error Handling
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
import { HttpError } from 'express-file-cluster';
|
|
331
|
+
|
|
332
|
+
// Throw from any handler — caught and formatted automatically
|
|
333
|
+
export const GET = async (req, res) => {
|
|
334
|
+
const user = await User.findById(req.params.id);
|
|
335
|
+
if (!user) throw new HttpError(404, 'User not found');
|
|
336
|
+
res.json(user);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// Override the global handler
|
|
340
|
+
ignite({
|
|
341
|
+
onError: (err, req, res, next) => {
|
|
342
|
+
logger.error(err);
|
|
343
|
+
res.status(err.statusCode ?? 500).json({ error: err.message });
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
---
|
|
349
|
+
|
|
350
|
+
## Environment Variables
|
|
351
|
+
|
|
352
|
+
`create-efc-app` generates `.env` (gitignored, `JWT_SECRET` pre-filled) and `.env.example` (committed, documented).
|
|
353
|
+
|
|
354
|
+
| Variable | Required | Description |
|
|
355
|
+
|---|---|---|
|
|
356
|
+
| `PORT` | No (default `3000`) | HTTP listen port |
|
|
357
|
+
| `NODE_ENV` | No | `development \| production \| test` |
|
|
358
|
+
| `DATABASE_URL` | Yes | MongoDB or PostgreSQL connection string |
|
|
359
|
+
| `JWT_SECRET` | Yes | JWT signing key — auto-generated by scaffolder |
|
|
360
|
+
| `JWT_EXPIRES_IN` | No (default `7d`) | Token lifetime |
|
|
361
|
+
| `COOKIE_DOMAIN` | No | Cookie domain for `http-only` auth |
|
|
362
|
+
| `REDIS_URL` | If using BullMQ | Redis connection for the task queue |
|
|
363
|
+
| `CORS_ORIGINS` | No | Comma-separated allowed origins — e.g. `http://localhost:3000,https://myapp.com` |
|
|
364
|
+
|
|
365
|
+
## License
|
|
366
|
+
|
|
367
|
+
MIT © 2026 EFC Contributors
|
package/package.json
CHANGED