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.
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/bin/millas.js +6 -0
- package/package.json +56 -0
- package/src/admin/Admin.js +617 -0
- package/src/admin/index.js +13 -0
- package/src/admin/resources/AdminResource.js +317 -0
- package/src/auth/Auth.js +254 -0
- package/src/auth/AuthController.js +188 -0
- package/src/auth/AuthMiddleware.js +67 -0
- package/src/auth/Hasher.js +51 -0
- package/src/auth/JwtDriver.js +74 -0
- package/src/auth/RoleMiddleware.js +44 -0
- package/src/cache/Cache.js +231 -0
- package/src/cache/drivers/FileDriver.js +152 -0
- package/src/cache/drivers/MemoryDriver.js +158 -0
- package/src/cache/drivers/NullDriver.js +27 -0
- package/src/cache/index.js +8 -0
- package/src/cli.js +27 -0
- package/src/commands/make.js +61 -0
- package/src/commands/migrate.js +174 -0
- package/src/commands/new.js +50 -0
- package/src/commands/queue.js +92 -0
- package/src/commands/route.js +93 -0
- package/src/commands/serve.js +50 -0
- package/src/container/Application.js +177 -0
- package/src/container/Container.js +281 -0
- package/src/container/index.js +13 -0
- package/src/controller/Controller.js +367 -0
- package/src/errors/HttpError.js +29 -0
- package/src/events/Event.js +39 -0
- package/src/events/EventEmitter.js +151 -0
- package/src/events/Listener.js +46 -0
- package/src/events/index.js +15 -0
- package/src/index.js +93 -0
- package/src/mail/Mail.js +210 -0
- package/src/mail/MailMessage.js +196 -0
- package/src/mail/TemplateEngine.js +150 -0
- package/src/mail/drivers/LogDriver.js +36 -0
- package/src/mail/drivers/MailgunDriver.js +84 -0
- package/src/mail/drivers/SendGridDriver.js +97 -0
- package/src/mail/drivers/SmtpDriver.js +67 -0
- package/src/mail/index.js +19 -0
- package/src/middleware/AuthMiddleware.js +46 -0
- package/src/middleware/CorsMiddleware.js +59 -0
- package/src/middleware/LogMiddleware.js +61 -0
- package/src/middleware/Middleware.js +36 -0
- package/src/middleware/MiddlewarePipeline.js +94 -0
- package/src/middleware/ThrottleMiddleware.js +61 -0
- package/src/orm/drivers/DatabaseManager.js +135 -0
- package/src/orm/fields/index.js +132 -0
- package/src/orm/index.js +19 -0
- package/src/orm/migration/MigrationRunner.js +216 -0
- package/src/orm/migration/ModelInspector.js +338 -0
- package/src/orm/migration/SchemaBuilder.js +173 -0
- package/src/orm/model/Model.js +371 -0
- package/src/orm/query/QueryBuilder.js +197 -0
- package/src/providers/AdminServiceProvider.js +40 -0
- package/src/providers/AuthServiceProvider.js +53 -0
- package/src/providers/CacheStorageServiceProvider.js +71 -0
- package/src/providers/DatabaseServiceProvider.js +45 -0
- package/src/providers/EventServiceProvider.js +34 -0
- package/src/providers/MailServiceProvider.js +51 -0
- package/src/providers/ProviderRegistry.js +82 -0
- package/src/providers/QueueServiceProvider.js +52 -0
- package/src/providers/ServiceProvider.js +45 -0
- package/src/queue/Job.js +135 -0
- package/src/queue/Queue.js +147 -0
- package/src/queue/drivers/DatabaseDriver.js +194 -0
- package/src/queue/drivers/SyncDriver.js +72 -0
- package/src/queue/index.js +16 -0
- package/src/queue/workers/QueueWorker.js +140 -0
- package/src/router/MiddlewareRegistry.js +82 -0
- package/src/router/Route.js +255 -0
- package/src/router/RouteGroup.js +19 -0
- package/src/router/RouteRegistry.js +55 -0
- package/src/router/Router.js +138 -0
- package/src/router/index.js +15 -0
- package/src/scaffold/generator.js +34 -0
- package/src/scaffold/maker.js +272 -0
- package/src/scaffold/templates.js +350 -0
- package/src/storage/Storage.js +170 -0
- package/src/storage/drivers/LocalDriver.js +215 -0
- package/src/storage/index.js +6 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const HttpError = require('../errors/HttpError');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Controller
|
|
7
|
+
*
|
|
8
|
+
* Base class for all Millas controllers.
|
|
9
|
+
*
|
|
10
|
+
* Provides:
|
|
11
|
+
* - Response helpers : this.ok(), this.created(), this.noContent(),
|
|
12
|
+
* this.badRequest(), this.unauthorized(),
|
|
13
|
+
* this.forbidden(), this.notFound(), this.json()
|
|
14
|
+
* - Request helpers : this.input(), this.param(), this.query(), this.all()
|
|
15
|
+
* - Validation : this.validate()
|
|
16
|
+
* - Pagination helper : this.paginate()
|
|
17
|
+
* - Error throwing : this.abort()
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* class UserController extends Controller {
|
|
21
|
+
* async index(req, res) {
|
|
22
|
+
* return this.ok(res, { users: [] });
|
|
23
|
+
* }
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
class Controller {
|
|
27
|
+
|
|
28
|
+
// ─── Response Helpers ────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 200 OK
|
|
32
|
+
* @param {object} res
|
|
33
|
+
* @param {*} data
|
|
34
|
+
*/
|
|
35
|
+
ok(res, data = null) {
|
|
36
|
+
return res.status(200).json(this._envelope(200, data));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 201 Created
|
|
41
|
+
*/
|
|
42
|
+
created(res, data = null) {
|
|
43
|
+
return res.status(201).json(this._envelope(201, data));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 204 No Content
|
|
48
|
+
*/
|
|
49
|
+
noContent(res) {
|
|
50
|
+
return res.status(204).send();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 200 with a custom JSON payload (no envelope)
|
|
55
|
+
*/
|
|
56
|
+
json(res, data, status = 200) {
|
|
57
|
+
return res.status(status).json(data);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 400 Bad Request
|
|
62
|
+
*/
|
|
63
|
+
badRequest(res, message = 'Bad Request', errors = null) {
|
|
64
|
+
return res.status(400).json({
|
|
65
|
+
error: 'Bad Request',
|
|
66
|
+
message,
|
|
67
|
+
status: 400,
|
|
68
|
+
...(errors && { errors }),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 401 Unauthorized
|
|
74
|
+
*/
|
|
75
|
+
unauthorized(res, message = 'Unauthorized') {
|
|
76
|
+
return res.status(401).json({ error: 'Unauthorized', message, status: 401 });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 403 Forbidden
|
|
81
|
+
*/
|
|
82
|
+
forbidden(res, message = 'Forbidden') {
|
|
83
|
+
return res.status(403).json({ error: 'Forbidden', message, status: 403 });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 404 Not Found
|
|
88
|
+
*/
|
|
89
|
+
notFound(res, message = 'Not Found') {
|
|
90
|
+
return res.status(404).json({ error: 'Not Found', message, status: 404 });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 422 Unprocessable Entity — validation failed
|
|
95
|
+
*/
|
|
96
|
+
unprocessable(res, errors) {
|
|
97
|
+
return res.status(422).json({
|
|
98
|
+
error: 'Unprocessable Entity',
|
|
99
|
+
message: 'Validation failed',
|
|
100
|
+
status: 422,
|
|
101
|
+
errors,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 500 Internal Server Error
|
|
107
|
+
*/
|
|
108
|
+
serverError(res, message = 'Internal Server Error') {
|
|
109
|
+
return res.status(500).json({ error: 'Internal Server Error', message, status: 500 });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Abort (throw HttpError) ─────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Throw an HTTP error — caught by the Router error handler.
|
|
116
|
+
*
|
|
117
|
+
* this.abort(404, 'User not found')
|
|
118
|
+
* this.abort(403)
|
|
119
|
+
*/
|
|
120
|
+
abort(status, message) {
|
|
121
|
+
throw new HttpError(status, message);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── Request Helpers ─────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Get a value from req.body, req.query, or req.params — in that order.
|
|
128
|
+
* Falls back to defaultValue if not found.
|
|
129
|
+
*
|
|
130
|
+
* this.input(req, 'email')
|
|
131
|
+
* this.input(req, 'page', 1)
|
|
132
|
+
*/
|
|
133
|
+
input(req, key, defaultValue = null) {
|
|
134
|
+
if (key === undefined) return this.all(req);
|
|
135
|
+
const value =
|
|
136
|
+
(req.body && req.body[key] !== undefined ? req.body[key] : undefined) ??
|
|
137
|
+
(req.query && req.query[key] !== undefined ? req.query[key] : undefined) ??
|
|
138
|
+
(req.params && req.params[key] !== undefined ? req.params[key] : undefined);
|
|
139
|
+
return value !== undefined ? value : defaultValue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Get a URL parameter. this.param(req, 'id')
|
|
144
|
+
*/
|
|
145
|
+
param(req, key, defaultValue = null) {
|
|
146
|
+
return req.params?.[key] ?? defaultValue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Get a query string value. this.query(req, 'page', 1)
|
|
151
|
+
*/
|
|
152
|
+
query(req, key, defaultValue = null) {
|
|
153
|
+
if (key === undefined) return req.query || {};
|
|
154
|
+
return req.query?.[key] ?? defaultValue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Merge body + query + params into one flat object.
|
|
159
|
+
*/
|
|
160
|
+
all(req) {
|
|
161
|
+
return {
|
|
162
|
+
...req.params,
|
|
163
|
+
...req.query,
|
|
164
|
+
...req.body,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Return only the specified keys from the request.
|
|
170
|
+
*
|
|
171
|
+
* this.only(req, ['name', 'email'])
|
|
172
|
+
*/
|
|
173
|
+
only(req, keys = []) {
|
|
174
|
+
const all = this.all(req);
|
|
175
|
+
return keys.reduce((acc, k) => {
|
|
176
|
+
if (k in all) acc[k] = all[k];
|
|
177
|
+
return acc;
|
|
178
|
+
}, {});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Return all request data except the specified keys.
|
|
183
|
+
*
|
|
184
|
+
* this.except(req, ['password', 'token'])
|
|
185
|
+
*/
|
|
186
|
+
except(req, keys = []) {
|
|
187
|
+
const all = this.all(req);
|
|
188
|
+
return Object.fromEntries(
|
|
189
|
+
Object.entries(all).filter(([k]) => !keys.includes(k))
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─── Validation ──────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Validate request data against a set of rules.
|
|
197
|
+
* Throws a 422 HttpError on failure (caught by the router).
|
|
198
|
+
*
|
|
199
|
+
* const data = await this.validate(req, {
|
|
200
|
+
* name: 'required|string|min:2|max:100',
|
|
201
|
+
* email: 'required|email',
|
|
202
|
+
* age: 'optional|number|min:0',
|
|
203
|
+
* });
|
|
204
|
+
*
|
|
205
|
+
* On success returns the validated + sanitised data object.
|
|
206
|
+
*/
|
|
207
|
+
async validate(req, rules) {
|
|
208
|
+
const data = this.all(req);
|
|
209
|
+
const errors = {};
|
|
210
|
+
|
|
211
|
+
for (const [field, ruleString] of Object.entries(rules)) {
|
|
212
|
+
const fieldRules = ruleString.split('|').map(r => r.trim());
|
|
213
|
+
const value = data[field];
|
|
214
|
+
const fieldErrors = [];
|
|
215
|
+
|
|
216
|
+
for (const rule of fieldRules) {
|
|
217
|
+
const [ruleName, ruleArg] = rule.split(':');
|
|
218
|
+
const err = this._applyRule(field, value, ruleName, ruleArg);
|
|
219
|
+
if (err) fieldErrors.push(err);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (fieldErrors.length) errors[field] = fieldErrors;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (Object.keys(errors).length > 0) {
|
|
226
|
+
throw new HttpError(422, 'Validation failed', errors);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Return only the validated fields
|
|
230
|
+
const validated = {};
|
|
231
|
+
for (const field of Object.keys(rules)) {
|
|
232
|
+
if (data[field] !== undefined) validated[field] = data[field];
|
|
233
|
+
}
|
|
234
|
+
return validated;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
_applyRule(field, value, rule, arg) {
|
|
238
|
+
const isEmpty = value === undefined || value === null || value === '';
|
|
239
|
+
|
|
240
|
+
switch (rule) {
|
|
241
|
+
case 'required':
|
|
242
|
+
if (isEmpty) return `${field} is required`;
|
|
243
|
+
break;
|
|
244
|
+
|
|
245
|
+
case 'optional':
|
|
246
|
+
if (isEmpty) return null; // skip further checks if optional and empty
|
|
247
|
+
break;
|
|
248
|
+
|
|
249
|
+
case 'string':
|
|
250
|
+
if (!isEmpty && typeof value !== 'string')
|
|
251
|
+
return `${field} must be a string`;
|
|
252
|
+
break;
|
|
253
|
+
|
|
254
|
+
case 'number':
|
|
255
|
+
if (!isEmpty && (isNaN(Number(value))))
|
|
256
|
+
return `${field} must be a number`;
|
|
257
|
+
break;
|
|
258
|
+
|
|
259
|
+
case 'boolean':
|
|
260
|
+
if (!isEmpty && !['true', 'false', true, false, '1', '0', 1, 0].includes(value))
|
|
261
|
+
return `${field} must be a boolean`;
|
|
262
|
+
break;
|
|
263
|
+
|
|
264
|
+
case 'email': {
|
|
265
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
266
|
+
if (!isEmpty && !emailRegex.test(value))
|
|
267
|
+
return `${field} must be a valid email address`;
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
case 'min':
|
|
272
|
+
if (!isEmpty) {
|
|
273
|
+
if (typeof value === 'string' && value.length < Number(arg))
|
|
274
|
+
return `${field} must be at least ${arg} characters`;
|
|
275
|
+
if (typeof value === 'number' && value < Number(arg))
|
|
276
|
+
return `${field} must be at least ${arg}`;
|
|
277
|
+
}
|
|
278
|
+
break;
|
|
279
|
+
|
|
280
|
+
case 'max':
|
|
281
|
+
if (!isEmpty) {
|
|
282
|
+
if (typeof value === 'string' && value.length > Number(arg))
|
|
283
|
+
return `${field} must not exceed ${arg} characters`;
|
|
284
|
+
if (typeof value === 'number' && value > Number(arg))
|
|
285
|
+
return `${field} must not exceed ${arg}`;
|
|
286
|
+
}
|
|
287
|
+
break;
|
|
288
|
+
|
|
289
|
+
case 'in': {
|
|
290
|
+
const allowed = arg.split(',');
|
|
291
|
+
if (!isEmpty && !allowed.includes(String(value)))
|
|
292
|
+
return `${field} must be one of: ${allowed.join(', ')}`;
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
case 'alpha':
|
|
297
|
+
if (!isEmpty && !/^[a-zA-Z]+$/.test(value))
|
|
298
|
+
return `${field} must contain only letters`;
|
|
299
|
+
break;
|
|
300
|
+
|
|
301
|
+
case 'alphanumeric':
|
|
302
|
+
if (!isEmpty && !/^[a-zA-Z0-9]+$/.test(value))
|
|
303
|
+
return `${field} must contain only letters and numbers`;
|
|
304
|
+
break;
|
|
305
|
+
|
|
306
|
+
case 'url':
|
|
307
|
+
try {
|
|
308
|
+
if (!isEmpty) new URL(value);
|
|
309
|
+
} catch {
|
|
310
|
+
return `${field} must be a valid URL`;
|
|
311
|
+
}
|
|
312
|
+
break;
|
|
313
|
+
|
|
314
|
+
case 'confirmed': {
|
|
315
|
+
// Expects a matching field named `${field}_confirmation` in the request
|
|
316
|
+
// We store the raw req data on the instance during validate()
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
default:
|
|
321
|
+
// Unknown rule — silently ignore (extensible in future)
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ─── Pagination Helper ───────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Build a pagination envelope for list responses.
|
|
332
|
+
*
|
|
333
|
+
* return this.paginate(res, {
|
|
334
|
+
* data: users,
|
|
335
|
+
* total: 100,
|
|
336
|
+
* page: 2,
|
|
337
|
+
* perPage: 15,
|
|
338
|
+
* });
|
|
339
|
+
*/
|
|
340
|
+
paginate(res, { data, total, page = 1, perPage = 15 }) {
|
|
341
|
+
const lastPage = Math.ceil(total / perPage);
|
|
342
|
+
return res.status(200).json({
|
|
343
|
+
data,
|
|
344
|
+
meta: {
|
|
345
|
+
total,
|
|
346
|
+
per_page: perPage,
|
|
347
|
+
current_page: Number(page),
|
|
348
|
+
last_page: lastPage,
|
|
349
|
+
from: (page - 1) * perPage + 1,
|
|
350
|
+
to: Math.min(page * perPage, total),
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ─── Internal ────────────────────────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
_envelope(status, data) {
|
|
358
|
+
if (data === null) return { status };
|
|
359
|
+
// If data already has a recognised top-level shape, return as-is
|
|
360
|
+
if (typeof data === 'object' && !Array.isArray(data) && ('data' in data || 'message' in data)) {
|
|
361
|
+
return { status, ...data };
|
|
362
|
+
}
|
|
363
|
+
return { status, data };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
module.exports = Controller;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HttpError
|
|
5
|
+
*
|
|
6
|
+
* A structured error that carries an HTTP status code.
|
|
7
|
+
* Thrown by Controller.abort() and the validation system.
|
|
8
|
+
* Caught automatically by the Router's global error handler.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* throw new HttpError(404, 'User not found')
|
|
12
|
+
* throw new HttpError(422, 'Validation failed', { email: ['email is required'] })
|
|
13
|
+
*/
|
|
14
|
+
class HttpError extends Error {
|
|
15
|
+
/**
|
|
16
|
+
* @param {number} status HTTP status code
|
|
17
|
+
* @param {string} message Human-readable message
|
|
18
|
+
* @param {object} errors Optional field-level errors (for validation)
|
|
19
|
+
*/
|
|
20
|
+
constructor(status = 500, message = 'Internal Server Error', errors = null) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'HttpError';
|
|
23
|
+
this.status = status;
|
|
24
|
+
this.statusCode = status;
|
|
25
|
+
this.errors = errors;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = HttpError;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Event
|
|
5
|
+
*
|
|
6
|
+
* Base class for all Millas events.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* class UserRegistered extends Event {
|
|
10
|
+
* constructor(user) {
|
|
11
|
+
* super();
|
|
12
|
+
* this.user = user;
|
|
13
|
+
* this.timestamp = new Date();
|
|
14
|
+
* }
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* // Fire the event
|
|
18
|
+
* await emit(new UserRegistered(user));
|
|
19
|
+
*/
|
|
20
|
+
class Event {
|
|
21
|
+
constructor() {
|
|
22
|
+
this._name = this.constructor.name;
|
|
23
|
+
this._timestamp = new Date().toISOString();
|
|
24
|
+
this._stopped = false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Stop event propagation — subsequent listeners won't be called.
|
|
29
|
+
*/
|
|
30
|
+
stopPropagation() {
|
|
31
|
+
this._stopped = true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get name() { return this._name; }
|
|
35
|
+
get timestamp() { return this._timestamp; }
|
|
36
|
+
get stopped() { return this._stopped; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = Event;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* EventEmitter — Millas event bus.
|
|
5
|
+
*/
|
|
6
|
+
class EventEmitter {
|
|
7
|
+
constructor() {
|
|
8
|
+
this._listeners = new Map();
|
|
9
|
+
this._wildcards = [];
|
|
10
|
+
this._queue = null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
listen(event, listeners) {
|
|
14
|
+
const name = this._name(event);
|
|
15
|
+
if (!this._listeners.has(name)) this._listeners.set(name, []);
|
|
16
|
+
const list = Array.isArray(listeners) ? listeners : [listeners];
|
|
17
|
+
for (const l of list) this._listeners.get(name).push({ handler: l, once: false });
|
|
18
|
+
return this;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
on(event, fn) { return this.listen(event, fn); }
|
|
22
|
+
|
|
23
|
+
once(event, fn) {
|
|
24
|
+
const name = this._name(event);
|
|
25
|
+
if (!this._listeners.has(name)) this._listeners.set(name, []);
|
|
26
|
+
this._listeners.get(name).push({ handler: fn, once: true });
|
|
27
|
+
return this;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
off(event, fn) {
|
|
31
|
+
const name = this._name(event);
|
|
32
|
+
if (!this._listeners.has(name)) return this;
|
|
33
|
+
this._listeners.set(name, this._listeners.get(name).filter(l => l.handler !== fn));
|
|
34
|
+
return this;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
onWildcard(pattern, fn) {
|
|
38
|
+
const rx = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
|
|
39
|
+
this._wildcards.push({ rx, handler: fn });
|
|
40
|
+
return this;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async emit(event, data = {}) {
|
|
44
|
+
let ev = event;
|
|
45
|
+
if (typeof event === 'string') {
|
|
46
|
+
const Ev = require('./Event');
|
|
47
|
+
ev = Object.assign(new Ev(), { _name: event, ...data });
|
|
48
|
+
}
|
|
49
|
+
const name = ev._name || ev.constructor?.name || String(event);
|
|
50
|
+
const entries = [...(this._listeners.get(name) || [])];
|
|
51
|
+
const remove = [];
|
|
52
|
+
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (ev.stopped) break;
|
|
55
|
+
await this._invoke(entry.handler, ev);
|
|
56
|
+
if (entry.once) remove.push(entry);
|
|
57
|
+
}
|
|
58
|
+
if (remove.length) {
|
|
59
|
+
this._listeners.set(name, (this._listeners.get(name) || []).filter(l => !remove.includes(l)));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const { rx, handler } of this._wildcards) {
|
|
63
|
+
if (ev.stopped) break;
|
|
64
|
+
if (rx.test(name)) await this._invoke(handler, ev);
|
|
65
|
+
}
|
|
66
|
+
return ev;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
emitAsync(event, data = {}) {
|
|
70
|
+
Promise.resolve(this.emit(event, data)).catch(err =>
|
|
71
|
+
console.error('[EventEmitter] Unhandled error:', err.message)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
hasListeners(event) { return (this._listeners.get(this._name(event)) || []).length > 0; }
|
|
76
|
+
getListeners(event) { return (this._listeners.get(this._name(event)) || []).map(l => l.handler); }
|
|
77
|
+
removeAll(event) { this._listeners.delete(this._name(event)); return this; }
|
|
78
|
+
flush() { this._listeners.clear(); this._wildcards = []; return this; }
|
|
79
|
+
setQueue(queue) { this._queue = queue; }
|
|
80
|
+
|
|
81
|
+
async _invoke(handler, event) {
|
|
82
|
+
// Listener class (has handle() on prototype)
|
|
83
|
+
if (typeof handler === 'function' && typeof handler.prototype?.handle === 'function') {
|
|
84
|
+
const inst = new handler();
|
|
85
|
+
if (handler.queue && this._queue) {
|
|
86
|
+
const Job = require('../queue/Job');
|
|
87
|
+
const q = this._queue;
|
|
88
|
+
class LJob extends Job {
|
|
89
|
+
async handle() { await inst.handle(event); }
|
|
90
|
+
async failed(e) { if (typeof inst.failed === 'function') await inst.failed(event, e); }
|
|
91
|
+
}
|
|
92
|
+
LJob.queue = handler.queueName || 'default';
|
|
93
|
+
await q.push(new LJob());
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
try { await inst.handle(event); }
|
|
97
|
+
catch (e) {
|
|
98
|
+
if (typeof inst.failed === 'function') await inst.failed(event, e);
|
|
99
|
+
else throw e;
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// Instantiated listener object
|
|
104
|
+
if (handler && typeof handler === 'object' && typeof handler.handle === 'function') {
|
|
105
|
+
await handler.handle(event); return;
|
|
106
|
+
}
|
|
107
|
+
// Raw function
|
|
108
|
+
if (typeof handler === 'function') { await handler(event); return; }
|
|
109
|
+
throw new Error('Invalid listener: ' + handler);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
_name(e) {
|
|
113
|
+
if (typeof e === 'string') return e;
|
|
114
|
+
if (typeof e === 'function') return e.name;
|
|
115
|
+
return e?._name || e?.constructor?.name || String(e);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Singleton ─────────────────────────────────────────────────────────────────
|
|
120
|
+
const _inst = new EventEmitter();
|
|
121
|
+
|
|
122
|
+
// Standalone emit function — IMPORTANT: does NOT use module.exports.emit
|
|
123
|
+
// to avoid the circular reference where module.exports === _inst so
|
|
124
|
+
// _inst.emit gets overwritten by the wrapper.
|
|
125
|
+
async function emit(event, data) {
|
|
126
|
+
// Call the class method bound to _inst, bypassing any property overwrites
|
|
127
|
+
return EventEmitter.prototype.emit.call(_inst, event, data);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Export a plain wrapper object (NOT the singleton itself) to prevent
|
|
131
|
+
// module.exports.emit from writing back onto _inst.
|
|
132
|
+
module.exports = {
|
|
133
|
+
// Proxy all EventEmitter instance methods to _inst
|
|
134
|
+
listen: (...a) => _inst.listen(...a),
|
|
135
|
+
on: (...a) => _inst.on(...a),
|
|
136
|
+
once: (...a) => _inst.once(...a),
|
|
137
|
+
off: (...a) => _inst.off(...a),
|
|
138
|
+
onWildcard: (...a) => _inst.onWildcard(...a),
|
|
139
|
+
emit: (...a) => EventEmitter.prototype.emit.call(_inst, ...a),
|
|
140
|
+
emitAsync: (...a) => _inst.emitAsync(...a),
|
|
141
|
+
hasListeners:(...a) => _inst.hasListeners(...a),
|
|
142
|
+
getListeners:(...a) => _inst.getListeners(...a),
|
|
143
|
+
removeAll: (...a) => _inst.removeAll(...a),
|
|
144
|
+
flush: () => _inst.flush(),
|
|
145
|
+
setQueue: (q) => _inst.setQueue(q),
|
|
146
|
+
// Named exports
|
|
147
|
+
EventEmitter,
|
|
148
|
+
emit,
|
|
149
|
+
// Expose singleton for advanced use
|
|
150
|
+
_instance: _inst,
|
|
151
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Listener
|
|
5
|
+
*
|
|
6
|
+
* Base class for all event listeners.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* class SendWelcomeEmail extends Listener {
|
|
10
|
+
* static queue = true; // run this listener via the queue (Phase 9)
|
|
11
|
+
*
|
|
12
|
+
* async handle(event) {
|
|
13
|
+
* await Mail.send({
|
|
14
|
+
* to: event.user.email,
|
|
15
|
+
* subject: 'Welcome!',
|
|
16
|
+
* template: 'welcome',
|
|
17
|
+
* data: { name: event.user.name },
|
|
18
|
+
* });
|
|
19
|
+
* }
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* Register:
|
|
23
|
+
* EventEmitter.listen(UserRegistered, [SendWelcomeEmail, NotifyAdmin]);
|
|
24
|
+
*/
|
|
25
|
+
class Listener {
|
|
26
|
+
/**
|
|
27
|
+
* Whether to run this listener via the queue.
|
|
28
|
+
* Set to true for slow operations (email, notifications, etc.)
|
|
29
|
+
*/
|
|
30
|
+
static queue = false;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Handle the event.
|
|
34
|
+
* @param {Event} event
|
|
35
|
+
*/
|
|
36
|
+
async handle(event) {
|
|
37
|
+
throw new Error(`${this.constructor.name} must implement handle(event)`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Called when the listener fails.
|
|
42
|
+
*/
|
|
43
|
+
async failed(event, error) {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = Listener;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const emitterModule = require('./EventEmitter');
|
|
4
|
+
const EventEmitter = emitterModule.EventEmitter
|
|
5
|
+
? emitterModule // if default export is singleton
|
|
6
|
+
: emitterModule;
|
|
7
|
+
const Event = require('./Event');
|
|
8
|
+
const Listener = require('./Listener');
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
EventEmitter: emitterModule,
|
|
12
|
+
Event,
|
|
13
|
+
Listener,
|
|
14
|
+
emit: emitterModule.emit,
|
|
15
|
+
};
|