mastercontroller 1.2.14 → 1.3.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/MasterControl.js +148 -101
- package/MasterCors.js +29 -0
- package/MasterPipeline.js +344 -0
- package/MasterRouter.js +44 -22
- package/MasterSession.js +19 -0
- package/MasterTimeout.js +332 -0
- package/README.md +1496 -36
- package/docs/timeout-and-error-handling.md +712 -0
- package/error/MasterErrorRenderer.js +529 -0
- package/package.json +5 -5
- package/security/SecurityMiddleware.js +73 -1
package/MasterRouter.js
CHANGED
|
@@ -246,88 +246,110 @@ var loadScopedListClasses = function(){
|
|
|
246
246
|
};
|
|
247
247
|
|
|
248
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Normalize route path: lowercase segments but preserve param names
|
|
251
|
+
*
|
|
252
|
+
* @param {String} path - Route path like "/Period/:periodId/Items/:itemId"
|
|
253
|
+
* @returns {String} - Normalized: "period/:periodId/items/:itemId"
|
|
254
|
+
*/
|
|
255
|
+
function normalizeRoutePath(path) {
|
|
256
|
+
const trimmed = path.replace(/^\/|\/$/g, '');
|
|
257
|
+
const segments = trimmed.split('/');
|
|
258
|
+
|
|
259
|
+
const normalized = segments.map(segment => {
|
|
260
|
+
// Preserve parameter names (start with :)
|
|
261
|
+
if (segment.startsWith(':')) {
|
|
262
|
+
return segment;
|
|
263
|
+
}
|
|
264
|
+
// Lowercase path segments
|
|
265
|
+
return segment.toLowerCase();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
return normalized.join('/');
|
|
269
|
+
}
|
|
270
|
+
|
|
249
271
|
class MasterRouter {
|
|
250
272
|
currentRouteName = null
|
|
251
273
|
_routes = {}
|
|
252
|
-
|
|
274
|
+
|
|
253
275
|
start(){
|
|
254
276
|
var $that = this;
|
|
255
277
|
return {
|
|
256
278
|
route : function(path, toPath, type, constraint){ // function to add to list of routes
|
|
257
|
-
|
|
279
|
+
|
|
258
280
|
var pathList = toPath.replace(/^\/|\/$/g, '').split("#");
|
|
259
|
-
|
|
281
|
+
|
|
260
282
|
var route = {
|
|
261
283
|
type: type.toLowerCase(),
|
|
262
|
-
path: path
|
|
284
|
+
path: normalizeRoutePath(path),
|
|
263
285
|
toController :pathList[0].replace(/^\/|\/$/g, ''),
|
|
264
286
|
toAction: pathList[1],
|
|
265
287
|
constraint : constraint
|
|
266
288
|
};
|
|
267
|
-
|
|
289
|
+
|
|
268
290
|
$that._routes[$that.currentRouteName].routes.push(route);
|
|
269
|
-
|
|
291
|
+
|
|
270
292
|
},
|
|
271
293
|
|
|
272
294
|
resources: function(routeName){ // function to add to list of routes using resources bulk
|
|
273
|
-
|
|
295
|
+
|
|
274
296
|
|
|
275
297
|
$that._routes[$that.currentRouteName].routes.push({
|
|
276
298
|
type: "get",
|
|
277
|
-
path: routeName
|
|
299
|
+
path: normalizeRoutePath(routeName),
|
|
278
300
|
toController :routeName,
|
|
279
301
|
toAction: "index",
|
|
280
302
|
constraint : null
|
|
281
303
|
});
|
|
282
|
-
|
|
304
|
+
|
|
283
305
|
$that._routes[$that.currentRouteName].routes.push({
|
|
284
306
|
type: "get",
|
|
285
|
-
path: routeName
|
|
307
|
+
path: normalizeRoutePath(routeName),
|
|
286
308
|
toController :routeName,
|
|
287
309
|
toAction: "new",
|
|
288
310
|
constraint : null
|
|
289
311
|
});
|
|
290
|
-
|
|
312
|
+
|
|
291
313
|
$that._routes[$that.currentRouteName].routes.push({
|
|
292
314
|
type: "post",
|
|
293
|
-
path: routeName
|
|
315
|
+
path: normalizeRoutePath(routeName),
|
|
294
316
|
toController :routeName,
|
|
295
317
|
toAction: "create",
|
|
296
318
|
constraint : null
|
|
297
319
|
});
|
|
298
|
-
|
|
320
|
+
|
|
299
321
|
$that._routes[$that.currentRouteName].routes.push({
|
|
300
322
|
// pages/3
|
|
301
323
|
type: "get",
|
|
302
|
-
path: routeName
|
|
324
|
+
path: normalizeRoutePath(routeName + "/:id"),
|
|
303
325
|
toController :routeName,
|
|
304
326
|
toAction: "show",
|
|
305
327
|
constraint : null
|
|
306
328
|
});
|
|
307
|
-
|
|
329
|
+
|
|
308
330
|
$that._routes[$that.currentRouteName].routes.push({
|
|
309
331
|
type: "get",
|
|
310
|
-
path: routeName
|
|
332
|
+
path: normalizeRoutePath(routeName + "/:id/edit"),
|
|
311
333
|
toController :routeName,
|
|
312
334
|
toAction: "edit",
|
|
313
|
-
constraint : null
|
|
335
|
+
constraint : null
|
|
314
336
|
});
|
|
315
|
-
|
|
337
|
+
|
|
316
338
|
$that._routes[$that.currentRouteName].routes.push({
|
|
317
339
|
type: "put",
|
|
318
|
-
path: routeName
|
|
340
|
+
path: normalizeRoutePath(routeName + "/:id"),
|
|
319
341
|
toController :routeName,
|
|
320
342
|
toAction: "update",
|
|
321
343
|
constraint : null
|
|
322
344
|
});
|
|
323
|
-
|
|
345
|
+
|
|
324
346
|
$that._routes[$that.currentRouteName].routes.push({
|
|
325
347
|
type: "delete",
|
|
326
|
-
path: routeName
|
|
348
|
+
path: normalizeRoutePath(routeName + "/:id"),
|
|
327
349
|
toController :routeName,
|
|
328
350
|
toAction: "destroy",
|
|
329
351
|
constraint : null
|
|
330
|
-
});
|
|
352
|
+
});
|
|
331
353
|
}
|
|
332
354
|
}
|
|
333
355
|
}
|
package/MasterSession.js
CHANGED
|
@@ -35,6 +35,11 @@ class MasterSession{
|
|
|
35
35
|
this.options.secret = TID;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// Auto-register with pipeline if available
|
|
39
|
+
if (master.pipeline) {
|
|
40
|
+
master.pipeline.use(this.middleware());
|
|
41
|
+
}
|
|
42
|
+
|
|
38
43
|
return {
|
|
39
44
|
setPath : function(path){
|
|
40
45
|
$that.options.path = path === undefined ? '/' : path;
|
|
@@ -184,6 +189,20 @@ class MasterSession{
|
|
|
184
189
|
return -1;
|
|
185
190
|
}
|
|
186
191
|
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Get session middleware for the pipeline
|
|
195
|
+
* Sessions are accessed lazily via master.sessions in controllers
|
|
196
|
+
*/
|
|
197
|
+
middleware() {
|
|
198
|
+
var $that = this;
|
|
199
|
+
|
|
200
|
+
return async (ctx, next) => {
|
|
201
|
+
// Sessions are available via master.sessions.get/set in controllers
|
|
202
|
+
// No action needed here - just continue pipeline
|
|
203
|
+
await next();
|
|
204
|
+
};
|
|
205
|
+
}
|
|
187
206
|
}
|
|
188
207
|
|
|
189
208
|
master.extend("sessions", MasterSession);
|
package/MasterTimeout.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MasterTimeout - Professional timeout system for MasterController
|
|
3
|
+
*
|
|
4
|
+
* Provides per-request timeout tracking with configurable options:
|
|
5
|
+
* - Global timeout (all requests)
|
|
6
|
+
* - Route-specific timeouts
|
|
7
|
+
* - Controller-level timeouts
|
|
8
|
+
* - Graceful cleanup on timeout
|
|
9
|
+
* - Detailed timeout logging
|
|
10
|
+
*
|
|
11
|
+
* Inspired by Rails ActionController::Timeout and Django MIDDLEWARE_TIMEOUT
|
|
12
|
+
*
|
|
13
|
+
* @version 1.0.0
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
var master = require('./MasterControl');
|
|
17
|
+
const { logger } = require('./error/MasterErrorLogger');
|
|
18
|
+
|
|
19
|
+
class MasterTimeout {
|
|
20
|
+
constructor() {
|
|
21
|
+
this.globalTimeout = 120000; // 120 seconds default
|
|
22
|
+
this.routeTimeouts = new Map();
|
|
23
|
+
this.activeRequests = new Map();
|
|
24
|
+
this.timeoutHandlers = [];
|
|
25
|
+
this.enabled = true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Initialize timeout system
|
|
30
|
+
*
|
|
31
|
+
* @param {Object} options - Configuration options
|
|
32
|
+
* @param {Number} options.globalTimeout - Default timeout in ms (default: 120000)
|
|
33
|
+
* @param {Boolean} options.enabled - Enable/disable timeouts (default: true)
|
|
34
|
+
* @param {Function} options.onTimeout - Custom timeout handler
|
|
35
|
+
*/
|
|
36
|
+
init(options = {}) {
|
|
37
|
+
if (options.globalTimeout) {
|
|
38
|
+
this.globalTimeout = options.globalTimeout;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (options.enabled !== undefined) {
|
|
42
|
+
this.enabled = options.enabled;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (options.onTimeout && typeof options.onTimeout === 'function') {
|
|
46
|
+
this.timeoutHandlers.push(options.onTimeout);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
logger.info({
|
|
50
|
+
code: 'MC_TIMEOUT_INIT',
|
|
51
|
+
message: 'Timeout system initialized',
|
|
52
|
+
globalTimeout: this.globalTimeout,
|
|
53
|
+
enabled: this.enabled
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return this;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Set timeout for specific route pattern
|
|
61
|
+
*
|
|
62
|
+
* @param {String} routePattern - Route pattern (e.g., '/api/*', '/admin/reports')
|
|
63
|
+
* @param {Number} timeout - Timeout in milliseconds
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* master.timeout.setRouteTimeout('/api/*', 30000); // 30 seconds for APIs
|
|
67
|
+
* master.timeout.setRouteTimeout('/admin/reports', 300000); // 5 minutes for reports
|
|
68
|
+
*/
|
|
69
|
+
setRouteTimeout(routePattern, timeout) {
|
|
70
|
+
if (typeof timeout !== 'number' || timeout <= 0) {
|
|
71
|
+
throw new Error('Timeout must be a positive number in milliseconds');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this.routeTimeouts.set(routePattern, timeout);
|
|
75
|
+
|
|
76
|
+
logger.info({
|
|
77
|
+
code: 'MC_TIMEOUT_ROUTE_SET',
|
|
78
|
+
message: 'Route timeout configured',
|
|
79
|
+
routePattern,
|
|
80
|
+
timeout
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return this;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get timeout for request based on route
|
|
88
|
+
* Priority: Route-specific > Global
|
|
89
|
+
*
|
|
90
|
+
* @param {String} path - Request path
|
|
91
|
+
* @returns {Number} - Timeout in milliseconds
|
|
92
|
+
*/
|
|
93
|
+
getTimeoutForPath(path) {
|
|
94
|
+
// Check route-specific timeouts
|
|
95
|
+
for (const [pattern, timeout] of this.routeTimeouts.entries()) {
|
|
96
|
+
if (this._pathMatches(path, pattern)) {
|
|
97
|
+
return timeout;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Return global timeout
|
|
102
|
+
return this.globalTimeout;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Start timeout tracking for request
|
|
107
|
+
*
|
|
108
|
+
* @param {Object} ctx - Request context
|
|
109
|
+
* @returns {String} - Request ID
|
|
110
|
+
*/
|
|
111
|
+
startTracking(ctx) {
|
|
112
|
+
if (!this.enabled) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const requestId = this._generateRequestId();
|
|
117
|
+
const timeout = this.getTimeoutForPath(ctx.pathName || ctx.request.url);
|
|
118
|
+
const startTime = Date.now();
|
|
119
|
+
|
|
120
|
+
const timer = setTimeout(() => {
|
|
121
|
+
this._handleTimeout(requestId, ctx, startTime);
|
|
122
|
+
}, timeout);
|
|
123
|
+
|
|
124
|
+
this.activeRequests.set(requestId, {
|
|
125
|
+
timer,
|
|
126
|
+
timeout,
|
|
127
|
+
startTime,
|
|
128
|
+
path: ctx.pathName || ctx.request.url,
|
|
129
|
+
method: ctx.type || ctx.request.method.toLowerCase()
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Attach cleanup to response finish
|
|
133
|
+
ctx.response.once('finish', () => {
|
|
134
|
+
this.stopTracking(requestId);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
ctx.response.once('close', () => {
|
|
138
|
+
this.stopTracking(requestId);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return requestId;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Stop timeout tracking for request
|
|
146
|
+
*
|
|
147
|
+
* @param {String} requestId - Request ID
|
|
148
|
+
*/
|
|
149
|
+
stopTracking(requestId) {
|
|
150
|
+
const tracked = this.activeRequests.get(requestId);
|
|
151
|
+
|
|
152
|
+
if (tracked) {
|
|
153
|
+
clearTimeout(tracked.timer);
|
|
154
|
+
this.activeRequests.delete(requestId);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Handle request timeout
|
|
160
|
+
*
|
|
161
|
+
* @private
|
|
162
|
+
*/
|
|
163
|
+
_handleTimeout(requestId, ctx, startTime) {
|
|
164
|
+
const tracked = this.activeRequests.get(requestId);
|
|
165
|
+
|
|
166
|
+
if (!tracked) {
|
|
167
|
+
return; // Already cleaned up
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const duration = Date.now() - startTime;
|
|
171
|
+
|
|
172
|
+
// Log timeout
|
|
173
|
+
logger.error({
|
|
174
|
+
code: 'MC_REQUEST_TIMEOUT',
|
|
175
|
+
message: 'Request timeout exceeded',
|
|
176
|
+
requestId,
|
|
177
|
+
path: tracked.path,
|
|
178
|
+
method: tracked.method,
|
|
179
|
+
timeout: tracked.timeout,
|
|
180
|
+
duration
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Call custom handlers
|
|
184
|
+
for (const handler of this.timeoutHandlers) {
|
|
185
|
+
try {
|
|
186
|
+
handler(ctx, {
|
|
187
|
+
requestId,
|
|
188
|
+
path: tracked.path,
|
|
189
|
+
method: tracked.method,
|
|
190
|
+
timeout: tracked.timeout,
|
|
191
|
+
duration
|
|
192
|
+
});
|
|
193
|
+
} catch (err) {
|
|
194
|
+
logger.error({
|
|
195
|
+
code: 'MC_TIMEOUT_HANDLER_ERROR',
|
|
196
|
+
message: 'Timeout handler threw error',
|
|
197
|
+
error: err.message
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Send timeout response if not already sent
|
|
203
|
+
if (!ctx.response.headersSent) {
|
|
204
|
+
ctx.response.statusCode = 504; // Gateway Timeout
|
|
205
|
+
ctx.response.setHeader('Content-Type', 'application/json');
|
|
206
|
+
ctx.response.end(JSON.stringify({
|
|
207
|
+
error: 'Request Timeout',
|
|
208
|
+
message: 'The server did not receive a complete request within the allowed time',
|
|
209
|
+
code: 'MC_REQUEST_TIMEOUT',
|
|
210
|
+
timeout: tracked.timeout
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Cleanup
|
|
215
|
+
this.stopTracking(requestId);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Get middleware function for pipeline
|
|
220
|
+
*
|
|
221
|
+
* @returns {Function} - Middleware function
|
|
222
|
+
*/
|
|
223
|
+
middleware() {
|
|
224
|
+
const $that = this;
|
|
225
|
+
|
|
226
|
+
return async (ctx, next) => {
|
|
227
|
+
if (!$that.enabled) {
|
|
228
|
+
await next();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const requestId = $that.startTracking(ctx);
|
|
233
|
+
ctx.requestId = requestId;
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
await next();
|
|
237
|
+
} catch (err) {
|
|
238
|
+
// Stop tracking on error
|
|
239
|
+
$that.stopTracking(requestId);
|
|
240
|
+
throw err;
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Disable timeouts (useful for debugging)
|
|
247
|
+
*/
|
|
248
|
+
disable() {
|
|
249
|
+
this.enabled = false;
|
|
250
|
+
logger.info({
|
|
251
|
+
code: 'MC_TIMEOUT_DISABLED',
|
|
252
|
+
message: 'Timeout system disabled'
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Enable timeouts
|
|
258
|
+
*/
|
|
259
|
+
enable() {
|
|
260
|
+
this.enabled = true;
|
|
261
|
+
logger.info({
|
|
262
|
+
code: 'MC_TIMEOUT_ENABLED',
|
|
263
|
+
message: 'Timeout system enabled'
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Get current timeout statistics
|
|
269
|
+
*
|
|
270
|
+
* @returns {Object} - Timeout stats
|
|
271
|
+
*/
|
|
272
|
+
getStats() {
|
|
273
|
+
return {
|
|
274
|
+
enabled: this.enabled,
|
|
275
|
+
globalTimeout: this.globalTimeout,
|
|
276
|
+
routeTimeouts: Array.from(this.routeTimeouts.entries()).map(([pattern, timeout]) => ({
|
|
277
|
+
pattern,
|
|
278
|
+
timeout
|
|
279
|
+
})),
|
|
280
|
+
activeRequests: this.activeRequests.size,
|
|
281
|
+
requests: Array.from(this.activeRequests.entries()).map(([id, data]) => ({
|
|
282
|
+
requestId: id,
|
|
283
|
+
path: data.path,
|
|
284
|
+
method: data.method,
|
|
285
|
+
timeout: data.timeout,
|
|
286
|
+
elapsed: Date.now() - data.startTime,
|
|
287
|
+
remaining: data.timeout - (Date.now() - data.startTime)
|
|
288
|
+
}))
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Check if path matches pattern
|
|
294
|
+
*
|
|
295
|
+
* @private
|
|
296
|
+
*/
|
|
297
|
+
_pathMatches(path, pattern) {
|
|
298
|
+
if (typeof pattern === 'string') {
|
|
299
|
+
// Normalize paths
|
|
300
|
+
const normalizedPath = '/' + path.replace(/^\/|\/$/g, '');
|
|
301
|
+
const normalizedPattern = '/' + pattern.replace(/^\/|\/$/g, '');
|
|
302
|
+
|
|
303
|
+
// Wildcard support
|
|
304
|
+
if (normalizedPattern.endsWith('/*')) {
|
|
305
|
+
const prefix = normalizedPattern.slice(0, -2);
|
|
306
|
+
return normalizedPath === prefix || normalizedPath.startsWith(prefix + '/');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Exact match
|
|
310
|
+
return normalizedPath === normalizedPattern;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (pattern instanceof RegExp) {
|
|
314
|
+
return pattern.test(path);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Generate unique request ID
|
|
322
|
+
*
|
|
323
|
+
* @private
|
|
324
|
+
*/
|
|
325
|
+
_generateRequestId() {
|
|
326
|
+
return `req_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
master.extend("timeout", MasterTimeout);
|
|
331
|
+
|
|
332
|
+
module.exports = MasterTimeout;
|