millas 0.1.0

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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -0
  3. package/bin/millas.js +6 -0
  4. package/package.json +56 -0
  5. package/src/admin/Admin.js +617 -0
  6. package/src/admin/index.js +13 -0
  7. package/src/admin/resources/AdminResource.js +317 -0
  8. package/src/auth/Auth.js +254 -0
  9. package/src/auth/AuthController.js +188 -0
  10. package/src/auth/AuthMiddleware.js +67 -0
  11. package/src/auth/Hasher.js +51 -0
  12. package/src/auth/JwtDriver.js +74 -0
  13. package/src/auth/RoleMiddleware.js +44 -0
  14. package/src/cache/Cache.js +231 -0
  15. package/src/cache/drivers/FileDriver.js +152 -0
  16. package/src/cache/drivers/MemoryDriver.js +158 -0
  17. package/src/cache/drivers/NullDriver.js +27 -0
  18. package/src/cache/index.js +8 -0
  19. package/src/cli.js +27 -0
  20. package/src/commands/make.js +61 -0
  21. package/src/commands/migrate.js +174 -0
  22. package/src/commands/new.js +50 -0
  23. package/src/commands/queue.js +92 -0
  24. package/src/commands/route.js +93 -0
  25. package/src/commands/serve.js +50 -0
  26. package/src/container/Application.js +177 -0
  27. package/src/container/Container.js +281 -0
  28. package/src/container/index.js +13 -0
  29. package/src/controller/Controller.js +367 -0
  30. package/src/errors/HttpError.js +29 -0
  31. package/src/events/Event.js +39 -0
  32. package/src/events/EventEmitter.js +151 -0
  33. package/src/events/Listener.js +46 -0
  34. package/src/events/index.js +15 -0
  35. package/src/index.js +93 -0
  36. package/src/mail/Mail.js +210 -0
  37. package/src/mail/MailMessage.js +196 -0
  38. package/src/mail/TemplateEngine.js +150 -0
  39. package/src/mail/drivers/LogDriver.js +36 -0
  40. package/src/mail/drivers/MailgunDriver.js +84 -0
  41. package/src/mail/drivers/SendGridDriver.js +97 -0
  42. package/src/mail/drivers/SmtpDriver.js +67 -0
  43. package/src/mail/index.js +19 -0
  44. package/src/middleware/AuthMiddleware.js +46 -0
  45. package/src/middleware/CorsMiddleware.js +59 -0
  46. package/src/middleware/LogMiddleware.js +61 -0
  47. package/src/middleware/Middleware.js +36 -0
  48. package/src/middleware/MiddlewarePipeline.js +94 -0
  49. package/src/middleware/ThrottleMiddleware.js +61 -0
  50. package/src/orm/drivers/DatabaseManager.js +135 -0
  51. package/src/orm/fields/index.js +132 -0
  52. package/src/orm/index.js +19 -0
  53. package/src/orm/migration/MigrationRunner.js +216 -0
  54. package/src/orm/migration/ModelInspector.js +338 -0
  55. package/src/orm/migration/SchemaBuilder.js +173 -0
  56. package/src/orm/model/Model.js +371 -0
  57. package/src/orm/query/QueryBuilder.js +197 -0
  58. package/src/providers/AdminServiceProvider.js +40 -0
  59. package/src/providers/AuthServiceProvider.js +53 -0
  60. package/src/providers/CacheStorageServiceProvider.js +71 -0
  61. package/src/providers/DatabaseServiceProvider.js +45 -0
  62. package/src/providers/EventServiceProvider.js +34 -0
  63. package/src/providers/MailServiceProvider.js +51 -0
  64. package/src/providers/ProviderRegistry.js +82 -0
  65. package/src/providers/QueueServiceProvider.js +52 -0
  66. package/src/providers/ServiceProvider.js +45 -0
  67. package/src/queue/Job.js +135 -0
  68. package/src/queue/Queue.js +147 -0
  69. package/src/queue/drivers/DatabaseDriver.js +194 -0
  70. package/src/queue/drivers/SyncDriver.js +72 -0
  71. package/src/queue/index.js +16 -0
  72. package/src/queue/workers/QueueWorker.js +140 -0
  73. package/src/router/MiddlewareRegistry.js +82 -0
  74. package/src/router/Route.js +255 -0
  75. package/src/router/RouteGroup.js +19 -0
  76. package/src/router/RouteRegistry.js +55 -0
  77. package/src/router/Router.js +138 -0
  78. package/src/router/index.js +15 -0
  79. package/src/scaffold/generator.js +34 -0
  80. package/src/scaffold/maker.js +272 -0
  81. package/src/scaffold/templates.js +350 -0
  82. package/src/storage/Storage.js +170 -0
  83. package/src/storage/drivers/LocalDriver.js +215 -0
  84. package/src/storage/index.js +6 -0
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * MailgunDriver
5
+ *
6
+ * Sends mail via the Mailgun API v3.
7
+ * No extra npm package needed.
8
+ *
9
+ * Config (config/mail.js):
10
+ * drivers: {
11
+ * mailgun: {
12
+ * key: process.env.MAILGUN_API_KEY,
13
+ * domain: process.env.MAILGUN_DOMAIN,
14
+ * region: 'us', // 'us' | 'eu'
15
+ * }
16
+ * }
17
+ */
18
+ class MailgunDriver {
19
+ constructor(config = {}) {
20
+ this._key = config.key || process.env.MAILGUN_API_KEY;
21
+ this._domain = config.domain || process.env.MAILGUN_DOMAIN;
22
+ this._region = config.region || 'us';
23
+
24
+ if (!this._key) throw new Error('MailgunDriver: API key is required');
25
+ if (!this._domain) throw new Error('MailgunDriver: domain is required');
26
+ }
27
+
28
+ async send(message) {
29
+ const params = new URLSearchParams();
30
+
31
+ const toStr = [].concat(message.to).join(', ');
32
+ params.append('from', message.from);
33
+ params.append('to', toStr);
34
+ params.append('subject', message.subject);
35
+
36
+ if (message.cc) params.append('cc', [].concat(message.cc).join(', '));
37
+ if (message.bcc) params.append('bcc', [].concat(message.bcc).join(', '));
38
+ if (message.html) params.append('html', message.html);
39
+ if (message.text) params.append('text', message.text);
40
+
41
+ const host = this._region === 'eu'
42
+ ? 'api.eu.mailgun.net'
43
+ : 'api.mailgun.net';
44
+
45
+ return this._post(host, `/v3/${this._domain}/messages`, params.toString());
46
+ }
47
+
48
+ _post(host, path, body) {
49
+ return new Promise((resolve, reject) => {
50
+ const https = require('https');
51
+ const auth = Buffer.from(`api:${this._key}`).toString('base64');
52
+
53
+ const options = {
54
+ hostname: host,
55
+ port: 443,
56
+ path,
57
+ method: 'POST',
58
+ headers: {
59
+ 'Authorization': `Basic ${auth}`,
60
+ 'Content-Type': 'application/x-www-form-urlencoded',
61
+ 'Content-Length': Buffer.byteLength(body),
62
+ },
63
+ };
64
+
65
+ const req = https.request(options, (res) => {
66
+ let raw = '';
67
+ res.on('data', c => raw += c);
68
+ res.on('end', () => {
69
+ if (res.statusCode >= 200 && res.statusCode < 300) {
70
+ resolve(JSON.parse(raw || '{}'));
71
+ } else {
72
+ reject(new Error(`Mailgun error ${res.statusCode}: ${raw}`));
73
+ }
74
+ });
75
+ });
76
+
77
+ req.on('error', reject);
78
+ req.write(body);
79
+ req.end();
80
+ });
81
+ }
82
+ }
83
+
84
+ module.exports = MailgunDriver;
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * SendGridDriver
5
+ *
6
+ * Sends mail via the SendGrid Web API v3.
7
+ * Does not require any extra npm package — uses the built-in https module.
8
+ *
9
+ * Config (config/mail.js):
10
+ * drivers: {
11
+ * sendgrid: {
12
+ * key: process.env.SENDGRID_API_KEY,
13
+ * }
14
+ * }
15
+ */
16
+ class SendGridDriver {
17
+ constructor(config = {}) {
18
+ this._apiKey = config.key || process.env.SENDGRID_API_KEY;
19
+ if (!this._apiKey) {
20
+ throw new Error('SendGridDriver: API key is required (config/mail.js → drivers.sendgrid.key)');
21
+ }
22
+ }
23
+
24
+ async send(message) {
25
+ const payload = {
26
+ personalizations: [{
27
+ to: this._toRecipients(message.to),
28
+ cc: message.cc ? this._toRecipients(message.cc) : undefined,
29
+ bcc: message.bcc ? this._toRecipients(message.bcc) : undefined,
30
+ subject: message.subject,
31
+ }],
32
+ from: this._parseAddress(message.from),
33
+ reply_to: message.replyTo ? this._parseAddress(message.replyTo) : undefined,
34
+ content: [
35
+ ...(message.text ? [{ type: 'text/plain', value: message.text }] : []),
36
+ ...(message.html ? [{ type: 'text/html', value: message.html }] : []),
37
+ ],
38
+ attachments: message.attachments
39
+ ? message.attachments.map(a => ({
40
+ content: Buffer.isBuffer(a.content)
41
+ ? a.content.toString('base64')
42
+ : Buffer.from(a.content || '').toString('base64'),
43
+ filename: a.filename,
44
+ type: a.contentType,
45
+ }))
46
+ : undefined,
47
+ };
48
+
49
+ return this._post('/v3/mail/send', payload);
50
+ }
51
+
52
+ async _post(path, body) {
53
+ return new Promise((resolve, reject) => {
54
+ const https = require('https');
55
+ const data = JSON.stringify(body);
56
+ const options = {
57
+ hostname: 'api.sendgrid.com',
58
+ port: 443,
59
+ path,
60
+ method: 'POST',
61
+ headers: {
62
+ 'Authorization': `Bearer ${this._apiKey}`,
63
+ 'Content-Type': 'application/json',
64
+ 'Content-Length': Buffer.byteLength(data),
65
+ },
66
+ };
67
+
68
+ const req = https.request(options, (res) => {
69
+ let raw = '';
70
+ res.on('data', chunk => raw += chunk);
71
+ res.on('end', () => {
72
+ if (res.statusCode >= 200 && res.statusCode < 300) {
73
+ resolve({ statusCode: res.statusCode });
74
+ } else {
75
+ reject(new Error(`SendGrid API error ${res.statusCode}: ${raw}`));
76
+ }
77
+ });
78
+ });
79
+
80
+ req.on('error', reject);
81
+ req.write(data);
82
+ req.end();
83
+ });
84
+ }
85
+
86
+ _toRecipients(addresses) {
87
+ return [].concat(addresses).map(a => this._parseAddress(a));
88
+ }
89
+
90
+ _parseAddress(address) {
91
+ const match = String(address).match(/^(.+?)\s*<(.+?)>$/);
92
+ if (match) return { name: match[1].trim(), email: match[2].trim() };
93
+ return { email: address };
94
+ }
95
+ }
96
+
97
+ module.exports = SendGridDriver;
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * SmtpDriver
5
+ *
6
+ * Sends mail via SMTP using nodemailer.
7
+ *
8
+ * Config (config/mail.js):
9
+ * drivers: {
10
+ * smtp: {
11
+ * host: 'smtp.mailtrap.io',
12
+ * port: 2525,
13
+ * username: 'user',
14
+ * password: 'pass',
15
+ * encryption: 'tls', // 'tls' | 'ssl' | false
16
+ * }
17
+ * }
18
+ */
19
+ class SmtpDriver {
20
+ constructor(config = {}) {
21
+ this._config = config;
22
+ this._transport = null;
23
+ }
24
+
25
+ async send(message) {
26
+ const transport = this._getTransport();
27
+ return transport.sendMail(this._toNodemailer(message));
28
+ }
29
+
30
+ _getTransport() {
31
+ if (this._transport) return this._transport;
32
+
33
+ const nodemailer = require('nodemailer');
34
+ const cfg = this._config;
35
+
36
+ const secure = cfg.encryption === 'ssl';
37
+ const tls = cfg.encryption === 'tls' ? { rejectUnauthorized: false } : undefined;
38
+
39
+ this._transport = nodemailer.createTransport({
40
+ host: cfg.host || 'localhost',
41
+ port: cfg.port || 587,
42
+ secure,
43
+ auth: cfg.username ? { user: cfg.username, pass: cfg.password } : undefined,
44
+ tls,
45
+ });
46
+
47
+ return this._transport;
48
+ }
49
+
50
+ _toNodemailer(msg) {
51
+ return {
52
+ from: msg.from,
53
+ to: Array.isArray(msg.to) ? msg.to.join(', ') : msg.to,
54
+ cc: Array.isArray(msg.cc) ? msg.cc.join(', ') : msg.cc,
55
+ bcc: Array.isArray(msg.bcc) ? msg.bcc.join(', ') : msg.bcc,
56
+ replyTo: msg.replyTo,
57
+ subject: msg.subject,
58
+ html: msg.html,
59
+ text: msg.text,
60
+ attachments: msg.attachments,
61
+ headers: msg.headers,
62
+ priority: msg.priority,
63
+ };
64
+ }
65
+ }
66
+
67
+ module.exports = SmtpDriver;
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+
3
+ const Mail = require('./Mail');
4
+ const MailMessage = require('./MailMessage');
5
+ const TemplateEngine = require('./TemplateEngine');
6
+ const SmtpDriver = require('./drivers/SmtpDriver');
7
+ const SendGridDriver = require('./drivers/SendGridDriver');
8
+ const MailgunDriver = require('./drivers/MailgunDriver');
9
+ const LogDriver = require('./drivers/LogDriver');
10
+
11
+ module.exports = {
12
+ Mail,
13
+ MailMessage,
14
+ TemplateEngine,
15
+ SmtpDriver,
16
+ SendGridDriver,
17
+ MailgunDriver,
18
+ LogDriver,
19
+ };
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+
3
+ const Middleware = require('./Middleware');
4
+ const HttpError = require('../errors/HttpError');
5
+
6
+ /**
7
+ * AuthMiddleware
8
+ *
9
+ * Guards routes from unauthenticated access.
10
+ * Full JWT/session implementation unlocked in Phase 7.
11
+ *
12
+ * For now: checks for presence of Authorization header.
13
+ * Replace the verify() method with real token logic in Phase 7.
14
+ *
15
+ * Register:
16
+ * middlewareRegistry.register('auth', AuthMiddleware);
17
+ *
18
+ * Apply:
19
+ * Route.prefix('/api').middleware(['auth']).group(() => { ... });
20
+ */
21
+ class AuthMiddleware extends Middleware {
22
+ async handle(req, res, next) {
23
+ const header = req.headers['authorization'];
24
+
25
+ if (!header) {
26
+ throw new HttpError(401, 'No authorization token provided');
27
+ }
28
+
29
+ const [scheme, token] = header.split(' ');
30
+
31
+ if (scheme !== 'Bearer' || !token) {
32
+ throw new HttpError(401, 'Invalid authorization format. Use: Bearer <token>');
33
+ }
34
+
35
+ // Phase 7 will replace this with real JWT verification:
36
+ // const user = await Auth.verifyToken(token);
37
+ // req.user = user;
38
+
39
+ // For now: attach a stub user so downstream handlers don't break
40
+ req.user = { id: null, token, authenticated: false, _stub: true };
41
+
42
+ next();
43
+ }
44
+ }
45
+
46
+ module.exports = AuthMiddleware;
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ const Middleware = require('./Middleware');
4
+
5
+ /**
6
+ * CorsMiddleware
7
+ *
8
+ * Adds Cross-Origin Resource Sharing headers to every response.
9
+ *
10
+ * Register:
11
+ * middlewareRegistry.register('cors', CorsMiddleware);
12
+ *
13
+ * Configure in bootstrap/app.js:
14
+ * new CorsMiddleware({
15
+ * origins: ['https://myapp.com'],
16
+ * methods: ['GET', 'POST'],
17
+ * })
18
+ *
19
+ * Or register the class directly for default permissive settings.
20
+ */
21
+ class CorsMiddleware extends Middleware {
22
+ constructor(options = {}) {
23
+ super();
24
+ this.origins = options.origins || ['*'];
25
+ this.methods = options.methods || ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'];
26
+ this.headers = options.headers || ['Content-Type', 'Authorization', 'X-Requested-With'];
27
+ this.credentials = options.credentials ?? false;
28
+ this.maxAge = options.maxAge || 86400;
29
+ }
30
+
31
+ async handle(req, res, next) {
32
+ const origin = req.headers.origin;
33
+
34
+ // Origin matching
35
+ if (this.origins.includes('*')) {
36
+ res.setHeader('Access-Control-Allow-Origin', '*');
37
+ } else if (origin && this.origins.includes(origin)) {
38
+ res.setHeader('Access-Control-Allow-Origin', origin);
39
+ res.setHeader('Vary', 'Origin');
40
+ }
41
+
42
+ res.setHeader('Access-Control-Allow-Methods', this.methods.join(', '));
43
+ res.setHeader('Access-Control-Allow-Headers', this.headers.join(', '));
44
+ res.setHeader('Access-Control-Max-Age', String(this.maxAge));
45
+
46
+ if (this.credentials) {
47
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
48
+ }
49
+
50
+ // Handle preflight
51
+ if (req.method === 'OPTIONS') {
52
+ return res.status(204).send();
53
+ }
54
+
55
+ next();
56
+ }
57
+ }
58
+
59
+ module.exports = CorsMiddleware;
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ const Middleware = require('./Middleware');
4
+
5
+ /**
6
+ * LogMiddleware
7
+ *
8
+ * Logs every incoming request and its response time.
9
+ *
10
+ * Output format:
11
+ * [2026-03-14 12:00:00] GET /api/users 200 12ms
12
+ *
13
+ * Register:
14
+ * middlewareRegistry.register('log', LogMiddleware);
15
+ *
16
+ * Options:
17
+ * silent — suppress output (default: false)
18
+ * format — 'dev' (coloured) | 'combined' (plain)
19
+ */
20
+ class LogMiddleware extends Middleware {
21
+ constructor(options = {}) {
22
+ super();
23
+ this.silent = options.silent || false;
24
+ this.format = options.format || 'dev';
25
+ }
26
+
27
+ async handle(req, res, next) {
28
+ if (this.silent) return next();
29
+
30
+ const start = Date.now();
31
+ const { method, path: routePath, url } = req;
32
+
33
+ res.on('finish', () => {
34
+ const ms = Date.now() - start;
35
+ const status = res.statusCode;
36
+ const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
37
+
38
+ if (this.format === 'dev') {
39
+ const statusColor = status < 300 ? '\x1b[32m'
40
+ : status < 400 ? '\x1b[36m'
41
+ : status < 500 ? '\x1b[33m'
42
+ : '\x1b[31m';
43
+ const reset = '\x1b[0m';
44
+ const methodColor = '\x1b[1m';
45
+ console.log(
46
+ ` ${'\x1b[90m'}[${ts}]${reset} ` +
47
+ `${methodColor}${method.padEnd(7)}${reset} ` +
48
+ `${routePath || url} ` +
49
+ `${statusColor}${status}${reset} ` +
50
+ `${'\x1b[90m'}${ms}ms${reset}`
51
+ );
52
+ } else {
53
+ console.log(`[${ts}] ${method} ${routePath || url} ${status} ${ms}ms`);
54
+ }
55
+ });
56
+
57
+ next();
58
+ }
59
+ }
60
+
61
+ module.exports = LogMiddleware;
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Middleware
5
+ *
6
+ * Base class for all Millas middleware.
7
+ * Subclasses must implement handle(req, res, next).
8
+ *
9
+ * Usage:
10
+ * class AuthMiddleware extends Middleware {
11
+ * async handle(req, res, next) {
12
+ * // ...
13
+ * next();
14
+ * }
15
+ * }
16
+ */
17
+ class Middleware {
18
+ /**
19
+ * Handle the incoming request.
20
+ *
21
+ * @param {import('express').Request} req
22
+ * @param {import('express').Response} res
23
+ * @param {Function} next
24
+ */
25
+ async handle(req, res, next) {
26
+ throw new Error(`${this.constructor.name} must implement handle(req, res, next)`);
27
+ }
28
+
29
+ /**
30
+ * Called after the response is sent.
31
+ * Override for cleanup, logging, etc.
32
+ */
33
+ async terminate(req, res) {}
34
+ }
35
+
36
+ module.exports = Middleware;
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * MiddlewarePipeline
5
+ *
6
+ * Composes an ordered list of middleware handlers into a single
7
+ * Express-compatible function. Used internally by the Router.
8
+ *
9
+ * Each handler can be:
10
+ * - A Middleware subclass (instantiated automatically)
11
+ * - An already-instantiated Middleware object
12
+ * - A raw Express function (req, res, next) => {}
13
+ *
14
+ * Usage (internal):
15
+ * const pipeline = new MiddlewarePipeline([AuthMiddleware, LogMiddleware]);
16
+ * app.use(pipeline.compose());
17
+ */
18
+ class MiddlewarePipeline {
19
+ constructor(handlers = []) {
20
+ this._handlers = handlers;
21
+ }
22
+
23
+ /**
24
+ * Add a handler to the end of the pipeline.
25
+ */
26
+ pipe(handler) {
27
+ this._handlers.push(handler);
28
+ return this;
29
+ }
30
+
31
+ /**
32
+ * Add a handler to the beginning of the pipeline.
33
+ */
34
+ prepend(handler) {
35
+ this._handlers.unshift(handler);
36
+ return this;
37
+ }
38
+
39
+ /**
40
+ * Compose all handlers into a single (req, res, next) function.
41
+ */
42
+ compose() {
43
+ const fns = this._handlers.map(h => this._resolve(h));
44
+
45
+ return function pipeline(req, res, next) {
46
+ let index = 0;
47
+
48
+ function dispatch(i) {
49
+ if (i >= fns.length) return next();
50
+ const fn = fns[i];
51
+
52
+ try {
53
+ const result = fn(req, res, (err) => {
54
+ if (err) return next(err);
55
+ dispatch(i + 1);
56
+ });
57
+ // Handle async middleware that returns a Promise
58
+ if (result && typeof result.catch === 'function') {
59
+ result.catch(next);
60
+ }
61
+ } catch (err) {
62
+ next(err);
63
+ }
64
+ }
65
+
66
+ dispatch(0);
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Resolve a handler to a plain (req, res, next) => {} function.
72
+ */
73
+ _resolve(handler) {
74
+ // Raw Express function
75
+ if (typeof handler === 'function' && !(handler.prototype instanceof require('./Middleware'))) {
76
+ return handler;
77
+ }
78
+
79
+ // Middleware class (not yet instantiated)
80
+ if (typeof handler === 'function' && handler.prototype instanceof require('./Middleware')) {
81
+ const instance = new handler();
82
+ return (req, res, next) => instance.handle(req, res, next);
83
+ }
84
+
85
+ // Instantiated Middleware object
86
+ if (handler && typeof handler.handle === 'function') {
87
+ return (req, res, next) => handler.handle(req, res, next);
88
+ }
89
+
90
+ throw new Error(`Invalid middleware: ${handler}. Must be a Middleware class, instance, or function.`);
91
+ }
92
+ }
93
+
94
+ module.exports = MiddlewarePipeline;
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ const Middleware = require('./Middleware');
4
+
5
+ /**
6
+ * ThrottleMiddleware
7
+ *
8
+ * Simple in-memory rate limiter.
9
+ * For production use, replace the store with a Redis-backed one (Phase 11).
10
+ *
11
+ * Register:
12
+ * middlewareRegistry.register('throttle', new ThrottleMiddleware({ max: 60, window: 60 }));
13
+ *
14
+ * Options:
15
+ * max — max requests per window (default: 60)
16
+ * window — window in seconds (default: 60)
17
+ * keyBy — function(req) => string, defaults to IP
18
+ */
19
+ class ThrottleMiddleware extends Middleware {
20
+ constructor(options = {}) {
21
+ super();
22
+ this.max = options.max || 60;
23
+ this.window = options.window || 60; // seconds
24
+ this.keyBy = options.keyBy || ((req) => req.ip || 'anonymous');
25
+ this._store = new Map(); // { key: { count, resetAt } }
26
+ }
27
+
28
+ async handle(req, res, next) {
29
+ const key = this.keyBy(req);
30
+ const now = Date.now();
31
+
32
+ let record = this._store.get(key);
33
+
34
+ if (!record || now > record.resetAt) {
35
+ record = { count: 0, resetAt: now + this.window * 1000 };
36
+ this._store.set(key, record);
37
+ }
38
+
39
+ record.count++;
40
+
41
+ const remaining = Math.max(0, this.max - record.count);
42
+ const resetIn = Math.ceil((record.resetAt - now) / 1000);
43
+
44
+ res.setHeader('X-RateLimit-Limit', String(this.max));
45
+ res.setHeader('X-RateLimit-Remaining', String(remaining));
46
+ res.setHeader('X-RateLimit-Reset', String(Math.ceil(record.resetAt / 1000)));
47
+
48
+ if (record.count > this.max) {
49
+ return res.status(429).json({
50
+ error: 'Too Many Requests',
51
+ message: `Rate limit exceeded. Try again in ${resetIn}s.`,
52
+ status: 429,
53
+ retryAfter: resetIn,
54
+ });
55
+ }
56
+
57
+ next();
58
+ }
59
+ }
60
+
61
+ module.exports = ThrottleMiddleware;