@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.
- package/examples/basic-server.js +95 -0
- package/examples/chat-server.js +416 -0
- package/examples/client-test.js +58 -0
- package/examples/realtime-api.js +98 -0
- package/examples//342/200/214stress-test-server.js +495 -0
- package/package.json +3 -1
- package/src/core/ConnectionManager.js +213 -0
- package/src/core/FrameParser.js +115 -0
- package/src/core/HandshakeHandler.js +93 -0
- package/src/core/ProtocolHandler.js +186 -0
- package/src/core/WebSocketFactory.js +293 -0
- package/src/drivers/http-driver.js +576 -0
- package/src/drivers/index.js +29 -0
- package/src/drivers/memory-driver.js +422 -0
- package/src/drivers/tcp-driver.js +471 -0
- package/src/drivers/tls-driver.js +502 -0
- package/src/middleware/auth-middleware.js +37 -0
- package/src/middleware/broadcast-manager.js +173 -0
- package/src/middleware/compression.js +194 -0
- package/src/middleware/message-logger.js +322 -0
- package/src/middleware/rate-limiter.js +142 -0
- package/src/utils/config-loader.js +183 -0
- package/src/utils/connection-pool.js +110 -0
- package/src/utils/error-handler.js +59 -0
- package/src/utils/frame-encoder.js +211 -0
- package/src/utils/heartbeat-manager.js +91 -0
|
@@ -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;
|