mastercontroller 1.3.13 → 1.3.15

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/MasterSocket.js CHANGED
@@ -3,11 +3,118 @@
3
3
  const { Server } = require('socket.io');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
+ const { logger } = require('./error/MasterErrorLogger');
6
7
 
7
- var jsUcfirst = function(string){
8
+ // Socket Configuration Constants
9
+ const SOCKET_CONFIG = {
10
+ DEFAULT_TIMEOUT: 30000, // 30 seconds
11
+ MAX_EVENT_NAME_LENGTH: 255,
12
+ MAX_PAYLOAD_SIZE: 10 * 1024 * 1024 // 10MB
13
+ };
14
+
15
+ // Socket Event Names
16
+ const SOCKET_EVENTS = {
17
+ CONNECTION: 'connection',
18
+ DISCONNECT: 'disconnect',
19
+ ERROR: 'error',
20
+ CONNECT_ERROR: 'connect_error'
21
+ };
22
+
23
+ // Transport Types
24
+ const TRANSPORT_TYPES = {
25
+ WEBSOCKET: 'websocket',
26
+ POLLING: 'polling'
27
+ };
28
+
29
+ // HTTP Methods for CORS
30
+ const HTTP_METHODS = {
31
+ GET: 'GET',
32
+ POST: 'POST',
33
+ PUT: 'PUT',
34
+ DELETE: 'DELETE'
35
+ };
36
+
37
+ /**
38
+ * Validate controller/action name for security
39
+ *
40
+ * @param {string} name - Name to validate
41
+ * @param {string} type - Type (controller or action)
42
+ * @throws {Error} If name is invalid
43
+ */
44
+ function validateSocketIdentifier(name, type) {
45
+ if (!name || typeof name !== 'string') {
46
+ throw new TypeError(`${type} name must be a non-empty string`);
47
+ }
48
+
49
+ // Must be valid JavaScript identifier (no path traversal, no special chars)
50
+ if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name)) {
51
+ throw new Error(`Invalid ${type} name: ${name}. Must be a valid identifier.`);
52
+ }
53
+
54
+ // Check for path traversal attempts
55
+ if (name.includes('..') || name.includes('/') || name.includes('\\')) {
56
+ throw new Error(`Security violation: ${type} name contains path traversal characters`);
57
+ }
58
+
59
+ // Check max length
60
+ if (name.length > SOCKET_CONFIG.MAX_EVENT_NAME_LENGTH) {
61
+ throw new Error(`${type} name exceeds maximum length (${SOCKET_CONFIG.MAX_EVENT_NAME_LENGTH})`);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Validate socket data array
67
+ *
68
+ * @param {Array} data - Data array to validate
69
+ * @throws {Error} If data is invalid
70
+ */
71
+ function validateSocketData(data) {
72
+ if (!Array.isArray(data)) {
73
+ throw new TypeError('Socket data must be an array');
74
+ }
75
+
76
+ if (data.length < 2) {
77
+ throw new Error('Socket data must contain [action, payload]');
78
+ }
79
+
80
+ const [action, payload] = data;
81
+
82
+ if (!action || typeof action !== 'string') {
83
+ throw new TypeError('Socket action (data[0]) must be a non-empty string');
84
+ }
85
+
86
+ validateSocketIdentifier(action, 'action');
87
+
88
+ // Check payload size (prevent DoS)
89
+ const payloadStr = JSON.stringify(payload || {});
90
+ if (payloadStr.length > SOCKET_CONFIG.MAX_PAYLOAD_SIZE) {
91
+ throw new Error(`Payload exceeds maximum size (${SOCKET_CONFIG.MAX_PAYLOAD_SIZE} bytes)`);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Uppercase first character of a string
97
+ *
98
+ * @param {string} string - String to transform
99
+ * @returns {string} String with first character uppercased
100
+ * @example
101
+ * jsUcfirst("hello") // Returns: "Hello"
102
+ */
103
+ const jsUcfirst = function(string){
8
104
  return string.charAt(0).toUpperCase() + string.slice(1);
9
105
  };
10
106
 
107
+ /**
108
+ * MasterSocket - WebSocket management with Socket.IO
109
+ *
110
+ * Handles:
111
+ * - Socket.IO server initialization with CORS
112
+ * - Event routing to socket controllers
113
+ * - Cross-platform socket module loading
114
+ * - Automatic CORS configuration from config/initializers/cors.json
115
+ *
116
+ * @class MasterSocket
117
+ */
11
118
  class MasterSocket{
12
119
 
13
120
  // Lazy-load master to avoid circular dependency (Google-style lazy initialization)
@@ -18,7 +125,43 @@ class MasterSocket{
18
125
  return this.__masterCache;
19
126
  }
20
127
 
128
+ /**
129
+ * Initialize Socket.IO server
130
+ *
131
+ * Supports three initialization patterns:
132
+ * 1. Pass HTTP server: init(server, options)
133
+ * 2. Pass Socket.IO instance: init(io)
134
+ * 3. Use master server: init(undefined, options) or init()
135
+ *
136
+ * Automatically loads CORS config from config/initializers/cors.json
137
+ *
138
+ * @param {Server|Object} [serverOrIo] - HTTP server or Socket.IO instance
139
+ * @param {Object} [options={}] - Socket.IO options to merge with defaults
140
+ * @param {Object} [options.cors] - CORS configuration
141
+ * @param {string|string[]|boolean} [options.cors.origin] - Allowed origins
142
+ * @param {boolean} [options.cors.credentials] - Allow credentials
143
+ * @param {string[]} [options.cors.methods] - Allowed HTTP methods
144
+ * @param {string[]} [options.transports] - Transport types ['websocket', 'polling']
145
+ * @throws {Error} If no HTTP server is available
146
+ * @returns {void}
147
+ *
148
+ * @example
149
+ * // Pattern 1: Pass server explicitly
150
+ * this._master.socket.init(server, { cors: { origin: 'https://app.com' }});
151
+ *
152
+ * // Pattern 2: Pass pre-configured Socket.IO instance
153
+ * const io = new Server(server, opts);
154
+ * this._master.socket.init(io);
155
+ *
156
+ * // Pattern 3: Use master server (call after this._master.start(server))
157
+ * this._master.socket.init();
158
+ */
21
159
  init(serverOrIo, options = {}){
160
+ // Input validation
161
+ if (options !== undefined && (typeof options !== 'object' || options === null || Array.isArray(options))) {
162
+ throw new TypeError('Socket options must be an object');
163
+ }
164
+
22
165
  this._baseurl = this._master.root;
23
166
 
24
167
  // Build Socket.IO options using master cors initializer when available
@@ -46,9 +189,24 @@ class MasterSocket{
46
189
  this._bind();
47
190
  }
48
191
 
192
+ /**
193
+ * Build default Socket.IO options with CORS configuration
194
+ *
195
+ * Loads CORS settings from config/initializers/cors.json if available.
196
+ * Falls back to sensible defaults for development.
197
+ *
198
+ * @private
199
+ * @returns {Object} Socket.IO options object
200
+ * @returns {Object} result.cors - CORS configuration
201
+ * @returns {string[]} result.transports - Transport types
202
+ *
203
+ * @example
204
+ * const opts = this._buildDefaultIoOptions();
205
+ * // Returns: { cors: { origin: true, credentials: true, methods: ['GET', 'POST'] }, transports: ['websocket', 'polling'] }
206
+ */
49
207
  _buildDefaultIoOptions(){
50
208
  const corsCfg = this._loadCorsConfig();
51
- const transports = ['websocket', 'polling'];
209
+ const transports = [TRANSPORT_TYPES.WEBSOCKET, TRANSPORT_TYPES.POLLING];
52
210
  const cors = {};
53
211
  try {
54
212
  if (corsCfg) {
@@ -60,12 +218,27 @@ class MasterSocket{
60
218
  // sensible defaults for dev
61
219
  cors.origin = true;
62
220
  cors.credentials = true;
63
- cors.methods = ['GET','POST'];
221
+ cors.methods = [HTTP_METHODS.GET, HTTP_METHODS.POST];
64
222
  }
65
223
  } catch (_) {}
66
224
  return { cors, transports };
67
225
  }
68
226
 
227
+ /**
228
+ * Load CORS configuration from config/initializers/cors.json
229
+ *
230
+ * @private
231
+ * @returns {Object|null} CORS configuration object or null if not found
232
+ * @returns {string|string[]|boolean} result.origin - Allowed origins
233
+ * @returns {boolean} result.credentials - Allow credentials
234
+ * @returns {string[]} result.methods - Allowed HTTP methods
235
+ * @returns {string[]} result.allowedHeaders - Allowed request headers
236
+ *
237
+ * @example
238
+ * const corsCfg = this._loadCorsConfig();
239
+ * // Returns: { origin: ['https://app.com'], credentials: true, methods: ['GET', 'POST'] }
240
+ * // or null if file not found
241
+ */
69
242
  _loadCorsConfig(){
70
243
  try {
71
244
  const cfgPath = path.join(this._master.root, 'config', 'initializers', 'cors.json');
@@ -74,52 +247,183 @@ class MasterSocket{
74
247
  return JSON.parse(raw);
75
248
  }
76
249
  } catch (e) {
77
- try { console.warn('[MasterSocket] Failed to load cors.json:', e && e.message ? e.message : e); } catch(_){}
250
+ logger.warn({
251
+ code: 'MC_SOCKET_CORS_LOAD_FAILED',
252
+ message: 'Failed to load cors.json configuration',
253
+ error: e.message,
254
+ path: cfgPath
255
+ });
78
256
  }
79
257
  return null;
80
258
  }
81
259
 
260
+ /**
261
+ * Bind Socket.IO event handlers
262
+ *
263
+ * Sets up connection handler and routes all socket events through
264
+ * the MasterSocket.load() method for controller dispatch.
265
+ *
266
+ * @private
267
+ * @returns {void}
268
+ *
269
+ * @example
270
+ * // Called internally by init()
271
+ * this._bind();
272
+ */
82
273
  _bind(){
83
274
  const io = this.io;
84
- io.on('connection', (socket) => {
275
+ io.on(SOCKET_EVENTS.CONNECTION, (socket) => {
85
276
  try{
277
+ logger.info({
278
+ code: 'MC_SOCKET_CONNECTED',
279
+ message: 'Socket client connected',
280
+ socketId: socket.id,
281
+ controller: socket.handshake?.query?.socket
282
+ });
283
+
86
284
  // Route all events through MasterSocket loader
87
285
  socket.onAny((eventName, payload) => {
88
286
  try{
89
287
  // MasterSocket.load expects [action, payload]
90
288
  const data = [eventName, payload];
91
- if (master && this._master.socket && typeof this._master.socket.load === 'function') {
289
+ // CRITICAL FIX: Use this._master instead of undefined 'master'
290
+ if (this._master && this._master.socket && typeof this._master.socket.load === 'function') {
92
291
  this._master.socket.load(data, socket, io);
93
292
  }
94
293
  }catch(e){
95
- try { console.error('Socket routing error:', e?.message || e); } catch(_){}
294
+ logger.error({
295
+ code: 'MC_SOCKET_ROUTING_ERROR',
296
+ message: 'Socket event routing failed',
297
+ socketId: socket.id,
298
+ eventName,
299
+ error: e.message,
300
+ stack: e.stack
301
+ });
96
302
  }
97
303
  });
304
+
305
+ // MEMORY LEAK FIX: Add disconnect handler for cleanup
306
+ socket.on(SOCKET_EVENTS.DISCONNECT, (reason) => {
307
+ try {
308
+ logger.info({
309
+ code: 'MC_SOCKET_DISCONNECTED',
310
+ message: 'Socket client disconnected',
311
+ socketId: socket.id,
312
+ reason,
313
+ controller: socket.handshake?.query?.socket
314
+ });
315
+
316
+ // Clean up event listeners
317
+ socket.offAny();
318
+ socket.removeAllListeners();
319
+ } catch (cleanupError) {
320
+ logger.error({
321
+ code: 'MC_SOCKET_CLEANUP_ERROR',
322
+ message: 'Socket cleanup failed',
323
+ socketId: socket.id,
324
+ error: cleanupError.message
325
+ });
326
+ }
327
+ });
328
+
329
+ // Handle socket errors
330
+ socket.on(SOCKET_EVENTS.ERROR, (error) => {
331
+ logger.error({
332
+ code: 'MC_SOCKET_ERROR',
333
+ message: 'Socket error occurred',
334
+ socketId: socket.id,
335
+ error: error.message || error
336
+ });
337
+ });
338
+
98
339
  }catch(e){
99
- try { console.error('Socket connection handler error:', e?.message || e); } catch(_){}
340
+ logger.error({
341
+ code: 'MC_SOCKET_CONNECTION_ERROR',
342
+ message: 'Socket connection handler failed',
343
+ error: e.message,
344
+ stack: e.stack
345
+ });
100
346
  }
101
347
  });
102
348
  }
103
349
 
350
+ /**
351
+ * Load and execute socket controller action
352
+ *
353
+ * Routes socket events to appropriate controller actions. Supports:
354
+ * - PascalCase and camelCase controller names (cross-platform)
355
+ * - Before action filters via callBeforeAction()
356
+ * - Automatic error handling and logging
357
+ *
358
+ * Controller file location: {root}/app/sockets/{Controller}Socket.js
359
+ * Controller must be specified in socket.handshake.query.socket
360
+ *
361
+ * @param {Array} data - [eventName, payload] tuple
362
+ * @param {string} data[0] - Action/event name to call
363
+ * @param {*} data[1] - Payload to pass to action
364
+ * @param {Socket} socket - Socket.IO socket instance
365
+ * @param {Server} io - Socket.IO server instance
366
+ * @returns {Promise<void>}
367
+ * @throws {Error} If controller not found or action fails
368
+ *
369
+ * @example
370
+ * // Client emits: socket.emit('updateBoard', { boardId: 123 })
371
+ * // Routes to: app/sockets/BoardSocket.js -> updateBoard(payload, socket, io)
372
+ * await this.load(['updateBoard', { boardId: 123 }], socket, io);
373
+ */
104
374
  async load(data, socket, io){
105
- var controller = jsUcfirst(socket.handshake.query.socket);
375
+ try {
376
+ // Input validation
377
+ validateSocketData(data);
378
+
379
+ if (!socket || typeof socket !== 'object') {
380
+ throw new TypeError('Socket parameter must be a valid Socket.IO socket object');
381
+ }
382
+
383
+ if (!io || typeof io !== 'object') {
384
+ throw new TypeError('IO parameter must be a valid Socket.IO server object');
385
+ }
386
+
387
+ // Validate socket has required properties
388
+ if (!socket.handshake || !socket.handshake.query) {
389
+ throw new Error('Socket handshake.query is required');
390
+ }
391
+
392
+ const controllerName = socket.handshake.query.socket;
393
+
394
+ if (!controllerName) {
395
+ logger.warn({
396
+ code: 'MC_SOCKET_NO_CONTROLLER',
397
+ message: 'Socket connection missing controller name in handshake.query.socket'
398
+ });
399
+ socket.emit(SOCKET_EVENTS.ERROR, {
400
+ error: 'Missing controller name',
401
+ code: 'MC_SOCKET_NO_CONTROLLER'
402
+ });
403
+ return;
404
+ }
405
+
406
+ validateSocketIdentifier(controllerName, 'controller');
407
+
408
+ const controller = jsUcfirst(controllerName);
409
+
106
410
  if(controller){
107
411
  try{
108
412
  // Try case-sensitive first (PascalCase), then fallback to camelCase for cross-platform compatibility
109
- var moduleName = this._baseurl + "/app/sockets/" + controller + "Socket";
110
- var BoardSocket;
413
+ const moduleName = path.join(this._baseurl, 'app', 'sockets', controller + 'Socket');
414
+ let BoardSocket;
111
415
  try {
112
416
  BoardSocket = require(moduleName);
113
417
  } catch (e) {
114
418
  // If PascalCase fails (Linux case-sensitive), try camelCase
115
419
  if (e.code === 'MODULE_NOT_FOUND') {
116
- var camelCaseModuleName = this._baseurl + "/app/sockets/" + controller.charAt(0).toLowerCase() + controller.slice(1) + "Socket";
420
+ const camelCaseModuleName = path.join(this._baseurl, 'app', 'sockets', controller.charAt(0).toLowerCase() + controller.slice(1) + 'Socket');
117
421
  BoardSocket = require(camelCaseModuleName);
118
422
  } else {
119
423
  throw e;
120
424
  }
121
425
  }
122
- var bs = new BoardSocket();
426
+ const bs = new BoardSocket();
123
427
  bs.request = socket.request;
124
428
  bs.response = socket.response;
125
429
  bs.namespace = (controller).toLowerCase();
@@ -136,22 +440,83 @@ class MasterSocket{
136
440
  await bs.callBeforeAction(data);
137
441
  }
138
442
 
443
+ // Check if action method exists
444
+ if (typeof bs[data[0]] !== 'function') {
445
+ throw new Error(`Action '${data[0]}' not found in socket controller ${controller}`);
446
+ }
447
+
139
448
  bs[data[0]](data[1], socket, io);
140
449
  }
141
450
  catch(ex){
142
- this._master.error.log(ex, "warn");
451
+ logger.error({
452
+ code: 'MC_SOCKET_LOAD_ERROR',
453
+ message: 'Socket controller load failed',
454
+ controller,
455
+ action: data[0],
456
+ error: ex.message,
457
+ stack: ex.stack
458
+ });
459
+
460
+ // Send error back to client
461
+ socket.emit(SOCKET_EVENTS.ERROR, {
462
+ error: 'Controller action failed',
463
+ code: 'MC_SOCKET_LOAD_ERROR',
464
+ action: data[0]
465
+ });
143
466
  }
144
467
 
145
468
  }
469
+ } catch (error) {
470
+ // Validation or unexpected errors
471
+ logger.error({
472
+ code: 'MC_SOCKET_VALIDATION_ERROR',
473
+ message: 'Socket load validation failed',
474
+ error: error.message,
475
+ stack: error.stack
476
+ });
477
+
478
+ if (socket && typeof socket.emit === 'function') {
479
+ socket.emit(SOCKET_EVENTS.ERROR, {
480
+ error: error.message,
481
+ code: 'MC_SOCKET_VALIDATION_ERROR'
482
+ });
483
+ }
484
+ }
146
485
  }
147
486
  }
148
487
 
149
488
  module.exports = { MasterSocket };
150
489
 
151
- // shallow+deep merge helper
490
+ /**
491
+ * Check if value is a plain object
492
+ *
493
+ * @param {*} item - Value to check
494
+ * @returns {boolean} True if plain object, false otherwise
495
+ * @example
496
+ * isObject({}) // true
497
+ * isObject([]) // false
498
+ * isObject(null) // false
499
+ */
152
500
  function isObject(item) {
153
501
  return (item && typeof item === 'object' && !Array.isArray(item));
154
502
  }
503
+
504
+ /**
505
+ * Deep merge two objects
506
+ *
507
+ * Recursively merges source into target, creating a new object.
508
+ * Arrays and primitives are replaced, objects are merged.
509
+ *
510
+ * @param {Object} target - Target object
511
+ * @param {Object} source - Source object to merge
512
+ * @returns {Object} New merged object
513
+ *
514
+ * @example
515
+ * const a = { cors: { origin: 'a' }, port: 3000 };
516
+ * const b = { cors: { credentials: true }, host: 'localhost' };
517
+ * mergeDeep(a, b);
518
+ * // Returns: { cors: { origin: 'a', credentials: true }, port: 3000, host: 'localhost' }
519
+ */
155
520
  function mergeDeep(target, source) {
156
521
  const output = Object.assign({}, target);
157
522
  if (isObject(target) && isObject(source)) {