mastercontroller 1.3.6 → 1.3.8

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.
@@ -0,0 +1,536 @@
1
+ /**
2
+ * MasterErrorRenderer - Professional error page rendering system
3
+ *
4
+ * Inspired by Rails ActionDispatch::ExceptionWrapper and Django error views
5
+ *
6
+ * Features:
7
+ * - Environment-specific rendering (dev vs production)
8
+ * - Dynamic error pages with template data
9
+ * - Multiple error codes (401, 403, 404, 422, 429, 500, 503, etc.)
10
+ * - Content negotiation (HTML vs JSON)
11
+ * - Custom error handlers
12
+ * - Template-based error pages
13
+ *
14
+ * @version 1.0.0
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { logger } = require('./MasterErrorLogger');
20
+
21
+ class MasterErrorRenderer {
22
+ constructor() {
23
+ this.errorTemplates = new Map();
24
+ this.customHandlers = new Map();
25
+ this.templateDir = null;
26
+ this.environment = 'development';
27
+ }
28
+
29
+ // Lazy-load master to avoid circular dependency (Google-style lazy initialization)
30
+ get _master() {
31
+ if (!this.__masterCache) {
32
+ this.__masterCache = require('../MasterControl');
33
+ }
34
+ return this.__masterCache;
35
+ }
36
+
37
+ /**
38
+ * Initialize error renderer
39
+ *
40
+ * @param {Object} options - Configuration options
41
+ * @param {String} options.templateDir - Directory for error templates (default: 'public/errors')
42
+ * @param {String} options.environment - Environment (development, production, test)
43
+ * @param {Boolean} options.showStackTrace - Show stack traces in dev (default: true in dev)
44
+ */
45
+ init(options = {}) {
46
+ this.templateDir = options.templateDir || path.join(this._master.root, 'public/errors');
47
+ this.environment = options.environment || this._master.environmentType || 'development';
48
+ this.showStackTrace = options.showStackTrace !== undefined
49
+ ? options.showStackTrace
50
+ : (this.environment === 'development');
51
+
52
+ // Create error templates directory if it doesn't exist
53
+ if (!fs.existsSync(this.templateDir)) {
54
+ fs.mkdirSync(this.templateDir, { recursive: true });
55
+ logger.info({
56
+ code: 'MC_ERROR_RENDERER_DIR_CREATED',
57
+ message: 'Created error templates directory',
58
+ dir: this.templateDir
59
+ });
60
+ }
61
+
62
+ // Load error templates
63
+ this._loadTemplates();
64
+
65
+ logger.info({
66
+ code: 'MC_ERROR_RENDERER_INIT',
67
+ message: 'Error renderer initialized',
68
+ templateDir: this.templateDir,
69
+ environment: this.environment,
70
+ showStackTrace: this.showStackTrace
71
+ });
72
+
73
+ return this;
74
+ }
75
+
76
+ /**
77
+ * Render error page
78
+ *
79
+ * @param {Object} ctx - Request context
80
+ * @param {Number} statusCode - HTTP status code
81
+ * @param {Object} errorData - Error data
82
+ * @returns {String} - Rendered HTML or JSON
83
+ */
84
+ render(ctx, statusCode, errorData = {}) {
85
+ const isApiRequest = this._isApiRequest(ctx);
86
+
87
+ if (isApiRequest) {
88
+ return this._renderJSON(statusCode, errorData);
89
+ } else {
90
+ return this._renderHTML(ctx, statusCode, errorData);
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Send error response
96
+ *
97
+ * @param {Object} ctx - Request context
98
+ * @param {Number} statusCode - HTTP status code
99
+ * @param {Object} errorData - Error data
100
+ */
101
+ send(ctx, statusCode, errorData = {}) {
102
+ const content = this.render(ctx, statusCode, errorData);
103
+ const isApiRequest = this._isApiRequest(ctx);
104
+
105
+ if (!ctx.response.headersSent) {
106
+ ctx.response.statusCode = statusCode;
107
+ ctx.response.setHeader('Content-Type', isApiRequest ? 'application/json' : 'text/html');
108
+ ctx.response.end(content);
109
+ }
110
+
111
+ // Log error
112
+ logger.error({
113
+ code: errorData.code || 'MC_HTTP_ERROR',
114
+ message: errorData.message || this._getDefaultMessage(statusCode),
115
+ statusCode,
116
+ path: ctx.pathName || ctx.request.url,
117
+ method: ctx.type || ctx.request.method.toLowerCase(),
118
+ stack: errorData.stack
119
+ });
120
+ }
121
+
122
+ /**
123
+ * Register custom error handler for specific status code
124
+ *
125
+ * @param {Number} statusCode - HTTP status code
126
+ * @param {Function} handler - Handler function (ctx, errorData) => String
127
+ *
128
+ * @example
129
+ * this._master.errorRenderer.registerHandler(404, (ctx, errorData) => {
130
+ * return `<html><body>Custom 404: ${errorData.message}</body></html>`;
131
+ * });
132
+ */
133
+ registerHandler(statusCode, handler) {
134
+ if (typeof handler !== 'function') {
135
+ throw new Error('Handler must be a function');
136
+ }
137
+
138
+ this.customHandlers.set(statusCode, handler);
139
+
140
+ logger.info({
141
+ code: 'MC_ERROR_HANDLER_REGISTERED',
142
+ message: 'Custom error handler registered',
143
+ statusCode
144
+ });
145
+
146
+ return this;
147
+ }
148
+
149
+ /**
150
+ * Render HTML error page
151
+ *
152
+ * @private
153
+ */
154
+ _renderHTML(ctx, statusCode, errorData) {
155
+ // Check for custom handler
156
+ if (this.customHandlers.has(statusCode)) {
157
+ try {
158
+ return this.customHandlers.get(statusCode)(ctx, errorData);
159
+ } catch (err) {
160
+ logger.error({
161
+ code: 'MC_ERROR_HANDLER_FAILED',
162
+ message: 'Custom error handler failed',
163
+ statusCode,
164
+ error: err.message
165
+ });
166
+ // Fall through to default rendering
167
+ }
168
+ }
169
+
170
+ // Check for template
171
+ const template = this._getTemplate(statusCode);
172
+ if (template) {
173
+ return this._renderTemplate(template, statusCode, errorData);
174
+ }
175
+
176
+ // Fallback to default error page
177
+ return this._renderDefaultHTML(statusCode, errorData);
178
+ }
179
+
180
+ /**
181
+ * Render JSON error response
182
+ *
183
+ * @private
184
+ */
185
+ _renderJSON(statusCode, errorData) {
186
+ const response = {
187
+ error: this._getDefaultMessage(statusCode),
188
+ statusCode: statusCode,
189
+ code: errorData.code || 'MC_HTTP_ERROR'
190
+ };
191
+
192
+ if (errorData.message) {
193
+ response.message = errorData.message;
194
+ }
195
+
196
+ if (this.showStackTrace && errorData.stack) {
197
+ response.stack = errorData.stack;
198
+ }
199
+
200
+ if (errorData.details) {
201
+ response.details = errorData.details;
202
+ }
203
+
204
+ if (errorData.suggestions) {
205
+ response.suggestions = errorData.suggestions;
206
+ }
207
+
208
+ return JSON.stringify(response, null, 2);
209
+ }
210
+
211
+ /**
212
+ * Render template with data
213
+ *
214
+ * @private
215
+ */
216
+ _renderTemplate(template, statusCode, errorData) {
217
+ let html = template;
218
+
219
+ const data = {
220
+ statusCode: statusCode,
221
+ title: this._getDefaultTitle(statusCode),
222
+ message: errorData.message || this._getDefaultMessage(statusCode),
223
+ description: errorData.description || '',
224
+ code: errorData.code || 'MC_HTTP_ERROR',
225
+ stack: this.showStackTrace && errorData.stack ? errorData.stack : null,
226
+ suggestions: errorData.suggestions || [],
227
+ path: errorData.path || '',
228
+ environment: this.environment,
229
+ showStackTrace: this.showStackTrace
230
+ };
231
+
232
+ // Simple template rendering (replace {{key}} with values)
233
+ for (const [key, value] of Object.entries(data)) {
234
+ const regex = new RegExp(`{{${key}}}`, 'g');
235
+ html = html.replace(regex, value || '');
236
+ }
237
+
238
+ // Handle conditionals {{#if showStackTrace}}...{{/if}}
239
+ html = this._processConditionals(html, data);
240
+
241
+ // Handle loops {{#each suggestions}}...{{/each}}
242
+ html = this._processLoops(html, data);
243
+
244
+ return html;
245
+ }
246
+
247
+ /**
248
+ * Process conditional blocks in template
249
+ *
250
+ * @private
251
+ */
252
+ _processConditionals(html, data) {
253
+ const conditionalRegex = /{{#if\s+(\w+)}}([\s\S]*?){{\/if}}/g;
254
+
255
+ return html.replace(conditionalRegex, (match, condition, content) => {
256
+ return data[condition] ? content : '';
257
+ });
258
+ }
259
+
260
+ /**
261
+ * Process loop blocks in template
262
+ *
263
+ * @private
264
+ */
265
+ _processLoops(html, data) {
266
+ const loopRegex = /{{#each\s+(\w+)}}([\s\S]*?){{\/each}}/g;
267
+
268
+ return html.replace(loopRegex, (match, arrayName, content) => {
269
+ const array = data[arrayName];
270
+ if (!Array.isArray(array)) {
271
+ return '';
272
+ }
273
+
274
+ return array.map(item => {
275
+ let itemHtml = content;
276
+ if (typeof item === 'string') {
277
+ itemHtml = itemHtml.replace(/{{this}}/g, item);
278
+ } else if (typeof item === 'object') {
279
+ for (const [key, value] of Object.entries(item)) {
280
+ itemHtml = itemHtml.replace(new RegExp(`{{${key}}}`, 'g'), value);
281
+ }
282
+ }
283
+ return itemHtml;
284
+ }).join('');
285
+ });
286
+ }
287
+
288
+ /**
289
+ * Render default HTML error page
290
+ *
291
+ * @private
292
+ */
293
+ _renderDefaultHTML(statusCode, errorData) {
294
+ const title = this._getDefaultTitle(statusCode);
295
+ const message = errorData.message || this._getDefaultMessage(statusCode);
296
+ const stack = this.showStackTrace && errorData.stack
297
+ ? `<pre style="background: #f5f5f5; padding: 1em; overflow: auto;">${this._escapeHtml(errorData.stack)}</pre>`
298
+ : '';
299
+
300
+ const suggestions = errorData.suggestions && errorData.suggestions.length > 0
301
+ ? `<div style="margin-top: 2em;">
302
+ <h3>Suggestions:</h3>
303
+ <ul>
304
+ ${errorData.suggestions.map(s => `<li>${this._escapeHtml(s)}</li>`).join('')}
305
+ </ul>
306
+ </div>`
307
+ : '';
308
+
309
+ return `<!DOCTYPE html>
310
+ <html>
311
+ <head>
312
+ <title>${title}</title>
313
+ <meta name="viewport" content="width=device-width,initial-scale=1">
314
+ <style>
315
+ body {
316
+ background-color: #f8f9fa;
317
+ color: #212529;
318
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
319
+ margin: 0;
320
+ padding: 2em;
321
+ }
322
+ .container {
323
+ max-width: 800px;
324
+ margin: 0 auto;
325
+ }
326
+ .error-box {
327
+ background: white;
328
+ border-left: 4px solid #dc3545;
329
+ border-radius: 4px;
330
+ padding: 2em;
331
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
332
+ }
333
+ h1 {
334
+ margin: 0 0 0.5em 0;
335
+ color: #dc3545;
336
+ font-size: 2em;
337
+ }
338
+ .status-code {
339
+ font-size: 4em;
340
+ font-weight: bold;
341
+ color: #dc3545;
342
+ margin: 0;
343
+ }
344
+ .message {
345
+ font-size: 1.2em;
346
+ margin: 1em 0;
347
+ }
348
+ .code {
349
+ background: #f8f9fa;
350
+ padding: 0.5em 1em;
351
+ border-radius: 4px;
352
+ font-family: monospace;
353
+ font-size: 0.9em;
354
+ margin: 1em 0;
355
+ }
356
+ pre {
357
+ background: #f5f5f5;
358
+ padding: 1em;
359
+ overflow: auto;
360
+ border-radius: 4px;
361
+ }
362
+ .footer {
363
+ margin-top: 2em;
364
+ text-align: center;
365
+ color: #6c757d;
366
+ font-size: 0.9em;
367
+ }
368
+ ul {
369
+ line-height: 1.6;
370
+ }
371
+ </style>
372
+ </head>
373
+ <body>
374
+ <div class="container">
375
+ <div class="error-box">
376
+ <p class="status-code">${statusCode}</p>
377
+ <h1>${title}</h1>
378
+ <p class="message">${this._escapeHtml(message)}</p>
379
+ ${errorData.code ? `<div class="code">Error Code: ${errorData.code}</div>` : ''}
380
+ ${suggestions}
381
+ ${stack}
382
+ </div>
383
+ <div class="footer">
384
+ <p>MasterController Framework • ${this.environment} environment</p>
385
+ </div>
386
+ </div>
387
+ </body>
388
+ </html>`;
389
+ }
390
+
391
+ /**
392
+ * Load error templates from disk
393
+ *
394
+ * @private
395
+ */
396
+ _loadTemplates() {
397
+ const statusCodes = [400, 401, 403, 404, 405, 422, 429, 500, 502, 503, 504];
398
+
399
+ for (const code of statusCodes) {
400
+ const templatePath = path.join(this.templateDir, `${code}.html`);
401
+
402
+ if (fs.existsSync(templatePath)) {
403
+ try {
404
+ const template = fs.readFileSync(templatePath, 'utf8');
405
+ this.errorTemplates.set(code, template);
406
+ logger.info({
407
+ code: 'MC_ERROR_TEMPLATE_LOADED',
408
+ message: 'Error template loaded',
409
+ statusCode: code,
410
+ path: templatePath
411
+ });
412
+ } catch (err) {
413
+ logger.error({
414
+ code: 'MC_ERROR_TEMPLATE_LOAD_FAILED',
415
+ message: 'Failed to load error template',
416
+ statusCode: code,
417
+ error: err.message
418
+ });
419
+ }
420
+ }
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Get template for status code
426
+ *
427
+ * @private
428
+ */
429
+ _getTemplate(statusCode) {
430
+ // Check exact match
431
+ if (this.errorTemplates.has(statusCode)) {
432
+ return this.errorTemplates.get(statusCode);
433
+ }
434
+
435
+ // Check category (4xx -> 400, 5xx -> 500)
436
+ const category = Math.floor(statusCode / 100) * 100;
437
+ if (this.errorTemplates.has(category)) {
438
+ return this.errorTemplates.get(category);
439
+ }
440
+
441
+ return null;
442
+ }
443
+
444
+ /**
445
+ * Check if request is API request
446
+ *
447
+ * @private
448
+ */
449
+ _isApiRequest(ctx) {
450
+ // Check Accept header
451
+ const accept = ctx.request.headers['accept'] || '';
452
+ if (accept.includes('application/json')) {
453
+ return true;
454
+ }
455
+
456
+ // Check path
457
+ const path = ctx.pathName || ctx.request.url;
458
+ if (path.startsWith('api/') || path.startsWith('/api/')) {
459
+ return true;
460
+ }
461
+
462
+ // Check Content-Type
463
+ const contentType = ctx.request.headers['content-type'] || '';
464
+ if (contentType.includes('application/json')) {
465
+ return true;
466
+ }
467
+
468
+ return false;
469
+ }
470
+
471
+ /**
472
+ * Get default title for status code
473
+ *
474
+ * @private
475
+ */
476
+ _getDefaultTitle(statusCode) {
477
+ const titles = {
478
+ 400: 'Bad Request',
479
+ 401: 'Unauthorized',
480
+ 403: 'Forbidden',
481
+ 404: 'Page Not Found',
482
+ 405: 'Method Not Allowed',
483
+ 422: 'Unprocessable Entity',
484
+ 429: 'Too Many Requests',
485
+ 500: 'Internal Server Error',
486
+ 502: 'Bad Gateway',
487
+ 503: 'Service Unavailable',
488
+ 504: 'Gateway Timeout'
489
+ };
490
+
491
+ return titles[statusCode] || `Error ${statusCode}`;
492
+ }
493
+
494
+ /**
495
+ * Get default message for status code
496
+ *
497
+ * @private
498
+ */
499
+ _getDefaultMessage(statusCode) {
500
+ const messages = {
501
+ 400: 'The request could not be understood by the server due to malformed syntax.',
502
+ 401: 'You need to be authenticated to access this resource.',
503
+ 403: 'You don\'t have permission to access this resource.',
504
+ 404: 'The page you were looking for doesn\'t exist.',
505
+ 405: 'The method specified in the request is not allowed for this resource.',
506
+ 422: 'The request was well-formed but contains invalid data.',
507
+ 429: 'Too many requests. Please slow down and try again later.',
508
+ 500: 'We\'re sorry, but something went wrong on our end.',
509
+ 502: 'The server received an invalid response from the upstream server.',
510
+ 503: 'The service is temporarily unavailable. Please try again later.',
511
+ 504: 'The server did not receive a timely response from the upstream server.'
512
+ };
513
+
514
+ return messages[statusCode] || 'An error occurred while processing your request.';
515
+ }
516
+
517
+ /**
518
+ * Escape HTML entities
519
+ *
520
+ * @private
521
+ */
522
+ _escapeHtml(text) {
523
+ if (!text) return '';
524
+ return text
525
+ .toString()
526
+ .replace(/&/g, '&amp;')
527
+ .replace(/</g, '&lt;')
528
+ .replace(/>/g, '&gt;')
529
+ .replace(/"/g, '&quot;')
530
+ .replace(/'/g, '&#039;');
531
+ }
532
+ }
533
+
534
+ module.exports = { MasterErrorRenderer };
535
+
536
+ module.exports = MasterErrorRenderer;
File without changes