mastercontroller 1.2.14 → 1.3.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/.claude/settings.local.json +2 -1
- package/MasterControl.js +150 -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/MasterTools.js +40 -13
- package/README.md +1632 -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/security/SessionSecurity.js +99 -2
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// MasterPipeline - Middleware Pipeline System
|
|
2
|
+
// version 1.0
|
|
3
|
+
|
|
4
|
+
var master = require('./MasterControl');
|
|
5
|
+
const { logger } = require('./error/MasterErrorLogger');
|
|
6
|
+
|
|
7
|
+
class MasterPipeline {
|
|
8
|
+
constructor() {
|
|
9
|
+
this.middleware = [];
|
|
10
|
+
this.errorHandlers = [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Use: Add middleware that processes request/response
|
|
15
|
+
*
|
|
16
|
+
* Middleware signature: async (ctx, next) => { await next(); }
|
|
17
|
+
* - ctx: Request context { request, response, params, state, ... }
|
|
18
|
+
* - next: Function to call next middleware in chain
|
|
19
|
+
*
|
|
20
|
+
* Example:
|
|
21
|
+
* master.use(async (ctx, next) => {
|
|
22
|
+
* console.log('Before');
|
|
23
|
+
* await next();
|
|
24
|
+
* console.log('After');
|
|
25
|
+
* });
|
|
26
|
+
*
|
|
27
|
+
* @param {Function} middleware - Middleware function
|
|
28
|
+
* @returns {MasterPipeline} - For chaining
|
|
29
|
+
*/
|
|
30
|
+
use(middleware) {
|
|
31
|
+
if (typeof middleware !== 'function') {
|
|
32
|
+
throw new Error('Middleware must be a function');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
this.middleware.push({
|
|
36
|
+
type: 'use',
|
|
37
|
+
handler: middleware,
|
|
38
|
+
path: null
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return this; // Chainable
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Run: Add terminal middleware that ends the pipeline
|
|
46
|
+
*
|
|
47
|
+
* Terminal middleware signature: async (ctx) => { ... send response ... }
|
|
48
|
+
* - Does NOT call next()
|
|
49
|
+
* - Must send response
|
|
50
|
+
*
|
|
51
|
+
* Example:
|
|
52
|
+
* master.run(async (ctx) => {
|
|
53
|
+
* ctx.response.end('Hello World');
|
|
54
|
+
* });
|
|
55
|
+
*
|
|
56
|
+
* @param {Function} middleware - Terminal middleware function
|
|
57
|
+
* @returns {MasterPipeline} - For chaining
|
|
58
|
+
*/
|
|
59
|
+
run(middleware) {
|
|
60
|
+
if (typeof middleware !== 'function') {
|
|
61
|
+
throw new Error('Terminal middleware must be a function');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.middleware.push({
|
|
65
|
+
type: 'run',
|
|
66
|
+
handler: middleware,
|
|
67
|
+
path: null
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return this; // Chainable
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Map: Conditionally execute middleware based on path
|
|
75
|
+
*
|
|
76
|
+
* Map signature: (path, configure)
|
|
77
|
+
* - path: String or RegExp to match request path
|
|
78
|
+
* - configure: Function that receives a branch pipeline
|
|
79
|
+
*
|
|
80
|
+
* Example:
|
|
81
|
+
* master.map('/api/*', (api) => {
|
|
82
|
+
* api.use(authMiddleware);
|
|
83
|
+
* api.use(jsonMiddleware);
|
|
84
|
+
* });
|
|
85
|
+
*
|
|
86
|
+
* @param {String|RegExp} path - Path pattern to match
|
|
87
|
+
* @param {Function} configure - Function to configure branch pipeline
|
|
88
|
+
* @returns {MasterPipeline} - For chaining
|
|
89
|
+
*/
|
|
90
|
+
map(path, configure) {
|
|
91
|
+
if (typeof configure !== 'function') {
|
|
92
|
+
throw new Error('Map configuration must be a function');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Create sub-pipeline for this branch
|
|
96
|
+
const branch = new MasterPipeline();
|
|
97
|
+
configure(branch);
|
|
98
|
+
|
|
99
|
+
// Wrap branch in conditional middleware
|
|
100
|
+
const conditionalMiddleware = async (ctx, next) => {
|
|
101
|
+
const requestPath = ctx.pathName || ctx.request.url;
|
|
102
|
+
|
|
103
|
+
if (this._pathMatches(requestPath, path)) {
|
|
104
|
+
// Execute branch pipeline
|
|
105
|
+
await branch.execute(ctx);
|
|
106
|
+
// After branch completes, continue main pipeline
|
|
107
|
+
await next();
|
|
108
|
+
} else {
|
|
109
|
+
// Skip branch, continue main pipeline
|
|
110
|
+
await next();
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
this.middleware.push({
|
|
115
|
+
type: 'map',
|
|
116
|
+
handler: conditionalMiddleware,
|
|
117
|
+
path: path
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return this; // Chainable
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* UseError: Add error handling middleware
|
|
125
|
+
*
|
|
126
|
+
* Error middleware signature: async (error, ctx, next) => { }
|
|
127
|
+
* - error: The caught error
|
|
128
|
+
* - ctx: Request context
|
|
129
|
+
* - next: Pass to next error handler or rethrow
|
|
130
|
+
*
|
|
131
|
+
* Example:
|
|
132
|
+
* master.useError(async (err, ctx, next) => {
|
|
133
|
+
* if (err.statusCode === 404) {
|
|
134
|
+
* ctx.response.statusCode = 404;
|
|
135
|
+
* ctx.response.end('Not Found');
|
|
136
|
+
* } else {
|
|
137
|
+
* await next(); // Pass to next error handler
|
|
138
|
+
* }
|
|
139
|
+
* });
|
|
140
|
+
*
|
|
141
|
+
* @param {Function} handler - Error handler function
|
|
142
|
+
* @returns {MasterPipeline} - For chaining
|
|
143
|
+
*/
|
|
144
|
+
useError(handler) {
|
|
145
|
+
if (typeof handler !== 'function') {
|
|
146
|
+
throw new Error('Error handler must be a function');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
this.errorHandlers.push(handler);
|
|
150
|
+
return this; // Chainable
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Execute: Run the middleware pipeline for a request
|
|
155
|
+
*
|
|
156
|
+
* Called internally by the framework for each request
|
|
157
|
+
*
|
|
158
|
+
* @param {Object} context - Request context
|
|
159
|
+
*/
|
|
160
|
+
async execute(context) {
|
|
161
|
+
let index = 0;
|
|
162
|
+
|
|
163
|
+
// Create the next function for middleware chain
|
|
164
|
+
const next = async () => {
|
|
165
|
+
// If we've run all middleware, we're done
|
|
166
|
+
if (index >= this.middleware.length) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const current = this.middleware[index++];
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
if (current.type === 'run') {
|
|
174
|
+
// Terminal middleware - don't pass next
|
|
175
|
+
await current.handler(context);
|
|
176
|
+
} else {
|
|
177
|
+
// Regular middleware - pass next
|
|
178
|
+
await current.handler(context, next);
|
|
179
|
+
}
|
|
180
|
+
} catch (error) {
|
|
181
|
+
// Error occurred, run error handlers
|
|
182
|
+
await this._handleError(error, context);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// Start the pipeline
|
|
187
|
+
await next();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Handle errors through error handler chain
|
|
192
|
+
*
|
|
193
|
+
* @param {Error} error - The error that occurred
|
|
194
|
+
* @param {Object} context - Request context
|
|
195
|
+
*/
|
|
196
|
+
async _handleError(error, context) {
|
|
197
|
+
let errorIndex = 0;
|
|
198
|
+
|
|
199
|
+
const nextError = async () => {
|
|
200
|
+
if (errorIndex >= this.errorHandlers.length) {
|
|
201
|
+
// No more error handlers, log and send generic error
|
|
202
|
+
logger.error({
|
|
203
|
+
code: 'MC_ERR_UNHANDLED',
|
|
204
|
+
message: 'Unhandled error in middleware pipeline',
|
|
205
|
+
error: error.message,
|
|
206
|
+
stack: error.stack
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (!context.response.headersSent) {
|
|
210
|
+
context.response.statusCode = 500;
|
|
211
|
+
context.response.end('Internal Server Error');
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const handler = this.errorHandlers[errorIndex++];
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
await handler(error, context, nextError);
|
|
220
|
+
} catch (handlerError) {
|
|
221
|
+
// Error in error handler
|
|
222
|
+
logger.error({
|
|
223
|
+
code: 'MC_ERR_ERROR_HANDLER_FAILED',
|
|
224
|
+
message: 'Error handler threw an error',
|
|
225
|
+
error: handlerError.message
|
|
226
|
+
});
|
|
227
|
+
await nextError();
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
await nextError();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Check if request path matches the map path pattern
|
|
236
|
+
*
|
|
237
|
+
* @param {String} requestPath - The request path
|
|
238
|
+
* @param {String|RegExp} pattern - The pattern to match
|
|
239
|
+
* @returns {Boolean} - True if matches
|
|
240
|
+
*/
|
|
241
|
+
_pathMatches(requestPath, pattern) {
|
|
242
|
+
// Normalize paths (ensure leading slash)
|
|
243
|
+
requestPath = '/' + requestPath.replace(/^\/|\/$/g, '');
|
|
244
|
+
|
|
245
|
+
if (typeof pattern === 'string') {
|
|
246
|
+
pattern = '/' + pattern.replace(/^\/|\/$/g, '');
|
|
247
|
+
|
|
248
|
+
// Wildcard support: /api/* matches /api/users, /api/posts, etc.
|
|
249
|
+
if (pattern.endsWith('/*')) {
|
|
250
|
+
const prefix = pattern.slice(0, -2);
|
|
251
|
+
return requestPath === prefix || requestPath.startsWith(prefix + '/');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Exact or prefix match
|
|
255
|
+
return requestPath === pattern || requestPath.startsWith(pattern + '/');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (pattern instanceof RegExp) {
|
|
259
|
+
return pattern.test(requestPath);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Discover and load middleware from folders
|
|
267
|
+
*
|
|
268
|
+
* @param {String|Object} options - Folder path or options object
|
|
269
|
+
*/
|
|
270
|
+
discoverMiddleware(options) {
|
|
271
|
+
const fs = require('fs');
|
|
272
|
+
const path = require('path');
|
|
273
|
+
|
|
274
|
+
const folders = typeof options === 'string'
|
|
275
|
+
? [options]
|
|
276
|
+
: (options.folders || ['middleware']);
|
|
277
|
+
|
|
278
|
+
folders.forEach(folder => {
|
|
279
|
+
const dir = path.join(master.root, folder);
|
|
280
|
+
if (!fs.existsSync(dir)) {
|
|
281
|
+
console.warn(`[Middleware] Folder not found: ${folder}`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const files = fs.readdirSync(dir)
|
|
286
|
+
.filter(file => file.endsWith('.js'))
|
|
287
|
+
.sort(); // Alphabetical order
|
|
288
|
+
|
|
289
|
+
files.forEach(file => {
|
|
290
|
+
try {
|
|
291
|
+
const middlewarePath = path.join(dir, file);
|
|
292
|
+
const middleware = require(middlewarePath);
|
|
293
|
+
|
|
294
|
+
// Support two patterns:
|
|
295
|
+
// Pattern 1: module.exports = async (ctx, next) => {}
|
|
296
|
+
if (typeof middleware === 'function') {
|
|
297
|
+
this.use(middleware);
|
|
298
|
+
}
|
|
299
|
+
// Pattern 2: module.exports = { register: (master) => {} }
|
|
300
|
+
else if (middleware.register && typeof middleware.register === 'function') {
|
|
301
|
+
middleware.register(master);
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
console.warn(`[Middleware] Invalid export in ${folder}/${file}`);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
console.log(`[Middleware] Loaded: ${folder}/${file}`);
|
|
309
|
+
} catch (err) {
|
|
310
|
+
console.error(`[Middleware] Failed to load ${folder}/${file}:`, err.message);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Clear all middleware (useful for testing)
|
|
318
|
+
*/
|
|
319
|
+
clear() {
|
|
320
|
+
this.middleware = [];
|
|
321
|
+
this.errorHandlers = [];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Inspect pipeline (for debugging)
|
|
326
|
+
*
|
|
327
|
+
* @returns {Object} - Pipeline information
|
|
328
|
+
*/
|
|
329
|
+
inspect() {
|
|
330
|
+
return {
|
|
331
|
+
middlewareCount: this.middleware.length,
|
|
332
|
+
errorHandlerCount: this.errorHandlers.length,
|
|
333
|
+
middleware: this.middleware.map((m, i) => ({
|
|
334
|
+
index: i,
|
|
335
|
+
type: m.type,
|
|
336
|
+
path: m.path,
|
|
337
|
+
name: m.handler.name || 'anonymous'
|
|
338
|
+
}))
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Register with master
|
|
344
|
+
master.extend("pipeline", MasterPipeline);
|
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);
|