@aetherframework/websocket 1.0.1 → 1.0.3

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,194 @@
1
+ /**
2
+ * @license MIT
3
+ * Copyright (c) 2026-present AetherFramework Contributors.
4
+ * SPDX-License-Identifier: MIT
5
+ * @module @aetherframework/src/middleware/compression
6
+ */
7
+
8
+ import { createDeflate, createInflate, constants } from 'zlib';
9
+
10
+ class Compression {
11
+ constructor(options = {}) {
12
+ this.options = {
13
+ level: options.level || constants.Z_DEFAULT_COMPRESSION,
14
+ memLevel: options.memLevel || 8,
15
+ chunkSize: options.chunkSize || 1024 * 16,
16
+ windowBits: options.windowBits || 15,
17
+ threshold: options.threshold || 1024, // Minimum size to compress
18
+ ...options
19
+ };
20
+
21
+ this.compressionEnabled = new Map(); // connectionId -> boolean
22
+ }
23
+
24
+ /**
25
+ * Compression middleware function
26
+ * @returns {Function} Middleware function
27
+ */
28
+ middleware() {
29
+ return async (connection, next) => {
30
+ // Check if compression is supported and enabled
31
+ const extensions = connection.headers['sec-websocket-extensions'];
32
+ if (extensions && extensions.includes('permessage-deflate')) {
33
+ this.compressionEnabled.set(connection.id, true);
34
+ connection.supportsCompression = true;
35
+ }
36
+
37
+ // Override send method to add compression
38
+ const originalSend = connection.send;
39
+ connection.send = (data, options = {}) => {
40
+ return this._sendCompressed(connection, data, options, originalSend);
41
+ };
42
+
43
+ // Override binary send method
44
+ const originalSendBinary = connection.sendBinary;
45
+ if (originalSendBinary) {
46
+ connection.sendBinary = (data) => {
47
+ return this._sendCompressed(connection, data, { binary: true }, originalSendBinary);
48
+ };
49
+ }
50
+
51
+ next();
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Send compressed data
57
+ * @param {Object} connection - WebSocket connection
58
+ * @param {string|Buffer} data - Data to send
59
+ * @param {Object} options - Send options
60
+ * @param {Function} originalSend - Original send function
61
+ * @returns {boolean} Success status
62
+ * @private
63
+ */
64
+ _sendCompressed(connection, data, options, originalSend) {
65
+ // Check if compression should be used
66
+ if (!this.compressionEnabled.get(connection.id) ||
67
+ (typeof data === 'string' && data.length < this.options.threshold) ||
68
+ (Buffer.isBuffer(data) && data.length < this.options.threshold)) {
69
+ return originalSend.call(connection, data, options);
70
+ }
71
+
72
+ try {
73
+ const isBinary = options.binary || Buffer.isBuffer(data);
74
+ const input = typeof data === 'string' ? Buffer.from(data, 'utf8') : data;
75
+
76
+ // Compress the data
77
+ const compressed = this._compress(input);
78
+
79
+ // Send with compression flag
80
+ return originalSend.call(connection, compressed, {
81
+ ...options,
82
+ compressed: true,
83
+ originalSize: input.length,
84
+ compressedSize: compressed.length
85
+ });
86
+ } catch (error) {
87
+ // Fall back to uncompressed
88
+ console.warn('Compression failed, falling back to uncompressed:', error.message);
89
+ return originalSend.call(connection, data, options);
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Compress data using DEFLATE
95
+ * @param {Buffer} data - Data to compress
96
+ * @returns {Buffer} Compressed data
97
+ * @private
98
+ */
99
+ _compress(data) {
100
+ return new Promise((resolve, reject) => {
101
+ const deflate = createDeflate({
102
+ level: this.options.level,
103
+ memLevel: this.options.memLevel,
104
+ chunkSize: this.options.chunkSize,
105
+ windowBits: this.options.windowBits
106
+ });
107
+
108
+ const chunks = [];
109
+ deflate.on('data', (chunk) => chunks.push(chunk));
110
+ deflate.on('end', () => resolve(Buffer.concat(chunks)));
111
+ deflate.on('error', reject);
112
+
113
+ deflate.write(data);
114
+ deflate.end();
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Decompress data
120
+ * @param {Buffer} data - Compressed data
121
+ * @returns {Buffer} Decompressed data
122
+ * @private
123
+ */
124
+ _decompress(data) {
125
+ return new Promise((resolve, reject) => {
126
+ const inflate = createInflate({
127
+ chunkSize: this.options.chunkSize,
128
+ windowBits: this.options.windowBits
129
+ });
130
+
131
+ const chunks = [];
132
+ inflate.on('data', (chunk) => chunks.push(chunk));
133
+ inflate.on('end', () => resolve(Buffer.concat(chunks)));
134
+ inflate.on('error', reject);
135
+
136
+ inflate.write(data);
137
+ inflate.end();
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Handle incoming compressed message
143
+ * @param {Object} connection - WebSocket connection
144
+ * @param {Buffer} data - Compressed data
145
+ * @param {boolean} isBinary - Whether data is binary
146
+ * @returns {Promise<Buffer|string>} Decompressed data
147
+ */
148
+ async handleIncoming(connection, data, isBinary) {
149
+ if (!this.compressionEnabled.get(connection.id)) {
150
+ return isBinary ? data : data.toString('utf8');
151
+ }
152
+
153
+ try {
154
+ const decompressed = await this._decompress(data);
155
+ return isBinary ? decompressed : decompressed.toString('utf8');
156
+ } catch (error) {
157
+ console.warn('Decompression failed:', error.message);
158
+ return isBinary ? data : data.toString('utf8');
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Enable compression for a connection
164
+ * @param {string} connectionId - Connection ID
165
+ */
166
+ enableForConnection(connectionId) {
167
+ this.compressionEnabled.set(connectionId, true);
168
+ }
169
+
170
+ /**
171
+ * Disable compression for a connection
172
+ * @param {string} connectionId - Connection ID
173
+ */
174
+ disableForConnection(connectionId) {
175
+ this.compressionEnabled.set(connectionId, false);
176
+ }
177
+
178
+ /**
179
+ * Get compression statistics
180
+ * @returns {Object} Compression statistics
181
+ */
182
+ getStats() {
183
+ const enabledConnections = Array.from(this.compressionEnabled.values())
184
+ .filter(enabled => enabled).length;
185
+
186
+ return {
187
+ enabledConnections,
188
+ totalConnections: this.compressionEnabled.size,
189
+ options: this.options
190
+ };
191
+ }
192
+ }
193
+
194
+ export default Compression;
@@ -0,0 +1,322 @@
1
+ /**
2
+ * @license MIT
3
+ * Copyright (c) 2026-present AetherFramework Contributors.
4
+ * SPDX-License-Identifier: MIT
5
+ * @module @aetherframework/src/middleware/message-logger
6
+ */
7
+
8
+ class MessageLogger {
9
+ constructor(options = {}) {
10
+ this.options = {
11
+ level: options.level || 'info', // 'debug', 'info', 'warn', 'error'
12
+ format: options.format || 'json', // 'json', 'text', 'simple'
13
+ maxMessageLength: options.maxMessageLength || 1000,
14
+ logConnect: options.logConnect !== false,
15
+ logDisconnect: options.logDisconnect !== false,
16
+ logMessages: options.logMessages !== false,
17
+ logErrors: options.logErrors !== false,
18
+ excludeMessages: options.excludeMessages || [],
19
+ includeMessages: options.includeMessages || null,
20
+ timestamp: options.timestamp !== false,
21
+ ...options
22
+ };
23
+
24
+ this.loggers = {
25
+ debug: console.debug || console.log,
26
+ info: console.info || console.log,
27
+ warn: console.warn || console.log,
28
+ error: console.error || console.log
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Message logging middleware function
34
+ * @returns {Function} Middleware function
35
+ */
36
+ middleware() {
37
+ return async (connection, next) => {
38
+ const connectionId = connection.id;
39
+ const remoteAddress = connection.remoteAddress || 'unknown';
40
+ const timestamp = new Date().toISOString();
41
+
42
+ // Log connection
43
+ if (this.options.logConnect) {
44
+ this._log('info', {
45
+ type: 'connect',
46
+ connectionId,
47
+ remoteAddress,
48
+ timestamp,
49
+ protocol: connection.protocol,
50
+ headers: this._sanitizeHeaders(connection.headers)
51
+ });
52
+ }
53
+
54
+ // Override send to log outgoing messages
55
+ const originalSend = connection.send;
56
+ connection.send = (data, options = {}) => {
57
+ const result = originalSend.call(connection, data, options);
58
+
59
+ if (result && this.options.logMessages && this._shouldLogMessage(data, 'outgoing')) {
60
+ this._logMessage('outgoing', connectionId, data, options);
61
+ }
62
+
63
+ return result;
64
+ };
65
+
66
+ // Listen for incoming messages
67
+ connection.on('message', (message, isBinary) => {
68
+ if (this.options.logMessages && this._shouldLogMessage(message, 'incoming')) {
69
+ this._logMessage('incoming', connectionId, message, { isBinary });
70
+ }
71
+ });
72
+
73
+ // Listen for errors
74
+ connection.on('error', (error) => {
75
+ if (this.options.logErrors) {
76
+ this._log('error', {
77
+ type: 'error',
78
+ connectionId,
79
+ remoteAddress,
80
+ timestamp: new Date().toISOString(),
81
+ error: error.message || String(error),
82
+ stack: error.stack
83
+ });
84
+ }
85
+ });
86
+
87
+ // Listen for disconnection
88
+ connection.on('close', (code, reason) => {
89
+ if (this.options.logDisconnect) {
90
+ this._log('info', {
91
+ type: 'disconnect',
92
+ connectionId,
93
+ remoteAddress,
94
+ timestamp: new Date().toISOString(),
95
+ code,
96
+ reason,
97
+ duration: Date.now() - connection.createdAt
98
+ });
99
+ }
100
+ });
101
+
102
+ next();
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Log a message
108
+ * @param {string} direction - 'incoming' or 'outgoing'
109
+ * @param {string} connectionId - Connection ID
110
+ * @param {any} message - Message content
111
+ * @param {Object} metadata - Additional metadata
112
+ * @private
113
+ */
114
+ _logMessage(direction, connectionId, message, metadata = {}) {
115
+ const logEntry = {
116
+ type: 'message',
117
+ direction,
118
+ connectionId,
119
+ timestamp: new Date().toISOString(),
120
+ message: this._formatMessage(message),
121
+ size: this._getMessageSize(message),
122
+ ...metadata
123
+ };
124
+
125
+ this._log('debug', logEntry);
126
+ }
127
+
128
+ /**
129
+ * Format message for logging
130
+ * @param {any} message - Message to format
131
+ * @returns {any} Formatted message
132
+ * @private
133
+ */
134
+ _formatMessage(message) {
135
+ if (Buffer.isBuffer(message)) {
136
+ return `<Buffer ${message.length} bytes>`;
137
+ }
138
+
139
+ if (typeof message === 'string') {
140
+ if (message.length > this.options.maxMessageLength) {
141
+ return message.substring(0, this.options.maxMessageLength) + '...';
142
+ }
143
+ return message;
144
+ }
145
+
146
+ if (typeof message === 'object') {
147
+ try {
148
+ const str = JSON.stringify(message);
149
+ if (str.length > this.options.maxMessageLength) {
150
+ return str.substring(0, this.options.maxMessageLength) + '...';
151
+ }
152
+ return JSON.parse(str); // Return parsed to maintain object structure
153
+ } catch {
154
+ return '[Circular or non-serializable object]';
155
+ }
156
+ }
157
+
158
+ return String(message);
159
+ }
160
+
161
+ /**
162
+ * Get message size in bytes
163
+ * @param {any} message - Message
164
+ * @returns {number} Size in bytes
165
+ * @private
166
+ */
167
+ _getMessageSize(message) {
168
+ if (Buffer.isBuffer(message)) {
169
+ return message.length;
170
+ }
171
+
172
+ if (typeof message === 'string') {
173
+ return Buffer.byteLength(message, 'utf8');
174
+ }
175
+
176
+ if (typeof message === 'object') {
177
+ try {
178
+ return Buffer.byteLength(JSON.stringify(message), 'utf8');
179
+ } catch {
180
+ return 0;
181
+ }
182
+ }
183
+
184
+ return Buffer.byteLength(String(message), 'utf8');
185
+ }
186
+
187
+ /**
188
+ * Check if a message should be logged
189
+ * @param {any} message - Message content
190
+ * @param {string} direction - Message direction
191
+ * @returns {boolean} Whether to log the message
192
+ * @private
193
+ */
194
+ _shouldLogMessage(message, direction) {
195
+ // Check exclude list
196
+ if (this.options.excludeMessages.length > 0) {
197
+ const messageStr = typeof message === 'string' ? message : JSON.stringify(message);
198
+ for (const pattern of this.options.excludeMessages) {
199
+ if (typeof pattern === 'string' && messageStr.includes(pattern)) {
200
+ return false;
201
+ }
202
+ if (pattern instanceof RegExp && pattern.test(messageStr)) {
203
+ return false;
204
+ }
205
+ }
206
+ }
207
+
208
+ // Check include list (if specified)
209
+ if (this.options.includeMessages && this.options.includeMessages.length > 0) {
210
+ const messageStr = typeof message === 'string' ? message : JSON.stringify(message);
211
+ for (const pattern of this.options.includeMessages) {
212
+ if (typeof pattern === 'string' && messageStr.includes(pattern)) {
213
+ return true;
214
+ }
215
+ if (pattern instanceof RegExp && pattern.test(messageStr)) {
216
+ return true;
217
+ }
218
+ }
219
+ return false;
220
+ }
221
+
222
+ return true;
223
+ }
224
+
225
+ /**
226
+ * Sanitize headers for logging
227
+ * @param {Object} headers - HTTP headers
228
+ * @returns {Object} Sanitized headers
229
+ * @private
230
+ */
231
+ _sanitizeHeaders(headers) {
232
+ if (!headers) return {};
233
+
234
+ const sanitized = { ...headers };
235
+
236
+ // Remove sensitive headers
237
+ const sensitiveHeaders = [
238
+ 'authorization',
239
+ 'cookie',
240
+ 'set-cookie',
241
+ 'proxy-authorization',
242
+ 'x-api-key',
243
+ 'x-auth-token'
244
+ ];
245
+
246
+ sensitiveHeaders.forEach(header => {
247
+ if (sanitized[header]) {
248
+ sanitized[header] = '[REDACTED]';
249
+ }
250
+ if (sanitized[header.toLowerCase()]) {
251
+ sanitized[header.toLowerCase()] = '[REDACTED]';
252
+ }
253
+ });
254
+
255
+ return sanitized;
256
+ }
257
+
258
+ /**
259
+ * Log with specified level
260
+ * @param {string} level - Log level
261
+ * @param {Object} data - Log data
262
+ * @private
263
+ */
264
+ _log(level, data) {
265
+ const logger = this.loggers[level];
266
+ if (!logger || !this._shouldLogLevel(level)) return;
267
+
268
+ const formatted = this._formatLogEntry(data);
269
+ logger(formatted);
270
+ }
271
+
272
+ /**
273
+ * Format log entry based on format option
274
+ * @param {Object} data - Log data
275
+ * @returns {string} Formatted log entry
276
+ * @private
277
+ */
278
+ _formatLogEntry(data) {
279
+ switch (this.options.format) {
280
+ case 'json':
281
+ return JSON.stringify(data, null, 2);
282
+
283
+ case 'text':
284
+ const timestamp = this.options.timestamp ? `[${data.timestamp || new Date().toISOString()}] ` : '';
285
+ const type = data.type ? `[${data.type.toUpperCase()}] ` : '';
286
+ const connection = data.connectionId ? `[${data.connectionId.substring(0, 8)}] ` : '';
287
+ const message = data.message ? `: ${typeof data.message === 'object' ? JSON.stringify(data.message) : data.message}` : '';
288
+ return `${timestamp}${type}${connection}${message}`;
289
+
290
+ case 'simple':
291
+ default:
292
+ return `${data.type || 'log'}: ${data.message || JSON.stringify(data)}`;
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Check if level should be logged
298
+ * @param {string} level - Log level to check
299
+ * @returns {boolean} Whether to log at this level
300
+ * @private
301
+ */
302
+ _shouldLogLevel(level) {
303
+ const levels = ['debug', 'info', 'warn', 'error'];
304
+ const currentLevelIndex = levels.indexOf(this.options.level);
305
+ const checkLevelIndex = levels.indexOf(level);
306
+
307
+ return checkLevelIndex >= currentLevelIndex;
308
+ }
309
+
310
+ /**
311
+ * Get logging statistics
312
+ * @returns {Object} Logging statistics
313
+ */
314
+ getStats() {
315
+ return {
316
+ options: this.options,
317
+ timestamp: new Date().toISOString()
318
+ };
319
+ }
320
+ }
321
+
322
+ export default MessageLogger;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * @license MIT
3
+ * Copyright (c) 2026-present AetherFramework Contributors.
4
+ * SPDX-License-Identifier: MIT
5
+ * @module @aetherframework/src/middleware/rate-limiter
6
+ */
7
+
8
+ class RateLimiter {
9
+ constructor(options = {}) {
10
+ this.options = {
11
+ windowMs: options.windowMs || 60000, // 1 minute window
12
+ max: options.max || 100, // Max requests per window
13
+ message: options.message || 'Rate limit exceeded',
14
+ statusCode: options.statusCode || 429,
15
+ skipFailedRequests: options.skipFailedRequests || false,
16
+ skipSuccessfulRequests: options.skipSuccessfulRequests || false,
17
+ keyGenerator: options.keyGenerator || ((connection) => connection.id),
18
+ skip: options.skip || (() => false),
19
+ ...options
20
+ };
21
+
22
+ this.store = new Map(); // key -> { count: number, resetTime: number }
23
+ this.cleanupInterval = setInterval(() => this._cleanup(), this.options.windowMs);
24
+ }
25
+
26
+ /**
27
+ * Rate limiting middleware function
28
+ * @returns {Function} Middleware function
29
+ */
30
+ middleware() {
31
+ return async (connection, next) => {
32
+ // Skip if configured
33
+ if (this.options.skip(connection)) {
34
+ return next();
35
+ }
36
+
37
+ const key = this.options.keyGenerator(connection);
38
+ const now = Date.now();
39
+
40
+ // Get or create rate limit info
41
+ let info = this.store.get(key);
42
+ if (!info || info.resetTime < now) {
43
+ info = {
44
+ count: 0,
45
+ resetTime: now + this.options.windowMs
46
+ };
47
+ this.store.set(key, info);
48
+ }
49
+
50
+ // Check rate limit
51
+ if (info.count >= this.options.max) {
52
+ const retryAfter = Math.ceil((info.resetTime - now) / 1000);
53
+
54
+ connection.sendJSON({
55
+ type: 'error',
56
+ code: this.options.statusCode,
57
+ message: this.options.message,
58
+ retryAfter
59
+ });
60
+
61
+ if (this.options.statusCode === 429) {
62
+ connection.close(1008, 'Rate limit exceeded');
63
+ }
64
+
65
+ return; // Stop further processing
66
+ }
67
+
68
+ // Increment counter
69
+ info.count++;
70
+
71
+ // Add rate limit headers to response
72
+ connection.rateLimit = {
73
+ limit: this.options.max,
74
+ remaining: this.options.max - info.count,
75
+ reset: info.resetTime
76
+ };
77
+
78
+ next();
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Clean up expired rate limit records
84
+ * @private
85
+ */
86
+ _cleanup() {
87
+ const now = Date.now();
88
+ for (const [key, info] of this.store.entries()) {
89
+ if (info.resetTime < now) {
90
+ this.store.delete(key);
91
+ }
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Reset rate limit for a specific key
97
+ * @param {string} key - Rate limit key
98
+ */
99
+ resetKey(key) {
100
+ this.store.delete(key);
101
+ }
102
+
103
+ /**
104
+ * Reset all rate limits
105
+ */
106
+ resetAll() {
107
+ this.store.clear();
108
+ }
109
+
110
+ /**
111
+ * Get rate limit info for a key
112
+ * @param {string} key - Rate limit key
113
+ * @returns {Object|null} Rate limit information
114
+ */
115
+ getInfo(key) {
116
+ const info = this.store.get(key);
117
+ if (!info) return null;
118
+
119
+ const now = Date.now();
120
+ if (info.resetTime < now) return null;
121
+
122
+ return {
123
+ limit: this.options.max,
124
+ remaining: this.options.max - info.count,
125
+ reset: info.resetTime,
126
+ windowMs: this.options.windowMs
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Destroy the rate limiter
132
+ */
133
+ destroy() {
134
+ if (this.cleanupInterval) {
135
+ clearInterval(this.cleanupInterval);
136
+ this.cleanupInterval = null;
137
+ }
138
+ this.store.clear();
139
+ }
140
+ }
141
+
142
+ export default RateLimiter;